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/v1 | |
| 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/v1')
| -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 |
7 files changed, 478 insertions, 0 deletions
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, + }, + }, + }) +} |