noisy webhook routes
This commit is contained in:
parent
ff3072861b
commit
42652a02fe
1 changed files with 151 additions and 159 deletions
|
|
@ -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<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 });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue