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:
parent
3d23bbb3f0
commit
912cf71bb9
13 changed files with 1156 additions and 22 deletions
36
app/api/support/badges/route.ts
Normal file
36
app/api/support/badges/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
126
app/api/support/kofi/claim/start/route.ts
Normal file
126
app/api/support/kofi/claim/start/route.ts
Normal 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 });
|
||||
}
|
||||
53
app/api/support/kofi/claim/verify/route.ts
Normal file
53
app/api/support/kofi/claim/verify/route.ts
Normal 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);
|
||||
}
|
||||
61
app/api/support/kofi/unlink/route.ts
Normal file
61
app/api/support/kofi/unlink/route.ts
Normal 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 });
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue