Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<CreateRulesOnboardingBody>;
}) {
const router = useRouter();
const { emailAccountId } = useAccount();

const [showExampleDialog, setShowExampleDialog] = useState(false);

Expand Down
42 changes: 12 additions & 30 deletions apps/web/app/(app)/[emailAccountId]/assistant/onboarding/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<GetOnboardingPreferencesResponse>(
"/api/user/onboarding-preferences",
);

if (isLoading) {
return (
<Card className="my-4 w-full max-w-2xl p-6 sm:mx-4 md:mx-auto">
<div className="space-y-6">
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-20 w-full" />
<div className="space-y-4">
{[...Array(7)].map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
<Skeleton className="h-12 w-full" />
</div>
</Card>
);
}
const {
data: defaultValues,
isLoading,
error,
} = useSWR<GetCategorizationPreferencesResponse>(
"/api/user/categorization-preferences",
);

return (
<Card className="my-4 w-full max-w-2xl p-6 sm:mx-4 md:mx-auto">
<CategoriesSetup
emailAccountId={emailAccountId}
defaultValues={defaultValues || undefined}
/>
<LoadingContent loading={isLoading} error={error}>
<CategoriesSetup defaultValues={defaultValues || undefined} />
</LoadingContent>
</Card>
);
}
172 changes: 159 additions & 13 deletions apps/web/app/api/ai/digest/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand All @@ -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,
});

Expand All @@ -54,3 +54,149 @@ export async function POST(request: Request) {
return new NextResponse("Internal Server Error", { status: 500 });
}
}

async function resolveRuleName(actionId?: string): Promise<string> {
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 }),
},
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type CategoryConfig = {
hasDigest: boolean | undefined;
};

export type GetOnboardingPreferencesResponse = Awaited<
export type GetCategorizationPreferencesResponse = Awaited<
ReturnType<typeof getUserPreferences>
>;

Expand Down
28 changes: 20 additions & 8 deletions apps/web/app/api/user/digest-settings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof getDigestSettings>
>;
Expand All @@ -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: {
Expand Down Expand Up @@ -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];
Expand Down
Loading
Loading