Files
zooqueenandHanzo Dev 2223dfa0db sqlite: await cookies, and move the json comparison out of the query
cookies() — awaited. It has returned a promise since next 15; the sync
    access these three kept using is deprecated and already gone in 16, and
    they gate dataroom and link sessions.

  isolation — sqlite offers only Serializable. It takes one writer at a time,
    so naming that instead of RepeatableRead is the same guarantee, not a
    relaxation.

  year in review — sqlite cannot compare a value inside a json column: both
    the array and string forms of `path` are rejected. The `totalViews > 1`
    test moved into the caller, and deliberately runs BEFORE the batch is
    taken. Filtering afterwards would let teams with nothing to report fill
    the batch and starve everyone behind them.

  Team.ignoredDomains — one more postgres String[] that became Json.

99 errors -> 5, no file worse than it was.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-09 11:58:05 -07:00

280 lines
8.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { render } from "@react-email/components";
import { nanoid } from "nanoid";
import prisma from "@/lib/prisma";
import { resend } from "@/lib/resend";
import { log } from "@/lib/utils";
import { generateUnsubscribeUrl } from "@/lib/utils/unsubscribe";
import YearInReviewEmail from "@/components/emails/year-in-review";
const BATCH_SIZE = 100; // Maximum number of emails Resend supports in one batch
const MAX_ATTEMPTS = 3;
const RATE_LIMIT_DELAY = 10000; // 10 seconds
type YearReviewStats = {
totalDocuments: number;
totalLinks: number;
totalViews: number;
mostViewedDocument: {
documentId: string;
documentName: string;
viewCount: number;
};
mostActiveMonth: {
month: string;
viewCount: number;
};
totalDuration: number;
uniqueCountries: string[];
sharerPercentile: number;
};
type EmailWithMetadata = {
email: {
from: string;
to: string;
subject: string;
react: React.ReactElement;
text: string;
headers: {
"X-Entity-Ref-ID": string;
"List-Unsubscribe": string;
};
};
jobId: string;
teamId: string;
userId: string;
};
function msToMinutes(ms: number): number {
return Math.ceil(ms / 60000);
}
export async function processEmailQueue() {
if (!resend) {
console.log("❌ Resend client not initialized");
return;
}
const pending = await prisma.yearInReview.findMany({
where: {
AND: [{ status: "pending" }, { attempts: { lt: MAX_ATTEMPTS } }],
},
orderBy: { createdAt: "asc" },
});
// sqlite cannot compare a value inside a json column, so the `totalViews > 1`
// test that used to sit in the where clause happens here. It runs BEFORE the
// batch is taken: a team with nothing worth mailing about stays pending
// forever, and taking the batch first would let those teams fill it and
// starve everyone behind them.
const jobs = pending
.filter((job) => {
const stats = job.stats;
if (typeof stats !== "object" || stats === null || Array.isArray(stats)) {
return false;
}
return Number((stats as Record<string, unknown>).totalViews ?? 0) > 1;
})
.slice(0, BATCH_SIZE);
if (jobs.length === 0) {
console.log("ℹ️ No jobs to process");
return;
}
console.log(
`📬 Processing ${jobs.length} jobs:`,
jobs.map((job) => job.id),
);
try {
// Fetch team data for all jobs
const teamsData = await prisma.$transaction(async (tx) => {
return Promise.all(
jobs.map(async (job) => {
const team = await tx.team.findUnique({
where: { id: job.teamId },
include: {
users: {
where: { role: { in: ["ADMIN", "MANAGER"] } },
include: {
user: {
select: {
email: true,
},
},
},
},
},
});
return {
job,
team,
};
}),
);
});
console.log(`📋 Found ${teamsData.length} teams with valid data`);
// Prepare batch of emails
const emailsWithMetadata: EmailWithMetadata[] = (
await Promise.all(
teamsData
.filter(({ team }) => team !== null) // Filter out teams that weren't found
.flatMap(async ({ job, team }) => {
const stats = job.stats as YearReviewStats;
return Promise.all(
team!.users
.filter((userTeam) => userTeam.user.email)
.map(async (userTeam) => {
const unsubscribeUrl = generateUnsubscribeUrl({
viewerId: userTeam.userId,
teamId: team!.id,
});
const react = YearInReviewEmail({
year: 2024,
minutesSpentOnDocs: msToMinutes(stats.totalDuration),
uploadedDocuments: stats.totalDocuments,
sharedLinks: stats.totalLinks,
receivedViews: stats.totalViews,
topDocumentName: stats.mostViewedDocument.documentName,
topDocumentViews: stats.mostViewedDocument.viewCount,
mostActiveMonth: stats.mostActiveMonth.month,
mostActiveMonthViews: stats.mostActiveMonth.viewCount,
sharerPercentile: stats.sharerPercentile,
viewingLocations: stats.uniqueCountries,
unsubscribeUrl,
});
const plainText = await render(react, { plainText: true });
return {
email: {
from: "Hanzo Dataroom <[email protected]>",
to: userTeam.user.email || "[email protected]",
subject: "2024 in Review: Your Year with Hanzo Dataroom",
react,
text: plainText,
headers: {
"X-Entity-Ref-ID": nanoid(),
"List-Unsubscribe": unsubscribeUrl,
},
},
jobId: job.id,
teamId: team!.id,
userId: userTeam.userId,
};
}),
);
}),
)
).flat();
console.log(`📧 Preparing to send ${emailsWithMetadata.length} emails`);
// Process emails in batches
for (let i = 0; i < emailsWithMetadata.length; i += BATCH_SIZE) {
const batch = emailsWithMetadata.slice(i, i + BATCH_SIZE);
console.log(
`\n🚀 Sending batch ${Math.floor(i / BATCH_SIZE) + 1} of ${Math.ceil(emailsWithMetadata.length / BATCH_SIZE)}`,
);
console.log(
`📨 Recipients:`,
batch.map((b) => b.email.to),
);
try {
const emailBatch = batch.map((item) => item.email);
const { data, error } = await resend.batch.send(emailBatch);
if (error) {
console.log(`❌ Batch send failed:`, error);
} else {
console.log(`✅ Batch sent successfully:`, {
sent: data?.data.length,
total: batch.length,
});
}
// Track success/failure counts by job
const jobCounts = new Map<
string,
{ success: number; failed: number }
>();
// Match results with metadata using array indices
if (data) {
data.data.forEach((result, index) => {
const metadata = batch[index];
const counts = jobCounts.get(metadata.jobId) || {
success: 0,
failed: 0,
};
counts.success++;
jobCounts.set(metadata.jobId, counts);
});
} else if (error) {
batch.forEach((metadata) => {
const counts = jobCounts.get(metadata.jobId) || {
success: 0,
failed: 0,
};
counts.failed++;
log({
message: `Failed to send email to ${metadata.email.to}: ${error.message}`,
type: "error",
mention: true,
});
jobCounts.set(metadata.jobId, counts);
});
}
// Update job statuses
for (const [jobId, counts] of jobCounts.entries()) {
const totalExpectedEmails =
teamsData
.find(({ job }) => job.id === jobId)
?.team?.users.filter((ut) => ut.user.email).length || 0;
const job = jobs.find((j) => j.id === jobId);
if (!job) continue;
const status =
counts.failed === 0 && counts.success === totalExpectedEmails
? "completed"
: job.attempts >= MAX_ATTEMPTS - 1
? "failed"
: "pending";
await prisma.yearInReview.update({
where: { id: jobId },
data: {
status,
error:
counts.failed > 0
? `Failed to send ${counts.failed} out of ${totalExpectedEmails} emails`
: null,
},
});
}
// Respect rate limit between batches
if (i + BATCH_SIZE < emailsWithMetadata.length) {
console.log(
`⏳ Waiting ${RATE_LIMIT_DELAY / 1000}s before next batch...`,
);
await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAY));
}
} catch (error) {
console.log(`❌ Error processing batch:`, error);
}
}
} catch (error) {
console.log(`❌ Fatal error processing email queue:`, error);
}
}