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,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 });
}