// 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 | 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 = { 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 }); }