diff --git a/lib/notifications.server.ts b/lib/notifications.server.ts new file mode 100644 index 00000000..afcee5d8 --- /dev/null +++ b/lib/notifications.server.ts @@ -0,0 +1,184 @@ +/** + * [#489] Farcaster notification system for PlotLink. + * + * Handles notification token storage (Supabase) and sending push + * notifications to Farcaster clients via the miniapp notification API. + */ + +import { createClient } from "@supabase/supabase-js"; + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ""; +const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || ""; + +function getSupabase() { + return createClient(supabaseUrl, supabaseServiceKey, { + auth: { autoRefreshToken: false, persistSession: false }, + }); +} + +export interface NotificationToken { + fid: number; + notificationToken: string; + notificationUrl: string; +} + +// ---- Token Management ---- + +export async function saveUserNotificationToken( + fid: number, + token: string, + url: string, + clientAppFid?: number, +): Promise { + const supabase = getSupabase(); + + const { error } = await supabase.from("notification_tokens").upsert( + { + fid, + notification_token: token, + notification_url: url, + client_app_fid: clientAppFid || null, + enabled: true, + updated_at: new Date().toISOString(), + }, + { onConflict: "fid" }, + ); + + if (error) { + console.error("Failed to save notification token:", error); + throw new Error(`Failed to save notification token: ${error.message}`); + } +} + +export async function disableUserNotifications(fid: number): Promise { + const supabase = getSupabase(); + + const { error } = await supabase + .from("notification_tokens") + .update({ enabled: false }) + .eq("fid", fid); + + if (error) { + console.error("Failed to disable notifications:", error); + } +} + +export async function getEnabledTokens(): Promise { + const supabase = getSupabase(); + + const { data, error } = await supabase + .from("notification_tokens") + .select("*") + .eq("enabled", true); + + if (error) { + console.error("Failed to get notification tokens:", error); + return []; + } + + return (data || []).map((row) => ({ + fid: row.fid, + notificationToken: row.notification_token, + notificationUrl: row.notification_url, + })); +} + +// ---- Notification Sending ---- + +export async function sendNotification(params: { + notificationId: string; + title: string; + body: string; + targetUrl: string; + tokens: NotificationToken[]; +}): Promise<{ successful: number; failed: number }> { + const { notificationId, title, body, targetUrl, tokens } = params; + const supabase = getSupabase(); + + if (tokens.length === 0) return { successful: 0, failed: 0 }; + + // Group tokens by notification URL + const tokensByUrl = new Map(); + for (const t of tokens) { + if (!tokensByUrl.has(t.notificationUrl)) { + tokensByUrl.set(t.notificationUrl, []); + } + tokensByUrl.get(t.notificationUrl)!.push(t.notificationToken); + } + + let successful = 0; + let failed = 0; + + for (const [url, urlTokens] of tokensByUrl.entries()) { + // Batch up to 100 tokens per request (Farcaster API limit) + for (let i = 0; i < urlTokens.length; i += 100) { + const batch = urlTokens.slice(i, i + 100); + + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + notificationId, + title, + body, + targetUrl, + tokens: batch, + }), + }); + + if (response.ok) { + const result = await response.json(); + const invalidBatchTokens = + result.invalidTokens || result.result?.invalidTokens || []; + successful += batch.length - invalidBatchTokens.length; + + // Delete invalid tokens + if (invalidBatchTokens.length > 0) { + await supabase + .from("notification_tokens") + .delete() + .in("notification_token", invalidBatchTokens); + } + } else { + failed += batch.length; + console.error( + `[NOTIFICATION] Failed batch to ${url}: ${response.status}`, + ); + } + } catch (error) { + console.error("Error sending notification batch:", error); + failed += batch.length; + } + } + } + + return { successful, failed }; +} + +// ---- PlotLink-Specific Triggers ---- + +const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://plotlink.xyz"; + +/** + * Notify all users with enabled notifications about a new plot. + * Called from the backfill cron when a new plot is indexed. + */ +export async function notifyNewPlot( + storylineId: number, + storyTitle: string, + plotIndex: number, +): Promise { + const tokens = await getEnabledTokens(); + if (tokens.length === 0) return; + + const label = plotIndex === 0 ? "Genesis" : `Chapter ${plotIndex}`; + + await sendNotification({ + notificationId: `pl-new-plot-${storylineId}-${plotIndex}`, + title: `New ${label} published`, + body: `"${storyTitle.slice(0, 40)}" has a new plot on PlotLink`, + targetUrl: `${appUrl}/story/${storylineId}`, + tokens, + }); +} diff --git a/package-lock.json b/package-lock.json index 3175bd6f..05eaab6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ ], "dependencies": { "@aws-sdk/client-s3": "^3.1009.0", + "@farcaster/miniapp-node": "^0.1.13", "@farcaster/miniapp-sdk": "^0.2.3", "@farcaster/miniapp-wagmi-connector": "^1.1.1", "@supabase/supabase-js": "^2.99.1", @@ -2026,6 +2027,53 @@ } } }, + "node_modules/@farcaster/miniapp-node": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@farcaster/miniapp-node/-/miniapp-node-0.1.13.tgz", + "integrity": "sha512-CN+JlMPGWlDXZxNY6GRCu7Pkwd1B21ACrOnu7ZxAHkd9vkwU75onls55lAQp27Qk06KwHBtRsp1SnWKBvtnf5Q==", + "license": "MIT", + "dependencies": { + "@farcaster/miniapp-core": "0.5.1", + "@noble/curves": "^1.7.0", + "ox": "^0.4.4", + "zod": "^3.24.1" + } + }, + "node_modules/@farcaster/miniapp-node/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/@farcaster/miniapp-node/node_modules/ox": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.4.4.tgz", + "integrity": "sha512-oJPEeCDs9iNiPs6J0rTx+Y0KGeCGyCAA3zo94yZhm8G5WpOxrwUtn2Ie/Y8IyARSqqY/j9JTKA3Fc1xs1DvFnw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.10.1", + "@noble/curves": "^1.6.0", + "@noble/hashes": "^1.5.0", + "@scure/bip32": "^1.5.0", + "@scure/bip39": "^1.4.0", + "abitype": "^1.0.6", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/@farcaster/miniapp-sdk": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/@farcaster/miniapp-sdk/-/miniapp-sdk-0.2.3.tgz", diff --git a/package.json b/package.json index b150f485..da5d0b98 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1009.0", + "@farcaster/miniapp-node": "^0.1.13", "@farcaster/miniapp-sdk": "^0.2.3", "@farcaster/miniapp-wagmi-connector": "^1.1.1", "@supabase/supabase-js": "^2.99.1", diff --git a/public/.well-known/farcaster.json b/public/.well-known/farcaster.json index e627c17e..a80cf91c 100644 --- a/public/.well-known/farcaster.json +++ b/public/.well-known/farcaster.json @@ -31,7 +31,8 @@ "requiredCapabilities": [ "wallet.getEthereumProvider", "actions.swapToken" - ] + ], + "webhookUrl": "https://plotlink.xyz/api/webhook/notifications" } } diff --git a/src/app/api/cron/backfill/route.ts b/src/app/api/cron/backfill/route.ts index 8d15051c..22ff0ff1 100644 --- a/src/app/api/cron/backfill/route.ts +++ b/src/app/api/cron/backfill/route.ts @@ -7,6 +7,7 @@ import { STORY_FACTORY } from "../../../../../lib/contracts/constants"; import { hashContent } from "../../../../../lib/content"; import { detectWriterType } from "../../../../../lib/contracts/erc8004"; import { reconcileStorylinePlotCount } from "../../../../../lib/reconcile"; +import { notifyNewPlot } from "../../../../../lib/notifications.server"; import type { Database } from "../../../../../lib/supabase"; const IPFS_GATEWAY = "https://ipfs.filebase.io/ipfs/"; @@ -162,6 +163,11 @@ export async function GET(req: Request) { ); storylinesInserted++; if (result.genesisPlotFailed) failures++; + else { + // Notify users about the new story + const args = decoded.args as { storylineId: bigint; title: string }; + notifyNewPlot(Number(args.storylineId), args.title, 0).catch(() => {}); + } } else if (decoded.eventName === "PlotChained") { const failed = await processPlotChained( decoded, @@ -172,7 +178,13 @@ export async function GET(req: Request) { getCachedBlockTimestamp ); if (failed) failures++; - else plotsInserted++; + else { + plotsInserted++; + // Notify users about the new plot + const args = decoded.args as { storylineId: bigint; plotIndex: bigint; title: string }; + const storyTitle = args.title || `Story #${Number(args.storylineId)}`; + notifyNewPlot(Number(args.storylineId), storyTitle, Number(args.plotIndex)).catch(() => {}); + } } else if (decoded.eventName === "Donation") { await processDonation( decoded, diff --git a/src/app/api/notifications/save-token/route.ts b/src/app/api/notifications/save-token/route.ts new file mode 100644 index 00000000..232fe18a --- /dev/null +++ b/src/app/api/notifications/save-token/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { saveUserNotificationToken } from "../../../../../lib/notifications.server"; + +/** + * [#489] Client-side endpoint for saving notification tokens. + * Belt-and-suspenders alongside the Farcaster webhook. + */ +export async function POST(request: NextRequest) { + try { + const { fid, token, url } = await request.json(); + + if (!fid || !token || !url) { + return NextResponse.json({ error: "Missing fields" }, { status: 400 }); + } + + await saveUserNotificationToken(fid, token, url); + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Failed to save notification token:", error); + return NextResponse.json({ error: "Internal error" }, { status: 500 }); + } +} diff --git a/src/app/api/webhook/notifications/route.ts b/src/app/api/webhook/notifications/route.ts new file mode 100644 index 00000000..d9baf250 --- /dev/null +++ b/src/app/api/webhook/notifications/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + saveUserNotificationToken, + disableUserNotifications, +} from "../../../../../lib/notifications.server"; +import { + parseWebhookEvent, + verifyAppKeyWithNeynar, + type ParseWebhookEvent, +} from "@farcaster/miniapp-node"; + +/** + * [#489] Webhook for Farcaster miniapp notification events. + * Handles: miniapp_added, miniapp_removed, notifications_enabled, notifications_disabled + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + if (!process.env.NEYNAR_API_KEY) { + console.error("[WEBHOOK] NEYNAR_API_KEY not set — rejecting unverified webhook"); + return NextResponse.json({ error: "Server misconfigured" }, { status: 503 }); + } + + let data; + try { + data = await parseWebhookEvent(body, verifyAppKeyWithNeynar); + } catch (e: unknown) { + const error = e as ParseWebhookEvent.ErrorType; + + switch (error.name) { + case "VerifyJsonFarcasterSignature.InvalidDataError": + case "VerifyJsonFarcasterSignature.InvalidEventDataError": + return NextResponse.json({ error: "Invalid request data" }, { status: 400 }); + case "VerifyJsonFarcasterSignature.InvalidAppKeyError": + return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + default: + console.error("Webhook verification error:", error); + return NextResponse.json({ error: "Verification failed" }, { status: 500 }); + } + } + + const { fid, event, appFid } = data; + + switch (event.event) { + case "miniapp_added": + case "notifications_enabled": + if (event.notificationDetails?.token && event.notificationDetails?.url) { + await saveUserNotificationToken( + fid, + event.notificationDetails.token, + event.notificationDetails.url, + appFid > 0 ? appFid : undefined, + ); + } + break; + + case "notifications_disabled": + case "miniapp_removed": + await disableUserNotifications(fid); + break; + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("[WEBHOOK] Error processing webhook:", error); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/components/FarcasterMiniApp.tsx b/src/components/FarcasterMiniApp.tsx index 62edbc0e..e5ddd1ec 100644 --- a/src/components/FarcasterMiniApp.tsx +++ b/src/components/FarcasterMiniApp.tsx @@ -9,7 +9,8 @@ import { usePlatformDetection } from "../hooks/usePlatformDetection"; * 1. Calls `sdk.actions.ready()` to dismiss the splash screen. * 2. If the user hasn't added the app yet, triggers `sdk.actions.addMiniApp()` * which shows the native Farcaster modal for install + notification permission. - * The SDK/client handles "already added" state — no re-prompting. + * 3. Saves notification token via webhook (Farcaster sends events server-side). + * Also saves client-side from addMiniApp() result as a belt-and-suspenders approach. * * Renders nothing — mount once near the root of the component tree. */ @@ -31,11 +32,24 @@ export function FarcasterMiniApp() { const context = await sdk.context; if (cancelled || !context?.client) return; + // Save existing notification token if user already added + if (context.client.added && context.client.notificationDetails) { + saveTokenClientSide( + context.user?.fid, + context.client.notificationDetails, + ); + } + if (!context.client.added) { - // Trigger native add/notification modal — SDK handles dismissal gracefully - sdk.actions.addMiniApp().catch(() => { + // Trigger native add/notification modal + try { + const result = await sdk.actions.addMiniApp(); + if (result?.notificationDetails) { + saveTokenClientSide(context.user?.fid, result.notificationDetails); + } + } catch { // User dismissed or SDK error — no action needed - }); + } } }).catch(() => { // Not in a Farcaster context — silently ignore @@ -48,3 +62,26 @@ export function FarcasterMiniApp() { return null; } + +/** + * Client-side token save — belt-and-suspenders alongside the webhook. + * Calls a lightweight API endpoint that upserts the token. + */ +function saveTokenClientSide( + fid: number | undefined, + details: { token: string; url: string }, +) { + if (!fid || !details.token || !details.url) return; + + fetch("/api/notifications/save-token", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + fid, + token: details.token, + url: details.url, + }), + }).catch(() => { + // Non-critical — webhook is the primary path + }); +} diff --git a/supabase/migrations/00021_notification_tokens.sql b/supabase/migrations/00021_notification_tokens.sql new file mode 100644 index 00000000..be5422ce --- /dev/null +++ b/supabase/migrations/00021_notification_tokens.sql @@ -0,0 +1,13 @@ +-- [#489] Notification tokens for Farcaster miniapp push notifications +CREATE TABLE IF NOT EXISTS notification_tokens ( + fid INTEGER PRIMARY KEY, + notification_token TEXT NOT NULL, + notification_url TEXT NOT NULL, + client_app_fid INTEGER, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_notification_tokens_enabled + ON notification_tokens (enabled) WHERE enabled = TRUE;