summaryrefslogtreecommitdiff
path: root/apps/web/app/api/v1
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-07 01:42:57 -0800
committerFuwn <[email protected]>2026-02-07 01:42:57 -0800
commit5c5b1993edd890a80870ee05607ac5f088191d4e (patch)
treea721b76bcd49ba10826c53efc87302c7a689512f /apps/web/app/api/v1
downloadasa.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.ts72
-rw-r--r--apps/web/app/api/v1/entries/route.ts114
-rw-r--r--apps/web/app/api/v1/feeds/route.ts55
-rw-r--r--apps/web/app/api/v1/folders/route.ts36
-rw-r--r--apps/web/app/api/v1/keys/[keyIdentifier]/route.ts36
-rw-r--r--apps/web/app/api/v1/keys/route.ts116
-rw-r--r--apps/web/app/api/v1/profile/route.ts49
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,
+ },
+ },
+ })
+}