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
9 changes: 8 additions & 1 deletion apps/web/app/(app)/settings/EmailUpdatesSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,19 @@ function StatsUpdateSectionForm(props: { statsEmailFrequency: Frequency }) {

return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Select
{/* <Select
name="statsEmailFrequency"
label="Stats Update Email"
options={options}
registerProps={register("statsEmailFrequency")}
error={errors.statsEmailFrequency}
/> */}
<Select
name="summaryEmailFrequency"
label="Summary Email"
options={options}
registerProps={register("summaryEmailFrequency")}
error={errors.summaryEmailFrequency}
/>

<Button type="submit" loading={isSubmitting}>
Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/(app)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { AboutSection } from "@/app/(app)/settings/AboutSection";
// import { LabelsSection } from "@/app/(app)/settings/LabelsSection";
import { DeleteSection } from "@/app/(app)/settings/DeleteSection";
import { ModelSection } from "@/app/(app)/settings/ModelSection";
// import { EmailUpdatesSection } from "@/app/(app)/settings/EmailUpdatesSection";
import { EmailUpdatesSection } from "@/app/(app)/settings/EmailUpdatesSection";
import { MultiAccountSection } from "@/app/(app)/settings/MultiAccountSection";

export default function Settings() {
Expand All @@ -12,7 +12,7 @@ export default function Settings() {
<AboutSection />
{/* <LabelsSection /> */}
<ModelSection />
{/* <EmailUpdatesSection /> */}
<EmailUpdatesSection />
<MultiAccountSection />
<DeleteSection />
</FormWrapper>
Expand Down
112 changes: 0 additions & 112 deletions apps/web/app/api/resend/activity-update/route.ts

This file was deleted.

4 changes: 0 additions & 4 deletions apps/web/app/api/resend/all/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,6 @@ import { captureException } from "@/utils/error";
export const dynamic = "force-dynamic";
export const maxDuration = 300;

export type SendWeeklyStatsAllUpdateResponse = Awaited<
ReturnType<typeof sendWeeklyStatsAllUpdate>
>;

async function sendWeeklyStatsAllUpdate() {
const users = await prisma.user.findMany({
select: { email: true },
Expand Down
4 changes: 0 additions & 4 deletions apps/web/app/api/resend/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,6 @@ import { getGmailClient } from "@/utils/gmail/client";
import { loadTinybirdEmails } from "@/app/api/user/stats/tinybird/load/load-emails";

const sendWeeklyStatsBody = z.object({ email: z.string() });
export type SendWeeklyStatsBody = z.infer<typeof sendWeeklyStatsBody>;
export type SendWeeklyStatsResponse = Awaited<
ReturnType<typeof sendWeeklyStats>
>;

async function sendWeeklyStats(options: { email: string }) {
const { email } = options;
Expand Down
42 changes: 42 additions & 0 deletions apps/web/app/api/resend/summary/all/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import prisma from "@/utils/prisma";
import { withError } from "@/utils/middleware";
import { env } from "@/env.mjs";
import { hasCronSecret } from "@/utils/cron";
import { Frequency } from "@prisma/client";
import { captureException } from "@/utils/error";

export const dynamic = "force-dynamic";
export const maxDuration = 300;

async function sendSummaryAllUpdate() {
const users = await prisma.user.findMany({
select: { email: true },
where: { summaryEmailFrequency: { not: Frequency.NEVER } },
});

await Promise.all(
users.map(async (user) => {
return fetch(`${env.NEXT_PUBLIC_BASE_URL}/api/resend/summary`, {
method: "POST",
body: JSON.stringify({ email: user.email }),
headers: {
authorization: `Bearer ${env.CRON_SECRET}`,
},
});
}),
);

return { count: users.length };
}

export const GET = withError(async (request) => {
if (!hasCronSecret(request)) {
captureException(new Error("Unauthorized request: api/resend/all"));
return new Response("Unauthorized", { status: 401 });
}

const result = await sendSummaryAllUpdate();

return NextResponse.json(result);
});
97 changes: 97 additions & 0 deletions apps/web/app/api/resend/summary/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { z } from "zod";
import { NextResponse } from "next/server";
import { subHours } from "date-fns";
import { sendSummaryEmail } from "@inboxzero/resend";
import { withError } from "@/utils/middleware";
import { env } from "@/env.mjs";
import { hasCronSecret } from "@/utils/cron";
import { captureException } from "@/utils/error";
import prisma from "@/utils/prisma";
import { ExecutedRuleStatus } from "@prisma/client";
import { auth } from "@/app/api/auth/[...nextauth]/auth";

const sendSummaryEmailBody = z.object({ email: z.string() });

async function sendEmail({ email }: { email: string }) {
// run every 7 days. but overlap by 1 hour
const days = 7;
const cutOffDate = subHours(new Date(), days * 24 + 1);

const user = await prisma.user.findUnique({
where: {
email,
OR: [
{ lastSummaryEmailAt: { lt: cutOffDate } },
{ lastSummaryEmailAt: null },
],
},
select: {
coldEmails: { where: { createdAt: { gt: cutOffDate } } },
_count: {
select: {
executedRules: {
where: {
status: ExecutedRuleStatus.PENDING,
createdAt: { gt: cutOffDate },
},
},
},
},
},
});

if (!user) return { success: false };

const coldEmailers = user.coldEmails.map((e) => ({
from: e.fromEmail,
subject: "",
}));
const pendingCount = user._count.executedRules;
const shouldSendEmail = coldEmailers.length && pendingCount;

await Promise.all([
shouldSendEmail
? sendSummaryEmail({
to: email,
emailProps: {
baseUrl: env.NEXT_PUBLIC_BASE_URL,
coldEmailers,
pendingCount,
},
})
: async () => {},
prisma.user.update({
where: { email },
data: { lastSummaryEmailAt: new Date() },
}),
]);

return { success: true };
}

export const GET = withError(async () => {
const session = await auth();

// send to self
const email = session?.user.email;
if (!email) return NextResponse.json({ error: "Not authenticated" });

const result = await sendEmail({ email });

return NextResponse.json(result);
});

export const POST = withError(async (request: Request) => {
console.log("sending summary email to user");
if (!hasCronSecret(request)) {
captureException(new Error("Unauthorized cron request: resend"));
return new Response("Unauthorized", { status: 401 });
}

const json = await request.json();
const body = sendSummaryEmailBody.parse(json);

const result = await sendEmail(body);

return NextResponse.json(result);
});
1 change: 1 addition & 0 deletions apps/web/app/api/user/me/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ async function getUser(userId: string) {
aiModel: true,
openAIApiKey: true,
statsEmailFrequency: true,
summaryEmailFrequency: true,
coldEmailBlocker: true,
coldEmailPrompt: true,
premium: {
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/api/user/settings/email-updates/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ async function saveEmailUpdateSettings(options: SaveEmailUpdateSettingsBody) {
where: { email: session.user.email },
data: {
statsEmailFrequency: options.statsEmailFrequency,
summaryEmailFrequency: options.summaryEmailFrequency,
},
});
}
Expand Down
1 change: 1 addition & 0 deletions apps/web/app/api/user/settings/email-updates/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Frequency } from "@prisma/client";

export const saveEmailUpdateSettingsBody = z.object({
statsEmailFrequency: z.enum([Frequency.WEEKLY, Frequency.NEVER]),
summaryEmailFrequency: z.enum([Frequency.WEEKLY, Frequency.NEVER]),
});
export type SaveEmailUpdateSettingsBody = z.infer<
typeof saveEmailUpdateSettingsBody
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "lastSummaryEmailAt" TIMESTAMP(3),
ADD COLUMN "summaryEmailFrequency" "Frequency" NOT NULL DEFAULT 'WEEKLY';
16 changes: 9 additions & 7 deletions apps/web/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,15 @@ model User {
utms Json?

// settings
aiProvider String?
aiModel String?
openAIApiKey String?
statsEmailFrequency Frequency @default(WEEKLY)
categorizeEmails Boolean @default(true)
coldEmailBlocker ColdEmailSetting?
coldEmailPrompt String?
aiProvider String?
aiModel String?
openAIApiKey String?
statsEmailFrequency Frequency @default(WEEKLY)
summaryEmailFrequency Frequency @default(WEEKLY)
lastSummaryEmailAt DateTime?
categorizeEmails Boolean @default(true)
coldEmailBlocker ColdEmailSetting?
coldEmailPrompt String?

// premium can be shared among multiple users
premiumId String?
Expand Down
4 changes: 1 addition & 3 deletions apps/web/providers/SWRProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import { SWRConfig, mutate } from "swr";

// https://swr.vercel.app/docs/error-handling#status-code-and-error-object
export const fetcher = async (url: string, init?: RequestInit | undefined) => {
const fetcher = async (url: string, init?: RequestInit | undefined) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Specify a more precise type instead of any for the error object.

- const error: Error & { info?: any; status?: number } = new Error(
+ const error: Error & { info?: Record<string, unknown>; status?: number } = new Error(

Committable suggestion was skipped due low confidence.

const res = await fetch(url, init);

// If the status code is not in the range 200-299,
Expand Down Expand Up @@ -44,8 +44,6 @@ const defaultContextValue = {

const SWRContext = createContext<Context>(defaultContextValue);

export const useSWRContext = () => useContext<Context>(SWRContext);

export const SWRProvider = (props: { children: React.ReactNode }) => {
const [provider, setProvider] = useState(new Map());

Expand Down
Loading