chore: initial project import
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
Some checks failed
CI - Production Readiness / Verify (push) Has been cancelled
This commit is contained in:
133
app/api/jobs/campaign-retry/route.ts
Normal file
133
app/api/jobs/campaign-retry/route.ts
Normal file
@ -0,0 +1,133 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user