diff --git a/app/api/webhooks/kofi/route.ts b/app/api/webhooks/kofi/route.ts index 4c9a61e5..d95a156b 100644 --- a/app/api/webhooks/kofi/route.ts +++ b/app/api/webhooks/kofi/route.ts @@ -1,25 +1,31 @@ // app/api/webhooks/kofi/route.ts +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + import { NextRequest, NextResponse } from "next/server"; -/** - * ENV required: - * - KOFI_VERIFY_TOKEN - * - DIRECTUS_URL (e.g. https://forms.lasereverything.net) - * - DIRECTUS_TOKEN_ADMIN_SUPPORTER (supporter-bot service token) - * - * Notes: - * - renews_at is computed as (timestamp + 1 calendar month + 1 day) - * - started_at is the first subscription payment timestamp seen and is preserved - */ -const VERIFY = process.env.KOFI_VERIFY_TOKEN!; +const VERIFY = process.env.KOFI_VERIFY_TOKEN || ""; const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, ""); -const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!; +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; // ISO + timestamp?: string; type: "Donation" | "Subscription" | "Commission" | "Shop Order" | string; is_public?: boolean; from_name?: string; @@ -38,153 +44,20 @@ type KofiPayload = { discord_userid?: string | null; }; -export async function POST(req: NextRequest) { - // 1) Expect application/x-www-form-urlencoded with "data" JSON string - let data: KofiPayload | null = null; - - try { - const ctype = req.headers.get("content-type") || ""; - if (ctype.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"); - data = JSON.parse(raw) as KofiPayload; - } else { - // Try to be forgiving if Ko-fi ever sends JSON directly - data = (await req.json()) as KofiPayload; - } - } catch { - return NextResponse.json({ ok: false, error: "invalid_payload" }, { status: 400 }); - } - - if (!data) { - return NextResponse.json({ ok: false, error: "empty_payload" }, { status: 400 }); - } - - // 2) Verify token (Ko-fi puts it inside the data JSON) - if (!data.verification_token || data.verification_token !== VERIFY) { - // Ko-fi retries on non-200, so 401 is fine here - return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 }); - } - - // 3) Normalize → membership record - const t = (data.type || "").toLowerCase(); - const isRecurring = Boolean(data.is_subscription_payment) || t.includes("subscription"); - const status = isRecurring ? "active" : "one_time"; - - // Choose a stable external id (prefer email; fallback to discord id, then a name) - const external_user_id = - (data.email && data.email.trim()) || - (data.discord_userid && data.discord_userid.trim()) || - (data.from_name && data.from_name.trim()) || - "unknown"; - - // Fetch existing record for started_at preservation - const filter = encodeURIComponent( - JSON.stringify({ - _and: [{ provider: { _eq: "kofi" } }, { external_user_id: { _eq: external_user_id } }], - }) - ); - const existingRes = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, { - headers: { Authorization: `Bearer ${BOT_TOKEN}` }, - cache: "no-store", - }); - if (!existingRes.ok) { - return NextResponse.json( - { ok: false, error: "directus_error", detail: `read failed ${existingRes.status}` }, - { status: 500 } - ); - } - const existingJson = await existingRes.json().catch(() => ({} as any)); - const existing = existingJson?.data?.[0]; - - const ts = data.timestamp ? new Date(data.timestamp) : null; - - // Helper: add 1 calendar month + 1 day, clamping end-of-month correctly - function addOneMonthPlusOneDay(d: Date) { - const nd = new Date(d); - const origDay = nd.getDate(); - const origMonth = nd.getMonth(); - nd.setMonth(origMonth + 1); - - // If month rolled over (e.g., Jan 31 -> Mar 02/03), clamp to end of intended next month. - if (nd.getMonth() !== ((origMonth + 1) % 12)) { - nd.setDate(0); // last day of previous month (i.e., end of the intended next month) - } else { - // Keep the original day if possible (handles 28/29/30-day months naturally) - // Already set by setMonth above. - } - - // Plus one extra day buffer - nd.setDate(nd.getDate() + 1); - return nd; - } - - // Determine started_at: first subscription payment time; keep existing if present - const started_at = - isRecurring && ts - ? existing?.started_at - ? new Date(existing.started_at) - : ts - : existing?.started_at - ? new Date(existing.started_at) - : null; - - // Determine renews_at: for subscription payments, slide to 1 month + 1 day past ts - const renews_at = isRecurring && ts ? addOneMonthPlusOneDay(ts) : null; - - const record = { - provider: "kofi", - external_user_id, - email: data.email || null, - username: data.from_name || null, // Ko-fi often provides from_name rather than a handle - status, // "active" for subs; "one_time" for donations/shop/commission - tier: data.tier_name || null, // present for membership tiers - started_at, - renews_at, - last_event_at: new Date(), - raw: safeStringify(data), // Long Text column with JSON editor interface - }; - - // 4) Upsert by (provider, external_user_id) - try { - const url = existing?.id - ? `${DIRECTUS}/items/${COLLECTION}/${existing.id}` - : `${DIRECTUS}/items/${COLLECTION}`; - const method = existing?.id ? "PATCH" : "POST"; - - // If we're patching and this event wasn't a subscription, avoid clobbering existing started_at - if (existing?.id && (!isRecurring || !ts)) { - // don't send started_at; preserve what's in DB - delete (record as any).started_at; - delete (record as any).renews_at; // keep existing renews_at if not a sub event - } - - 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(() => ""); - throw new Error(`${method} failed ${writeRes.status} ${body}`); - } - } catch (err: any) { - // Non-200 makes Ko-fi retry, which is usually what we want on DB error - return NextResponse.json( - { ok: false, error: "directus_error", detail: String(err?.message || err) }, - { status: 500 } - ); - } - - // 5) Return 200 per Ko-fi requirement (stops retries) - return NextResponse.json({ ok: true }, { status: 200 }); +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); @@ -192,3 +65,122 @@ function safeStringify(v: unknown) { 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 }); +}