Skip to content

Commit 3baea16

Browse files
committed
fix(ops): prevent daily-digest POST action fallthrough and add daily idempotency guard
1 parent 1b9bf89 commit 3baea16

2 files changed

Lines changed: 74 additions & 12 deletions

File tree

‎apps/ops/src/App.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -721,7 +721,12 @@ export function App() {
721721
const res = await fetch(`${siteOrigin}/api/ops/daily-digest`, {
722722
method: "POST",
723723
headers: { "Content-Type": "application/json" },
724-
body: JSON.stringify({ sendEmail: true, to: "alex@codingcat.dev" }),
724+
body: JSON.stringify({
725+
action: "send_digest",
726+
sendEmail: true,
727+
force: true,
728+
to: "alex@codingcat.dev",
729+
}),
725730
});
726731
const data = await res.json();
727732
if (!res.ok) {

‎apps/site/src/pages/api/ops/daily-digest.ts‎

Lines changed: 68 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ async function fetchUpcomingCalendarEvents(
132132

133133
async function buildAndOptionallySendDigest(opts: {
134134
sendEmail: boolean;
135+
force?: boolean;
135136
recipientEmail?: string;
136137
siteOrigin: string;
137138
}) {
@@ -283,11 +284,46 @@ async function buildAndOptionallySendDigest(opts: {
283284
let emailResult: { sent: boolean; provider: string } | null = null;
284285

285286
if (opts.sendEmail) {
286-
emailResult = await sendCodingCatEmail({
287-
to: opts.recipientEmail || "alex@codingcat.dev",
288-
subject: `[CodingCat.dev Today] ${todayEtStr}: ${pendingHolds.length} Holds, ${upcoming72hBookings.length} Upcoming Recordings, ${actionLeads.length} Deals`,
289-
text: emailText,
290-
});
287+
const digestStateId = "private.opsDigestState";
288+
let alreadySentToday = false;
289+
if (!opts.force) {
290+
try {
291+
const lastSentDateEt = await client.fetch<string | null>(
292+
`*[_id == $id][0].lastSentDateEt`,
293+
{ id: digestStateId },
294+
);
295+
if (lastSentDateEt === todayEtStr) {
296+
alreadySentToday = true;
297+
}
298+
} catch {
299+
// Proceed if state lookup fails
300+
}
301+
}
302+
303+
if (alreadySentToday) {
304+
emailResult = {
305+
sent: false,
306+
provider: `skipped-already-sent-${todayEtStr}`,
307+
};
308+
} else {
309+
emailResult = await sendCodingCatEmail({
310+
to: opts.recipientEmail || "alex@codingcat.dev",
311+
subject: `[CodingCat.dev Today] ${todayEtStr}: ${pendingHolds.length} Holds, ${upcoming72hBookings.length} Upcoming Recordings, ${actionLeads.length} Deals`,
312+
text: emailText,
313+
});
314+
if (emailResult.sent) {
315+
try {
316+
await client.createOrReplace({
317+
_id: digestStateId,
318+
_type: "opsDigestState",
319+
lastSentDateEt: todayEtStr,
320+
lastSentAt: new Date().toISOString(),
321+
});
322+
} catch {
323+
// Ignore state persistence failure
324+
}
325+
}
326+
}
291327
}
292328

293329
return {
@@ -311,11 +347,15 @@ export const GET: APIRoute = async ({ url, request }) => {
311347
const sendEmail =
312348
url.searchParams.get("sendEmail") === "1" ||
313349
url.searchParams.get("sendEmail") === "true";
350+
const force =
351+
url.searchParams.get("force") === "1" ||
352+
url.searchParams.get("force") === "true";
314353
const recipientEmail = url.searchParams.get("to") || undefined;
315354
const siteOrigin = url.origin || new URL(request.url).origin;
316355

317356
const payload = await buildAndOptionallySendDigest({
318357
sendEmail,
358+
force,
319359
recipientEmail,
320360
siteOrigin,
321361
});
@@ -327,23 +367,29 @@ export const GET: APIRoute = async ({ url, request }) => {
327367

328368
export const POST: APIRoute = async ({ url, request }) => {
329369
let body: {
330-
action?: "patch_doc" | "create_doc" | "delete_doc";
370+
action?: "patch_doc" | "create_doc" | "delete_doc" | "send_digest";
331371
docId?: string;
332372
patch?: Record<string, any>;
373+
set?: Record<string, any>;
333374
doc?: Record<string, any>;
334375
sendEmail?: boolean;
376+
force?: boolean;
335377
to?: string;
336378
} = {};
337379
try {
338380
body = await request.json();
339381
} catch {
340-
// Default to sending email on POST
382+
// Ignore invalid JSON body
341383
}
342384

343-
if (body.action) {
385+
if (body.action && body.action !== "send_digest") {
344386
const { client } = getSanityServerClient();
345-
if (body.action === "patch_doc" && body.docId && body.patch) {
346-
const updated = await client.patch(body.docId).set(body.patch).commit();
387+
const patchPayload = body.patch || body.set;
388+
if (body.action === "patch_doc" && body.docId && patchPayload) {
389+
const updated = await client
390+
.patch(body.docId)
391+
.set(patchPayload)
392+
.commit();
347393
return new Response(JSON.stringify({ ok: true, doc: updated }), {
348394
status: 200,
349395
headers: CORS_HEADERS,
@@ -363,11 +409,22 @@ export const POST: APIRoute = async ({ url, request }) => {
363409
headers: CORS_HEADERS,
364410
});
365411
}
412+
return new Response(
413+
JSON.stringify({
414+
ok: false,
415+
error: `Invalid or incomplete payload for action "${body.action}"`,
416+
}),
417+
{
418+
status: 400,
419+
headers: CORS_HEADERS,
420+
},
421+
);
366422
}
367423

368424
const siteOrigin = url.origin || new URL(request.url).origin;
369425
const payload = await buildAndOptionallySendDigest({
370-
sendEmail: body.sendEmail ?? true,
426+
sendEmail: body.sendEmail === true || body.action === "send_digest",
427+
force: body.force === true,
371428
recipientEmail: body.to,
372429
siteOrigin,
373430
});

0 commit comments

Comments
 (0)