diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/CategoriesSetup.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/CategoriesSetup.tsx index b40d4a1e47..ce6c6cf762 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/CategoriesSetup.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/CategoriesSetup.tsx @@ -33,17 +33,17 @@ import { SeeExampleDialogButton, } from "@/app/(app)/[emailAccountId]/assistant/onboarding/ExampleDialog"; import { categoryConfig } from "@/utils/category-config"; +import { useAccount } from "@/providers/EmailAccountProvider"; const NEXT_URL = "/assistant/onboarding/draft-replies"; export function CategoriesSetup({ - emailAccountId, defaultValues, }: { - emailAccountId: string; defaultValues?: Partial; }) { const router = useRouter(); + const { emailAccountId } = useAccount(); const [showExampleDialog, setShowExampleDialog] = useState(false); diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/page.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/page.tsx index cfafb6ea62..a4fbda1fe2 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/onboarding/page.tsx @@ -3,41 +3,23 @@ import useSWR from "swr"; import { Card } from "@/components/ui/card"; import { CategoriesSetup } from "./CategoriesSetup"; -import type { GetOnboardingPreferencesResponse } from "@/app/api/user/onboarding-preferences/route"; -import { Skeleton } from "@/components/ui/skeleton"; -import { useAccount } from "@/providers/EmailAccountProvider"; +import type { GetCategorizationPreferencesResponse } from "@/app/api/user/categorization-preferences/route"; +import { LoadingContent } from "@/components/LoadingContent"; export default function OnboardingPage() { - const { emailAccountId } = useAccount(); - - const { data: defaultValues, isLoading } = - useSWR( - "/api/user/onboarding-preferences", - ); - - if (isLoading) { - return ( - -
- - -
- {[...Array(7)].map((_, i) => ( - - ))} -
- -
-
- ); - } + const { + data: defaultValues, + isLoading, + error, + } = useSWR( + "/api/user/categorization-preferences", + ); return ( - + + + ); } diff --git a/apps/web/app/api/ai/digest/route.ts b/apps/web/app/api/ai/digest/route.ts index b3219234b1..1538d54776 100644 --- a/apps/web/app/api/ai/digest/route.ts +++ b/apps/web/app/api/ai/digest/route.ts @@ -7,34 +7,34 @@ import { RuleName } from "@/utils/rule/consts"; import { getRuleNameByExecutedAction } from "@/utils/actions/rule"; import { aiSummarizeEmailForDigest } from "@/utils/ai/digest/summarize-email-for-digest"; import { getEmailAccountWithAi } from "@/utils/user/get"; -import { upsertDigest } from "@/utils/digest/index"; import { hasCronSecret } from "@/utils/cron"; import { captureException } from "@/utils/error"; +import type { DigestEmailSummarySchema } from "@/app/api/resend/digest/validation"; + +const LOGGER_NAME = "digest"; export async function POST(request: Request) { if (!hasCronSecret(request)) { captureException(new Error("Unauthorized cron request: api/ai/digest")); return new Response("Unauthorized", { status: 401 }); } - const body = digestBody.parse(await request.json()); - const { emailAccountId, coldEmailId, actionId, message } = body; - const logger = createScopedLogger("digest").with({ - emailAccountId, - messageId: message.id, - }); + const logger = createScopedLogger(LOGGER_NAME); try { + const body = digestBody.parse(await request.json()); + const { emailAccountId, coldEmailId, actionId, message } = body; + + logger.with({ emailAccountId, messageId: message.id }); + const emailAccount = await getEmailAccountWithAi({ emailAccountId }); if (!emailAccount) { throw new Error("Email account not found"); } - const ruleName = - (actionId && (await getRuleNameByExecutedAction(actionId))) || - RuleName.ColdEmail; + const ruleName = await resolveRuleName(actionId); const summary = await aiSummarizeEmailForDigest({ - ruleName: ruleName, + ruleName, emailAccount, messageToSummarize: message, }); @@ -43,8 +43,8 @@ export async function POST(request: Request) { messageId: message.id || "", threadId: message.threadId || "", emailAccountId, - actionId: actionId === undefined ? undefined : actionId, - coldEmailId: coldEmailId === undefined ? undefined : coldEmailId, + actionId, + coldEmailId, content: summary, }); @@ -54,3 +54,149 @@ export async function POST(request: Request) { return new NextResponse("Internal Server Error", { status: 500 }); } } + +async function resolveRuleName(actionId?: string): Promise { + if (!actionId) return RuleName.ColdEmail; + + const ruleName = await getRuleNameByExecutedAction(actionId); + return ruleName || RuleName.ColdEmail; +} + +async function upsertDigest({ + messageId, + threadId, + emailAccountId, + actionId, + coldEmailId, + content, +}: { + messageId: string; + threadId: string; + emailAccountId: string; + actionId?: string; + coldEmailId?: string; + content: DigestEmailSummarySchema; +}) { + const logger = createScopedLogger(LOGGER_NAME).with({ + messageId, + threadId, + emailAccountId, + actionId, + coldEmailId, + }); + + try { + const digest = await findOrCreateDigest( + emailAccountId, + messageId, + threadId, + ); + const existingItem = digest.items[0]; + const contentString = JSON.stringify(content); + + if (existingItem) { + logger.info("Updating existing digest item"); + await updateDigestItem( + existingItem.id, + contentString, + actionId, + coldEmailId, + ); + } else { + logger.info("Creating new digest item"); + await createDigestItem({ + digestId: digest.id, + messageId, + threadId, + contentString, + actionId, + coldEmailId, + }); + } + } catch (error) { + logger.error("Failed to upsert digest", { error }); + throw error; + } +} + +async function findOrCreateDigest( + emailAccountId: string, + messageId: string, + threadId: string, +) { + const digestWithItem = await prisma.digest.findFirst({ + where: { + emailAccountId, + status: DigestStatus.PENDING, + }, + orderBy: { + createdAt: "asc", + }, + include: { + items: { + where: { messageId, threadId }, + take: 1, + }, + }, + }); + + if (digestWithItem) { + return digestWithItem; + } + + return await prisma.digest.create({ + data: { + emailAccountId, + status: DigestStatus.PENDING, + }, + include: { + items: { + where: { messageId, threadId }, + take: 1, + }, + }, + }); +} + +async function updateDigestItem( + itemId: string, + contentString: string, + actionId?: string, + coldEmailId?: string, +) { + return await prisma.digestItem.update({ + where: { id: itemId }, + data: { + content: contentString, + ...(actionId && { actionId }), + ...(coldEmailId && { coldEmailId }), + }, + }); +} + +async function createDigestItem({ + digestId, + messageId, + threadId, + contentString, + actionId, + coldEmailId, +}: { + digestId: string; + messageId: string; + threadId: string; + contentString: string; + actionId?: string; + coldEmailId?: string; +}) { + return await prisma.digestItem.create({ + data: { + messageId, + threadId, + content: contentString, + digestId, + ...(actionId && { actionId }), + ...(coldEmailId && { coldEmailId }), + }, + }); +} diff --git a/apps/web/app/api/user/onboarding-preferences/route.ts b/apps/web/app/api/user/categorization-preferences/route.ts similarity index 98% rename from apps/web/app/api/user/onboarding-preferences/route.ts rename to apps/web/app/api/user/categorization-preferences/route.ts index 8e9a73ff9f..d3d4050c6d 100644 --- a/apps/web/app/api/user/onboarding-preferences/route.ts +++ b/apps/web/app/api/user/categorization-preferences/route.ts @@ -17,7 +17,7 @@ type CategoryConfig = { hasDigest: boolean | undefined; }; -export type GetOnboardingPreferencesResponse = Awaited< +export type GetCategorizationPreferencesResponse = Awaited< ReturnType >; diff --git a/apps/web/app/api/user/digest-settings/route.ts b/apps/web/app/api/user/digest-settings/route.ts index 1848c51b85..b65a726573 100644 --- a/apps/web/app/api/user/digest-settings/route.ts +++ b/apps/web/app/api/user/digest-settings/route.ts @@ -3,6 +3,16 @@ import { withEmailAccount } from "@/utils/middleware"; import prisma from "@/utils/prisma"; import { ActionType, SystemType } from "@prisma/client"; +// Define supported system types for digest settings +const SUPPORTED_SYSTEM_TYPES = [ + SystemType.TO_REPLY, + SystemType.NEWSLETTER, + SystemType.MARKETING, + SystemType.CALENDAR, + SystemType.RECEIPT, + SystemType.NOTIFICATION, +] as const; + export type GetDigestSettingsResponse = Awaited< ReturnType >; @@ -26,14 +36,7 @@ async function getDigestSettings({ rules: { where: { systemType: { - in: [ - SystemType.TO_REPLY, - SystemType.NEWSLETTER, - SystemType.MARKETING, - SystemType.CALENDAR, - SystemType.RECEIPT, - SystemType.NOTIFICATION, - ], + in: [...SUPPORTED_SYSTEM_TYPES], }, }, select: { @@ -81,6 +84,15 @@ async function getDigestSettings({ [SystemType.NOTIFICATION]: "notification", }; + // Verify all supported system types are mapped + SUPPORTED_SYSTEM_TYPES.forEach((systemType) => { + if (!(systemType in systemTypeToKey)) { + throw new Error( + `SystemType ${systemType} is not mapped in systemTypeToKey`, + ); + } + }); + emailAccount.rules.forEach((rule) => { if (rule.systemType && rule.systemType in systemTypeToKey) { const key = systemTypeToKey[rule.systemType]; diff --git a/apps/web/utils/digest/index.ts b/apps/web/utils/digest/index.ts index 533283edc8..3184ed9e78 100644 --- a/apps/web/utils/digest/index.ts +++ b/apps/web/utils/digest/index.ts @@ -2,10 +2,7 @@ import { env } from "@/env"; import { publishToQstashQueue } from "@/utils/upstash"; import { getCronSecretHeader } from "@/utils/cron"; import { createScopedLogger } from "@/utils/logger"; -import prisma from "@/utils/prisma"; import type { DigestBody } from "@/app/api/ai/digest/validation"; -import { DigestStatus } from "@prisma/client"; -import type { DigestEmailSummarySchema } from "@/app/api/resend/digest/validation"; import type { EmailForAction } from "@/utils/ai/types"; const logger = createScopedLogger("digest"); @@ -49,107 +46,3 @@ export async function enqueueDigestItem({ }); } } - -async function findOldestUnsentDigest(emailAccountId: string) { - return prisma.digest.findFirst({ - where: { - emailAccountId, - status: DigestStatus.PENDING, - }, - orderBy: { - createdAt: "asc", - }, - }); -} - -export async function upsertDigest({ - messageId, - threadId, - emailAccountId, - actionId, - coldEmailId, - content, -}: { - messageId: string; - threadId: string; - emailAccountId: string; - actionId?: string; - coldEmailId?: string; - content: DigestEmailSummarySchema; -}) { - const logger = createScopedLogger("upsert-digest").with({ - messageId, - threadId, - emailAccountId, - actionId, - coldEmailId, - }); - - try { - // Find or create the digest atomically with digestItems included - const digest = - (await prisma.digest.findFirst({ - where: { - emailAccountId, - status: DigestStatus.PENDING, - }, - orderBy: { - createdAt: "asc", - }, - include: { - items: { - where: { - messageId, - threadId, - }, - take: 1, - }, - }, - })) || - (await prisma.digest.create({ - data: { - emailAccountId, - status: DigestStatus.PENDING, - }, - include: { - items: { - where: { - messageId, - threadId, - }, - take: 1, - }, - }, - })); - - const digestItem = digest.items.length > 0 ? digest.items[0] : null; - const contentString = JSON.stringify(content); - - if (digestItem) { - logger.info("Updating existing digest"); - await prisma.digestItem.update({ - where: { id: digestItem.id }, - data: { - content: contentString, - ...(actionId ? { actionId } : {}), - ...(coldEmailId ? { coldEmailId } : {}), - }, - }); - } else { - logger.info("Creating new digest"); - await prisma.digestItem.create({ - data: { - messageId, - threadId, - content: contentString, - digestId: digest.id, - ...(actionId ? { actionId } : {}), - ...(coldEmailId ? { coldEmailId } : {}), - }, - }); - } - } catch (error) { - logger.error("Failed to upsert digest", { error }); - throw error; - } -} diff --git a/version.txt b/version.txt index a9bbd79f98..535cc4dda6 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v1.7.4 +v1.7.5