Files
2026-01-21 01:45:57 +00:00

124 lines
4.1 KiB
TypeScript

import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/auth/requireSession";
import { buildDowntimeActionReminderEmail, sendEmail } from "@/lib/email";
import { getBaseUrl } from "@/lib/appUrl";
const DEFAULT_DUE_DAYS = 7;
const DEFAULT_LIMIT = 100;
const MS_PER_HOUR = 60 * 60 * 1000;
type ReminderStage = "week" | "day" | "hour" | "overdue";
function formatDueDate(value?: Date | null) {
if (!value) return null;
return value.toISOString().slice(0, 10);
}
function getReminderStage(dueDate: Date, now: Date): ReminderStage | null {
const diffMs = dueDate.getTime() - now.getTime();
if (diffMs <= 0) return "overdue";
if (diffMs <= MS_PER_HOUR) return "hour";
if (diffMs <= 24 * MS_PER_HOUR) return "day";
if (diffMs <= 7 * 24 * MS_PER_HOUR) return "week";
return null;
}
function buildActionUrl(baseUrl: string, action: { machineId: string | null; reasonCode: string | null; hmDay: number | null; hmHour: number | null }) {
const params = new URLSearchParams();
if (action.machineId) params.set("machineId", action.machineId);
if (action.reasonCode) params.set("reasonCode", action.reasonCode);
if (action.hmDay != null && action.hmHour != null) {
params.set("hmDay", String(action.hmDay));
params.set("hmHour", String(action.hmHour));
}
const qs = params.toString();
return qs ? `${baseUrl}/downtime?${qs}` : `${baseUrl}/downtime`;
}
async function authorizeRequest(req: Request) {
const secret = process.env.DOWNTIME_ACTION_REMINDER_SECRET;
if (!secret) {
const session = await requireSession();
return { ok: !!session };
}
const authHeader = req.headers.get("authorization") || "";
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : null;
const urlToken = new URL(req.url).searchParams.get("token");
return { ok: token === secret || urlToken === secret };
}
export async function POST(req: Request) {
const auth = await authorizeRequest(req);
if (!auth.ok) return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
const sp = new URL(req.url).searchParams;
const dueInDays = Number(sp.get("dueInDays") || DEFAULT_DUE_DAYS);
const limit = Number(sp.get("limit") || DEFAULT_LIMIT);
const now = new Date();
const dueBy = new Date(now.getTime() + dueInDays * 24 * 60 * 60 * 1000);
const actions = await prisma.downtimeAction.findMany({
where: {
status: { not: "done" },
ownerUserId: { not: null },
dueDate: { not: null, lte: dueBy },
},
include: {
ownerUser: { select: { name: true, email: true } },
org: { select: { name: true } },
},
orderBy: { dueDate: "asc" },
take: Number.isFinite(limit) ? Math.max(1, Math.min(500, limit)) : DEFAULT_LIMIT,
});
const baseUrl = getBaseUrl(req);
const sentIds: string[] = [];
const failures: Array<{ id: string; error: string }> = [];
for (const action of actions) {
const email = action.ownerUser?.email;
if (!email) continue;
if (!action.dueDate) continue;
const stage = getReminderStage(action.dueDate, now);
if (!stage) continue;
if (action.reminderStage === stage) continue;
try {
const content = buildDowntimeActionReminderEmail({
appName: "MIS Control Tower",
orgName: action.org.name,
actionTitle: action.title,
assigneeName: action.ownerUser?.name ?? email,
dueDate: formatDueDate(action.dueDate),
actionUrl: buildActionUrl(baseUrl, action),
priority: action.priority,
status: action.status,
});
await sendEmail({
to: email,
subject: content.subject,
text: content.text,
html: content.html,
});
sentIds.push(action.id);
await prisma.downtimeAction.update({
where: { id: action.id },
data: { reminderStage: stage, lastReminderAt: now },
});
} catch (err: unknown) {
failures.push({
id: action.id,
error: err instanceof Error ? err.message : "Failed to send reminder email",
});
}
}
return NextResponse.json({
ok: true,
sent: sentIds.length,
failed: failures.length,
failures,
});
}