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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@dnd-kit/utilities": "3.2.2",
"@google-cloud/storage": "^7.19.0",
"@hookform/resolvers": "5.2.1",
"@napi-rs/canvas": "^1.0.0",
"@prisma/adapter-pg": "7.7.0",
"@prisma/client": "7.7.0",
"@radix-ui/react-dialog": "^1.1.15",
Expand Down
125 changes: 125 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 26 additions & 1 deletion src/lib/resume/extractText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,39 @@ type PdfParseModule = {
PDFParse?: PdfParseCtor;
};

// pdfjs (inside pdf-parse) references browser canvas globals that Node does
// not provide. On Vercel's runtime this surfaced as "ReferenceError: DOMMatrix
// is not defined" and failed every PDF parse, so the globals are polyfilled
// from @napi-rs/canvas before pdf-parse loads.
let domGlobalsPromise: Promise<void> | null = null;

function ensurePdfDomGlobals(): Promise<void> {
domGlobalsPromise ??= (async () => {
const g = globalThis as Record<string, unknown>;
if (typeof g.DOMMatrix !== "undefined") return;
try {
const canvas = await import("@napi-rs/canvas");
g.DOMMatrix ??= canvas.DOMMatrix;
g.ImageData ??= canvas.ImageData;
g.Path2D ??= canvas.Path2D;
} catch (err) {
// Parsing of text-only PDFs may still succeed without the polyfill.
console.warn("[extractText] canvas polyfill unavailable:", err);
}
})();
return domGlobalsPromise;
}

// pdf-parse is listed in next.config.ts `serverExternalPackages`, so both
// webpack and Turbopack leave this dynamic import external and Node resolves
// the real package at runtime. (A createRequire(import.meta.url) + require()
// combo broke under the Next bundler — see commit history.)
let pdfParsePromise: Promise<PdfParseModule> | null = null;

function loadPdfParse(): Promise<PdfParseModule> {
pdfParsePromise ??= import("pdf-parse") as Promise<PdfParseModule>;
pdfParsePromise ??= ensurePdfDomGlobals().then(
() => import("pdf-parse") as Promise<PdfParseModule>,
);
return pdfParsePromise;
}

Expand Down
10 changes: 8 additions & 2 deletions src/server/jobs/dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ async function flushInlineTasks(): Promise<void> {
await new Promise((resolve) => setImmediate(resolve));
}

// The inline admin fan-out paces consecutive sends with a real 300ms timer
// (Resend rate limit), so flushing setImmediate alone is not enough.
async function flushPacedFanOut(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 400));
}

describe("dispatchResumeParse", () => {
it("enqueues to pg-boss in worker mode", async () => {
process.env.JOB_RUNNER = "worker";
Expand Down Expand Up @@ -155,7 +161,7 @@ describe("dispatchAdminNotificationEmails", () => {
process.env.JOB_RUNNER = "inline";

const result = await dispatchAdminNotificationEmails(input);
await flushInlineTasks();
await flushPacedFanOut();

expect(dispatchEmailJobMock).toHaveBeenCalledTimes(2);
expect(dispatchEmailJobMock).toHaveBeenCalledWith(
Expand All @@ -174,7 +180,7 @@ describe("dispatchAdminNotificationEmails", () => {

try {
const result = await dispatchAdminNotificationEmails(input);
await flushInlineTasks();
await flushPacedFanOut();

// Inline delivery is fire-and-forget post-response: failures are logged
// to EmailLog by the send functions, never surfaced to the caller.
Expand Down
8 changes: 7 additions & 1 deletion src/server/jobs/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,13 @@ export async function dispatchAdminNotificationEmails(
}));

runAfterResponse("email-admin-fan-out", async () => {
for (const job of jobs) {
for (const [index, job] of jobs.entries()) {
// Sequential alone is not enough: rejected sends return in ~50ms, so a
// burst of admins (+ the applicant confirmation) still trips Resend's
// requests-per-second cap. Pace consecutive sends.
if (index > 0) {
await new Promise((resolve) => setTimeout(resolve, 300));
}
const result = await dispatchEmailJob(job);
if (result.skipped) {
console.warn(`[jobs:email] skipped ${job.kind} for ${job.payload.to}`);
Expand Down
Loading