diff --git a/package-lock.json b/package-lock.json index 6e970f33..cc72ced8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plotlink", - "version": "1.33.1", + "version": "1.34.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plotlink", - "version": "1.33.1", + "version": "1.34.0", "workspaces": [ "packages/*" ], diff --git a/package.json b/package.json index 7bb61bc4..4b9265f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.33.1", + "version": "1.34.0", "private": true, "workspaces": [ "packages/*" diff --git a/src/app/api/airdrop/leaderboard/route.ts b/src/app/api/airdrop/leaderboard/route.ts index 66d7412c..6359ad45 100644 --- a/src/app/api/airdrop/leaderboard/route.ts +++ b/src/app/api/airdrop/leaderboard/route.ts @@ -1,10 +1,6 @@ -/** - * Points leaderboard (#885) - * GET /api/airdrop/leaderboard?address=0x... (optional) - */ - import { NextResponse, type NextRequest } from "next/server"; import { createServerClient } from "../../../../../lib/supabase"; +import { getAirdropConfig } from "../../../../../lib/airdrop/config"; export async function GET(req: NextRequest) { const supabase = createServerClient(); @@ -16,37 +12,39 @@ export async function GET(req: NextRequest) { const page = Math.max(1, parseInt(req.nextUrl.searchParams.get("page") ?? "1", 10) || 1); const limit = Math.min(50, Math.max(1, parseInt(req.nextUrl.searchParams.get("limit") ?? "20", 10) || 20)); - // Aggregate points per address - const { data: allPoints } = await supabase - .from("pl_points") - .select("address, points"); + const config = getAirdropConfig(); - if (!allPoints || allPoints.length === 0) { - return NextResponse.json({ entries: [], userRank: null, totalParticipants: 0, page: 1, totalPages: 0, limit }); - } + const { data: rows, error } = await supabase.rpc("weighted_spend", { + p_campaign_start: config.CAMPAIGN_START.toISOString(), + p_campaign_end: config.CAMPAIGN_END.toISOString(), + p_min_referral_threshold: config.MIN_REFERRAL_THRESHOLD, + p_multiplier_per_ref: config.REFERRAL_MULTIPLIER_PER_REF, + p_multiplier_cap: config.REFERRAL_MULTIPLIER_CAP, + }); - // Sum points by address - const pointsByAddress = new Map(); - let globalTotal = 0; - for (const row of allPoints) { - const addr = row.address.toLowerCase(); - pointsByAddress.set(addr, (pointsByAddress.get(addr) ?? 0) + row.points); - globalTotal += row.points; + if (error) { + console.error("[leaderboard] weighted_spend RPC failed:", error.message); + return NextResponse.json({ error: "Failed to compute leaderboard" }, { status: 500 }); } - // Sort descending by points - const sorted = [...pointsByAddress.entries()] - .sort((a, b) => b[1] - a[1]); + const allRows = (rows ?? []) as Array<{ + address: string; + weighted_spend: number; + buy_volume: number; + qualified_refs: number; + has_fc_bonus: number; + multiplier: number; + community_total: number; + }>; - // Paginate - const totalParticipants = pointsByAddress.size; + const sorted = [...allRows].sort((a, b) => Number(b.weighted_spend) - Number(a.weighted_spend)); + + const totalParticipants = sorted.length; const totalPages = Math.ceil(totalParticipants / limit); const start = (page - 1) * limit; const pageSlice = sorted.slice(start, start + limit); - // Look up usernames for current page - const pageAddresses = pageSlice.map(([addr]) => addr); - + const pageAddresses = pageSlice.map(r => r.address); const { data: users } = await supabase .from("pl_referral_codes") .select("address, code, is_farcaster_username") @@ -56,18 +54,22 @@ export async function GET(req: NextRequest) { (users ?? []).map((u) => [u.address.toLowerCase(), u.is_farcaster_username ? u.code : null]), ); - const entries = pageSlice.map(([addr, pts], i) => ({ + const communityTotal = Number(sorted[0]?.community_total ?? 0); + + const entries = pageSlice.map((row, i) => ({ rank: start + i + 1, - address: addr, - username: usernameMap.get(addr) ?? null, - totalPoints: Math.round(pts * 100) / 100, - sharePercent: globalTotal > 0 ? Math.round((pts / globalTotal) * 10000) / 100 : 0, + address: row.address, + username: usernameMap.get(row.address) ?? null, + weighted_spend: Number(row.weighted_spend), + totalPoints: Number(row.weighted_spend), + buy_volume: Number(row.buy_volume), + multiplier: Number(row.multiplier), + sharePercent: communityTotal > 0 ? Math.round((Number(row.weighted_spend) / communityTotal) * 10000) / 100 : 0, })); - // Find user's rank if requested let userRank: number | null = null; if (userAddress) { - const idx = sorted.findIndex(([addr]) => addr === userAddress); + const idx = sorted.findIndex(r => r.address === userAddress); userRank = idx >= 0 ? idx + 1 : null; } diff --git a/src/app/api/airdrop/points/route.ts b/src/app/api/airdrop/points/route.ts index 7772da90..62bc0217 100644 --- a/src/app/api/airdrop/points/route.ts +++ b/src/app/api/airdrop/points/route.ts @@ -1,12 +1,6 @@ -/** - * User points breakdown (#885) - * GET /api/airdrop/points?address=0x... - */ - import { NextResponse, type NextRequest } from "next/server"; import { createServerClient } from "../../../../../lib/supabase"; -import { AIRDROP_CONFIG } from "../../../../../lib/airdrop/config"; -import { getStreakBoost, getNextTier } from "../../../../../lib/airdrop/streak"; +import { getAirdropConfig } from "../../../../../lib/airdrop/config"; export async function GET(req: NextRequest) { const supabase = createServerClient(); @@ -19,15 +13,20 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: "Missing address param" }, { status: 400 }); } - // Points breakdown by action - const { data: points } = await supabase - .from("pl_points") - .select("action, points") - .eq("address", address); + const config = getAirdropConfig(); + + const [pointsRes, allPointsRes, streakRes, referralCodeRes, referredByRes, referredCountRes] = await Promise.all([ + supabase.from("pl_points").select("action, points").eq("address", address), + supabase.from("pl_points").select("points"), + supabase.from("pl_streaks").select("current_streak, last_checkin, longest_streak").eq("address", address).single(), + supabase.from("pl_referral_codes").select("code, is_farcaster_username").eq("address", address).single(), + supabase.from("pl_referrals").select("referral_code").eq("referred_address", address).single(), + supabase.from("pl_referrals").select("id", { count: "exact", head: true }).eq("referrer_address", address), + ]); const breakdown = { buy: 0, referral: 0, write: 0, rate: 0 }; let totalPoints = 0; - for (const row of points ?? []) { + for (const row of pointsRes.data ?? []) { const action = row.action as keyof typeof breakdown; if (action in breakdown) { breakdown[action] += row.points; @@ -35,54 +34,21 @@ export async function GET(req: NextRequest) { totalPoints += row.points; } - // Total points across all users (for share %) - const { data: allPoints } = await supabase - .from("pl_points") - .select("points"); - const globalTotal = (allPoints ?? []).reduce((sum, r) => sum + r.points, 0); + const globalTotal = (allPointsRes.data ?? []).reduce((sum, r) => sum + r.points, 0); const sharePercent = globalTotal > 0 ? (totalPoints / globalTotal) * 100 : 0; - // Streak info - const { data: streak } = await supabase - .from("pl_streaks") - .select("current_streak, last_checkin, longest_streak") - .eq("address", address) - .single(); - - const currentStreak = streak?.current_streak ?? 0; - const boostPercent = getStreakBoost(currentStreak) * 100; - const nextTier = getNextTier(currentStreak); - + const currentStreak = streakRes.data?.current_streak ?? 0; const todayUtc = new Date().toISOString().slice(0, 10); - const checkedInToday = streak?.last_checkin - ? new Date(streak.last_checkin).toISOString().slice(0, 10) === todayUtc + const checkedInToday = streakRes.data?.last_checkin + ? new Date(streakRes.data.last_checkin).toISOString().slice(0, 10) === todayUtc : false; - // Referral info - const { data: referralCode } = await supabase - .from("pl_referral_codes") - .select("code, is_farcaster_username") - .eq("address", address) - .single(); - - const { data: referredBy } = await supabase - .from("pl_referrals") - .select("referral_code") - .eq("referred_address", address) - .single(); - - const { count: referredUsersCount } = await supabase - .from("pl_referrals") - .select("id", { count: "exact", head: true }) - .eq("referrer_address", address); - - // Estimated airdrop per milestone tier const estimatedAirdrop = sharePercent > 0 ? { - bronze: Math.round((sharePercent / 100) * AIRDROP_CONFIG.POOL_AMOUNT * (AIRDROP_CONFIG.MILESTONES.BRONZE.pct / 100)), - silver: Math.round((sharePercent / 100) * AIRDROP_CONFIG.POOL_AMOUNT * (AIRDROP_CONFIG.MILESTONES.SILVER.pct / 100)), - gold: Math.round((sharePercent / 100) * AIRDROP_CONFIG.POOL_AMOUNT * (AIRDROP_CONFIG.MILESTONES.GOLD.pct / 100)), - diamond: Math.round((sharePercent / 100) * AIRDROP_CONFIG.POOL_AMOUNT * (AIRDROP_CONFIG.MILESTONES.DIAMOND.pct / 100)), + bronze: Math.round((sharePercent / 100) * config.POOL_AMOUNT * (config.MILESTONES.BRONZE.pct / 100)), + silver: Math.round((sharePercent / 100) * config.POOL_AMOUNT * (config.MILESTONES.SILVER.pct / 100)), + gold: Math.round((sharePercent / 100) * config.POOL_AMOUNT * (config.MILESTONES.GOLD.pct / 100)), + diamond: Math.round((sharePercent / 100) * config.POOL_AMOUNT * (config.MILESTONES.DIAMOND.pct / 100)), } : { bronze: 0, silver: 0, gold: 0, diamond: 0 }; @@ -98,19 +64,25 @@ export async function GET(req: NextRequest) { }, streak: { currentStreak, - boostPercent, - nextTier, + boostPercent: 0, + nextTier: null, checkedInToday, - lastCheckin: streak?.last_checkin ?? null, + lastCheckin: streakRes.data?.last_checkin ?? null, }, referral: { - code: referralCode?.code ?? null, - isFarcasterUsername: referralCode?.is_farcaster_username ?? false, - referredBy: referredBy?.referral_code ?? null, - referredUsersCount: referredUsersCount ?? 0, + code: referralCodeRes.data?.code ?? null, + isFarcasterUsername: referralCodeRes.data?.is_farcaster_username ?? false, + referredBy: referredByRes.data?.referral_code ?? null, + referredUsersCount: referredCountRes.count ?? 0, }, estimatedAirdrop, + buy_volume_plot: breakdown.buy, + fetched_at: new Date().toISOString(), }, { - headers: { "Cache-Control": "public, s-maxage=10, stale-while-revalidate=5" }, + headers: { + "Cache-Control": "public, s-maxage=10, stale-while-revalidate=5", + "Deprecation": "true", + "Link": "; rel=\"successor-version\"", + }, }); } diff --git a/src/app/api/airdrop/status/route.ts b/src/app/api/airdrop/status/route.ts index 85535e3f..8560fbfd 100644 --- a/src/app/api/airdrop/status/route.ts +++ b/src/app/api/airdrop/status/route.ts @@ -1,77 +1,90 @@ -/** - * Campaign status overview (#885) - * GET /api/airdrop/status — no auth required - */ - import { NextResponse } from "next/server"; import { createServerClient } from "../../../../../lib/supabase"; -import { AIRDROP_CONFIG } from "../../../../../lib/airdrop/config"; +import { getAirdropConfig } from "../../../../../lib/airdrop/config"; import { getPlotUsdPrice } from "../../../../../lib/usd-price"; +function checkEnvConfig(): boolean { + const secrets = [ + process.env.TWITTERAPI_IO_KEY, + process.env.NEYNAR_API_KEY, + process.env.SUPABASE_SERVICE_ROLE_KEY, + process.env.CRON_SECRET, + ]; + if (secrets.some(s => !s)) return false; + + const config = getAirdropConfig(); + if (!config.SIWE_DOMAIN || !config.SIWE_URI || !config.SIWE_STATEMENT) return false; + if (!config.SIWE_CHAIN_ID || !config.PLOTLINK_X_HANDLE) return false; + if (!config.PLOTLINK_FC_FID) return false; + if (config.POOL_AMOUNT <= 0) return false; + if (config.CAMPAIGN_START >= config.CAMPAIGN_END) return false; + if (!config.MILESTONES.BRONZE || !config.MILESTONES.SILVER || !config.MILESTONES.GOLD || !config.MILESTONES.DIAMOND) return false; + + return true; +} + export async function GET() { const supabase = createServerClient(); if (!supabase) { return NextResponse.json({ error: "Supabase not configured" }, { status: 500 }); } + const config = getAirdropConfig(); const now = new Date(); - const start = AIRDROP_CONFIG.CAMPAIGN_START; - const end = AIRDROP_CONFIG.CAMPAIGN_END; + const start = config.CAMPAIGN_START; + const end = config.CAMPAIGN_END; const totalMs = end.getTime() - start.getTime(); const elapsedMs = Math.max(0, now.getTime() - start.getTime()); const remainingMs = Math.max(0, end.getTime() - now.getTime()); - // Latest price: try pl_daily_prices first, fall back to live price - const { data: latestPrice } = await supabase - .from("pl_daily_prices") - .select("price_usd, mcap_usd") - .order("recorded_at", { ascending: false }) - .limit(1) - .single(); + const [priceRes, activationRes, eligibleRes] = await Promise.all([ + supabase + .from("pl_daily_prices") + .select("price_usd, mcap_usd") + .order("recorded_at", { ascending: false }) + .limit(1) + .single(), + supabase + .from("pl_activations") + .select("address", { count: "exact", head: true }) + .not("activated_at", "is", null), + supabase + .from("pl_activations") + .select("address", { count: "exact", head: true }) + .not("activated_at", "is", null) + .eq("is_blacklisted", false), + ]); - // Live price fallback when daily snapshots haven't been recorded yet + const latestPrice = priceRes.data; const livePriceUsd = latestPrice?.price_usd ?? (await getPlotUsdPrice()); - // Total points earned + unique participants - const { data: allPoints } = await supabase - .from("pl_points") - .select("address, points"); - - let totalPointsEarned = 0; - const uniqueAddresses = new Set(); - for (const row of allPoints ?? []) { - totalPointsEarned += row.points; - uniqueAddresses.add(row.address); - } - const totalParticipants = uniqueAddresses.size; - - // Milestone status — use stored FDV or compute from live price const MAX_SUPPLY = 1_000_000; const currentFdv = latestPrice?.mcap_usd ? Number(latestPrice.mcap_usd) : livePriceUsd ? livePriceUsd * MAX_SUPPLY : 0; + const milestones = { bronze: { - mcap: AIRDROP_CONFIG.MILESTONES.BRONZE.mcap, - pct: AIRDROP_CONFIG.MILESTONES.BRONZE.pct, - reached: currentFdv >= AIRDROP_CONFIG.MILESTONES.BRONZE.mcap, + mcap: config.MILESTONES.BRONZE.mcap, + pct: config.MILESTONES.BRONZE.pct, + reached: currentFdv >= config.MILESTONES.BRONZE.mcap, }, silver: { - mcap: AIRDROP_CONFIG.MILESTONES.SILVER.mcap, - pct: AIRDROP_CONFIG.MILESTONES.SILVER.pct, - reached: currentFdv >= AIRDROP_CONFIG.MILESTONES.SILVER.mcap, + mcap: config.MILESTONES.SILVER.mcap, + pct: config.MILESTONES.SILVER.pct, + reached: currentFdv >= config.MILESTONES.SILVER.mcap, }, gold: { - mcap: AIRDROP_CONFIG.MILESTONES.GOLD.mcap, - pct: AIRDROP_CONFIG.MILESTONES.GOLD.pct, - reached: currentFdv >= AIRDROP_CONFIG.MILESTONES.GOLD.mcap, + mcap: config.MILESTONES.GOLD.mcap, + pct: config.MILESTONES.GOLD.pct, + reached: currentFdv >= config.MILESTONES.GOLD.mcap, }, diamond: { - mcap: AIRDROP_CONFIG.MILESTONES.DIAMOND.mcap, - pct: AIRDROP_CONFIG.MILESTONES.DIAMOND.pct, - reached: currentFdv >= AIRDROP_CONFIG.MILESTONES.DIAMOND.mcap, + mcap: config.MILESTONES.DIAMOND.mcap, + pct: config.MILESTONES.DIAMOND.pct, + reached: currentFdv >= config.MILESTONES.DIAMOND.mcap, }, }; @@ -80,13 +93,14 @@ export async function GET() { campaignEnd: end.toISOString().slice(0, 10), timeRemainingDays: Math.ceil(remainingMs / (1000 * 60 * 60 * 24)), timeElapsedPercent: totalMs > 0 ? Math.min(100, Math.round((elapsedMs / totalMs) * 100)) : 0, - poolAmount: AIRDROP_CONFIG.POOL_AMOUNT, + poolAmount: config.POOL_AMOUNT, currentFdv, latestPriceUsd: livePriceUsd ?? null, milestones, - totalPointsEarned, - totalParticipants, - lockerTx: AIRDROP_CONFIG.LOCKER_TX, + activation_count: activationRes.count ?? 0, + eligible_activation_count: eligibleRes.count ?? 0, + env_check: { all_present: checkEnvConfig() }, + lockerTx: config.LOCKER_TX, }, { headers: { "Cache-Control": "public, s-maxage=60, stale-while-revalidate=30" }, });