feat: batch-cook shopping list (already worked) + leftover expiry reminders
Shopping list add already worked generically for batch-cook recipes — no code needed there. New: mark a specific batch-cook dish as "cooked today", track its fridge expiry (cookingHistory.batchDishId), surface a "Leftovers expiring soon" widget on the pantry page, and send a daily push+email reminder via a new /api/internal/cron/leftover-reminders endpoint (mirrors the weekly-digest cron pattern; doesn't use the social notifications table, which requires a non-null actor and isn't built for self-reminders). Also fixes, from user-reported bugs: - Recipe cards showed no batch-cook badge/dish-count/prep-time in some views — added dishCount + prepMins/cookMins (now generated by the AI and persisted) to the card component and /recipes query. - Batch-cook descriptions occasionally contained raw markdown (**bold**) — added explicit "plain prose only" prompt instructions and a stripMarkdown() defensive fallback at render time. - Truncated/cut-off descriptions — the generateObject call had no maxOutputTokens set, so long structured responses could get cut off mid-field; now capped explicitly at 8000. - Generate dialogs (batch-cook + the main AI dialog) could show buttons unreachable once the progress bar appeared mid-generation — restructured so the action row is pinned outside the scrollable content area, not affected by content height changes. - /api/internal/* routes were being redirected to /login by middleware before their own CRON_SECRET check ever ran (pre-existing bug, affected the weekly-digest cron too) — added to PUBLIC_PATHS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import crypto from "node:crypto";
|
||||
import { db, cookingHistory, eq, and, isNotNull, isNull } from "@epicure/db";
|
||||
import { sendEmail, notificationEmailHtml } from "@/lib/email";
|
||||
import { sendPushNotification } from "@/lib/push";
|
||||
import { isLeftoverExpiringSoon } from "@/lib/leftover-match";
|
||||
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
||||
|
||||
// Internal cron endpoint — triggered daily by a cron container (see
|
||||
// compose.prod.yml / cron/crontab). Not part of the public API surface;
|
||||
// protected by a shared secret rather than user auth.
|
||||
//
|
||||
// For every cooking_history row tied to a batch-cook dish, checks whether it
|
||||
// expires soon (see lib/leftover-match.ts) and hasn't already been reminded
|
||||
// about, then sends one push + email and marks it reminded so it never fires
|
||||
// twice for the same cooked dish.
|
||||
|
||||
function isAuthorized(req: NextRequest): boolean {
|
||||
const secret = process.env["CRON_SECRET"];
|
||||
if (!secret) return false;
|
||||
|
||||
const header = req.headers.get("authorization");
|
||||
if (!header?.startsWith("Bearer ")) return false;
|
||||
const provided = header.slice("Bearer ".length);
|
||||
|
||||
const a = Buffer.from(provided);
|
||||
const b = Buffer.from(secret);
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!isAuthorized(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const candidates = await db.query.cookingHistory.findMany({
|
||||
where: and(isNotNull(cookingHistory.batchDishId), isNull(cookingHistory.expiryReminderSentAt)),
|
||||
with: {
|
||||
batchDish: { columns: { id: true, name: true, fridgeDays: true } },
|
||||
recipe: { columns: { id: true, title: true } },
|
||||
user: { columns: { id: true, email: true, locale: true } },
|
||||
},
|
||||
});
|
||||
|
||||
let sent = 0;
|
||||
for (const log of candidates) {
|
||||
if (!log.batchDish || !log.user) continue;
|
||||
if (!isLeftoverExpiringSoon(log.cookedAt, log.batchDish.fridgeDays)) continue;
|
||||
|
||||
const messages = getMessages(log.user.locale);
|
||||
const template = messages.notifications.detail.leftoverExpiring;
|
||||
const title = messages.notifications.pushTitle.leftoverExpiring;
|
||||
const body = formatMessage(template, { dish: log.batchDish.name, title: log.recipe.title });
|
||||
const url = `/recipes/${log.recipe.id}`;
|
||||
|
||||
await Promise.all([
|
||||
sendPushNotification(log.user.id, { title, body, url }).catch((err) => {
|
||||
console.error("[leftover-reminders] push failed", err);
|
||||
}),
|
||||
log.user.email
|
||||
? sendEmail({
|
||||
to: log.user.email,
|
||||
subject: title,
|
||||
html: notificationEmailHtml(title, body, `${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"}${url}`),
|
||||
}).catch((err) => {
|
||||
console.error("[leftover-reminders] email failed", err);
|
||||
})
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
|
||||
await db.update(cookingHistory)
|
||||
.set({ expiryReminderSentAt: new Date() })
|
||||
.where(eq(cookingHistory.id, log.id));
|
||||
sent++;
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, checked: candidates.length, sent });
|
||||
}
|
||||
Reference in New Issue
Block a user