feat(support): Ko-fi end-to-end linking + badges (derive-on-read, no cron)

- Add Ko-fi webhook (/api/webhooks/kofi) with upsert by (provider, external_user_id)
  • Computes renews_at = timestamp + 1 calendar month + 1 day
  • Preserves first started_at; stores raw payload; canonicalizes by email when available
- Add Ko-fi claim flow
  • POST /api/support/kofi/claim/start — sends verification email via SMTP
  • GET  /api/support/kofi/claim/verify — finalizes link (sets app_user), redirects to /portal/account
  • POST /api/support/kofi/unlink — clears app_user on Ko-fi rows
- Add derive-on-read membership logic
  • /lib/memberships.ts — single source of truth for badges & “active” state
  • /api/support/badges — thin wrapper that returns per-provider badges
- Account UI
  • components/account/SupporterBadges.tsx — renders provider badges (Ko-fi now; extensible)
  • components/account/ConnectKofi.tsx — “Link Ko-fi” form (email → verify link)
  • components/account/LinkStatus.tsx — success/error banner on return
  • app/portal/account/AccountPanel.tsx — integrates badges, link panel, and banner
- Config/env
  • Requires: DIRECTUS_URL, DIRECTUS_TOKEN_ADMIN_SUPPORTER, KOFI_VERIFY_TOKEN
  • SMTP: SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASS, EMAIL_FROM
  • APP_ORIGIN used to build absolute verify URLs
- Misc
  • Fixed import to use @/lib/memberships
  • No cron required; UI derives active state via status === active && renews_at >= now

Refs: beta readiness for Ko-fi supporters
This commit is contained in:
makearmy 2025-10-19 17:51:04 -04:00
parent 3d23bbb3f0
commit 912cf71bb9
13 changed files with 1156 additions and 22 deletions

View file

@ -0,0 +1,36 @@
// /app/api/support/badges/route.ts
import { NextRequest, NextResponse } from "next/server";
import { fetchMembershipBadges } from "lib/support/memberships";
// Replace this with your real auth lookup if/when you wire it in
async function getCurrentUser(req: NextRequest): Promise<{ id?: string; email?: string } | null> {
const uid = req.headers.get("x-user-id") || undefined;
const email = req.headers.get("x-user-email") || undefined;
return uid || email ? { id: uid, email } : null;
}
export async function GET(req: NextRequest) {
try {
const me = await getCurrentUser(req);
// Accept explicit query params so the component can work without special headers
const emailParam = (req.nextUrl.searchParams.get("email") || "").trim().toLowerCase() || undefined;
const userIdParam = (req.nextUrl.searchParams.get("userId") || "").trim() || undefined;
if (!emailParam && !userIdParam && !me?.id && !me?.email) {
return NextResponse.json({ error: "Provide email or userId" }, { status: 400 });
}
const badges = await fetchMembershipBadges({
userId: userIdParam || me?.id,
email: emailParam || me?.email,
});
return NextResponse.json({ badges });
} catch (e: any) {
return NextResponse.json(
{ error: "badges_fetch_failed", detail: String(e?.message || e) },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,126 @@
// app/api/support/kofi/claim/start/route.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import nodemailer from "nodemailer";
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!;
const COLLECTION = "user_memberships";
const APP_ORIGIN = process.env.APP_ORIGIN || "https://makearmy.io"; // your site base URL
// SMTP envs
const SMTP_HOST = process.env.SMTP_HOST || "";
const SMTP_PORT = process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : 587;
const SMTP_USER = process.env.SMTP_USER || "";
const SMTP_PASS = process.env.SMTP_PASS || "";
const SMTP_SECURE = process.env.SMTP_SECURE === "true";
const EMAIL_FROM = process.env.EMAIL_FROM || "MakeArmy Support <no-reply@makearmy.io>";
// TODO: replace with your actual auth/session resolver
async function getCurrentUserId(req: NextRequest): Promise<string | null> {
const uid = req.headers.get("x-user-id");
return uid && uid.trim() ? uid : null;
}
export async function POST(req: NextRequest) {
// Basic SMTP sanity check (fail fast)
if (!SMTP_HOST || !SMTP_PORT || !EMAIL_FROM) {
return NextResponse.json(
{ ok: false, error: "email_not_configured", detail: "SMTP_HOST/PORT/EMAIL_FROM must be set" },
{ status: 500 }
);
}
const userId = await getCurrentUserId(req);
if (!userId) {
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
}
let email = "";
try {
const body = await req.json();
email = String(body?.email || "").trim().toLowerCase();
} catch {
return NextResponse.json({ ok: false, error: "invalid_payload" }, { status: 400 });
}
if (!email) {
return NextResponse.json({ ok: false, error: "missing_email" }, { status: 400 });
}
// 1) find Ko-fi membership by email
const filter = encodeURIComponent(
JSON.stringify({
_and: [{ provider: { _eq: "kofi" } }, { email: { _eq: email } }],
})
);
const res = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, {
headers: { Authorization: `Bearer ${BOT_TOKEN}` },
cache: "no-store",
});
if (!res.ok) {
const t = await res.text().catch(() => "");
return NextResponse.json({ ok: false, error: "directus_read_failed", detail: t }, { status: 500 });
}
const json = await res.json();
const rec = json?.data?.[0];
if (!rec) {
return NextResponse.json({ ok: false, error: "not_found" }, { status: 404 });
}
if (rec.app_user) {
// Already linked
return NextResponse.json({ ok: true, alreadyLinked: true });
}
// 2) create a one-time token
const token = crypto.randomBytes(24).toString("base64url");
const expires = new Date(Date.now() + 15 * 60 * 1000); // 15 minutes
const patch = await fetch(`${DIRECTUS}/items/${COLLECTION}/${rec.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${BOT_TOKEN}`,
},
body: JSON.stringify({
claim_token: token,
claim_expires_at: expires.toISOString(),
claim_user_id: userId,
}),
});
if (!patch.ok) {
const t = await patch.text().catch(() => "");
return NextResponse.json({ ok: false, error: "directus_write_failed", detail: t }, { status: 500 });
}
// 3) send verification email
const verifyUrl = `${APP_ORIGIN}/api/support/kofi/claim/verify?token=${encodeURIComponent(token)}`;
try {
const transporter = nodemailer.createTransport({
host: SMTP_HOST, // e.g. "mail.arrmail.net"
port: SMTP_PORT, // 465 in your case
secure: SMTP_SECURE, // true for 465, false for 587 STARTTLS
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
// If your server uses a self-signed certificate, uncomment the next line.
// Prefer installing a valid cert instead.
// tls: { rejectUnauthorized: false },
});
await transporter.sendMail({
from: EMAIL_FROM, // "MakeArmy Support <noreply@makearmy.io>" recommended
to: email,
subject: "Verify Ko-fi link to your MakeArmy account",
text: `Tap to verify your Ko-fi link: ${verifyUrl}\nThis link expires in 15 minutes.`,
html: `<p>Tap to verify your Ko-fi link:</p><p><a href="${verifyUrl}">${verifyUrl}</a></p><p>This link expires in 15 minutes.</p>`,
});
} catch (e: any) {
return NextResponse.json(
{ ok: false, error: "email_send_failed", detail: String(e?.message || e) },
{ status: 500 }
);
}
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,53 @@
// /app/api/support/kofi/claim/verify/route.ts
import { NextRequest, NextResponse } from "next/server";
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!;
const COLLECTION = "user_memberships";
// Default redirects; can be overridden by env
const SUCCESS_REDIRECT = process.env.KOFI_LINK_SUCCESS_URL || "/portal/account?linked=kofi&ok=1";
const FAIL_REDIRECT = process.env.KOFI_LINK_FAIL_URL || "/portal/account?linked=kofi&error=1";
export async function GET(req: NextRequest) {
const token = req.nextUrl.searchParams.get("token") || "";
const to = (path: string) => NextResponse.redirect(new URL(path, req.url));
if (!token) return to(FAIL_REDIRECT);
// Find the claim row by token
const filter = encodeURIComponent(JSON.stringify({ claim_token: { _eq: token } }));
const res = await fetch(`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=1`, {
headers: { Authorization: `Bearer ${BOT_TOKEN}` },
cache: "no-store",
});
if (!res.ok) return to(FAIL_REDIRECT);
const json = await res.json().catch(() => ({} as any));
const rec = json?.data?.[0];
if (!rec) return to(FAIL_REDIRECT);
// Validate expiry and sanity
const exp = rec.claim_expires_at ? new Date(rec.claim_expires_at).getTime() : 0;
if (!exp || Date.now() > exp) return to(FAIL_REDIRECT);
if (!rec.claim_user_id) return to(FAIL_REDIRECT);
// Finalize: set app_user to claim_user_id; clear claim fields
const patch = await fetch(`${DIRECTUS}/items/${COLLECTION}/${rec.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${BOT_TOKEN}`,
},
body: JSON.stringify({
app_user: rec.claim_user_id,
claim_token: null,
claim_expires_at: null,
claim_user_id: null,
last_event_at: new Date().toISOString(),
}),
});
if (!patch.ok) return to(FAIL_REDIRECT);
return to(SUCCESS_REDIRECT);
}

View file

@ -0,0 +1,61 @@
// app/api/support/kofi/unlink/route.ts
import { NextRequest, NextResponse } from "next/server";
const DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!;
const COLLECTION = "user_memberships";
// Replace with your real auth/session
async function getCurrentUserId(req: NextRequest): Promise<string | null> {
const uid = req.headers.get("x-user-id");
return uid && uid.trim() ? uid : null;
}
export async function POST(req: NextRequest) {
const userId = await getCurrentUserId(req);
if (!userId) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
// Find linked Ko-fi rows
const filter = encodeURIComponent(
JSON.stringify({
_and: [{ provider: { _eq: "kofi" } }, { app_user: { _eq: userId } }],
})
);
const list = await fetch(
`${DIRECTUS}/items/${COLLECTION}?filter=${filter}&limit=500`,
{ headers: { Authorization: `Bearer ${BOT_TOKEN}` }, cache: "no-store" }
);
if (!list.ok) {
const t = await list.text().catch(() => "");
return NextResponse.json({ error: "directus_read_failed", detail: t }, { status: 500 });
}
const rows = (await list.json()).data || [];
if (!rows.length) return NextResponse.json({ ok: true, changed: 0 });
// Batch PATCH: clear app_user
const body = rows.map((r: any) => ({
id: r.id,
app_user: null,
last_event_at: new Date().toISOString(),
}));
const res = await fetch(`${DIRECTUS}/items/${COLLECTION}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${BOT_TOKEN}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const t = await res.text().catch(() => "");
return NextResponse.json({ error: "directus_write_failed", detail: t }, { status: 500 });
}
return NextResponse.json({ ok: true, changed: rows.length });
}

View file

@ -0,0 +1,194 @@
// app/api/webhooks/kofi/route.ts
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 DIRECTUS = (process.env.DIRECTUS_URL || "").replace(/\/$/, "");
const BOT_TOKEN = process.env.DIRECTUS_TOKEN_ADMIN_SUPPORTER!;
const COLLECTION = "user_memberships";
type KofiPayload = {
verification_token: string;
message_id: string;
timestamp: string; // ISO
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;
};
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 safeStringify(v: unknown) {
try {
return JSON.stringify(v);
} catch {
return JSON.stringify({ _unstringifiable: true });
}
}