makearmy-app/app/api/webhooks/kofi/route.ts
2025-10-19 23:24:28 -04:00

186 lines
6.6 KiB
TypeScript

// app/api/webhooks/kofi/route.ts
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from "next/server";
const VERIFY = process.env.KOFI_VERIFY_TOKEN || "";
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER || "";
const COLLECTION = "user_memberships";
// TEMP healthcheck: prove code + envs are live (remove after testing)
export async function GET() {
return NextResponse.json({
ok: true,
env: {
hasVerifyToken: Boolean(VERIFY),
hasDirectusUrl: Boolean(DIRECTUS),
hasBotToken: Boolean(BOT_TOKEN),
collection: COLLECTION,
},
});
}
type KofiPayload = {
verification_token: string;
message_id: string;
timestamp?: string;
type: "Donation" | "Subscription" | "Commission" | "Shop Order" | string;
is_public?: boolean;
from_name?: string;
message?: string | null;
amount?: string;
url?: string;
email?: string | null;
currency?: string;
is_subscription_payment?: boolean;
is_first_subscription_payment?: boolean;
kofi_transaction_id?: string;
shop_items?: Array<{ direct_link_code: string; variation_name?: string; quantity?: number }> | null;
tier_name?: string | null;
shipping?: Record<string, unknown> | null;
discord_username?: string | null;
discord_userid?: string | null;
};
function log(o: unknown) {
process.stdout.write(`[kofi] ${JSON.stringify(o)}\n`);
}
function json(res: unknown, status = 200) {
return NextResponse.json(res, { status });
}
function addOneMonthPlusOneDay(d: Date) {
const nd = new Date(d);
const m = nd.getMonth();
nd.setMonth(m + 1);
if (nd.getMonth() !== ((m + 1) % 12)) nd.setDate(0);
nd.setDate(nd.getDate() + 1);
return nd;
}
function safeStringify(v: unknown) {
try {
return JSON.stringify(v);
} catch {
return JSON.stringify({ _unstringifiable: true });
}
}
export async function POST(req: NextRequest) {
const ct = req.headers.get("content-type") || "";
log({ step: "recv", ct });
if (!VERIFY || !DIRECTUS || !BOT_TOKEN) {
log({ step: "env-missing", hasVerify: !!VERIFY, hasDirectus: !!DIRECTUS, hasBot: !!BOT_TOKEN });
return json({ ok: false, error: "server_misconfigured" }, 500);
}
let data: KofiPayload | null = null;
try {
if (ct.includes("application/x-www-form-urlencoded")) {
const form = await req.formData();
const raw = form.get("data");
if (typeof raw !== "string") throw new Error("missing data field");
data = JSON.parse(raw) as KofiPayload;
log({ step: "parsed-formdata" });
} else {
// allow JSON for local tests
data = (await req.json()) as KofiPayload;
log({ step: "parsed-json" });
}
} catch (e: any) {
log({ step: "parse-error", err: String(e?.message || e) });
return json({ ok: false, error: "invalid_payload" }, 400);
}
if (!data?.verification_token || data.verification_token !== VERIFY) {
log({ step: "verify-fail", got: data?.verification_token, expectSet: Boolean(VERIFY) });
return json({ ok: false, error: "unauthorized" }, 401);
}
log({ step: "verify-ok", type: data.type, sub: data.is_subscription_payment });
// Compute values
const t = (data.type || "").toLowerCase();
const isRecurring = Boolean(data.is_subscription_payment) || t.includes("subscription");
const status = isRecurring ? "active" : "one_time";
const ts = data.timestamp ? new Date(data.timestamp) : null;
const emailId = (data.email && data.email.trim().toLowerCase()) || null;
const fallbackId =
(data.discord_userid && data.discord_userid.trim()) ||
(data.from_name && data.from_name.trim()) ||
"unknown";
const filter = encodeURIComponent(
JSON.stringify({
_and: [{ provider: { _eq: "kofi" } }],
_or: [
...(emailId ? [{ email: { _eq: emailId } }, { external_user_id: { _eq: emailId } }] : []),
{ external_user_id: { _eq: fallbackId } },
],
})
);
// Read existing
const existingRes = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, {
headers: { Authorization: `Bearer ${BOT_TOKEN}` },
cache: "no-store",
});
if (!existingRes.ok) {
const body = await existingRes.text().catch(() => "");
log({ step: "directus-read-fail", status: existingRes.status, body });
return json({ ok: false, error: "directus_error_read" }, 500);
}
const existingJson = await existingRes.json().catch(() => ({} as any));
const existing = existingJson?.data?.[0] ?? null;
log({ step: "directus-read-ok", hasExisting: Boolean(existing) });
const external_user_id = emailId || (existing?.external_user_id ?? fallbackId);
const started_at =
isRecurring && ts
? existing?.started_at
? existing.started_at
: ts.toISOString()
: existing?.started_at ?? null;
const renews_at = isRecurring && ts ? addOneMonthPlusOneDay(ts).toISOString() : null;
const record: Record<string, any> = {
provider: "kofi",
external_user_id,
email: emailId || data.email || null,
username: data.from_name || null,
status,
tier: data.tier_name || null,
started_at,
renews_at,
last_event_at: new Date().toISOString(),
raw: safeStringify(data),
};
const url = existing?.id
? `${DIRECTUS}/items/${COLLECTION}/${existing.id}`
: `${DIRECTUS}/items/${COLLECTION}`;
const method = existing?.id ? "PATCH" : "POST";
// Avoid clobbering start/renews when payload has no sub timestamp
if (existing?.id && (!isRecurring || !ts)) {
delete record.started_at;
delete record.renews_at;
}
const writeRes = await fetch(url, {
method,
headers: { "Content-Type": "application/json", Authorization: `Bearer ${BOT_TOKEN}` },
body: JSON.stringify(record),
});
if (!writeRes.ok) {
const body = await writeRes.text().catch(() => "");
log({ step: "directus-write-fail", status: writeRes.status, body });
return json({ ok: false, error: "directus_error_write" }, 500);
}
const written = await writeRes.json().catch(() => ({} as any));
log({ step: "directus-write-ok", method, id: written?.data?.id || existing?.id || "?" });
return json({ ok: true });
}