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/lib/queries | |
| 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/lib/queries')
21 files changed, 1590 insertions, 0 deletions
diff --git a/apps/web/lib/queries/query-keys.ts b/apps/web/lib/queries/query-keys.ts new file mode 100644 index 0000000..69e3407 --- /dev/null +++ b/apps/web/lib/queries/query-keys.ts @@ -0,0 +1,43 @@ +export const queryKeys = { + timeline: { + all: ["timeline"] as const, + list: (folderIdentifier?: string | null, feedIdentifier?: string | null, unreadOnly?: boolean) => + ["timeline", { folderIdentifier, feedIdentifier, unreadOnly }] as const, + }, + savedEntries: { + all: ["saved-entries"] as const, + }, + subscriptions: { + all: ["subscriptions"] as const, + }, + entryDetail: { + single: (entryIdentifier: string) => + ["entry-detail", entryIdentifier] as const, + }, + userProfile: { + all: ["user-profile"] as const, + }, + mutedKeywords: { + all: ["muted-keywords"] as const, + }, + unreadCounts: { + all: ["unread-counts"] as const, + }, + entrySearch: { + query: (searchQuery: string) => ["entry-search", searchQuery] as const, + }, + entryShare: { + single: (entryIdentifier: string) => + ["entry-share", entryIdentifier] as const, + }, + highlights: { + forEntry: (entryIdentifier: string) => + ["highlights", entryIdentifier] as const, + all: ["highlights"] as const, + }, + customFeeds: { + all: ["custom-feeds"] as const, + timeline: (customFeedIdentifier: string) => + ["custom-feed-timeline", customFeedIdentifier] as const, + }, +} diff --git a/apps/web/lib/queries/use-all-highlights.ts b/apps/web/lib/queries/use-all-highlights.ts new file mode 100644 index 0000000..39988de --- /dev/null +++ b/apps/web/lib/queries/use-all-highlights.ts @@ -0,0 +1,85 @@ +"use client" + +import { useInfiniteQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { HighlightWithEntryContext } from "@/lib/types/highlight" + +const HIGHLIGHTS_PAGE_SIZE = 50 + +interface HighlightWithContextRow { + id: string + entry_id: string + highlighted_text: string + note: string | null + text_offset: number + text_length: number + text_prefix: string + text_suffix: string + color: string + created_at: string + entries: { + id: string + title: string | null + feeds: { + title: string | null + } + } +} + +function mapRowToHighlightWithContext( + row: HighlightWithContextRow +): HighlightWithEntryContext { + return { + identifier: row.id, + entryIdentifier: row.entry_id, + highlightedText: row.highlighted_text, + note: row.note, + textOffset: row.text_offset, + textLength: row.text_length, + textPrefix: row.text_prefix, + textSuffix: row.text_suffix, + color: row.color, + createdAt: row.created_at, + entryTitle: row.entries?.title ?? null, + feedTitle: row.entries?.feeds?.title ?? null, + } +} + +export function useAllHighlights() { + const supabaseClient = createSupabaseBrowserClient() + + return useInfiniteQuery({ + queryKey: queryKeys.highlights.all, + queryFn: async ({ + pageParam, + }: { + pageParam: string | undefined + }) => { + let query = supabaseClient + .from("user_highlights") + .select( + "id, entry_id, highlighted_text, note, text_offset, text_length, text_prefix, text_suffix, color, created_at, entries!inner(id, title, feeds!inner(title))" + ) + .order("created_at", { ascending: false }) + .limit(HIGHLIGHTS_PAGE_SIZE) + + if (pageParam) { + query = query.lt("created_at", pageParam) + } + + const { data, error } = await query + + if (error) throw error + + return ( + (data as unknown as HighlightWithContextRow[]) ?? [] + ).map(mapRowToHighlightWithContext) + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage: HighlightWithEntryContext[]) => { + if (lastPage.length < HIGHLIGHTS_PAGE_SIZE) return undefined + return lastPage[lastPage.length - 1].createdAt + }, + }) +} diff --git a/apps/web/lib/queries/use-custom-feed-mutations.ts b/apps/web/lib/queries/use-custom-feed-mutations.ts new file mode 100644 index 0000000..f0751db --- /dev/null +++ b/apps/web/lib/queries/use-custom-feed-mutations.ts @@ -0,0 +1,122 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +export function useCreateCustomFeed() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + name, + query, + matchMode, + sourceFolderIdentifier, + }: { + name: string + query: string + matchMode: "and" | "or" + sourceFolderIdentifier: string | null + }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient.from("custom_feeds").insert({ + user_id: user.id, + name, + query, + match_mode: matchMode, + source_folder_id: sourceFolderIdentifier, + position: 0, + }) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.customFeeds.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("custom feed created") + }, + onError: (error: Error) => { + notify( + error.message.includes("limit") + ? "custom feed limit reached for your plan" + : "failed to create custom feed: " + error.message + ) + }, + }) +} + +export function useUpdateCustomFeed() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + customFeedIdentifier, + name, + query, + matchMode, + sourceFolderIdentifier, + }: { + customFeedIdentifier: string + name: string + query: string + matchMode: "and" | "or" + sourceFolderIdentifier: string | null + }) => { + const { error } = await supabaseClient + .from("custom_feeds") + .update({ + name, + query, + match_mode: matchMode, + source_folder_id: sourceFolderIdentifier, + }) + .eq("id", customFeedIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.customFeeds.all }) + notify("custom feed updated") + }, + onError: (error: Error) => { + notify("failed to update custom feed: " + error.message) + }, + }) +} + +export function useDeleteCustomFeed() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + customFeedIdentifier, + }: { + customFeedIdentifier: string + }) => { + const { error } = await supabaseClient + .from("custom_feeds") + .delete() + .eq("id", customFeedIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.customFeeds.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("custom feed deleted") + }, + onError: (error: Error) => { + notify("failed to delete custom feed: " + error.message) + }, + }) +} diff --git a/apps/web/lib/queries/use-custom-feed-timeline.ts b/apps/web/lib/queries/use-custom-feed-timeline.ts new file mode 100644 index 0000000..4224123 --- /dev/null +++ b/apps/web/lib/queries/use-custom-feed-timeline.ts @@ -0,0 +1,76 @@ +"use client" + +import { useInfiniteQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { TimelineEntry } from "@/lib/types/timeline" + +const TIMELINE_PAGE_SIZE = 50 + +interface TimelineRow { + entry_id: string + feed_id: string + feed_title: string + custom_title: string | null + entry_title: string + entry_url: string + author: string | null + summary: string | null + image_url: string | null + published_at: string + is_read: boolean + is_saved: boolean + enclosure_url: string | null + enclosure_type: string | null +} + +function mapRowToTimelineEntry(row: TimelineRow): TimelineEntry { + return { + entryIdentifier: row.entry_id, + feedIdentifier: row.feed_id, + feedTitle: row.feed_title, + customTitle: row.custom_title, + entryTitle: row.entry_title, + entryUrl: row.entry_url, + author: row.author, + summary: row.summary, + imageUrl: row.image_url, + publishedAt: row.published_at, + isRead: row.is_read, + isSaved: row.is_saved, + enclosureUrl: row.enclosure_url, + enclosureType: row.enclosure_type, + } +} + +export function useCustomFeedTimeline(customFeedIdentifier: string | null) { + const supabaseClient = createSupabaseBrowserClient() + + return useInfiniteQuery({ + queryKey: queryKeys.customFeeds.timeline(customFeedIdentifier ?? ""), + queryFn: async ({ + pageParam, + }: { + pageParam: string | undefined + }) => { + const { data, error } = await supabaseClient.rpc( + "get_custom_feed_timeline", + { + p_custom_feed_id: customFeedIdentifier!, + p_result_limit: TIMELINE_PAGE_SIZE, + p_pagination_cursor: pageParam ?? undefined, + } + ) + + if (error) throw error + + return ((data as TimelineRow[]) ?? []).map(mapRowToTimelineEntry) + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage: TimelineEntry[]) => { + if (lastPage.length < TIMELINE_PAGE_SIZE) return undefined + return lastPage[lastPage.length - 1].publishedAt + }, + enabled: !!customFeedIdentifier, + }) +} diff --git a/apps/web/lib/queries/use-custom-feeds.ts b/apps/web/lib/queries/use-custom-feeds.ts new file mode 100644 index 0000000..5c11721 --- /dev/null +++ b/apps/web/lib/queries/use-custom-feeds.ts @@ -0,0 +1,49 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { CustomFeed } from "@/lib/types/custom-feed" + +interface CustomFeedRow { + id: string + name: string + query: string + match_mode: string + source_folder_id: string | null + position: number +} + +export function useCustomFeeds() { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.customFeeds.all, + queryFn: async () => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { data, error } = await supabaseClient + .from("custom_feeds") + .select("id, name, query, match_mode, source_folder_id, position") + .eq("user_id", user.id) + .order("position") + + if (error) throw error + + return ((data as CustomFeedRow[]) ?? []).map( + (row): CustomFeed => ({ + identifier: row.id, + name: row.name, + query: row.query, + matchMode: row.match_mode as "and" | "or", + sourceFolderIdentifier: row.source_folder_id, + position: row.position, + }) + ) + }, + }) +} diff --git a/apps/web/lib/queries/use-entry-highlights.ts b/apps/web/lib/queries/use-entry-highlights.ts new file mode 100644 index 0000000..3fdada5 --- /dev/null +++ b/apps/web/lib/queries/use-entry-highlights.ts @@ -0,0 +1,56 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { Highlight } from "@/lib/types/highlight" + +interface HighlightRow { + id: string + entry_id: string + highlighted_text: string + note: string | null + text_offset: number + text_length: number + text_prefix: string + text_suffix: string + color: string + created_at: string +} + +function mapRowToHighlight(row: HighlightRow): Highlight { + return { + identifier: row.id, + entryIdentifier: row.entry_id, + highlightedText: row.highlighted_text, + note: row.note, + textOffset: row.text_offset, + textLength: row.text_length, + textPrefix: row.text_prefix, + textSuffix: row.text_suffix, + color: row.color, + createdAt: row.created_at, + } +} + +export function useEntryHighlights(entryIdentifier: string | null) { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.highlights.forEntry(entryIdentifier ?? ""), + enabled: !!entryIdentifier, + queryFn: async () => { + const { data, error } = await supabaseClient + .from("user_highlights") + .select( + "id, entry_id, highlighted_text, note, text_offset, text_length, text_prefix, text_suffix, color, created_at" + ) + .eq("entry_id", entryIdentifier!) + .order("text_offset", { ascending: true }) + + if (error) throw error + + return ((data as unknown as HighlightRow[]) ?? []).map(mapRowToHighlight) + }, + }) +} diff --git a/apps/web/lib/queries/use-entry-search.ts b/apps/web/lib/queries/use-entry-search.ts new file mode 100644 index 0000000..9e05ac8 --- /dev/null +++ b/apps/web/lib/queries/use-entry-search.ts @@ -0,0 +1,58 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { TimelineEntry } from "@/lib/types/timeline" + +interface SearchResultRow { + entry_id: string + feed_id: string + feed_title: string + custom_title: string | null + entry_title: string + entry_url: string + author: string | null + summary: string | null + image_url: string | null + published_at: string + is_read: boolean + is_saved: boolean +} + +export function useEntrySearch(searchQuery: string) { + const supabaseClient = createSupabaseBrowserClient() + const trimmedQuery = searchQuery.trim() + + return useQuery({ + queryKey: queryKeys.entrySearch.query(trimmedQuery), + queryFn: async () => { + const { data, error } = await supabaseClient.rpc("search_entries", { + p_query: trimmedQuery, + p_result_limit: 30, + }) + + if (error) throw error + + return ((data as SearchResultRow[]) ?? []).map( + (row): TimelineEntry => ({ + entryIdentifier: row.entry_id, + feedIdentifier: row.feed_id, + feedTitle: row.feed_title, + customTitle: row.custom_title, + entryTitle: row.entry_title, + entryUrl: row.entry_url, + author: row.author, + summary: row.summary, + imageUrl: row.image_url, + publishedAt: row.published_at, + isRead: row.is_read, + isSaved: row.is_saved, + enclosureUrl: null, + enclosureType: null, + }) + ) + }, + enabled: trimmedQuery.length >= 2, + }) +} diff --git a/apps/web/lib/queries/use-entry-share.ts b/apps/web/lib/queries/use-entry-share.ts new file mode 100644 index 0000000..bba7aa3 --- /dev/null +++ b/apps/web/lib/queries/use-entry-share.ts @@ -0,0 +1,36 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" + +interface EntryShareResult { + shareToken: string | null + isShared: boolean +} + +export function useEntryShare(entryIdentifier: string | null): { + data: EntryShareResult | undefined + isLoading: boolean +} { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.entryShare.single(entryIdentifier ?? ""), + enabled: !!entryIdentifier, + queryFn: async (): Promise<EntryShareResult> => { + const { data, error } = await supabaseClient + .from("shared_entries") + .select("share_token") + .eq("entry_id", entryIdentifier!) + .maybeSingle() + + if (error) throw error + + return { + shareToken: data?.share_token ?? null, + isShared: !!data, + } + }, + }) +} diff --git a/apps/web/lib/queries/use-entry-state-mutations.ts b/apps/web/lib/queries/use-entry-state-mutations.ts new file mode 100644 index 0000000..5f79fc0 --- /dev/null +++ b/apps/web/lib/queries/use-entry-state-mutations.ts @@ -0,0 +1,133 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { TimelineEntry } from "@/lib/types/timeline" +import type { InfiniteData } from "@tanstack/react-query" + +export function useToggleEntryReadState() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + entryIdentifier, + isRead, + }: { + entryIdentifier: string + isRead: boolean + }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient + .from("user_entry_states") + .upsert( + { + user_id: user.id, + entry_id: entryIdentifier, + read: isRead, + read_at: isRead ? new Date().toISOString() : null, + }, + { onConflict: "user_id,entry_id" } + ) + + if (error) throw error + }, + onMutate: async ({ entryIdentifier, isRead }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.timeline.all }) + + const previousTimeline = queryClient.getQueriesData< + InfiniteData<TimelineEntry[]> + >({ queryKey: queryKeys.timeline.all }) + + queryClient.setQueriesData<InfiniteData<TimelineEntry[]>>( + { queryKey: queryKeys.timeline.all }, + (existingData) => { + if (!existingData) return existingData + + return { + ...existingData, + pages: existingData.pages.map((page) => + page.map((entry) => + entry.entryIdentifier === entryIdentifier + ? { ...entry, isRead } + : entry + ) + ), + } + } + ) + + return { previousTimeline } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.savedEntries.all }) + }, + }) +} + +export function useToggleEntrySavedState() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + entryIdentifier, + isSaved, + }: { + entryIdentifier: string + isSaved: boolean + }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient + .from("user_entry_states") + .upsert( + { + user_id: user.id, + entry_id: entryIdentifier, + saved: isSaved, + saved_at: isSaved ? new Date().toISOString() : null, + }, + { onConflict: "user_id,entry_id" } + ) + + if (error) throw error + }, + onMutate: async ({ entryIdentifier, isSaved }) => { + await queryClient.cancelQueries({ queryKey: queryKeys.timeline.all }) + + queryClient.setQueriesData<InfiniteData<TimelineEntry[]>>( + { queryKey: queryKeys.timeline.all }, + (existingData) => { + if (!existingData) return existingData + + return { + ...existingData, + pages: existingData.pages.map((page) => + page.map((entry) => + entry.entryIdentifier === entryIdentifier + ? { ...entry, isSaved } + : entry + ) + ), + } + } + ) + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.savedEntries.all }) + }, + }) +} diff --git a/apps/web/lib/queries/use-folder-mutations.ts b/apps/web/lib/queries/use-folder-mutations.ts new file mode 100644 index 0000000..8595a60 --- /dev/null +++ b/apps/web/lib/queries/use-folder-mutations.ts @@ -0,0 +1,137 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +export function useCreateFolder() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ name }: { name: string }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient.from("folders").insert({ + user_id: user.id, + name, + position: 0, + }) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("folder created") + }, + onError: (error: Error) => { + notify(error.message.includes("limit") + ? "folder limit reached for your plan" + : "failed to create folder: " + error.message) + }, + }) +} + +export function useDeleteAllFolders() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async () => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + await supabaseClient + .from("subscriptions") + .update({ folder_id: null }) + .eq("user_id", user.id) + .not("folder_id", "is", null) + + const { error } = await supabaseClient + .from("folders") + .delete() + .eq("user_id", user.id) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("all folders deleted") + }, + onError: (error: Error) => { + notify("failed to delete all folders: " + error.message) + }, + }) +} + +export function useRenameFolder() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + folderIdentifier, + name, + }: { + folderIdentifier: string + name: string + }) => { + const { error } = await supabaseClient + .from("folders") + .update({ name }) + .eq("id", folderIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + notify("folder renamed") + }, + onError: (error: Error) => { + notify("failed to rename folder: " + error.message) + }, + }) +} + +export function useDeleteFolder() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + folderIdentifier, + }: { + folderIdentifier: string + }) => { + await supabaseClient + .from("subscriptions") + .update({ folder_id: null }) + .eq("folder_id", folderIdentifier) + + const { error } = await supabaseClient + .from("folders") + .delete() + .eq("id", folderIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("folder deleted") + }, + onError: (error: Error) => { + notify("failed to delete folder: " + error.message) + }, + }) +} diff --git a/apps/web/lib/queries/use-highlight-mutations.ts b/apps/web/lib/queries/use-highlight-mutations.ts new file mode 100644 index 0000000..0e228c8 --- /dev/null +++ b/apps/web/lib/queries/use-highlight-mutations.ts @@ -0,0 +1,132 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +interface CreateHighlightParameters { + entryIdentifier: string + highlightedText: string + textOffset: number + textLength: number + textPrefix: string + textSuffix: string + note?: string | null + color?: string +} + +export function useCreateHighlight() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (parameters: CreateHighlightParameters) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { data, error } = await supabaseClient + .from("user_highlights") + .insert({ + user_id: user.id, + entry_id: parameters.entryIdentifier, + highlighted_text: parameters.highlightedText, + text_offset: parameters.textOffset, + text_length: parameters.textLength, + text_prefix: parameters.textPrefix, + text_suffix: parameters.textSuffix, + note: parameters.note ?? null, + color: parameters.color ?? "yellow", + }) + .select("id") + .single() + + if (error) throw error + + return data.id as string + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.highlights.forEntry(variables.entryIdentifier), + }) + queryClient.invalidateQueries({ queryKey: queryKeys.highlights.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("text highlighted") + }, + onError: (error: Error) => { + notify( + error.message.includes("limit") + ? "highlight limit reached for your plan" + : "failed to create highlight" + ) + }, + }) +} + +export function useUpdateHighlightNote() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + highlightIdentifier, + note, + }: { + highlightIdentifier: string + note: string | null + entryIdentifier: string + }) => { + const { error } = await supabaseClient + .from("user_highlights") + .update({ note }) + .eq("id", highlightIdentifier) + + if (error) throw error + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.highlights.forEntry(variables.entryIdentifier), + }) + queryClient.invalidateQueries({ queryKey: queryKeys.highlights.all }) + notify("note updated") + }, + onError: () => { + notify("failed to update note") + }, + }) +} + +export function useDeleteHighlight() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + highlightIdentifier, + }: { + highlightIdentifier: string + entryIdentifier: string + }) => { + const { error } = await supabaseClient + .from("user_highlights") + .delete() + .eq("id", highlightIdentifier) + + if (error) throw error + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: queryKeys.highlights.forEntry(variables.entryIdentifier), + }) + queryClient.invalidateQueries({ queryKey: queryKeys.highlights.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("highlight removed") + }, + onError: () => { + notify("failed to remove highlight") + }, + }) +} diff --git a/apps/web/lib/queries/use-mark-all-as-read.ts b/apps/web/lib/queries/use-mark-all-as-read.ts new file mode 100644 index 0000000..fdda661 --- /dev/null +++ b/apps/web/lib/queries/use-mark-all-as-read.ts @@ -0,0 +1,48 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +export function useMarkAllAsRead() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + feedIdentifier, + folderIdentifier, + readState = true, + }: { + feedIdentifier?: string | null + folderIdentifier?: string | null + readState?: boolean + } = {}) => { + const { data, error } = await supabaseClient.rpc("mark_all_as_read", { + p_feed_id: feedIdentifier ?? null, + p_folder_id: folderIdentifier ?? null, + p_read_state: readState, + }) + + if (error) throw error + + return data as number + }, + onSuccess: (affectedCount, variables) => { + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.unreadCounts.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.savedEntries.all }) + + const action = variables?.readState === false ? "unread" : "read" + + if (affectedCount > 0) { + notify(`marked ${affectedCount} entries as ${action}`) + } + }, + onError: (_, variables) => { + const action = variables?.readState === false ? "unread" : "read" + notify(`failed to mark entries as ${action}`) + }, + }) +} diff --git a/apps/web/lib/queries/use-muted-keyword-mutations.ts b/apps/web/lib/queries/use-muted-keyword-mutations.ts new file mode 100644 index 0000000..67bcf33 --- /dev/null +++ b/apps/web/lib/queries/use-muted-keyword-mutations.ts @@ -0,0 +1,68 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +export function useAddMutedKeyword() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ keyword }: { keyword: string }) => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient.from("muted_keywords").insert({ + user_id: user.id, + keyword: keyword.toLowerCase().trim(), + }) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.mutedKeywords.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("keyword muted") + }, + onError: (error: Error) => { + notify(error.message.includes("limit") + ? "muted keyword limit reached for your plan" + : "failed to mute keyword: " + error.message) + }, + }) +} + +export function useDeleteMutedKeyword() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + keywordIdentifier, + }: { + keywordIdentifier: string + }) => { + const { error } = await supabaseClient + .from("muted_keywords") + .delete() + .eq("id", keywordIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.mutedKeywords.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("keyword unmuted") + }, + onError: (error: Error) => { + notify("failed to unmute keyword: " + error.message) + }, + }) +} diff --git a/apps/web/lib/queries/use-muted-keywords.ts b/apps/web/lib/queries/use-muted-keywords.ts new file mode 100644 index 0000000..ce1b53e --- /dev/null +++ b/apps/web/lib/queries/use-muted-keywords.ts @@ -0,0 +1,30 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { MutedKeyword } from "@/lib/types/user-profile" + +export function useMutedKeywords() { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.mutedKeywords.all, + queryFn: async () => { + const { data, error } = await supabaseClient + .from("muted_keywords") + .select("id, keyword, created_at") + .order("created_at", { ascending: false }) + + if (error) throw error + + const keywords: MutedKeyword[] = (data ?? []).map((row) => ({ + identifier: row.id, + keyword: row.keyword, + createdAt: row.created_at, + })) + + return keywords + }, + }) +} diff --git a/apps/web/lib/queries/use-saved-entries.ts b/apps/web/lib/queries/use-saved-entries.ts new file mode 100644 index 0000000..bdfcec9 --- /dev/null +++ b/apps/web/lib/queries/use-saved-entries.ts @@ -0,0 +1,88 @@ +"use client" + +import { useInfiniteQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { TimelineEntry } from "@/lib/types/timeline" + +const SAVED_PAGE_SIZE = 50 + +interface SavedEntryRow { + entry_id: string + read: boolean + saved: boolean + saved_at: string + entries: { + id: string + feed_id: string + 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 + feeds: { + title: string | null + } + } +} + +function mapSavedRowToTimelineEntry(row: SavedEntryRow): TimelineEntry { + return { + entryIdentifier: row.entries.id, + feedIdentifier: row.entries.feed_id, + feedTitle: row.entries.feeds?.title ?? "", + customTitle: null, + entryTitle: row.entries.title ?? "", + entryUrl: row.entries.url ?? "", + author: row.entries.author, + summary: row.entries.summary, + imageUrl: row.entries.image_url, + publishedAt: row.entries.published_at ?? "", + isRead: row.read, + isSaved: row.saved, + enclosureUrl: row.entries.enclosure_url, + enclosureType: row.entries.enclosure_type, + } +} + +export function useSavedEntries() { + const supabaseClient = createSupabaseBrowserClient() + + return useInfiniteQuery({ + queryKey: queryKeys.savedEntries.all, + queryFn: async ({ + pageParam, + }: { + pageParam: string | undefined + }) => { + let query = supabaseClient + .from("user_entry_states") + .select( + "entry_id, read, saved, saved_at, entries!inner(id, feed_id, title, url, author, summary, image_url, published_at, enclosure_url, enclosure_type, feeds!inner(title))" + ) + .eq("saved", true) + .order("saved_at", { ascending: false }) + .limit(SAVED_PAGE_SIZE) + + if (pageParam) { + query = query.lt("saved_at", pageParam) + } + + const { data, error } = await query + + if (error) throw error + + return ((data as unknown as SavedEntryRow[]) ?? []).map( + mapSavedRowToTimelineEntry + ) + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage: TimelineEntry[]) => { + if (lastPage.length < SAVED_PAGE_SIZE) return undefined + return lastPage[lastPage.length - 1].publishedAt + }, + }) +} diff --git a/apps/web/lib/queries/use-subscribe-to-feed.ts b/apps/web/lib/queries/use-subscribe-to-feed.ts new file mode 100644 index 0000000..5e585a9 --- /dev/null +++ b/apps/web/lib/queries/use-subscribe-to-feed.ts @@ -0,0 +1,37 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +export function useSubscribeToFeed() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (parameters: { + feedUrl: string + folderIdentifier?: string | null + customTitle?: string | null + }) => { + const { data, error } = await supabaseClient.rpc("subscribe_to_feed", { + feed_url: parameters.feedUrl, + target_folder_id: parameters.folderIdentifier ?? undefined, + feed_custom_title: parameters.customTitle ?? undefined, + }) + + if (error) throw error + + return data + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + notify("feed added successfully") + }, + onError: (error: Error) => { + notify("failed to add feed: " + error.message) + }, + }) +} diff --git a/apps/web/lib/queries/use-subscription-mutations.ts b/apps/web/lib/queries/use-subscription-mutations.ts new file mode 100644 index 0000000..3b4b3ba --- /dev/null +++ b/apps/web/lib/queries/use-subscription-mutations.ts @@ -0,0 +1,158 @@ +"use client" + +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import { notify } from "@/lib/notify" + +export function useUpdateSubscriptionTitle() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + subscriptionIdentifier, + customTitle, + }: { + subscriptionIdentifier: string + customTitle: string | null + }) => { + const { error } = await supabaseClient + .from("subscriptions") + .update({ custom_title: customTitle }) + .eq("id", subscriptionIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + notify("title updated") + }, + onError: (error: Error) => { + notify("failed to update title: " + error.message) + }, + }) +} + +export function useMoveSubscriptionToFolder() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + subscriptionIdentifier, + folderIdentifier, + }: { + subscriptionIdentifier: string + folderIdentifier: string | null + feedTitle?: string + sourceFolderName?: string + folderName?: string + }) => { + const { error } = await supabaseClient + .from("subscriptions") + .update({ folder_id: folderIdentifier }) + .eq("id", subscriptionIdentifier) + + if (error) throw error + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + const source = variables.sourceFolderName ?? "no folder" + const destination = variables.folderName ?? "no folder" + const feedLabel = variables.feedTitle ?? "feed" + notify(`moved "${feedLabel}" from ${source} to ${destination}`) + }, + onError: (error: Error) => { + notify("failed to move feed: " + error.message) + }, + }) +} + +export function useUnsubscribe() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + subscriptionIdentifier, + }: { + subscriptionIdentifier: string + }) => { + const { error } = await supabaseClient + .from("subscriptions") + .delete() + .eq("id", subscriptionIdentifier) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("unsubscribed") + }, + onError: (error: Error) => { + notify("failed to unsubscribe: " + error.message) + }, + }) +} + +export function useUnsubscribeAll() { + const supabaseClient = createSupabaseBrowserClient() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async () => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { error } = await supabaseClient + .from("subscriptions") + .delete() + .eq("user_id", user.id) + + if (error) throw error + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.subscriptions.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.timeline.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.unreadCounts.all }) + queryClient.invalidateQueries({ queryKey: queryKeys.userProfile.all }) + notify("all feeds removed") + }, + onError: (error: Error) => { + notify("failed to remove all feeds: " + error.message) + }, + }) +} + +export function useRequestFeedRefresh() { + const supabaseClient = createSupabaseBrowserClient() + + return useMutation({ + mutationFn: async ({ + subscriptionIdentifier, + }: { + subscriptionIdentifier: string + }) => { + const { error } = await supabaseClient.rpc("request_feed_refresh", { + target_subscription_id: subscriptionIdentifier, + }) + + if (error) throw error + }, + onSuccess: () => { + notify("refresh requested") + }, + onError: (error: Error) => { + notify(error.message.includes("Pro") + ? "manual refresh requires a pro subscription" + : "failed to request refresh: " + error.message) + }, + }) +} diff --git a/apps/web/lib/queries/use-subscriptions.ts b/apps/web/lib/queries/use-subscriptions.ts new file mode 100644 index 0000000..ebf099d --- /dev/null +++ b/apps/web/lib/queries/use-subscriptions.ts @@ -0,0 +1,78 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { Folder, Subscription } from "@/lib/types/subscription" + +interface SubscriptionRow { + id: string + feed_id: string + folder_id: string | null + custom_title: string | null + position: number + feeds: { + title: string | null + url: string + consecutive_failures: number + last_fetch_error: string | null + last_fetched_at: string | null + fetch_interval_seconds: number + feed_type: string | null + } +} + +interface FolderRow { + id: string + name: string + position: number +} + +export function useSubscriptions() { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.subscriptions.all, + queryFn: async () => { + const [subscriptionsResult, foldersResult] = await Promise.all([ + supabaseClient + .from("subscriptions") + .select("id, feed_id, folder_id, custom_title, position, feeds(title, url, consecutive_failures, last_fetch_error, last_fetched_at, fetch_interval_seconds, feed_type)") + .order("position", { ascending: true }), + supabaseClient + .from("folders") + .select("id, name, position") + .order("position", { ascending: true }), + ]) + + if (subscriptionsResult.error) throw subscriptionsResult.error + if (foldersResult.error) throw foldersResult.error + + const subscriptions: Subscription[] = ( + (subscriptionsResult.data as unknown as SubscriptionRow[]) ?? [] + ).map((row) => ({ + subscriptionIdentifier: row.id, + feedIdentifier: row.feed_id, + folderIdentifier: row.folder_id, + customTitle: row.custom_title, + feedTitle: row.feeds?.title ?? "", + feedUrl: row.feeds?.url ?? "", + consecutiveFailures: row.feeds?.consecutive_failures ?? 0, + lastFetchError: row.feeds?.last_fetch_error ?? null, + lastFetchedAt: row.feeds?.last_fetched_at ?? null, + fetchIntervalSeconds: row.feeds?.fetch_interval_seconds ?? 3600, + feedType: row.feeds?.feed_type ?? null, + })) + + const folders: Folder[] = ( + (foldersResult.data as unknown as FolderRow[]) ?? [] + ).map((row) => ({ + folderIdentifier: row.id, + name: row.name, + position: row.position, + })) + + return { subscriptions, folders } + }, + }) +} diff --git a/apps/web/lib/queries/use-timeline.ts b/apps/web/lib/queries/use-timeline.ts new file mode 100644 index 0000000..5a38aba --- /dev/null +++ b/apps/web/lib/queries/use-timeline.ts @@ -0,0 +1,78 @@ +"use client" + +import { useInfiniteQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { TimelineEntry } from "@/lib/types/timeline" + +const TIMELINE_PAGE_SIZE = 50 + +interface TimelineRow { + entry_id: string + feed_id: string + feed_title: string + custom_title: string | null + entry_title: string + entry_url: string + author: string | null + summary: string | null + image_url: string | null + published_at: string + is_read: boolean + is_saved: boolean + enclosure_url: string | null + enclosure_type: string | null +} + +function mapRowToTimelineEntry(row: TimelineRow): TimelineEntry { + return { + entryIdentifier: row.entry_id, + feedIdentifier: row.feed_id, + feedTitle: row.feed_title, + customTitle: row.custom_title, + entryTitle: row.entry_title, + entryUrl: row.entry_url, + author: row.author, + summary: row.summary, + imageUrl: row.image_url, + publishedAt: row.published_at, + isRead: row.is_read, + isSaved: row.is_saved, + enclosureUrl: row.enclosure_url, + enclosureType: row.enclosure_type, + } +} + +export function useTimeline( + folderIdentifier?: string | null, + feedIdentifier?: string | null, + unreadOnly?: boolean +) { + const supabaseClient = createSupabaseBrowserClient() + + return useInfiniteQuery({ + queryKey: queryKeys.timeline.list(folderIdentifier, feedIdentifier, unreadOnly), + queryFn: async ({ + pageParam, + }: { + pageParam: string | undefined + }) => { + const { data, error } = await supabaseClient.rpc("get_timeline", { + target_folder_id: folderIdentifier ?? undefined, + target_feed_id: feedIdentifier ?? undefined, + result_limit: TIMELINE_PAGE_SIZE, + pagination_cursor: pageParam ?? undefined, + unread_only: unreadOnly ?? false, + }) + + if (error) throw error + + return ((data as TimelineRow[]) ?? []).map(mapRowToTimelineEntry) + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage: TimelineEntry[]) => { + if (lastPage.length < TIMELINE_PAGE_SIZE) return undefined + return lastPage[lastPage.length - 1].publishedAt + }, + }) +} diff --git a/apps/web/lib/queries/use-unread-counts.ts b/apps/web/lib/queries/use-unread-counts.ts new file mode 100644 index 0000000..75deccb --- /dev/null +++ b/apps/web/lib/queries/use-unread-counts.ts @@ -0,0 +1,32 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" + +interface UnreadCountRow { + feed_id: string + unread_count: number +} + +export function useUnreadCounts() { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.unreadCounts.all, + queryFn: async () => { + const { data, error } = await supabaseClient.rpc("get_unread_counts") + + if (error) throw error + + const countsByFeedIdentifier: Record<string, number> = {} + + for (const row of (data as UnreadCountRow[]) ?? []) { + countsByFeedIdentifier[row.feed_id] = row.unread_count + } + + return countsByFeedIdentifier + }, + refetchInterval: 60_000, + }) +} diff --git a/apps/web/lib/queries/use-user-profile.ts b/apps/web/lib/queries/use-user-profile.ts new file mode 100644 index 0000000..760f970 --- /dev/null +++ b/apps/web/lib/queries/use-user-profile.ts @@ -0,0 +1,46 @@ +"use client" + +import { useQuery } from "@tanstack/react-query" +import { createSupabaseBrowserClient } from "@/lib/supabase/client" +import { queryKeys } from "./query-keys" +import type { UserProfile } from "@/lib/types/user-profile" + +export function useUserProfile() { + const supabaseClient = createSupabaseBrowserClient() + + return useQuery({ + queryKey: queryKeys.userProfile.all, + queryFn: async () => { + const { + data: { user }, + } = await supabaseClient.auth.getUser() + + if (!user) throw new Error("Not authenticated") + + const { data, error } = await supabaseClient + .from("user_profiles") + .select( + "id, display_name, tier, feed_count, folder_count, muted_keyword_count, custom_feed_count, stripe_subscription_status, stripe_current_period_end" + ) + .eq("id", user.id) + .single() + + if (error) throw error + + const profile: UserProfile = { + identifier: data.id, + email: user.email ?? null, + displayName: data.display_name, + tier: data.tier, + feedCount: data.feed_count, + folderCount: data.folder_count, + mutedKeywordCount: data.muted_keyword_count, + customFeedCount: data.custom_feed_count, + stripeSubscriptionStatus: data.stripe_subscription_status, + stripeCurrentPeriodEnd: data.stripe_current_period_end, + } + + return profile + }, + }) +} |