diff options
| author | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-07 01:42:57 -0800 |
| commit | 5c5b1993edd890a80870ee05607ac5f088191d4e (patch) | |
| tree | a721b76bcd49ba10826c53efc87302c7a689512f /apps/web/app/api | |
| download | asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.tar.xz asa.news-5c5b1993edd890a80870ee05607ac5f088191d4e.zip | |
feat: asa.news RSS reader with developer tier, REST API, and webhooks
Full-stack RSS reader SaaS: Supabase + Next.js + Go worker.
Includes three subscription tiers (free/pro/developer), API key auth,
read-only REST API, webhook push notifications, Stripe billing with
proration, and PWA support.
Diffstat (limited to 'apps/web/app/api')
| -rw-r--r-- | apps/web/app/api/account/data/route.ts | 96 | ||||
| -rw-r--r-- | apps/web/app/api/account/route.ts | 27 | ||||
| -rw-r--r-- | apps/web/app/api/billing/create-checkout-session/route.ts | 153 | ||||
| -rw-r--r-- | apps/web/app/api/billing/create-portal-session/route.ts | 51 | ||||
| -rw-r--r-- | apps/web/app/api/billing/webhook/route.ts | 181 | ||||
| -rw-r--r-- | apps/web/app/api/export/route.ts | 67 | ||||
| -rw-r--r-- | apps/web/app/api/share/[token]/route.ts | 85 | ||||
| -rw-r--r-- | apps/web/app/api/share/route.ts | 132 | ||||
| -rw-r--r-- | apps/web/app/api/v1/entries/[entryIdentifier]/route.ts | 72 | ||||
| -rw-r--r-- | apps/web/app/api/v1/entries/route.ts | 114 | ||||
| -rw-r--r-- | apps/web/app/api/v1/feeds/route.ts | 55 | ||||
| -rw-r--r-- | apps/web/app/api/v1/folders/route.ts | 36 | ||||
| -rw-r--r-- | apps/web/app/api/v1/keys/[keyIdentifier]/route.ts | 36 | ||||
| -rw-r--r-- | apps/web/app/api/v1/keys/route.ts | 116 | ||||
| -rw-r--r-- | apps/web/app/api/v1/profile/route.ts | 49 | ||||
| -rw-r--r-- | apps/web/app/api/webhook-config/route.ts | 117 | ||||
| -rw-r--r-- | apps/web/app/api/webhook-config/test/route.ts | 101 |
17 files changed, 1488 insertions, 0 deletions
diff --git a/apps/web/app/api/account/data/route.ts b/apps/web/app/api/account/data/route.ts new file mode 100644 index 0000000..dbee725 --- /dev/null +++ b/apps/web/app/api/account/data/route.ts @@ -0,0 +1,96 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" + +export async function GET() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const [ + profileResult, + subscriptionsResult, + foldersResult, + mutedKeywordsResult, + customFeedsResult, + entryStatesResult, + highlightsResult, + sharedEntriesResult, + savedEntriesResult, + ] = await Promise.all([ + supabaseClient + .from("user_profiles") + .select("id, display_name, tier, created_at") + .eq("id", user.id) + .single(), + supabaseClient + .from("subscriptions") + .select("id, feed_id, folder_id, custom_title, created_at, feeds(title, url)") + .eq("user_id", user.id), + supabaseClient + .from("folders") + .select("id, name, position, created_at") + .eq("user_id", user.id), + supabaseClient + .from("muted_keywords") + .select("id, keyword, created_at") + .eq("user_id", user.id), + supabaseClient + .from("custom_feeds") + .select("id, name, query, position, created_at") + .eq("user_id", user.id), + supabaseClient + .from("user_entry_states") + .select("entry_id, read, saved, updated_at") + .eq("user_id", user.id), + supabaseClient + .from("user_highlights") + .select( + "id, entry_id, highlighted_text, note, color, text_offset, text_length, created_at, entries(title, url)" + ) + .eq("user_id", user.id), + supabaseClient + .from("shared_entries") + .select("id, entry_id, share_token, created_at, entries(title, url)") + .eq("user_id", user.id), + supabaseClient + .from("user_entry_states") + .select( + "entries(id, title, url, author, summary, published_at, feeds(title, url))" + ) + .eq("user_id", user.id) + .eq("saved", true), + ]) + + const exportData = { + exportedAt: new Date().toISOString(), + account: { + emailAddress: user.email, + ...profileResult.data, + }, + subscriptions: subscriptionsResult.data ?? [], + folders: foldersResult.data ?? [], + mutedKeywords: mutedKeywordsResult.data ?? [], + customFeeds: customFeedsResult.data ?? [], + entryStates: entryStatesResult.data ?? [], + highlights: highlightsResult.data ?? [], + sharedEntries: sharedEntriesResult.data ?? [], + savedEntries: + (savedEntriesResult.data ?? []).map( + (row) => (row as Record<string, unknown>).entries + ) ?? [], + } + + const jsonString = JSON.stringify(exportData, null, 2) + + return new Response(jsonString, { + headers: { + "Content-Type": "application/json", + "Content-Disposition": `attachment; filename="asa-news-gdpr-export-${new Date().toISOString().slice(0, 10)}.json"`, + }, + }) +} diff --git a/apps/web/app/api/account/route.ts b/apps/web/app/api/account/route.ts new file mode 100644 index 0000000..6b1bc2d --- /dev/null +++ b/apps/web/app/api/account/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" + +export async function DELETE() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const adminClient = createSupabaseAdminClient() + + const { error } = await adminClient.auth.admin.deleteUser(user.id) + + if (error) { + return NextResponse.json( + { error: "Failed to delete account" }, + { status: 500 } + ) + } + + return new Response(null, { status: 204 }) +} diff --git a/apps/web/app/api/billing/create-checkout-session/route.ts b/apps/web/app/api/billing/create-checkout-session/route.ts new file mode 100644 index 0000000..cfbb388 --- /dev/null +++ b/apps/web/app/api/billing/create-checkout-session/route.ts @@ -0,0 +1,153 @@ +import { NextResponse } from "next/server" +import { headers } from "next/headers" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { getStripe } from "@/lib/stripe" +import { rateLimit } from "@/lib/rate-limit" + +export async function POST(request: Request) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const rateLimitResult = rateLimit(`checkout:${user.id}`, 10, 60_000) + if (!rateLimitResult.success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }) + } + + const body = await request.json().catch(() => ({})) + const billingInterval = + body.billingInterval === "yearly" ? "yearly" : "monthly" + const targetTier = + body.targetTier === "developer" ? "developer" : "pro" + + const priceIdentifierMap: Record<string, string | undefined> = { + "pro:monthly": process.env.STRIPE_PRO_MONTHLY_PRICE_IDENTIFIER, + "pro:yearly": process.env.STRIPE_PRO_YEARLY_PRICE_IDENTIFIER, + "developer:monthly": process.env.STRIPE_DEVELOPER_MONTHLY_PRICE_IDENTIFIER, + "developer:yearly": process.env.STRIPE_DEVELOPER_YEARLY_PRICE_IDENTIFIER, + } + + const stripePriceIdentifier = + priceIdentifierMap[`${targetTier}:${billingInterval}`] + + if (!stripePriceIdentifier) { + return NextResponse.json( + { error: "Invalid plan configuration" }, + { status: 500 } + ) + } + + const { data: profile, error: profileError } = await supabaseClient + .from("user_profiles") + .select("tier, stripe_customer_identifier, stripe_subscription_identifier") + .eq("id", user.id) + .single() + + if (profileError || !profile) { + return NextResponse.json( + { error: "Failed to load profile" }, + { status: 500 } + ) + } + + const tierRank: Record<string, number> = { free: 0, pro: 1, developer: 2 } + const currentRank = tierRank[profile.tier] ?? 0 + const targetRank = tierRank[targetTier] ?? 0 + + if (currentRank >= targetRank) { + return NextResponse.json( + { error: `Already on ${profile.tier} plan` }, + { status: 400 } + ) + } + + if (profile.stripe_subscription_identifier && currentRank > 0) { + const subscription = await getStripe().subscriptions.retrieve( + profile.stripe_subscription_identifier + ) + + const existingItemIdentifier = subscription.items.data[0]?.id + + if (!existingItemIdentifier) { + return NextResponse.json( + { error: "Could not find existing subscription item" }, + { status: 500 } + ) + } + + await getStripe().subscriptions.update( + profile.stripe_subscription_identifier, + { + items: [ + { + id: existingItemIdentifier, + price: stripePriceIdentifier, + }, + ], + proration_behavior: "always_invoice", + metadata: { supabase_user_identifier: user.id }, + } + ) + + const adminClient = createSupabaseAdminClient() + await adminClient + .from("user_profiles") + .update({ tier: targetTier }) + .eq("id", user.id) + + return NextResponse.json({ upgraded: true }) + } + + let stripeCustomerIdentifier = profile.stripe_customer_identifier + + if (!stripeCustomerIdentifier) { + const customer = await getStripe().customers.create({ + email: user.email, + metadata: { supabase_user_identifier: user.id }, + }) + + stripeCustomerIdentifier = customer.id + + const adminClient = createSupabaseAdminClient() + const { error: updateError } = await adminClient + .from("user_profiles") + .update({ stripe_customer_identifier: stripeCustomerIdentifier }) + .eq("id", user.id) + + if (updateError) { + console.error("Admin client update error:", updateError) + return NextResponse.json( + { error: "Failed to save customer: " + updateError.message }, + { status: 500 } + ) + } + } + + const headersList = await headers() + const origin = headersList.get("origin") || "http://localhost:3000" + + const checkoutSession = await getStripe().checkout.sessions.create({ + customer: stripeCustomerIdentifier, + mode: "subscription", + line_items: [ + { + price: stripePriceIdentifier, + quantity: 1, + }, + ], + success_url: `${origin}/reader/settings?billing=success`, + cancel_url: `${origin}/reader/settings?billing=cancelled`, + subscription_data: { + metadata: { supabase_user_identifier: user.id }, + }, + client_reference_id: user.id, + }) + + return NextResponse.json({ url: checkoutSession.url }) +} diff --git a/apps/web/app/api/billing/create-portal-session/route.ts b/apps/web/app/api/billing/create-portal-session/route.ts new file mode 100644 index 0000000..3832c0d --- /dev/null +++ b/apps/web/app/api/billing/create-portal-session/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server" +import { headers } from "next/headers" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { getStripe } from "@/lib/stripe" +import { rateLimit } from "@/lib/rate-limit" + +export async function POST() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const rateLimitResult = rateLimit(`portal:${user.id}`, 10, 60_000) + if (!rateLimitResult.success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }) + } + + const { data: profile, error: profileError } = await supabaseClient + .from("user_profiles") + .select("stripe_customer_identifier") + .eq("id", user.id) + .single() + + if (profileError || !profile) { + return NextResponse.json( + { error: "Failed to load profile" }, + { status: 500 } + ) + } + + if (!profile.stripe_customer_identifier) { + return NextResponse.json( + { error: "No billing account found" }, + { status: 400 } + ) + } + + const headersList = await headers() + const origin = headersList.get("origin") || "http://localhost:3000" + + const portalSession = await getStripe().billingPortal.sessions.create({ + customer: profile.stripe_customer_identifier, + return_url: `${origin}/reader/settings`, + }) + + return NextResponse.json({ url: portalSession.url }) +} diff --git a/apps/web/app/api/billing/webhook/route.ts b/apps/web/app/api/billing/webhook/route.ts new file mode 100644 index 0000000..8aed7d0 --- /dev/null +++ b/apps/web/app/api/billing/webhook/route.ts @@ -0,0 +1,181 @@ +import { NextResponse } from "next/server" +import type Stripe from "stripe" +import { getStripe } from "@/lib/stripe" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { rateLimit } from "@/lib/rate-limit" + +function determineTierFromSubscription( + subscription: Stripe.Subscription +): "pro" | "developer" { + const priceIdentifier = subscription.items?.data?.[0]?.price?.id + const developerPriceIdentifiers = [ + process.env.STRIPE_DEVELOPER_MONTHLY_PRICE_IDENTIFIER, + process.env.STRIPE_DEVELOPER_YEARLY_PRICE_IDENTIFIER, + ] + + if (priceIdentifier && developerPriceIdentifiers.includes(priceIdentifier)) { + return "developer" + } + + return "pro" +} + +function extractPeriodEnd(subscription: Stripe.Subscription): string | null { + const firstItem = subscription.items?.data?.[0] + if (firstItem?.current_period_end) { + return new Date(firstItem.current_period_end * 1000).toISOString() + } + + if (subscription.cancel_at) { + return new Date(subscription.cancel_at * 1000).toISOString() + } + + return null +} + +async function updateBillingState( + stripeCustomerIdentifier: string, + updates: Record<string, unknown> +) { + const adminClient = createSupabaseAdminClient() + const { error } = await adminClient + .from("user_profiles") + .update(updates) + .eq("stripe_customer_identifier", stripeCustomerIdentifier) + + if (error) { + console.error("Failed to update billing state:", error) + } +} + +async function handleCheckoutSessionCompleted( + session: Stripe.Checkout.Session +) { + if (session.mode !== "subscription" || !session.subscription) return + + const userIdentifier = session.client_reference_id + if (!userIdentifier) return + + const subscription = await getStripe().subscriptions.retrieve( + session.subscription as string, + { expand: ["items.data"] } + ) + + const adminClient = createSupabaseAdminClient() + await adminClient + .from("user_profiles") + .update({ + tier: determineTierFromSubscription(subscription), + stripe_customer_identifier: session.customer as string, + stripe_subscription_identifier: subscription.id, + stripe_subscription_status: subscription.status, + stripe_current_period_end: extractPeriodEnd(subscription), + }) + .eq("id", userIdentifier) +} + +async function handleSubscriptionUpdated(subscription: Stripe.Subscription) { + const stripeCustomerIdentifier = subscription.customer as string + + const updates: Record<string, unknown> = { + stripe_subscription_status: subscription.status, + stripe_current_period_end: extractPeriodEnd(subscription), + } + + if (subscription.status === "active") { + updates.tier = determineTierFromSubscription(subscription) + } + + await updateBillingState(stripeCustomerIdentifier, updates) +} + +async function handleSubscriptionDeleted(subscription: Stripe.Subscription) { + const stripeCustomerIdentifier = subscription.customer as string + + await updateBillingState(stripeCustomerIdentifier, { + tier: "free", + stripe_subscription_identifier: null, + stripe_subscription_status: "canceled", + stripe_current_period_end: null, + }) +} + +async function handleInvoicePaymentFailed(invoice: Stripe.Invoice) { + const stripeCustomerIdentifier = invoice.customer as string + + await updateBillingState(stripeCustomerIdentifier, { + stripe_subscription_status: "past_due", + }) +} + +async function handleInvoicePaid(invoice: Stripe.Invoice) { + const stripeCustomerIdentifier = invoice.customer as string + const lineItem = invoice.lines?.data?.[0] + const priceIdentifier = (lineItem as unknown as { price?: { id?: string } } | undefined)?.price?.id + const developerPriceIdentifiers = [ + process.env.STRIPE_DEVELOPER_MONTHLY_PRICE_IDENTIFIER, + process.env.STRIPE_DEVELOPER_YEARLY_PRICE_IDENTIFIER, + ] + const tier = + priceIdentifier && developerPriceIdentifiers.includes(priceIdentifier) + ? "developer" + : "pro" + + await updateBillingState(stripeCustomerIdentifier, { + tier, + stripe_subscription_status: "active", + }) +} + +export async function POST(request: Request) { + const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown" + const rateLimitResult = rateLimit(`webhook:${clientIp}`, 60, 60_000) + if (!rateLimitResult.success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }) + } + + const body = await request.text() + const signature = request.headers.get("stripe-signature") + + if (!signature) { + return NextResponse.json({ error: "Missing signature" }, { status: 400 }) + } + + let event: Stripe.Event + + try { + event = getStripe().webhooks.constructEvent( + body, + signature, + process.env.STRIPE_WEBHOOK_SECRET! + ) + } catch { + return NextResponse.json({ error: "Invalid signature" }, { status: 400 }) + } + + switch (event.type) { + case "checkout.session.completed": + await handleCheckoutSessionCompleted( + event.data.object as Stripe.Checkout.Session + ) + break + case "customer.subscription.updated": + await handleSubscriptionUpdated( + event.data.object as Stripe.Subscription + ) + break + case "customer.subscription.deleted": + await handleSubscriptionDeleted( + event.data.object as Stripe.Subscription + ) + break + case "invoice.payment_failed": + await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice) + break + case "invoice.paid": + await handleInvoicePaid(event.data.object as Stripe.Invoice) + break + } + + return NextResponse.json({ received: true }) +} diff --git a/apps/web/app/api/export/route.ts b/apps/web/app/api/export/route.ts new file mode 100644 index 0000000..4842f83 --- /dev/null +++ b/apps/web/app/api/export/route.ts @@ -0,0 +1,67 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" + +export async function GET() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const { data: profile } = await supabaseClient + .from("user_profiles") + .select("tier, display_name") + .eq("id", user.id) + .single() + + const tier = profile?.tier ?? "free" + + const { data: savedEntries } = await supabaseClient + .from("user_entry_states") + .select( + "entries(id, title, url, author, summary, published_at, feeds(title, url))" + ) + .eq("user_id", user.id) + .eq("saved", true) + + const exportData: Record<string, unknown> = { + exportedAt: new Date().toISOString(), + tier, + savedEntries: + (savedEntries ?? []).map((row) => (row as Record<string, unknown>).entries) ?? [], + } + + if (tier === "pro" || tier === "developer") { + const [subscriptionsResult, foldersResult, mutedKeywordsResult] = + await Promise.all([ + supabaseClient + .from("subscriptions") + .select("id, feed_id, folder_id, custom_title, feeds(title, url)") + .eq("user_id", user.id), + supabaseClient + .from("folders") + .select("id, name, position") + .eq("user_id", user.id), + supabaseClient + .from("muted_keywords") + .select("id, keyword") + .eq("user_id", user.id), + ]) + + exportData.subscriptions = subscriptionsResult.data ?? [] + exportData.folders = foldersResult.data ?? [] + exportData.mutedKeywords = mutedKeywordsResult.data ?? [] + } + + const jsonString = JSON.stringify(exportData, null, 2) + + return new Response(jsonString, { + headers: { + "Content-Type": "application/json", + "Content-Disposition": `attachment; filename="asa-news-export-${new Date().toISOString().slice(0, 10)}.json"`, + }, + }) +} diff --git a/apps/web/app/api/share/[token]/route.ts b/apps/web/app/api/share/[token]/route.ts new file mode 100644 index 0000000..45224aa --- /dev/null +++ b/apps/web/app/api/share/[token]/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" + +const MAX_NOTE_LENGTH = 1000 + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ token: string }> } +) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const { token } = await params + + const { error } = await supabaseClient + .from("shared_entries") + .delete() + .eq("share_token", token) + .eq("user_id", user.id) + + if (error) { + return NextResponse.json( + { error: "Failed to delete share" }, + { status: 500 } + ) + } + + return new Response(null, { status: 204 }) +} + +export async function PATCH( + request: Request, + { params }: { params: Promise<{ token: string }> } +) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const { token } = await params + const body = await request.json() + const rawNote = body.note + + let note: string | null = null + if (rawNote !== undefined && rawNote !== null) { + if (typeof rawNote !== "string") { + return NextResponse.json( + { error: "note must be a string" }, + { status: 400 } + ) + } + if (rawNote.length > MAX_NOTE_LENGTH) { + return NextResponse.json( + { error: `note must be ${MAX_NOTE_LENGTH} characters or fewer` }, + { status: 400 } + ) + } + note = rawNote.trim() || null + } + + const { error } = await supabaseClient + .from("shared_entries") + .update({ note }) + .eq("share_token", token) + .eq("user_id", user.id) + + if (error) { + return NextResponse.json( + { error: "Failed to update share" }, + { status: 500 } + ) + } + + return NextResponse.json({ note }) +} diff --git a/apps/web/app/api/share/route.ts b/apps/web/app/api/share/route.ts new file mode 100644 index 0000000..2558560 --- /dev/null +++ b/apps/web/app/api/share/route.ts @@ -0,0 +1,132 @@ +import { NextResponse } from "next/server" +import { randomBytes } from "crypto" +import { createSupabaseServerClient } from "@/lib/supabase/server" + +const MAX_NOTE_LENGTH = 1000 + +function buildOrigin(request: Request): string { + if (process.env.NEXT_PUBLIC_APP_URL) { + return process.env.NEXT_PUBLIC_APP_URL.replace(/\/$/, "") + } + + return ( + request.headers.get("origin") ?? + `https://${request.headers.get("host")}` + ) +} + +export async function POST(request: Request) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const { data: userProfile } = await supabaseClient + .from("user_profiles") + .select("tier") + .eq("id", user.id) + .single() + + const tier = userProfile?.tier ?? "free" + const expiryDays = tier === "pro" || tier === "developer" ? 30 : 7 + const expiresAt = new Date( + Date.now() + expiryDays * 24 * 60 * 60 * 1000 + ).toISOString() + + const body = await request.json() + const entryIdentifier = body.entryIdentifier as string + const rawNote = body.note + + if (!entryIdentifier || typeof entryIdentifier !== "string") { + return NextResponse.json( + { error: "entryIdentifier is required" }, + { status: 400 } + ) + } + + let note: string | null = null + if (rawNote !== undefined && rawNote !== null) { + if (typeof rawNote !== "string") { + return NextResponse.json( + { error: "note must be a string" }, + { status: 400 } + ) + } + if (rawNote.length > MAX_NOTE_LENGTH) { + return NextResponse.json( + { error: `note must be ${MAX_NOTE_LENGTH} characters or fewer` }, + { status: 400 } + ) + } + note = rawNote.trim() || null + } + + const { data: entryAccess } = await supabaseClient + .from("entries") + .select("id, feed_id") + .eq("id", entryIdentifier) + .maybeSingle() + + if (!entryAccess) { + return NextResponse.json( + { error: "Entry not found or not accessible" }, + { status: 404 } + ) + } + + const { data: subscriptionAccess } = await supabaseClient + .from("subscriptions") + .select("id") + .eq("feed_id", entryAccess.feed_id) + .eq("user_id", user.id) + .maybeSingle() + + if (!subscriptionAccess) { + return NextResponse.json( + { error: "You do not have access to this entry" }, + { status: 403 } + ) + } + + const origin = buildOrigin(request) + + const { data: existingShare } = await supabaseClient + .from("shared_entries") + .select("share_token") + .eq("entry_id", entryIdentifier) + .eq("user_id", user.id) + .maybeSingle() + + if (existingShare) { + const shareUrl = `${origin}/shared/${existingShare.share_token}` + return NextResponse.json({ + shareToken: existingShare.share_token, + shareUrl, + }) + } + + const shareToken = randomBytes(16).toString("base64url") + + const { error } = await supabaseClient.from("shared_entries").insert({ + user_id: user.id, + entry_id: entryIdentifier, + share_token: shareToken, + expires_at: expiresAt, + note, + }) + + if (error) { + return NextResponse.json( + { error: "Failed to create share" }, + { status: 500 } + ) + } + + const shareUrl = `${origin}/shared/${shareToken}` + + return NextResponse.json({ shareToken, shareUrl }) +} diff --git a/apps/web/app/api/v1/entries/[entryIdentifier]/route.ts b/apps/web/app/api/v1/entries/[entryIdentifier]/route.ts new file mode 100644 index 0000000..157366b --- /dev/null +++ b/apps/web/app/api/v1/entries/[entryIdentifier]/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from "next/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { authenticateApiRequest } from "@/lib/api-auth" + +export async function GET( + request: Request, + { params }: { params: Promise<{ entryIdentifier: string }> } +) { + const authResult = await authenticateApiRequest(request) + + if (!authResult.authenticated) { + return NextResponse.json( + { error: authResult.error }, + { status: authResult.status } + ) + } + + const { entryIdentifier } = await params + const adminClient = createSupabaseAdminClient() + + const { data: entry, error } = await adminClient + .from("entries") + .select( + "id, feed_id, guid, title, url, author, summary, content_html, image_url, published_at, enclosure_url, enclosure_type, enclosure_length, word_count" + ) + .eq("id", entryIdentifier) + .is("owner_id", null) + .single() + + if (error || !entry) { + return NextResponse.json({ error: "Entry not found" }, { status: 404 }) + } + + const { data: subscription } = await adminClient + .from("subscriptions") + .select("id") + .eq("user_id", authResult.user.userIdentifier) + .eq("feed_id", entry.feed_id) + .single() + + if (!subscription) { + return NextResponse.json({ error: "Entry not found" }, { status: 404 }) + } + + const { data: stateRow } = await adminClient + .from("user_entry_states") + .select("read, saved") + .eq("user_id", authResult.user.userIdentifier) + .eq("entry_id", entryIdentifier) + .single() + + return NextResponse.json({ + entry: { + entryIdentifier: entry.id, + feedIdentifier: entry.feed_id, + guid: entry.guid, + title: entry.title, + url: entry.url, + author: entry.author, + summary: entry.summary, + contentHtml: entry.content_html, + imageUrl: entry.image_url, + publishedAt: entry.published_at, + enclosureUrl: entry.enclosure_url, + enclosureType: entry.enclosure_type, + enclosureLength: entry.enclosure_length, + wordCount: entry.word_count, + isRead: stateRow?.read ?? false, + isSaved: stateRow?.saved ?? false, + }, + }) +} diff --git a/apps/web/app/api/v1/entries/route.ts b/apps/web/app/api/v1/entries/route.ts new file mode 100644 index 0000000..653c79b --- /dev/null +++ b/apps/web/app/api/v1/entries/route.ts @@ -0,0 +1,114 @@ +import { NextResponse } from "next/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { authenticateApiRequest } from "@/lib/api-auth" + +export async function GET(request: Request) { + const authResult = await authenticateApiRequest(request) + + if (!authResult.authenticated) { + return NextResponse.json( + { error: authResult.error }, + { status: authResult.status } + ) + } + + const { searchParams } = new URL(request.url) + const feedIdentifier = searchParams.get("feedIdentifier") + const isRead = searchParams.get("isRead") + const isSaved = searchParams.get("isSaved") + const cursor = searchParams.get("cursor") + const limitParameter = searchParams.get("limit") + const limit = Math.min(Math.max(Number(limitParameter) || 50, 1), 100) + + const adminClient = createSupabaseAdminClient() + + const { data: subscriptionRows } = await adminClient + .from("subscriptions") + .select("feed_id") + .eq("user_id", authResult.user.userIdentifier) + + const subscribedFeedIdentifiers = (subscriptionRows ?? []).map( + (row) => row.feed_id + ) + + if (subscribedFeedIdentifiers.length === 0) { + return NextResponse.json({ entries: [], nextCursor: null }) + } + + let query = adminClient + .from("entries") + .select( + "id, feed_id, guid, title, url, author, summary, image_url, published_at, enclosure_url, enclosure_type, user_entry_states!left(read, saved)" + ) + .in("feed_id", subscribedFeedIdentifiers) + .is("owner_id", null) + .order("published_at", { ascending: false }) + .limit(limit + 1) + + if (feedIdentifier) { + query = query.eq("feed_id", feedIdentifier) + } + + if (cursor) { + query = query.lt("published_at", cursor) + } + + const { data, error } = await query + + if (error) { + return NextResponse.json( + { error: "Failed to load entries" }, + { status: 500 } + ) + } + + interface EntryRow { + id: string + feed_id: string + guid: string | null + title: string | null + url: string | null + author: string | null + summary: string | null + image_url: string | null + published_at: string | null + enclosure_url: string | null + enclosure_type: string | null + user_entry_states: Array<{ read: boolean; saved: boolean }> | null + } + + let entries = (data as unknown as EntryRow[]).map((row) => { + const state = row.user_entry_states?.[0] + + return { + entryIdentifier: row.id, + feedIdentifier: row.feed_id, + guid: row.guid, + title: row.title, + url: row.url, + author: row.author, + summary: row.summary, + imageUrl: row.image_url, + publishedAt: row.published_at, + enclosureUrl: row.enclosure_url, + enclosureType: row.enclosure_type, + isRead: state?.read ?? false, + isSaved: state?.saved ?? false, + } + }) + + if (isRead === "true") entries = entries.filter((entry) => entry.isRead) + if (isRead === "false") entries = entries.filter((entry) => !entry.isRead) + if (isSaved === "true") entries = entries.filter((entry) => entry.isSaved) + if (isSaved === "false") entries = entries.filter((entry) => !entry.isSaved) + + const hasMore = entries.length > limit + if (hasMore) entries = entries.slice(0, limit) + + const nextCursor = + hasMore && entries.length > 0 + ? entries[entries.length - 1].publishedAt + : null + + return NextResponse.json({ entries, nextCursor }) +} diff --git a/apps/web/app/api/v1/feeds/route.ts b/apps/web/app/api/v1/feeds/route.ts new file mode 100644 index 0000000..adf5422 --- /dev/null +++ b/apps/web/app/api/v1/feeds/route.ts @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { authenticateApiRequest } from "@/lib/api-auth" + +export async function GET(request: Request) { + const authResult = await authenticateApiRequest(request) + + if (!authResult.authenticated) { + return NextResponse.json( + { error: authResult.error }, + { status: authResult.status } + ) + } + + const adminClient = createSupabaseAdminClient() + const { data, error } = await adminClient + .from("subscriptions") + .select( + "id, custom_title, folder_id, feeds!inner(id, url, title, feed_type, site_url)" + ) + .eq("user_id", authResult.user.userIdentifier) + + if (error) { + return NextResponse.json( + { error: "Failed to load feeds" }, + { status: 500 } + ) + } + + interface FeedRow { + id: string + custom_title: string | null + folder_id: string | null + feeds: { + id: string + url: string + title: string | null + feed_type: string | null + site_url: string | null + } + } + + return NextResponse.json({ + feeds: (data as unknown as FeedRow[]).map((row) => ({ + subscriptionIdentifier: row.id, + feedIdentifier: row.feeds.id, + feedUrl: row.feeds.url, + feedTitle: row.feeds.title, + customTitle: row.custom_title, + feedType: row.feeds.feed_type, + siteUrl: row.feeds.site_url, + folderIdentifier: row.folder_id, + })), + }) +} diff --git a/apps/web/app/api/v1/folders/route.ts b/apps/web/app/api/v1/folders/route.ts new file mode 100644 index 0000000..5fb006d --- /dev/null +++ b/apps/web/app/api/v1/folders/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { authenticateApiRequest } from "@/lib/api-auth" + +export async function GET(request: Request) { + const authResult = await authenticateApiRequest(request) + + if (!authResult.authenticated) { + return NextResponse.json( + { error: authResult.error }, + { status: authResult.status } + ) + } + + const adminClient = createSupabaseAdminClient() + const { data, error } = await adminClient + .from("folders") + .select("id, name, position") + .eq("user_id", authResult.user.userIdentifier) + .order("position", { ascending: true }) + + if (error) { + return NextResponse.json( + { error: "Failed to load folders" }, + { status: 500 } + ) + } + + return NextResponse.json({ + folders: (data ?? []).map((row) => ({ + identifier: row.id, + name: row.name, + position: row.position, + })), + }) +} diff --git a/apps/web/app/api/v1/keys/[keyIdentifier]/route.ts b/apps/web/app/api/v1/keys/[keyIdentifier]/route.ts new file mode 100644 index 0000000..8026f27 --- /dev/null +++ b/apps/web/app/api/v1/keys/[keyIdentifier]/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ keyIdentifier: string }> } +) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const { keyIdentifier } = await params + + const adminClient = createSupabaseAdminClient() + const { error } = await adminClient + .from("api_keys") + .update({ revoked_at: new Date().toISOString() }) + .eq("id", keyIdentifier) + .eq("user_id", user.id) + .is("revoked_at", null) + + if (error) { + return NextResponse.json( + { error: "Failed to revoke API key" }, + { status: 500 } + ) + } + + return NextResponse.json({ revoked: true }) +} diff --git a/apps/web/app/api/v1/keys/route.ts b/apps/web/app/api/v1/keys/route.ts new file mode 100644 index 0000000..7ac7144 --- /dev/null +++ b/apps/web/app/api/v1/keys/route.ts @@ -0,0 +1,116 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { generateApiKey } from "@/lib/api-key" +import { TIER_LIMITS, type SubscriptionTier } from "@asa-news/shared" +import { rateLimit } from "@/lib/rate-limit" + +const MAXIMUM_ACTIVE_KEYS = 5 + +export async function GET() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const adminClient = createSupabaseAdminClient() + const { data: keys, error } = await adminClient + .from("api_keys") + .select("id, key_prefix, label, created_at, last_used_at, revoked_at") + .eq("user_id", user.id) + .order("created_at", { ascending: false }) + + if (error) { + return NextResponse.json( + { error: "Failed to load API keys" }, + { status: 500 } + ) + } + + return NextResponse.json({ + keys: keys.map((key) => ({ + identifier: key.id, + keyPrefix: key.key_prefix, + label: key.label, + createdAt: key.created_at, + lastUsedAt: key.last_used_at, + isRevoked: key.revoked_at !== null, + })), + }) +} + +export async function POST(request: Request) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const rateLimitResult = rateLimit(`api-keys:${user.id}`, 10, 60_000) + if (!rateLimitResult.success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }) + } + + const adminClient = createSupabaseAdminClient() + + const { data: userProfile } = await adminClient + .from("user_profiles") + .select("tier") + .eq("id", user.id) + .single() + + if ( + !userProfile || + !TIER_LIMITS[userProfile.tier as SubscriptionTier]?.allowsApiAccess + ) { + return NextResponse.json( + { error: "API access requires the developer plan" }, + { status: 403 } + ) + } + + const { count: activeKeyCount } = await adminClient + .from("api_keys") + .select("id", { count: "exact", head: true }) + .eq("user_id", user.id) + .is("revoked_at", null) + + if ((activeKeyCount ?? 0) >= MAXIMUM_ACTIVE_KEYS) { + return NextResponse.json( + { error: `Maximum of ${MAXIMUM_ACTIVE_KEYS} active keys allowed` }, + { status: 400 } + ) + } + + const body = await request.json().catch(() => ({})) + const label = typeof body.label === "string" ? body.label.trim() || null : null + + const { fullKey, keyHash, keyPrefix } = generateApiKey() + + const { error: insertError } = await adminClient.from("api_keys").insert({ + user_id: user.id, + key_hash: keyHash, + key_prefix: keyPrefix, + label, + }) + + if (insertError) { + return NextResponse.json( + { error: "Failed to create API key" }, + { status: 500 } + ) + } + + return NextResponse.json({ + key: fullKey, + keyPrefix, + label, + }) +} diff --git a/apps/web/app/api/v1/profile/route.ts b/apps/web/app/api/v1/profile/route.ts new file mode 100644 index 0000000..f7ec308 --- /dev/null +++ b/apps/web/app/api/v1/profile/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { authenticateApiRequest } from "@/lib/api-auth" +import { TIER_LIMITS, type SubscriptionTier } from "@asa-news/shared" + +export async function GET(request: Request) { + const authResult = await authenticateApiRequest(request) + + if (!authResult.authenticated) { + return NextResponse.json( + { error: authResult.error }, + { status: authResult.status } + ) + } + + const adminClient = createSupabaseAdminClient() + const { data: profile, error } = await adminClient + .from("user_profiles") + .select( + "tier, feed_count, folder_count, muted_keyword_count, custom_feed_count" + ) + .eq("id", authResult.user.userIdentifier) + .single() + + if (error || !profile) { + return NextResponse.json( + { error: "Failed to load profile" }, + { status: 500 } + ) + } + + const tierLimits = TIER_LIMITS[profile.tier as SubscriptionTier] + + return NextResponse.json({ + profile: { + tier: profile.tier, + feedCount: profile.feed_count, + folderCount: profile.folder_count, + mutedKeywordCount: profile.muted_keyword_count, + customFeedCount: profile.custom_feed_count, + limits: { + maximumFeeds: tierLimits.maximumFeeds, + maximumFolders: tierLimits.maximumFolders, + maximumMutedKeywords: tierLimits.maximumMutedKeywords, + maximumCustomFeeds: tierLimits.maximumCustomFeeds, + }, + }, + }) +} diff --git a/apps/web/app/api/webhook-config/route.ts b/apps/web/app/api/webhook-config/route.ts new file mode 100644 index 0000000..1ce9a30 --- /dev/null +++ b/apps/web/app/api/webhook-config/route.ts @@ -0,0 +1,117 @@ +import { NextResponse } from "next/server" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { TIER_LIMITS, type SubscriptionTier } from "@asa-news/shared" +import { rateLimit } from "@/lib/rate-limit" + +export async function GET() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const adminClient = createSupabaseAdminClient() + const { data: profile, error } = await adminClient + .from("user_profiles") + .select( + "tier, webhook_url, webhook_secret, webhook_enabled, webhook_consecutive_failures" + ) + .eq("id", user.id) + .single() + + if (error || !profile) { + return NextResponse.json( + { error: "Failed to load webhook config" }, + { status: 500 } + ) + } + + return NextResponse.json({ + webhookUrl: profile.webhook_url, + webhookSecret: profile.webhook_secret, + webhookEnabled: profile.webhook_enabled, + consecutiveFailures: profile.webhook_consecutive_failures, + }) +} + +export async function PUT(request: Request) { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const rateLimitResult = rateLimit(`webhook-config:${user.id}`, 10, 60_000) + if (!rateLimitResult.success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }) + } + + const adminClient = createSupabaseAdminClient() + + const { data: profile } = await adminClient + .from("user_profiles") + .select("tier") + .eq("id", user.id) + .single() + + if ( + !profile || + !TIER_LIMITS[profile.tier as SubscriptionTier]?.allowsWebhooks + ) { + return NextResponse.json( + { error: "Webhooks require the developer plan" }, + { status: 403 } + ) + } + + const body = await request.json().catch(() => ({})) + + const updates: Record<string, unknown> = {} + + if (typeof body.webhookUrl === "string") { + const trimmedUrl = body.webhookUrl.trim() + if (trimmedUrl && !trimmedUrl.startsWith("https://")) { + return NextResponse.json( + { error: "Webhook URL must use HTTPS" }, + { status: 400 } + ) + } + updates.webhook_url = trimmedUrl || null + } + + if (typeof body.webhookSecret === "string") { + updates.webhook_secret = body.webhookSecret.trim() || null + } + + if (typeof body.webhookEnabled === "boolean") { + updates.webhook_enabled = body.webhookEnabled + if (body.webhookEnabled) { + updates.webhook_consecutive_failures = 0 + } + } + + if (Object.keys(updates).length === 0) { + return NextResponse.json({ error: "No updates provided" }, { status: 400 }) + } + + const { error } = await adminClient + .from("user_profiles") + .update(updates) + .eq("id", user.id) + + if (error) { + return NextResponse.json( + { error: "Failed to update webhook config" }, + { status: 500 } + ) + } + + return NextResponse.json({ updated: true }) +} diff --git a/apps/web/app/api/webhook-config/test/route.ts b/apps/web/app/api/webhook-config/test/route.ts new file mode 100644 index 0000000..684ec0c --- /dev/null +++ b/apps/web/app/api/webhook-config/test/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from "next/server" +import { createHmac } from "crypto" +import { createSupabaseServerClient } from "@/lib/supabase/server" +import { createSupabaseAdminClient } from "@/lib/supabase/admin" +import { TIER_LIMITS, type SubscriptionTier } from "@asa-news/shared" +import { rateLimit } from "@/lib/rate-limit" + +export async function POST() { + const supabaseClient = await createSupabaseServerClient() + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) { + return NextResponse.json({ error: "Not authenticated" }, { status: 401 }) + } + + const rateLimitResult = rateLimit(`webhook-test:${user.id}`, 5, 60_000) + if (!rateLimitResult.success) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }) + } + + const adminClient = createSupabaseAdminClient() + const { data: profile } = await adminClient + .from("user_profiles") + .select( + "tier, webhook_url, webhook_secret, webhook_enabled" + ) + .eq("id", user.id) + .single() + + if ( + !profile || + !TIER_LIMITS[profile.tier as SubscriptionTier]?.allowsWebhooks + ) { + return NextResponse.json( + { error: "Webhooks require the developer plan" }, + { status: 403 } + ) + } + + if (!profile.webhook_url) { + return NextResponse.json( + { error: "No webhook URL configured" }, + { status: 400 } + ) + } + + const testPayload = { + event: "test", + timestamp: new Date().toISOString(), + entries: [ + { + entryIdentifier: "test-entry-000", + feedIdentifier: "test-feed-000", + title: "Test webhook delivery", + url: "https://asa.news", + author: "asa.news", + summary: "This is a test webhook payload to verify your endpoint.", + publishedAt: new Date().toISOString(), + }, + ], + } + + const payloadString = JSON.stringify(testPayload) + const headers: Record<string, string> = { + "Content-Type": "application/json", + "User-Agent": "asa.news Webhook/1.0", + } + + if (profile.webhook_secret) { + const signature = createHmac("sha256", profile.webhook_secret) + .update(payloadString) + .digest("hex") + headers["X-Asa-Signature-256"] = `sha256=${signature}` + } + + try { + const response = await fetch(profile.webhook_url, { + method: "POST", + headers, + body: payloadString, + signal: AbortSignal.timeout(10_000), + }) + + return NextResponse.json({ + delivered: true, + statusCode: response.status, + }) + } catch (deliveryError) { + const errorMessage = + deliveryError instanceof Error + ? deliveryError.message + : "Unknown error" + + return NextResponse.json({ + delivered: false, + error: errorMessage, + }) + } +} |