Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ COINPAY_BUSINESS_ID=00000000-0000-0000-0000-000000000000
COINPAY_WEBHOOK_SECRET=whsecret_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
COINPAY_API_URL=https://coinpayportal.com

# --- Optional: GitHub Marketplace listing webhook ---
# Set on the listing's Webhook page (Payload URL + Secret), not in the App's
# developer settings. Must match the Secret field byte for byte.
# Generate with: openssl rand -hex 32
GITHUB_MARKETPLACE_WEBHOOK_SECRET=

# ─── Optional: Web Push (VAPID keys for PWA push notifications) ───
# Generate with: npx web-push generate-vapid-keys
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/app/admin/admin-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/lib/auth-context";
import { authHeaders } from "@/lib/auth-client";
import MarketplacePanel from "./marketplace-panel";

type Kind = "outrank" | "crawlproof";

Expand Down Expand Up @@ -168,7 +169,9 @@ export default function AdminContent() {

<div className="mx-auto max-w-4xl px-6 py-10">
<h1 className="text-3xl font-bold text-white mb-2">Admin</h1>
<p className="text-tc-text-dim mb-8">Blog publishing webhooks (Crawlproof, Outrank)</p>
<p className="text-tc-text-dim mb-8">
Blog publishing webhooks (Crawlproof, Outrank) and the GitHub Marketplace listing
</p>

{error && (
<div className="mb-4 rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
Expand Down Expand Up @@ -315,6 +318,8 @@ export default function AdminContent() {
</ul>
)}
</section>

<MarketplacePanel />
</div>
</div>
);
Expand Down
270 changes: 270 additions & 0 deletions apps/web/src/app/admin/marketplace-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
"use client";

import { useCallback, useEffect, useState } from "react";
import { authHeaders } from "@/lib/auth-client";

const WEBHOOK_PATH = "/api/webhooks/github/marketplace";

const DOCS_HREF =
"https://docs.github.com/en/apps/github-marketplace/listing-an-app-on-github-marketplace/configuring-a-webhook-to-notify-you-of-plan-changes";

type Subscription = {
id: string;
github_account_id: number;
github_account_login: string;
github_account_type: string | null;
plan_name: string | null;
plan_monthly_price_cents: number | null;
billing_cycle: string | null;
unit_count: number | null;
on_free_trial: boolean;
free_trial_ends_on: string | null;
next_billing_date: string | null;
status: string;
pending_plan_name: string | null;
pending_effective_date: string | null;
last_action: string | null;
updated_at: string;
};

type Delivery = {
id: string;
delivery_id: string | null;
action: string;
github_account_login: string | null;
applied: boolean;
skip_reason: string | null;
received_at: string;
};

type MarketplaceData = {
configured: boolean;
migrationApplied: boolean;
subscriptions: Subscription[];
events: Delivery[];
};

function fmtDate(value: string | null): string {
if (!value) return "—";
const ms = Date.parse(value);
if (Number.isNaN(ms)) return "—";
return new Date(ms).toISOString().slice(0, 10);
}

function fmtPrice(cents: number | null): string {
if (typeof cents !== "number") return "—";
return cents === 0 ? "Free" : `$${(cents / 100).toFixed(2)}`;
}

export default function MarketplacePanel() {
const [data, setData] = useState<MarketplaceData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);

const webhookUrl =
typeof window === "undefined" ? WEBHOOK_PATH : `${window.location.origin}${WEBHOOK_PATH}`;

const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/admin/marketplace", { headers: authHeaders() });
if (!res.ok) {
setError(res.status === 403 ? "Forbidden" : "Failed to load Marketplace data");
return;
}
setData((await res.json()) as MarketplaceData);
} catch {
setError("Network error");
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
void load();
}, [load]);

const copy = () => {
navigator.clipboard?.writeText(webhookUrl);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};

const subs = data?.subscriptions ?? [];
const events = data?.events ?? [];
const active = subs.filter((s) => s.status === "active").length;

return (
<section className="mt-8 rounded-lg border border-tc-border/50 bg-tc-dark p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">
GitHub Marketplace
<a
href={DOCS_HREF}
target="_blank"
rel="noreferrer"
className="ml-2 text-xs font-normal text-tc-text-dim hover:text-tc-green"
>
docs ↗
</a>
</h2>
<button
onClick={load}
disabled={loading}
className="text-sm text-tc-text-dim hover:text-tc-green disabled:opacity-50"
>
{loading ? "Loading..." : "Refresh"}
</button>
</div>

{error && (
<div className="mb-4 rounded border border-red-500/50 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{error}
</div>
)}

<p className="mb-3 text-sm text-tc-text-dim">
Paste this as the Payload URL on the listing&apos;s Webhook page. Content type{" "}
<code className="text-tc-green">application/json</code>, and set a Secret.
</p>

<div className="mb-4 flex gap-2">
<code className="flex-1 break-all rounded bg-tc-darker px-3 py-2 font-mono text-sm text-tc-green">
{webhookUrl}
</code>
<button
onClick={copy}
className="rounded bg-tc-green/10 px-3 py-2 text-sm text-tc-green hover:bg-tc-green/20"
>
{copied ? "Copied" : "Copy"}
</button>
</div>

<div className="mb-6 space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className={data?.configured ? "text-tc-green" : "text-red-400"}>
{data?.configured ? "●" : "○"}
</span>
<span className="text-tc-text-dim">
{data?.configured
? "Secret configured (GITHUB_MARKETPLACE_WEBHOOK_SECRET)"
: "No secret set. Deliveries are rejected with 503 until GITHUB_MARKETPLACE_WEBHOOK_SECRET is set on the service."}
</span>
</div>
{data && !data.migrationApplied && (
<div className="flex items-center gap-2">
<span className="text-red-400">○</span>
<span className="text-tc-text-dim">
Tables missing. Apply{" "}
<code className="text-tc-green">
supabase/migrations/20260821170000_github_marketplace.sql
</code>
.
</span>
</div>
)}
</div>

<h3 className="mb-2 text-sm font-medium text-white">
Subscriptions{" "}
<span className="font-normal text-tc-text-dim">
({active} active of {subs.length})
</span>
</h3>

{subs.length === 0 ? (
<p className="mb-6 text-sm text-tc-text-dim">
No purchases recorded yet. GitHub does not resend failed deliveries, so if a customer
reports a purchase that is missing here, replay it from the listing&apos;s delivery log.
</p>
) : (
<div className="mb-6 overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-tc-text-dim">
<tr className="border-b border-tc-border/50">
<th className="py-2 pr-4 font-medium">Account</th>
<th className="py-2 pr-4 font-medium">Plan</th>
<th className="py-2 pr-4 font-medium">Cycle</th>
<th className="py-2 pr-4 font-medium">Units</th>
<th className="py-2 pr-4 font-medium">Status</th>
<th className="py-2 pr-4 font-medium">Next billing</th>
</tr>
</thead>
<tbody>
{subs.map((s) => (
<tr key={s.id} className="border-b border-tc-border/20">
<td className="py-2 pr-4">
<a
href={`https://github.com/${s.github_account_login}`}
target="_blank"
rel="noreferrer"
className="text-tc-green hover:underline"
>
{s.github_account_login}
</a>
<span className="ml-1 text-xs text-tc-text-dim">
{s.github_account_type === "Organization" ? "org" : "user"}
</span>
</td>
<td className="py-2 pr-4 text-white">
{s.plan_name ?? "—"}
<span className="ml-1 text-xs text-tc-text-dim">
{fmtPrice(s.plan_monthly_price_cents)}
</span>
{s.pending_plan_name && (
<div className="text-xs text-yellow-400">
→ {s.pending_plan_name} on {fmtDate(s.pending_effective_date)}
</div>
)}
</td>
<td className="py-2 pr-4 text-tc-text-dim">{s.billing_cycle ?? "—"}</td>
<td className="py-2 pr-4 text-tc-text-dim">{s.unit_count ?? "—"}</td>
<td className="py-2 pr-4">
<span
className={
s.status === "active" ? "text-tc-green" : "text-tc-text-dim line-through"
}
>
{s.status}
</span>
{s.on_free_trial && (
<span className="ml-1 text-xs text-yellow-400">
trial → {fmtDate(s.free_trial_ends_on)}
</span>
)}
</td>
<td className="py-2 pr-4 text-tc-text-dim">{fmtDate(s.next_billing_date)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}

<h3 className="mb-2 text-sm font-medium text-white">Recent deliveries</h3>
{events.length === 0 ? (
<p className="text-sm text-tc-text-dim">Nothing delivered yet.</p>
) : (
<ul className="space-y-1 text-sm">
{events.map((e) => (
<li key={e.id} className="flex flex-wrap items-baseline gap-2">
<span className={e.applied ? "text-tc-green" : "text-tc-text-dim"}>
{e.applied ? "✓" : "·"}
</span>
<span className="font-mono text-white">{e.action}</span>
<span className="text-tc-text-dim">{e.github_account_login ?? "unknown"}</span>
<span className="text-xs text-tc-text-dim">
{new Date(e.received_at).toISOString().replace("T", " ").slice(0, 16)}
</span>
{e.skip_reason && (
<span className="text-xs text-yellow-400">skipped: {e.skip_reason}</span>
)}
</li>
))}
</ul>
)}
</section>
);
}
65 changes: 65 additions & 0 deletions apps/web/src/app/api/admin/marketplace/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAdmin } from "@/lib/admin-guard";
import { getSupabaseAdmin } from "@/lib/supabase";

export const runtime = "nodejs";

/**
* Admin view of the GitHub Marketplace listing's webhook.
*
* Reports whether the secret is configured, the current subscription per
* GitHub account, and the most recent deliveries. GitHub does not resend
* failed deliveries, so the delivery list is the thing to look at when a
* customer says they paid and nothing happened.
*
* The secret itself is never returned, only whether one is set.
*/
export async function GET(req: NextRequest) {
const guard = await requireAdmin(req);
if (guard instanceof NextResponse) return guard;

const supabase = getSupabaseAdmin();

const [subs, events] = await Promise.all([
supabase
.from("github_marketplace_purchases")
.select(
"id, github_account_id, github_account_login, github_account_type, plan_id, plan_name, plan_monthly_price_cents, billing_cycle, unit_count, on_free_trial, free_trial_ends_on, next_billing_date, status, pending_plan_name, pending_effective_date, effective_date, last_action, updated_at",
)
.order("updated_at", { ascending: false })
.limit(200),
supabase
.from("github_marketplace_events")
.select(
"id, delivery_id, action, github_account_login, effective_date, applied, skip_reason, received_at",
)
.order("received_at", { ascending: false })
.limit(50),
]);

// A missing table is the expected state until the migration is applied
// by hand, so say that plainly instead of returning a bare 500.
const missingTable =
subs.error?.code === "42P01" || events.error?.code === "42P01";
if (missingTable) {
return NextResponse.json({
configured: Boolean(process.env.GITHUB_MARKETPLACE_WEBHOOK_SECRET),
webhookPath: "/api/webhooks/github/marketplace",
migrationApplied: false,
subscriptions: [],
events: [],
});
}

if (subs.error || events.error) {
return NextResponse.json({ error: "Failed to load marketplace data" }, { status: 500 });
}

return NextResponse.json({
configured: Boolean(process.env.GITHUB_MARKETPLACE_WEBHOOK_SECRET),
webhookPath: "/api/webhooks/github/marketplace",
migrationApplied: true,
subscriptions: subs.data ?? [],
events: events.data ?? [],
});
}
Loading
Loading