diff --git a/lib/airdrop/award.ts b/lib/airdrop/award.ts new file mode 100644 index 00000000..22f2d671 --- /dev/null +++ b/lib/airdrop/award.ts @@ -0,0 +1,115 @@ +/** + * Airdrop point award helpers (#884) + * + * Convenience functions for awarding write and rate points, + * called from indexer/backfill/rating endpoints. + */ + +import { AIRDROP_CONFIG } from "./config"; +import { getStreakBoost } from "./streak"; +import { createServiceRoleClient } from "../supabase"; + +/** + * Award write points (50 PL) for publishing a storyline. + * Idempotent via metadata.storyline_id dedup. + */ +export async function awardWritePoints( + writerAddress: string, + storylineId: number, + timestamp?: Date, +): Promise { + const now = timestamp ?? new Date(); + if (now < AIRDROP_CONFIG.CAMPAIGN_START || now > AIRDROP_CONFIG.CAMPAIGN_END) return; + + const supabase = createServiceRoleClient(); + if (!supabase) return; + + const address = writerAddress.toLowerCase(); + + // Dedup check + const { data: existing } = await supabase + .from("pl_points") + .select("id") + .eq("action", "write") + .eq("address", address) + .eq("metadata->>storyline_id", String(storylineId)) + .limit(1); + + if (existing && existing.length > 0) return; + + // Look up streak + const { data: streak } = await supabase + .from("pl_streaks") + .select("current_streak") + .eq("address", address) + .single(); + + const boost = getStreakBoost(streak?.current_streak ?? 0); + const points = AIRDROP_CONFIG.POINTS.WRITE_FLAT * (1 + boost); + + await supabase.from("pl_points").insert({ + address, + action: "write", + points, + metadata: { storyline_id: storylineId }, + }); +} + +/** + * Award rate points (5 PL) for rating a story. + * Capped at RATE_DAILY_CAP per day per address. + * Dedup via metadata.storyline_id + address. + */ +export async function awardRatePoints( + raterAddress: string, + storylineId: number, +): Promise { + const now = new Date(); + if (now < AIRDROP_CONFIG.CAMPAIGN_START || now > AIRDROP_CONFIG.CAMPAIGN_END) return; + + const supabase = createServiceRoleClient(); + if (!supabase) return; + + const address = raterAddress.toLowerCase(); + + // Dedup check (one rating per story per user) + const { data: existing } = await supabase + .from("pl_points") + .select("id") + .eq("action", "rate") + .eq("address", address) + .eq("metadata->>storyline_id", String(storylineId)) + .limit(1); + + if (existing && existing.length > 0) return; + + // Daily cap check + const todayStart = new Date(); + todayStart.setUTCHours(0, 0, 0, 0); + + const { count } = await supabase + .from("pl_points") + .select("id", { count: "exact", head: true }) + .eq("action", "rate") + .eq("address", address) + .gte("created_at", todayStart.toISOString()); + + if ((count ?? 0) >= AIRDROP_CONFIG.POINTS.RATE_DAILY_CAP) return; + + // Look up streak + const { data: streak } = await supabase + .from("pl_streaks") + .select("current_streak") + .eq("address", address) + .single(); + + const boost = getStreakBoost(streak?.current_streak ?? 0); + const points = AIRDROP_CONFIG.POINTS.RATE_FLAT * (1 + boost); + + await supabase.from("pl_points").insert({ + address, + action: "rate", + points, + metadata: { storyline_id: storylineId }, + }); +} diff --git a/src/app/api/cron/backfill/route.ts b/src/app/api/cron/backfill/route.ts index 9aa6e17d..2658ff77 100644 --- a/src/app/api/cron/backfill/route.ts +++ b/src/app/api/cron/backfill/route.ts @@ -8,6 +8,7 @@ import { hashContent } from "../../../../../lib/content"; import { detectWriterType } from "../../../../../lib/contracts/erc8004"; import { reconcileStorylinePlotCount } from "../../../../../lib/reconcile"; import { notifyNewPlot, notifyNewStoryline } from "../../../../../lib/notifications.server"; +import { awardWritePoints } from "../../../../../lib/airdrop/award"; import type { Database } from "../../../../../lib/supabase"; const IPFS_GATEWAY = "https://ipfs.filebase.io/ipfs/"; @@ -167,6 +168,9 @@ export async function GET(req: Request) { // Notify users about the new storyline const args = decoded.args as { storylineId: bigint; title: string; writer: `0x${string}` }; notifyNewStoryline(Number(args.storylineId), args.title, args.writer).catch(() => {}); + // Award airdrop write points (non-blocking) + const scBlockTs = await getCachedBlockTimestamp(log.blockNumber!); + awardWritePoints(args.writer, Number(args.storylineId), scBlockTs ? new Date(Number(scBlockTs) * 1000) : undefined).catch(() => {}); } } else if (decoded.eventName === "PlotChained") { const failed = await processPlotChained( diff --git a/src/app/api/index/storyline/route.ts b/src/app/api/index/storyline/route.ts index 257f8359..c05421ad 100644 --- a/src/app/api/index/storyline/route.ts +++ b/src/app/api/index/storyline/route.ts @@ -13,6 +13,7 @@ import { hashContent } from "../../../../../lib/content"; import { GENRES, LANGUAGES } from "../../../../../lib/genres"; import type { Database } from "../../../../../lib/supabase"; import { reconcileStorylinePlotCount } from "../../../../../lib/reconcile"; +import { awardWritePoints } from "../../../../../lib/airdrop/award"; const IPFS_GATEWAY = "https://ipfs.filebase.io/ipfs/"; const IPFS_TIMEOUT_MS = 10_000; @@ -183,5 +184,8 @@ export async function POST(req: Request) { // Reconcile plot_count from actual plots rows (prevents genesis double-count) await reconcileStorylinePlotCount(supabase, Number(storylineId)); + // Award airdrop write points (non-blocking, using on-chain timestamp) + awardWritePoints(writer, Number(storylineId), new Date(Number(blockTimestamp) * 1000)).catch(() => {}); + return NextResponse.json({ success: true }); } diff --git a/src/app/api/ratings/route.ts b/src/app/api/ratings/route.ts index 2545966c..cfb13ea1 100644 --- a/src/app/api/ratings/route.ts +++ b/src/app/api/ratings/route.ts @@ -4,6 +4,7 @@ import { publicClient } from "../../../../lib/rpc"; import { createServerClient, supabase } from "../../../../lib/supabase"; import { erc20Abi } from "../../../../lib/price"; import { STORY_FACTORY } from "../../../../lib/contracts/constants"; +import { awardRatePoints } from "../../../../lib/airdrop/award"; const MAX_COMMENT_LENGTH = 500; @@ -189,5 +190,8 @@ export async function POST(req: NextRequest) { return error(`Database error: ${upsertError.message}`, 500); } + // Award airdrop rate points (non-blocking) + awardRatePoints(raterAddress, storylineId).catch(() => {}); + return NextResponse.json({ success: true }); }