134 lines
4.1 KiB
TypeScript
134 lines
4.1 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
|
|
import { getRequestAuditContext } from "@/lib/audit";
|
|
import { getCampaignRetryState, runCampaignRetryBatch } from "@/lib/campaign-dispatch-service";
|
|
import { consumeRateLimit, getRateLimitHeaders } from "@/lib/rate-limit";
|
|
|
|
type JobPayload = {
|
|
tenantId?: string;
|
|
campaignId?: string;
|
|
recipientBatchSize?: number;
|
|
maxCampaigns?: number;
|
|
};
|
|
|
|
function isAuthorized(req: NextRequest) {
|
|
const expected = process.env.CAMPAIGN_RETRY_JOB_TOKEN?.trim();
|
|
if (!expected) {
|
|
return process.env.NODE_ENV !== "production";
|
|
}
|
|
|
|
const tokenFromHeader = req.headers.get("authorization")?.trim() || req.headers.get("x-cron-token")?.trim();
|
|
const tokenFromQuery = new URL(req.url).searchParams.get("token")?.trim();
|
|
const token = tokenFromHeader || tokenFromQuery;
|
|
if (!token) {
|
|
return false;
|
|
}
|
|
|
|
return token === expected || token === `Bearer ${expected}`;
|
|
}
|
|
|
|
function resolveNumber(raw: string | undefined, fallback: number) {
|
|
const value = Number(raw?.trim());
|
|
if (!Number.isInteger(value) || value <= 0) {
|
|
return fallback;
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
export async function GET(req: NextRequest) {
|
|
const { ipAddress: requestIpAddress } = await getRequestAuditContext();
|
|
const retryRate = consumeRateLimit(requestIpAddress || "unknown", {
|
|
scope: "campaign_retry_job_get",
|
|
limit: resolveNumber(process.env.CAMPAIGN_RETRY_JOB_RATE_LIMIT_GET, 60),
|
|
windowMs: resolveNumber(process.env.CAMPAIGN_RETRY_JOB_RATE_LIMIT_WINDOW_MS, 60 * 1000)
|
|
});
|
|
|
|
if (!retryRate.allowed) {
|
|
return NextResponse.json(
|
|
{ ok: false, error: "Too many requests. Please retry later." },
|
|
{
|
|
status: 429,
|
|
headers: getRateLimitHeaders(retryRate)
|
|
}
|
|
);
|
|
}
|
|
|
|
if (!isAuthorized(req)) {
|
|
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
const state = await getCampaignRetryState();
|
|
const now = new Date();
|
|
const lockedUntil = state?.lockedUntil ? new Date(state.lockedUntil) : null;
|
|
const health = {
|
|
isLocked: Boolean(lockedUntil && lockedUntil > now),
|
|
isStaleLock: Boolean(lockedUntil && lockedUntil <= now),
|
|
lastRunStartedAt: state?.lastRunStartedAt ?? null,
|
|
lastRunCompletedAt: state?.lastRunCompletedAt ?? null,
|
|
lastRunStatus: state?.lastRunStatus ?? null
|
|
};
|
|
|
|
return NextResponse.json({ ok: true, state, health });
|
|
}
|
|
|
|
export async function POST(req: NextRequest) {
|
|
const { ipAddress: requestIpAddress } = await getRequestAuditContext();
|
|
const retryRate = consumeRateLimit(requestIpAddress || "unknown", {
|
|
scope: "campaign_retry_job_post",
|
|
limit: resolveNumber(process.env.CAMPAIGN_RETRY_JOB_RATE_LIMIT_POST, 20),
|
|
windowMs: resolveNumber(process.env.CAMPAIGN_RETRY_JOB_RATE_LIMIT_WINDOW_MS, 60 * 1000)
|
|
});
|
|
|
|
if (!retryRate.allowed) {
|
|
return NextResponse.json(
|
|
{ ok: false, error: "Too many requests. Please retry later." },
|
|
{
|
|
status: 429,
|
|
headers: getRateLimitHeaders(retryRate)
|
|
}
|
|
);
|
|
}
|
|
|
|
if (!isAuthorized(req)) {
|
|
return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
let payload: unknown = {};
|
|
try {
|
|
payload = (await req.json()) as unknown;
|
|
} catch {
|
|
payload = {};
|
|
}
|
|
|
|
const safePayload = payload as JobPayload;
|
|
const tenantId = safePayload?.tenantId?.trim?.() || undefined;
|
|
const campaignId = safePayload?.campaignId?.trim?.() || undefined;
|
|
|
|
const recipientBatchSize = Number.isInteger(safePayload?.recipientBatchSize)
|
|
? safePayload?.recipientBatchSize
|
|
: undefined;
|
|
const maxCampaigns = Number.isInteger(safePayload?.maxCampaigns)
|
|
? safePayload?.maxCampaigns
|
|
: undefined;
|
|
|
|
const { ipAddress, userAgent } = await getRequestAuditContext();
|
|
|
|
try {
|
|
const result = await runCampaignRetryBatch({
|
|
campaignId,
|
|
tenantId,
|
|
actorIpAddress: ipAddress,
|
|
actorUserAgent: userAgent,
|
|
actorUserId: null,
|
|
recipientBatchSize,
|
|
maxCampaigns
|
|
});
|
|
|
|
return NextResponse.json({ ok: true, ...result });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Campaign retry job failed";
|
|
return NextResponse.json({ ok: false, error: message }, { status: 500 });
|
|
}
|
|
}
|