makearmy-app/lib/memberships.ts
makearmy 912cf71bb9 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
2025-10-19 17:51:04 -04:00

109 lines
3.7 KiB
TypeScript

// /lib/support/memberships.ts
export type MembershipRow = {
id: string | number;
provider: "kofi" | "patreon" | "mighty" | string;
status: "active" | "one_time" | "canceled" | "inactive" | string;
tier?: string | null;
started_at?: string | null;
renews_at?: string | null;
email?: string | null;
username?: string | null;
app_user?: string | null;
};
export type SupportBadge = {
provider: "kofi" | "patreon" | "mighty" | string;
active: boolean; // true if currently entitled (status==="active" && renews_at >= now)
kind: "member" | "one_time" | "inactive";
label: string; // e.g., "Ko-fi • Bronze" or "Ko-fi Supporter"
tier?: string | null;
renews_at?: string | null;
started_at?: string | null;
};
const DIRECTUS = (process.env.NEXT_PUBLIC_API_BASE_URL || "").replace(/\/$/, "");
/** Active if status==="active" and renews_at >= now */
function computeActive(row: MembershipRow): boolean {
if (row.status !== "active") return false;
if (!row.renews_at) return false;
const due = new Date(row.renews_at).getTime();
return Number.isFinite(due) && due >= Date.now();
}
function providerLabel(p: string) {
if (p === "kofi") return "Ko-fi";
if (p === "patreon") return "Patreon";
if (p === "mighty") return "Mighty";
return p[0]?.toUpperCase() + p.slice(1);
}
/** Convert a membership row → displayable badge */
function rowToBadge(row: MembershipRow): SupportBadge {
const active = computeActive(row);
const prov = providerLabel(row.provider);
let kind: SupportBadge["kind"] = "inactive";
let label = prov;
if (active) {
kind = "member";
label = row.tier ? `${prov}${row.tier}` : `${prov} Member`;
} else if (row.status === "one_time") {
kind = "one_time";
label = `${prov} Supporter`;
} else {
kind = "inactive";
label = `${prov} (inactive)`;
}
return {
provider: row.provider,
active,
kind,
label,
tier: row.tier ?? null,
renews_at: row.renews_at ?? null,
started_at: row.started_at ?? null,
};
}
/**
* Fetch all memberships for a user by app_user (preferred) or by email.
* Pass either/both: { userId?, email? }
*/
export async function fetchMembershipBadges(opts: { userId?: string; email?: string }): Promise<SupportBadge[]> {
const clauses: any[] = [{ provider: { _in: ["kofi", "patreon", "mighty"] } }];
if (opts.userId) clauses.push({ app_user: { _eq: opts.userId } });
if (opts.email) clauses.push({ email: { _eq: opts.email.toLowerCase() } });
// Build filter: provider IN (...) AND (app_user = userId OR email = email)
const filter = encodeURIComponent(
JSON.stringify({
_and: [
clauses[0],
{ _or: clauses.slice(1).length ? clauses.slice(1) : [{ id: { _neq: null } }] },
],
})
);
const res = await fetch(`${DIRECTUS}/items/user_memberships?filter=${filter}&limit=500`, { cache: "no-store" });
const json = await res.json().catch(() => ({} as any));
const rows: MembershipRow[] = json?.data || [];
const badges = rows.map(rowToBadge);
// Sort: active first, then provider name asc
badges.sort((a, b) => {
if (a.active !== b.active) return a.active ? -1 : 1;
return a.provider.localeCompare(b.provider);
});
return badges;
}
/** Quick boolean if any active membership exists */
export async function hasActiveMembership(opts: { userId?: string; email?: string }): Promise<boolean> {
const list = await fetchMembershipBadges(opts);
return list.some(b => b.kind === "member" && b.active);
}