From b6a580d5e8eecb36c1f76665ed0ea544d30de6ae Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sun, 28 Jun 2026 06:00:19 +0700 Subject: [PATCH 01/18] feat(web): prototype RSS feed review shelves Add the throwaway RSS review prototype used to settle the source-grouped shelves direction before production implementation. --- .../prototype/prototype-switcher.tsx | 98 +++ .../web/src/features/feeds/prototype/NOTES.md | 13 + .../prototype/feeds-review-prototype.tsx | 747 ++++++++++++++++++ apps/web/src/routeTree.gen.ts | 23 + .../_with-layout/prototype-feeds.tsx | 43 + 5 files changed, 924 insertions(+) create mode 100644 apps/web/src/components/prototype/prototype-switcher.tsx create mode 100644 apps/web/src/features/feeds/prototype/NOTES.md create mode 100644 apps/web/src/features/feeds/prototype/feeds-review-prototype.tsx create mode 100644 apps/web/src/routes/_protected/_with-layout/prototype-feeds.tsx diff --git a/apps/web/src/components/prototype/prototype-switcher.tsx b/apps/web/src/components/prototype/prototype-switcher.tsx new file mode 100644 index 0000000..bf8511b --- /dev/null +++ b/apps/web/src/components/prototype/prototype-switcher.tsx @@ -0,0 +1,98 @@ +import { CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'; +import { useEffect } from 'react'; + +import { Button } from '@/components/ui/button'; + +import { cn } from '@/lib/utils'; + +interface PrototypeVariantOption { + key: string; + name: string; +} + +interface PrototypeSwitcherProps { + className?: string; + current: string; + onChange: (variant: string) => void; + variants: readonly PrototypeVariantOption[]; +} + +function isTextEntryElement(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + + const tagName = target.tagName.toLowerCase(); + return tagName === 'input' || tagName === 'textarea' || target.isContentEditable; +} + +export function PrototypeSwitcher({ + className, + current, + onChange, + variants +}: PrototypeSwitcherProps) { + const currentIndex = Math.max( + variants.findIndex((variant) => variant.key === current), + 0 + ); + const currentVariant = variants[currentIndex] ?? variants[0]; + + const move = (direction: -1 | 1) => { + const nextIndex = (currentIndex + direction + variants.length) % variants.length; + const nextVariant = variants[nextIndex]; + if (nextVariant) onChange(nextVariant.key); + }; + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (isTextEntryElement(event.target)) return; + if (event.key === 'ArrowLeft') { + event.preventDefault(); + move(-1); + } + if (event.key === 'ArrowRight') { + event.preventDefault(); + move(1); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }); + + if (import.meta.env.PROD || !currentVariant) return null; + + return ( +
+
+ +
+ {currentVariant.key} — {currentVariant.name} +
+ +
+
+ ); +} diff --git a/apps/web/src/features/feeds/prototype/NOTES.md b/apps/web/src/features/feeds/prototype/NOTES.md new file mode 100644 index 0000000..a673f28 --- /dev/null +++ b/apps/web/src/features/feeds/prototype/NOTES.md @@ -0,0 +1,13 @@ +# RSS feeds UI prototype notes + +**Question:** What should the RSS Feeds/Review surface look like before it becomes production UI? + +**How to view:** Run `pnpm --filter web dev`, open `/prototype-feeds`, and switch variants with the floating bottom bar or `?variant=A|B|C`. + +**Variants:** + +- `A` — Review desk: subscriptions on the left, staged items as the main work area. +- `B` — Quiet river: chronological review stream first, feed health tucked into a side rail. +- `C` — Feed shelves: each subscription owns its own review shelf. + +**Verdict:** TBD after review. Delete this prototype or fold the winning direction into production Feeds UI when decided. diff --git a/apps/web/src/features/feeds/prototype/feeds-review-prototype.tsx b/apps/web/src/features/feeds/prototype/feeds-review-prototype.tsx new file mode 100644 index 0000000..d712d0e --- /dev/null +++ b/apps/web/src/features/feeds/prototype/feeds-review-prototype.tsx @@ -0,0 +1,747 @@ +import { + ArrowSquareOutIcon, + BookmarkSimpleIcon, + CheckCircleIcon, + ClockIcon, + MagnifyingGlassIcon, + PlusIcon, + RssIcon, + WarningCircleIcon +} from '@phosphor-icons/react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; + +import { cn } from '@/lib/utils'; + +// PROTOTYPE — throwaway UI exploration for RSS Feed Support. +// Three variants of the Feeds/Review surface, switchable via `?variant=`, on `/prototype-feeds`. + +const subscriptions = [ + { + autoSave: false, + domain: 'interconnected.org', + error: null, + id: 'feeds-1', + lastFetched: '18 min ago', + newCount: 6, + title: 'Interconnected', + url: 'https://interconnected.org/home/feed' + }, + { + autoSave: true, + domain: 'ciechanow.ski', + error: null, + id: 'feeds-2', + lastFetched: '2 hr ago', + newCount: 0, + title: 'Bartosz Ciechanowski', + url: 'https://ciechanow.ski/atom.xml' + }, + { + autoSave: false, + domain: 'example.com', + error: 'Last fetch timed out. Loreo will try again later.', + id: 'feeds-3', + lastFetched: 'Yesterday', + newCount: 2, + title: 'Quiet Technical Notes', + url: 'https://example.com/rss.xml' + }, + { + autoSave: false, + domain: 'werd.io', + error: null, + id: 'feeds-4', + lastFetched: '31 min ago', + newCount: 4, + title: 'Werd I/O', + url: 'https://werd.io/content/feed' + }, + { + autoSave: false, + domain: 'simonwillison.net', + error: null, + id: 'feeds-5', + lastFetched: '44 min ago', + newCount: 9, + title: 'Simon Willison’s Weblog', + url: 'https://simonwillison.net/atom/everything/' + }, + { + autoSave: true, + domain: 'jvns.ca', + error: null, + id: 'feeds-6', + lastFetched: '1 hr ago', + newCount: 0, + title: 'Julia Evans', + url: 'https://jvns.ca/atom.xml' + }, + { + autoSave: false, + domain: 'pluralistic.net', + error: null, + id: 'feeds-7', + lastFetched: '3 hr ago', + newCount: 5, + title: 'Pluralistic', + url: 'https://pluralistic.net/feed/' + }, + { + autoSave: false, + domain: 'newsletter.pragmaticengineer.com', + error: 'Feed returned 429. Loreo is waiting before trying again.', + id: 'feeds-8', + lastFetched: '8 hr ago', + newCount: 1, + title: 'The Pragmatic Engineer', + url: 'https://newsletter.pragmaticengineer.com/feed' + }, + { + autoSave: false, + domain: 'maggieappleton.com', + error: null, + id: 'feeds-9', + lastFetched: '12 hr ago', + newCount: 3, + title: 'Maggie Appleton', + url: 'https://maggieappleton.com/rss.xml' + }, + { + autoSave: false, + domain: 'rachelbythebay.com', + error: null, + id: 'feeds-10', + lastFetched: 'Yesterday', + newCount: 7, + title: 'Rachel by the Bay', + url: 'https://rachelbythebay.com/w/atom.xml' + }, + { + autoSave: false, + domain: 'inkandswitch.com', + error: null, + id: 'feeds-11', + lastFetched: '2 days ago', + newCount: 2, + title: 'Ink & Switch', + url: 'https://www.inkandswitch.com/rss.xml' + }, + { + autoSave: false, + domain: 'nadia.xyz', + error: 'Feed metadata changed. Loreo kept the old title until the next clean fetch.', + id: 'feeds-12', + lastFetched: '4 days ago', + newCount: 1, + title: 'Nadia Asparouhova', + url: 'https://nadia.xyz/rss.xml' + } +]; + +const feedItems = [ + { + author: 'Matt Webb', + domain: 'interconnected.org', + excerpt: + 'A short meditation on small tools, slow attention, and how software changes the shape of a morning.', + feedId: 'feeds-1', + id: 'item-1', + publishedAt: 'Today', + source: 'Interconnected', + state: 'new', + title: 'Notes from a room full of clocks' + }, + { + author: 'Bartosz Ciechanowski', + domain: 'ciechanow.ski', + excerpt: + 'A visual explanation of a familiar mechanism, starting from first principles and building up patiently.', + feedId: 'feeds-2', + id: 'item-2', + publishedAt: 'Yesterday', + source: 'Bartosz Ciechanowski', + state: 'saved', + title: 'The shape of gears' + }, + { + author: 'A. Reader', + domain: 'example.com', + excerpt: + 'Practical notes on keeping local-first tools useful without turning them into another dashboard.', + feedId: 'feeds-3', + id: 'item-3', + publishedAt: '2 days ago', + source: 'Quiet Technical Notes', + state: 'new', + title: 'A calmer way to collect references' + }, + { + author: 'Matt Webb', + domain: 'interconnected.org', + excerpt: + 'A lightweight sketch of what happens when an interface gives people room to return later.', + feedId: 'feeds-1', + id: 'item-4', + publishedAt: '3 days ago', + source: 'Interconnected', + state: 'dismissed', + title: 'Later is a useful state' + }, + { + author: 'Ben Werdmuller', + domain: 'werd.io', + excerpt: + 'Notes on independent publishing, small protocols, and the social texture of owning your writing online.', + feedId: 'feeds-4', + id: 'item-5', + publishedAt: 'Today', + source: 'Werd I/O', + state: 'new', + title: 'Owning the little pieces' + }, + { + author: 'Simon Willison', + domain: 'simonwillison.net', + excerpt: + 'A compact walkthrough of a surprising command-line trick and the rough edges that made it memorable.', + feedId: 'feeds-5', + id: 'item-6', + publishedAt: 'Today', + source: 'Simon Willison’s Weblog', + state: 'new', + title: 'Small tools with sharp handles' + }, + { + author: 'Julia Evans', + domain: 'jvns.ca', + excerpt: + 'A friendly explanation of a networking behavior that looks mysterious until the packet path is drawn out.', + feedId: 'feeds-6', + id: 'item-7', + publishedAt: 'Yesterday', + source: 'Julia Evans', + state: 'saved', + title: 'Why did my DNS do that?' + }, + { + author: 'Cory Doctorow', + domain: 'pluralistic.net', + excerpt: + 'A long-form argument about interoperability, reader agency, and why defaults matter more than slogans.', + feedId: 'feeds-7', + id: 'item-8', + publishedAt: 'Yesterday', + source: 'Pluralistic', + state: 'new', + title: 'The durable web is a policy choice' + }, + { + author: 'Gergely Orosz', + domain: 'newsletter.pragmaticengineer.com', + excerpt: + 'A field report on engineering management, hiring loops, and the invisible maintenance cost of platforms.', + feedId: 'feeds-8', + id: 'item-9', + publishedAt: '2 days ago', + source: 'The Pragmatic Engineer', + state: 'new', + title: 'What platform teams inherit' + }, + { + author: 'Maggie Appleton', + domain: 'maggieappleton.com', + excerpt: + 'A beautifully illustrated note on digital gardens, knowledge rituals, and the difference between collecting and returning.', + feedId: 'feeds-9', + id: 'item-10', + publishedAt: '3 days ago', + source: 'Maggie Appleton', + state: 'new', + title: 'Tending notes without worshipping them' + }, + { + author: 'Rachel', + domain: 'rachelbythebay.com', + excerpt: + 'A terse production story about logs, queues, and the one metric nobody thought to graph.', + feedId: 'feeds-10', + id: 'item-11', + publishedAt: '3 days ago', + source: 'Rachel by the Bay', + state: 'new', + title: 'The graph was lying by omission' + }, + { + author: 'Ink & Switch', + domain: 'inkandswitch.com', + excerpt: + 'Research notes on malleable software and local collaboration that feels closer to sketching than filing.', + feedId: 'feeds-11', + id: 'item-12', + publishedAt: '4 days ago', + source: 'Ink & Switch', + state: 'new', + title: 'Tools as rooms, not rails' + }, + { + author: 'Nadia Asparouhova', + domain: 'nadia.xyz', + excerpt: + 'A careful essay about public goods, maintenance, and what happens after the launch attention fades.', + feedId: 'feeds-12', + id: 'item-13', + publishedAt: '5 days ago', + source: 'Nadia Asparouhova', + state: 'new', + title: 'Maintenance after applause' + }, + { + author: 'Simon Willison', + domain: 'simonwillison.net', + excerpt: + 'A short link roundup with enough context to decide whether the rabbit hole is worth saving for later.', + feedId: 'feeds-5', + id: 'item-14', + publishedAt: '5 days ago', + source: 'Simon Willison’s Weblog', + state: 'dismissed', + title: 'Links that almost became tabs' + }, + { + author: 'Cory Doctorow', + domain: 'pluralistic.net', + excerpt: + 'A dense policy essay that might be worth saving, but probably not reading in the middle of a workday.', + feedId: 'feeds-7', + id: 'item-15', + publishedAt: '6 days ago', + source: 'Pluralistic', + state: 'new', + title: 'A right to repair the timeline' + }, + { + author: 'Rachel', + domain: 'rachelbythebay.com', + excerpt: + 'A debugging story where the real bug was not the crash, but the assumption that made it invisible.', + feedId: 'feeds-10', + id: 'item-16', + publishedAt: '1 week ago', + source: 'Rachel by the Bay', + state: 'saved', + title: 'The crash was only the messenger' + } +]; +const stateSummary = { + currentPolicy: 'Review by default; auto-save only on trusted feeds', + duePolling: 'Global cadence with manual refresh and backoff', + retention: '90 days / latest 500 per feed', + stagedNew: feedItems.filter((item) => item.state === 'new').length, + subscriptions: subscriptions.length +}; + +interface VariantProps { + currentVariant: string; +} + +function PrototypeStatePanel({ className, currentVariant }: VariantProps & { className?: string }) { + return ( +
+
+

Prototype state

+ variant {currentVariant} +
+
+ {Object.entries(stateSummary).map(([key, value]) => ( +
+
{key}
+
{value}
+
+ ))} +
+
+ ); +} + +function AddFeedBar({ compact = false }: { compact?: boolean }) { + return ( +
+
+ + +
+ +
+ ); +} + +function FeedItemCard({ + item, + quiet = false +}: { + item: (typeof feedItems)[number]; + quiet?: boolean; +}) { + return ( +
+
+ {item.source} + · + {item.publishedAt} + {item.state === 'saved' && Saved} + {item.state === 'dismissed' && Dismissed} +
+

{item.title}

+

{item.excerpt}

+
+ + + +
+
+ ); +} + +function SubscriptionRow({ subscription }: { subscription: (typeof subscriptions)[number] }) { + return ( +
+
+
+

{subscription.title}

+

{subscription.domain}

+
+ + {subscription.autoSave ? 'Auto-save' : `${subscription.newCount} new`} + +
+
+ + Fetched {subscription.lastFetched} +
+ {subscription.error && ( +
+ +

{subscription.error}

+
+ )} +
+ ); +} + +export function VariantAReviewDesk({ currentVariant }: VariantProps) { + return ( +
+
+
+
+ Prototype · review desk +

+ Feeds, held at the edge of the shelf. +

+

+ New feed entries wait here until you decide they are worth saving. The reading list + stays reserved for articles you chose. +

+
+ +
+
+ +
+ + +
+
+
+

Ready to review

+

+ {stateSummary.stagedNew} new entries, no pressure to keep all of them. +

+
+
+ + +
+
+ {feedItems.map((item) => ( + + ))} +
+
+ + +
+ ); +} + +export function VariantBQuietRiver({ currentVariant }: VariantProps) { + return ( +
+
+
+
+ + Feeds prototype · quiet river +
+

+ A single stream of things you might want to keep. +

+

+ This version removes feed management from the primary path. The user scans a gentle + chronological river and saves only what belongs in Loreo. +

+
+ +
+
+ New + Saved + Dismissed + All feeds +
+
+ +
+ {feedItems.map((item) => ( +
+
+ {item.state === 'saved' ? ( + + ) : ( + + )} +
+ +
+ ))} +
+
+ + +
+ ); +} + +export function VariantCFeedShelves({ currentVariant }: VariantProps) { + const orderedShelves = [...subscriptions].sort((a, b) => { + const score = (subscription: (typeof subscriptions)[number]) => { + if (subscription.newCount > 0) return 0; + if (subscription.error) return 1; + return 2; + }; + + return score(a) - score(b) || b.newCount - a.newCount; + }); + + return ( +
+
+
+
+ Prototype · recommended shelves +

+ Review by source, save only what belongs on the shelf. +

+

+ Feeds with new entries rise first, warnings stay attached to their source, and quiet + feeds collapse so mobile review stays light. +

+
+ +
+
+ +
+
+ {[ + ['New', stateSummary.stagedNew], + ['Saved', feedItems.filter((item) => item.state === 'saved').length], + ['Dismissed', feedItems.filter((item) => item.state === 'dismissed').length], + ['Feeds', subscriptions.length] + ].map(([label, count], index) => ( + + ))} +
+
+ +
+ {orderedShelves.map((subscription) => { + const items = feedItems.filter((item) => item.feedId === subscription.id); + const newItems = items.filter((item) => item.state === 'new'); + const hasPriority = subscription.newCount > 0 || Boolean(subscription.error); + + return ( +
+ +
+
+
+

{subscription.title}

+ + › + +
+

+ {subscription.domain} +

+
+ 0 + ? 'default' + : 'outline' + } + > + {subscription.error + ? 'Backoff' + : subscription.autoSave + ? 'Auto' + : subscription.newCount > 0 + ? `${subscription.newCount} new` + : 'Quiet'} + +
+
+ Fetched {subscription.lastFetched} + · + {newItems.length} waiting here +
+
+ +
+ {subscription.error && ( +

+ {subscription.error} +

+ )} + +
+ {items.length > 0 ? ( + items.map((item) => ( +
+
+ {item.publishedAt} + + {item.state} + +
+

{item.title}

+

+ {item.excerpt} +

+
+ + +
+
+ )) + ) : ( +
+ Nothing waiting here. Auto-saved entries go straight to Articles. +
+ )} +
+
+
+ ); + })} +
+ + +
+ ); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 0dda665..4799440 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as ProtectedAdminIndexRouteImport } from './routes/_protected/adm import { Route as ProtectedWithLayoutIndexRouteImport } from './routes/_protected/_with-layout/index' import { Route as ProtectedArticlesIdRouteImport } from './routes/_protected/articles/$id' import { Route as ProtectedAdminConnectionsRouteImport } from './routes/_protected/admin/connections' +import { Route as ProtectedWithLayoutPrototypeFeedsRouteImport } from './routes/_protected/_with-layout/prototype-feeds' import { Route as ProtectedWithLayoutManageTagsRouteImport } from './routes/_protected/_with-layout/manage-tags' import { Route as ProtectedWithLayoutSettingsIndexRouteImport } from './routes/_protected/_with-layout/settings/index' import { Route as ProtectedWithLayoutArticlesIndexRouteImport } from './routes/_protected/_with-layout/articles/index' @@ -74,6 +75,12 @@ const ProtectedAdminConnectionsRoute = path: '/connections', getParentRoute: () => ProtectedAdminRoute, } as any) +const ProtectedWithLayoutPrototypeFeedsRoute = + ProtectedWithLayoutPrototypeFeedsRouteImport.update({ + id: '/prototype-feeds', + path: '/prototype-feeds', + getParentRoute: () => ProtectedWithLayoutRoute, + } as any) const ProtectedWithLayoutManageTagsRoute = ProtectedWithLayoutManageTagsRouteImport.update({ id: '/manage-tags', @@ -111,6 +118,7 @@ export interface FileRoutesByFullPath { '/register': typeof AuthRegisterRoute '/admin': typeof ProtectedAdminRouteWithChildren '/manage-tags': typeof ProtectedWithLayoutManageTagsRoute + '/prototype-feeds': typeof ProtectedWithLayoutPrototypeFeedsRoute '/admin/connections': typeof ProtectedAdminConnectionsRoute '/articles/$id': typeof ProtectedArticlesIdRoute '/admin/': typeof ProtectedAdminIndexRoute @@ -124,6 +132,7 @@ export interface FileRoutesByTo { '/login': typeof AuthLoginRoute '/register': typeof AuthRegisterRoute '/manage-tags': typeof ProtectedWithLayoutManageTagsRoute + '/prototype-feeds': typeof ProtectedWithLayoutPrototypeFeedsRoute '/admin/connections': typeof ProtectedAdminConnectionsRoute '/articles/$id': typeof ProtectedArticlesIdRoute '/admin': typeof ProtectedAdminIndexRoute @@ -141,6 +150,7 @@ export interface FileRoutesById { '/_protected/_with-layout': typeof ProtectedWithLayoutRouteWithChildren '/_protected/admin': typeof ProtectedAdminRouteWithChildren '/_protected/_with-layout/manage-tags': typeof ProtectedWithLayoutManageTagsRoute + '/_protected/_with-layout/prototype-feeds': typeof ProtectedWithLayoutPrototypeFeedsRoute '/_protected/admin/connections': typeof ProtectedAdminConnectionsRoute '/_protected/articles/$id': typeof ProtectedArticlesIdRoute '/_protected/_with-layout/': typeof ProtectedWithLayoutIndexRoute @@ -158,6 +168,7 @@ export interface FileRouteTypes { | '/register' | '/admin' | '/manage-tags' + | '/prototype-feeds' | '/admin/connections' | '/articles/$id' | '/admin/' @@ -171,6 +182,7 @@ export interface FileRouteTypes { | '/login' | '/register' | '/manage-tags' + | '/prototype-feeds' | '/admin/connections' | '/articles/$id' | '/admin' @@ -187,6 +199,7 @@ export interface FileRouteTypes { | '/_protected/_with-layout' | '/_protected/admin' | '/_protected/_with-layout/manage-tags' + | '/_protected/_with-layout/prototype-feeds' | '/_protected/admin/connections' | '/_protected/articles/$id' | '/_protected/_with-layout/' @@ -274,6 +287,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedAdminConnectionsRouteImport parentRoute: typeof ProtectedAdminRoute } + '/_protected/_with-layout/prototype-feeds': { + id: '/_protected/_with-layout/prototype-feeds' + path: '/prototype-feeds' + fullPath: '/prototype-feeds' + preLoaderRoute: typeof ProtectedWithLayoutPrototypeFeedsRouteImport + parentRoute: typeof ProtectedWithLayoutRoute + } '/_protected/_with-layout/manage-tags': { id: '/_protected/_with-layout/manage-tags' path: '/manage-tags' @@ -326,6 +346,7 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) interface ProtectedWithLayoutRouteChildren { ProtectedWithLayoutManageTagsRoute: typeof ProtectedWithLayoutManageTagsRoute + ProtectedWithLayoutPrototypeFeedsRoute: typeof ProtectedWithLayoutPrototypeFeedsRoute ProtectedWithLayoutIndexRoute: typeof ProtectedWithLayoutIndexRoute ProtectedWithLayoutArticlesIndexRoute: typeof ProtectedWithLayoutArticlesIndexRoute ProtectedWithLayoutSettingsIndexRoute: typeof ProtectedWithLayoutSettingsIndexRoute @@ -335,6 +356,8 @@ interface ProtectedWithLayoutRouteChildren { const ProtectedWithLayoutRouteChildren: ProtectedWithLayoutRouteChildren = { ProtectedWithLayoutManageTagsRoute: ProtectedWithLayoutManageTagsRoute, + ProtectedWithLayoutPrototypeFeedsRoute: + ProtectedWithLayoutPrototypeFeedsRoute, ProtectedWithLayoutIndexRoute: ProtectedWithLayoutIndexRoute, ProtectedWithLayoutArticlesIndexRoute: ProtectedWithLayoutArticlesIndexRoute, ProtectedWithLayoutSettingsIndexRoute: ProtectedWithLayoutSettingsIndexRoute, diff --git a/apps/web/src/routes/_protected/_with-layout/prototype-feeds.tsx b/apps/web/src/routes/_protected/_with-layout/prototype-feeds.tsx new file mode 100644 index 0000000..5248eea --- /dev/null +++ b/apps/web/src/routes/_protected/_with-layout/prototype-feeds.tsx @@ -0,0 +1,43 @@ +import { createFileRoute } from '@tanstack/react-router'; + +import { PrototypeSwitcher } from '@/components/prototype/prototype-switcher'; +import { + VariantAReviewDesk, + VariantBQuietRiver, + VariantCFeedShelves +} from '@/features/feeds/prototype/feeds-review-prototype'; + +const variants = [ + { key: 'A', name: 'Review desk' }, + { key: 'B', name: 'Quiet river' }, + { key: 'C', name: 'Feed shelves' } +] as const; + +export const Route = createFileRoute('/_protected/_with-layout/prototype-feeds')({ + head: () => ({ meta: [{ title: 'RSS feed prototype · Loreo' }] }), + validateSearch: (search) => ({ + variant: typeof search.variant === 'string' ? search.variant : undefined + }), + component: PrototypeFeedsRoute +}); + +function PrototypeFeedsRoute() { + const search = Route.useSearch(); + const navigate = Route.useNavigate(); + const variant = variants.some((option) => option.key === search.variant) + ? (search.variant as string) + : 'A'; + + const setVariant = (nextVariant: string) => { + void navigate({ replace: true, search: { variant: nextVariant } }); + }; + + return ( + <> + {variant === 'A' && } + {variant === 'B' && } + {variant === 'C' && } + + + ); +} From 74a24e85a8aefd341c4f2c5b477f1a6970243cfa Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sun, 28 Jun 2026 06:00:28 +0700 Subject: [PATCH 02/18] feat(server): add RSS feed persistence foundation Add feed subscription and feed item tables, repositories, migrations, and repository tests for the first RSS support slice. --- apps/server/scripts/migrate-test.ts | 1 + apps/server/src/app.ts | 4 + .../db/migrations/0001_married_sabretooth.sql | 57 + .../src/db/migrations/meta/0001_snapshot.json | 1870 +++++++++++++++++ .../src/db/migrations/meta/_journal.json | 7 + apps/server/src/db/schemas/feed-items.ts | 62 + .../src/db/schemas/feed-subscriptions.ts | 60 + apps/server/src/db/schemas/index.ts | 30 + apps/server/src/lib/types.ts | 4 + .../feed-items.repository.test.ts | 255 +++ .../src/repositories/feed-items.repository.ts | 294 +++ .../feed-subscriptions.repository.test.ts | 156 ++ .../feed-subscriptions.repository.ts | 231 ++ 13 files changed, 3031 insertions(+) create mode 100644 apps/server/src/db/migrations/0001_married_sabretooth.sql create mode 100644 apps/server/src/db/migrations/meta/0001_snapshot.json create mode 100644 apps/server/src/db/schemas/feed-items.ts create mode 100644 apps/server/src/db/schemas/feed-subscriptions.ts create mode 100644 apps/server/src/repositories/feed-items.repository.test.ts create mode 100644 apps/server/src/repositories/feed-items.repository.ts create mode 100644 apps/server/src/repositories/feed-subscriptions.repository.test.ts create mode 100644 apps/server/src/repositories/feed-subscriptions.repository.ts diff --git a/apps/server/scripts/migrate-test.ts b/apps/server/scripts/migrate-test.ts index 0d35bc0..79b44c8 100644 --- a/apps/server/scripts/migrate-test.ts +++ b/apps/server/scripts/migrate-test.ts @@ -23,6 +23,7 @@ if (!env.DATABASE_DB.includes('test')) { console.log('Resetting test database schema...'); await pool.query('DROP SCHEMA IF EXISTS public CASCADE;'); +await pool.query('DROP SCHEMA IF EXISTS drizzle CASCADE;'); await pool.query('CREATE SCHEMA public;'); console.log('Test database schema reset complete.'); diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index fb6d4aa..3c006e5 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -5,6 +5,8 @@ import createApp from './lib/create-app.js'; import type { Repos } from './lib/types.js'; import { createDrizzleAuthAdapter } from './repositories/auth.repository.js'; +import { createDrizzleFeedItemsAdapter } from './repositories/feed-items.repository.js'; +import { createDrizzleFeedSubscriptionsAdapter } from './repositories/feed-subscriptions.repository.js'; import { createDrizzleHighlightsAdapter } from './repositories/highlights.repository.js'; import { createDrizzleImportSessionsAdapter } from './repositories/import-sessions.repository.js'; import { createDrizzleLinksAdapter } from './repositories/links.repository.js'; @@ -24,6 +26,8 @@ const app = createApp(); const repos: Repos = { auth: createDrizzleAuthAdapter(db), + feedItems: createDrizzleFeedItemsAdapter(db), + feedSubscriptions: createDrizzleFeedSubscriptionsAdapter(db), highlights: createDrizzleHighlightsAdapter(db), importSessions: createDrizzleImportSessionsAdapter(db), links: createDrizzleLinksAdapter(db), diff --git a/apps/server/src/db/migrations/0001_married_sabretooth.sql b/apps/server/src/db/migrations/0001_married_sabretooth.sql new file mode 100644 index 0000000..25a9e92 --- /dev/null +++ b/apps/server/src/db/migrations/0001_married_sabretooth.sql @@ -0,0 +1,57 @@ +CREATE TABLE "feed_items" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "subscription_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "link_id" uuid, + "guid" text, + "url" text NOT NULL, + "normalized_url" text NOT NULL, + "title" text NOT NULL, + "excerpt" text, + "author" text, + "published_at" timestamp with time zone, + "image_url" text, + "state" varchar(20) DEFAULT 'new' NOT NULL, + "discovered_at" timestamp with time zone DEFAULT now() NOT NULL, + "saved_at" timestamp with time zone, + "dismissed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "feed_subscriptions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "feed_url" text NOT NULL, + "normalized_feed_url" text NOT NULL, + "site_url" text, + "title" text NOT NULL, + "description" text, + "image_url" text, + "auto_save" boolean DEFAULT false NOT NULL, + "status" varchar(20) DEFAULT 'active' NOT NULL, + "last_fetched_at" timestamp with time zone, + "last_successful_fetch_at" timestamp with time zone, + "next_fetch_after" timestamp with time zone, + "last_error" text, + "failure_count" integer DEFAULT 0 NOT NULL, + "etag" text, + "last_modified" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "feed_items" ADD CONSTRAINT "feed_items_subscription_id_feed_subscriptions_id_fk" FOREIGN KEY ("subscription_id") REFERENCES "public"."feed_subscriptions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "feed_items" ADD CONSTRAINT "feed_items_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "feed_items" ADD CONSTRAINT "feed_items_link_id_links_id_fk" FOREIGN KEY ("link_id") REFERENCES "public"."links"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "feed_subscriptions" ADD CONSTRAINT "feed_subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "uq_feed_items_subscription_guid" ON "feed_items" USING btree ("subscription_id","guid");--> statement-breakpoint +CREATE UNIQUE INDEX "uq_feed_items_subscription_normalized_url" ON "feed_items" USING btree ("subscription_id","normalized_url");--> statement-breakpoint +CREATE INDEX "idx_feed_items_user_state_published" ON "feed_items" USING btree ("user_id","state","published_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "idx_feed_items_user_normalized_url" ON "feed_items" USING btree ("user_id","normalized_url");--> statement-breakpoint +CREATE INDEX "idx_feed_items_subscription_discovered" ON "feed_items" USING btree ("subscription_id","discovered_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "idx_feed_items_link_id" ON "feed_items" USING btree ("link_id");--> statement-breakpoint +CREATE UNIQUE INDEX "uq_feed_subscriptions_user_normalized_url" ON "feed_subscriptions" USING btree ("user_id","normalized_feed_url");--> statement-breakpoint +CREATE INDEX "idx_feed_subscriptions_user_created" ON "feed_subscriptions" USING btree ("user_id","created_at" DESC NULLS LAST);--> statement-breakpoint +CREATE INDEX "idx_feed_subscriptions_status_next_fetch" ON "feed_subscriptions" USING btree ("status","next_fetch_after");--> statement-breakpoint +CREATE INDEX "idx_feed_subscriptions_user_status" ON "feed_subscriptions" USING btree ("user_id","status"); \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0001_snapshot.json b/apps/server/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..bc5e58f --- /dev/null +++ b/apps/server/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,1870 @@ +{ + "id": "54310944-236e-44a4-9def-127912959169", + "prevId": "97687d4b-242c-4e04-ad33-051de3e6bd9e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.feed_items": { + "name": "feed_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "link_id": { + "name": "link_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "guid": { + "name": "guid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_url": { + "name": "normalized_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "discovered_at": { + "name": "discovered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "saved_at": { + "name": "saved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_feed_items_subscription_guid": { + "name": "uq_feed_items_subscription_guid", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "guid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_feed_items_subscription_normalized_url": { + "name": "uq_feed_items_subscription_normalized_url", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_items_user_state_published": { + "name": "idx_feed_items_user_state_published", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_items_user_normalized_url": { + "name": "idx_feed_items_user_normalized_url", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_items_subscription_discovered": { + "name": "idx_feed_items_subscription_discovered", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "discovered_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_items_link_id": { + "name": "idx_feed_items_link_id", + "columns": [ + { + "expression": "link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feed_items_subscription_id_feed_subscriptions_id_fk": { + "name": "feed_items_subscription_id_feed_subscriptions_id_fk", + "tableFrom": "feed_items", + "tableTo": "feed_subscriptions", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feed_items_user_id_users_id_fk": { + "name": "feed_items_user_id_users_id_fk", + "tableFrom": "feed_items", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feed_items_link_id_links_id_fk": { + "name": "feed_items_link_id_links_id_fk", + "tableFrom": "feed_items", + "tableTo": "links", + "columnsFrom": ["link_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_subscriptions": { + "name": "feed_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "feed_url": { + "name": "feed_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_feed_url": { + "name": "normalized_feed_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_save": { + "name": "auto_save", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_successful_fetch_at": { + "name": "last_successful_fetch_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_fetch_after": { + "name": "next_fetch_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_modified": { + "name": "last_modified", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_feed_subscriptions_user_normalized_url": { + "name": "uq_feed_subscriptions_user_normalized_url", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_feed_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_subscriptions_user_created": { + "name": "idx_feed_subscriptions_user_created", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_subscriptions_status_next_fetch": { + "name": "idx_feed_subscriptions_status_next_fetch", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_fetch_after", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_subscriptions_user_status": { + "name": "idx_feed_subscriptions_user_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feed_subscriptions_user_id_users_id_fk": { + "name": "feed_subscriptions_user_id_users_id_fk", + "tableFrom": "feed_subscriptions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.highlights": { + "name": "highlights", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "link_id": { + "name": "link_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_highlights_link_id": { + "name": "idx_highlights_link_id", + "columns": [ + { + "expression": "link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_highlights_user_id": { + "name": "idx_highlights_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_highlights_link_user": { + "name": "idx_highlights_link_user", + "columns": [ + { + "expression": "link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_highlights_start_offset": { + "name": "idx_highlights_start_offset", + "columns": [ + { + "expression": "start_offset", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "highlights_link_id_links_id_fk": { + "name": "highlights_link_id_links_id_fk", + "tableFrom": "highlights", + "tableTo": "links", + "columnsFrom": ["link_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "highlights_user_id_users_id_fk": { + "name": "highlights_user_id_users_id_fk", + "tableFrom": "highlights", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.import_sessions": { + "name": "import_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "total_rows": { + "name": "total_rows", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "imported_count": { + "name": "imported_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "job_id": { + "name": "job_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "extraction_status": { + "name": "extraction_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "extraction_progress": { + "name": "extraction_progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "extraction_completed": { + "name": "extraction_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "extraction_failed": { + "name": "extraction_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_import_sessions_user_id": { + "name": "idx_import_sessions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_import_sessions_status": { + "name": "idx_import_sessions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_import_sessions_created_at": { + "name": "idx_import_sessions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "import_sessions_user_id_users_id_fk": { + "name": "import_sessions_user_id_users_id_fk", + "tableFrom": "import_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link_tags": { + "name": "link_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "link_id": { + "name": "link_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_link_tags_link_id": { + "name": "idx_link_tags_link_id", + "columns": [ + { + "expression": "link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_link_tags_tag_id": { + "name": "idx_link_tags_tag_id", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_link_tags_user_id": { + "name": "idx_link_tags_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_link_tags_link_tag": { + "name": "uq_link_tags_link_tag", + "columns": [ + { + "expression": "link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "link_tags_link_id_links_id_fk": { + "name": "link_tags_link_id_links_id_fk", + "tableFrom": "link_tags", + "tableTo": "links", + "columnsFrom": ["link_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "link_tags_tag_id_tags_id_fk": { + "name": "link_tags_tag_id_tags_id_fk", + "tableFrom": "link_tags", + "tableTo": "tags", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "link_tags_user_id_users_id_fk": { + "name": "link_tags_user_id_users_id_fk", + "tableFrom": "link_tags", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.links": { + "name": "links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text_content": { + "name": "text_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image": { + "name": "cover_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reading_time": { + "name": "reading_time", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reading_progress": { + "name": "reading_progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "time_spent_reading": { + "name": "time_spent_reading", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_favorite": { + "name": "is_favorite", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_paywalled": { + "name": "is_paywalled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "processing_status": { + "name": "processing_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_session_id": { + "name": "import_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_links_user_id": { + "name": "idx_links_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_url": { + "name": "idx_links_url", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_title": { + "name": "idx_links_title", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_is_read": { + "name": "idx_links_is_read", + "columns": [ + { + "expression": "is_read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_is_favorite": { + "name": "idx_links_is_favorite", + "columns": [ + { + "expression": "is_favorite", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_is_archived": { + "name": "idx_links_is_archived", + "columns": [ + { + "expression": "is_archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_priority": { + "name": "idx_links_priority", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_processing_status": { + "name": "idx_links_processing_status", + "columns": [ + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_created_at": { + "name": "idx_links_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_last_read_at": { + "name": "idx_links_last_read_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_read_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_user_created": { + "name": "idx_links_user_created", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_user_last_read_at": { + "name": "idx_links_user_last_read_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_read_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_import_session_id": { + "name": "idx_links_import_session_id", + "columns": [ + { + "expression": "import_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_processing_started_at": { + "name": "idx_links_processing_started_at", + "columns": [ + { + "expression": "processing_started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "links_user_id_users_id_fk": { + "name": "links_user_id_users_id_fk", + "tableFrom": "links", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "links_import_session_id_import_sessions_id_fk": { + "name": "links_import_session_id_import_sessions_id_fk", + "tableFrom": "links", + "tableTo": "import_sessions", + "columnsFrom": ["import_session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag_groups": { + "name": "tag_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_tag_groups_name": { + "name": "idx_tag_groups_name", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tag_groups_user_id": { + "name": "idx_tag_groups_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_tag_groups_user_id_name": { + "name": "uq_tag_groups_user_id_name", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tag_groups_user_id_users_id_fk": { + "name": "tag_groups_user_id_users_id_fk", + "tableFrom": "tag_groups", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_tags_user_id": { + "name": "idx_tags_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tags_group_id": { + "name": "idx_tags_group_id", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tags_name": { + "name": "idx_tags_name", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tags_user_group": { + "name": "idx_tags_user_group", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_tags_user_group_name": { + "name": "uq_tags_user_group_name", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tags_group_id_tag_groups_id_fk": { + "name": "tags_group_id_tag_groups_id_fk", + "tableFrom": "tags", + "tableTo": "tag_groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tags_user_id_users_id_fk": { + "name": "tags_user_id_users_id_fk", + "tableFrom": "tags", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_settings": { + "name": "idx_users_settings", + "columns": [ + { + "expression": "settings", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_users_role": { + "name": "idx_users_role", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/server/src/db/migrations/meta/_journal.json b/apps/server/src/db/migrations/meta/_journal.json index 2533832..d866b64 100644 --- a/apps/server/src/db/migrations/meta/_journal.json +++ b/apps/server/src/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1777355318209, "tag": "0000_melted_mandroid", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1782600940254, + "tag": "0001_married_sabretooth", + "breakpoints": true } ] } diff --git a/apps/server/src/db/schemas/feed-items.ts b/apps/server/src/db/schemas/feed-items.ts new file mode 100644 index 0000000..9ee7f6e --- /dev/null +++ b/apps/server/src/db/schemas/feed-items.ts @@ -0,0 +1,62 @@ +import { index, pgTable, text, timestamp, uniqueIndex, uuid, varchar } from 'drizzle-orm/pg-core'; +import { createInsertSchema, createSelectSchema } from 'drizzle-zod'; + +import { feedSubscriptionsTable } from './feed-subscriptions.js'; +import { linksTable } from './links.js'; +import { usersTable } from './users.js'; + +export const feedItemsTable = pgTable( + 'feed_items', + { + id: uuid('id').primaryKey().notNull().defaultRandom(), + subscriptionId: uuid('subscription_id') + .notNull() + .references(() => feedSubscriptionsTable.id, { onDelete: 'cascade' }), + userId: uuid('user_id') + .notNull() + .references(() => usersTable.id, { onDelete: 'cascade' }), + linkId: uuid('link_id').references(() => linksTable.id, { onDelete: 'set null' }), + guid: text('guid'), + url: text('url').notNull(), + normalizedUrl: text('normalized_url').notNull(), + title: text('title').notNull(), + excerpt: text('excerpt'), + author: text('author'), + publishedAt: timestamp('published_at', { withTimezone: true }), + imageUrl: text('image_url'), + state: varchar('state', { enum: ['new', 'dismissed', 'saved'], length: 20 }) + .notNull() + .default('new'), + discoveredAt: timestamp('discovered_at', { withTimezone: true }).notNull().defaultNow(), + savedAt: timestamp('saved_at', { withTimezone: true }), + dismissedAt: timestamp('dismissed_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow() + }, + (table) => [ + uniqueIndex('uq_feed_items_subscription_guid').on(table.subscriptionId, table.guid), + uniqueIndex('uq_feed_items_subscription_normalized_url').on( + table.subscriptionId, + table.normalizedUrl + ), + index('idx_feed_items_user_state_published').on( + table.userId, + table.state, + table.publishedAt.desc() + ), + index('idx_feed_items_user_normalized_url').on(table.userId, table.normalizedUrl), + index('idx_feed_items_subscription_discovered').on( + table.subscriptionId, + table.discoveredAt.desc() + ), + index('idx_feed_items_link_id').on(table.linkId) + ] +); + +export const selectFeedItemsSchema = createSelectSchema(feedItemsTable); + +export const insertFeedItemsSchema = createInsertSchema(feedItemsTable).omit({ + id: true, + createdAt: true, + updatedAt: true +}); diff --git a/apps/server/src/db/schemas/feed-subscriptions.ts b/apps/server/src/db/schemas/feed-subscriptions.ts new file mode 100644 index 0000000..9c3b72a --- /dev/null +++ b/apps/server/src/db/schemas/feed-subscriptions.ts @@ -0,0 +1,60 @@ +import { + boolean, + index, + integer, + pgTable, + text, + timestamp, + uniqueIndex, + uuid, + varchar +} from 'drizzle-orm/pg-core'; +import { createInsertSchema, createSelectSchema } from 'drizzle-zod'; + +import { usersTable } from './users.js'; + +export const feedSubscriptionsTable = pgTable( + 'feed_subscriptions', + { + id: uuid('id').primaryKey().notNull().defaultRandom(), + userId: uuid('user_id') + .notNull() + .references(() => usersTable.id, { onDelete: 'cascade' }), + feedUrl: text('feed_url').notNull(), + normalizedFeedUrl: text('normalized_feed_url').notNull(), + siteUrl: text('site_url'), + title: text('title').notNull(), + description: text('description'), + imageUrl: text('image_url'), + autoSave: boolean('auto_save').notNull().default(false), + status: varchar('status', { enum: ['active', 'paused'], length: 20 }) + .notNull() + .default('active'), + lastFetchedAt: timestamp('last_fetched_at', { withTimezone: true }), + lastSuccessfulFetchAt: timestamp('last_successful_fetch_at', { withTimezone: true }), + nextFetchAfter: timestamp('next_fetch_after', { withTimezone: true }), + lastError: text('last_error'), + failureCount: integer('failure_count').notNull().default(0), + etag: text('etag'), + lastModified: text('last_modified'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow() + }, + (table) => [ + uniqueIndex('uq_feed_subscriptions_user_normalized_url').on( + table.userId, + table.normalizedFeedUrl + ), + index('idx_feed_subscriptions_user_created').on(table.userId, table.createdAt.desc()), + index('idx_feed_subscriptions_status_next_fetch').on(table.status, table.nextFetchAfter), + index('idx_feed_subscriptions_user_status').on(table.userId, table.status) + ] +); + +export const selectFeedSubscriptionsSchema = createSelectSchema(feedSubscriptionsTable); + +export const insertFeedSubscriptionsSchema = createInsertSchema(feedSubscriptionsTable).omit({ + id: true, + createdAt: true, + updatedAt: true +}); diff --git a/apps/server/src/db/schemas/index.ts b/apps/server/src/db/schemas/index.ts index 206e1f5..1a044d6 100644 --- a/apps/server/src/db/schemas/index.ts +++ b/apps/server/src/db/schemas/index.ts @@ -1,11 +1,15 @@ import { relations } from 'drizzle-orm'; +import { feedItemsTable } from './feed-items.js'; +import { feedSubscriptionsTable } from './feed-subscriptions.js'; import { highlightsTable } from './highlights.js'; import { importSessionsTable } from './import-sessions.js'; import { linksTable, linkTagsTable } from './links.js'; import { tagGroupsTable, tagsTable } from './tags.js'; import { usersTable } from './users.js'; +export * from './feed-items.js'; +export * from './feed-subscriptions.js'; export * from './highlights.js'; export * from './import-sessions.js'; export * from './links.js'; @@ -14,6 +18,8 @@ export * from './user-settings.js'; export * from './users.js'; export const userRelations = relations(usersTable, ({ many }) => ({ + feedItems: many(feedItemsTable), + feedSubscriptions: many(feedSubscriptionsTable), highlights: many(highlightsTable), links: many(linksTable), linkTags: many(linkTagsTable), @@ -41,10 +47,34 @@ export const linksRelations = relations(linksTable, ({ many, one }) => ({ fields: [linksTable.importSessionId], references: [importSessionsTable.id] }), + feedItems: many(feedItemsTable), highlights: many(highlightsTable), linkTags: many(linkTagsTable) })); +export const feedSubscriptionsRelations = relations(feedSubscriptionsTable, ({ many, one }) => ({ + feedItems: many(feedItemsTable), + user: one(usersTable, { + fields: [feedSubscriptionsTable.userId], + references: [usersTable.id] + }) +})); + +export const feedItemsRelations = relations(feedItemsTable, ({ one }) => ({ + link: one(linksTable, { + fields: [feedItemsTable.linkId], + references: [linksTable.id] + }), + subscription: one(feedSubscriptionsTable, { + fields: [feedItemsTable.subscriptionId], + references: [feedSubscriptionsTable.id] + }), + user: one(usersTable, { + fields: [feedItemsTable.userId], + references: [usersTable.id] + }) +})); + export const linkTagRelations = relations(linkTagsTable, ({ one }) => ({ link: one(linksTable, { fields: [linkTagsTable.linkId], diff --git a/apps/server/src/lib/types.ts b/apps/server/src/lib/types.ts index 5657c8b..6fa7103 100644 --- a/apps/server/src/lib/types.ts +++ b/apps/server/src/lib/types.ts @@ -3,6 +3,8 @@ import type { Schema } from 'hono'; import type { PinoLogger } from 'hono-pino'; import type { AuthRepository } from '@/repositories/auth.repository.js'; +import type { FeedItemsRepository } from '@/repositories/feed-items.repository.js'; +import type { FeedSubscriptionsRepository } from '@/repositories/feed-subscriptions.repository.js'; import type { HighlightsRepository } from '@/repositories/highlights.repository.js'; import type { ImportSessionsRepository } from '@/repositories/import-sessions.repository.js'; import type { LinksRepository } from '@/repositories/links.repository.js'; @@ -12,6 +14,8 @@ import type { UserWithoutPassword } from '@/types/auth.js'; export interface Repos { auth: AuthRepository; + feedItems?: FeedItemsRepository; + feedSubscriptions?: FeedSubscriptionsRepository; highlights: HighlightsRepository; importSessions: ImportSessionsRepository; links: LinksRepository; diff --git a/apps/server/src/repositories/feed-items.repository.test.ts b/apps/server/src/repositories/feed-items.repository.test.ts new file mode 100644 index 0000000..216380f --- /dev/null +++ b/apps/server/src/repositories/feed-items.repository.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from 'vitest'; + +import { db } from '@/db/index.js'; +import { linksTable, usersTable } from '@/db/schemas/index.js'; + +import { createDrizzleFeedItemsAdapter } from './feed-items.repository.js'; +import { createDrizzleFeedSubscriptionsAdapter } from './feed-subscriptions.repository.js'; + +const USER_A_ID = '20000000-0000-0000-0000-000000000001'; +const USER_B_ID = '20000000-0000-0000-0000-000000000002'; +const LINK_ID = '20000000-0000-0000-0000-000000000010'; + +async function seedUser(id: string, email: string) { + await db.insert(usersTable).values({ + id, + email, + passwordHash: 'hashed-password', + name: email, + settings: {} + }); +} + +async function seedSubscription(userId: string, url = 'https://example.com/feed.xml') { + const subscriptions = createDrizzleFeedSubscriptionsAdapter(db); + return subscriptions.create({ + feedUrl: url, + normalizedFeedUrl: url, + title: `Feed ${url}`, + userId + }); +} + +async function seedLink(userId: string, id = LINK_ID, url = 'https://example.com/post') { + const [link] = await db + .insert(linksTable) + .values({ + id, + author: null, + content: null, + excerpt: null, + isArchived: false, + isFavorite: false, + isPaywalled: false, + isRead: false, + lastReadAt: null, + priority: 'none', + processingStatus: 'pending', + publishedAt: null, + readingProgress: 0, + readingTime: 0, + textContent: null, + timeSpentReading: 0, + title: url, + url, + userId + }) + .returning({ id: linksTable.id }); + + if (!link) throw new Error('Failed to seed link'); + return link.id; +} + +describe('feed items repository', () => { + it('creates and queries review items scoped by user and state', async () => { + const items = createDrizzleFeedItemsAdapter(db); + await seedUser(USER_A_ID, 'feed-items-a@example.com'); + await seedUser(USER_B_ID, 'feed-items-b@example.com'); + const subscriptionA = await seedSubscription(USER_A_ID); + const subscriptionB = await seedSubscription(USER_B_ID, 'https://other.example/feed.xml'); + + const itemA = await items.create({ + excerpt: 'A staged item', + guid: 'guid-a', + normalizedUrl: 'https://example.com/a', + publishedAt: new Date('2026-06-28T12:00:00Z'), + subscriptionId: subscriptionA.id, + title: 'Item A', + url: 'https://example.com/a', + userId: USER_A_ID + }); + await items.create({ + guid: 'guid-b', + normalizedUrl: 'https://example.com/b', + subscriptionId: subscriptionB.id, + title: 'Item B', + url: 'https://example.com/b', + userId: USER_B_ID + }); + + await expect(items.findById(itemA.id, USER_B_ID)).resolves.toBeNull(); + await expect( + items.findManyForReview({ state: 'new', userId: USER_A_ID }) + ).resolves.toMatchObject([{ id: itemA.id, title: 'Item A' }]); + await expect( + items.findManyForReview({ state: 'new', userId: USER_B_ID }) + ).resolves.toHaveLength(1); + }); + + it('upserts by GUID or normalized URL within a subscription', async () => { + const items = createDrizzleFeedItemsAdapter(db); + await seedUser(USER_A_ID, 'feed-items-a@example.com'); + const subscription = await seedSubscription(USER_A_ID); + + const created = await items.upsertByIdentity({ + guid: 'same-guid', + normalizedUrl: 'https://example.com/original', + subscriptionId: subscription.id, + title: 'Original title', + url: 'https://example.com/original', + userId: USER_A_ID + }); + const updatedByGuid = await items.upsertByIdentity({ + guid: 'same-guid', + normalizedUrl: 'https://example.com/original', + subscriptionId: subscription.id, + title: 'Updated title', + url: 'https://example.com/original', + userId: USER_A_ID + }); + const updatedByUrl = await items.upsertByIdentity({ + normalizedUrl: 'https://example.com/original', + subscriptionId: subscription.id, + title: 'Updated by URL', + url: 'https://example.com/original?utm=ignored', + userId: USER_A_ID + }); + + expect(updatedByGuid.id).toBe(created.id); + expect(updatedByGuid.title).toBe('Updated title'); + expect(updatedByUrl.id).toBe(created.id); + expect(updatedByUrl.title).toBe('Updated by URL'); + await expect(items.findManyForReview({ userId: USER_A_ID })).resolves.toHaveLength(1); + }); + + it('marks items dismissed and saved only for the owning user', async () => { + const items = createDrizzleFeedItemsAdapter(db); + await seedUser(USER_A_ID, 'feed-items-a@example.com'); + await seedUser(USER_B_ID, 'feed-items-b@example.com'); + const subscription = await seedSubscription(USER_A_ID); + const linkId = await seedLink(USER_A_ID); + + const item = await items.create({ + guid: 'guid-a', + normalizedUrl: 'https://example.com/post', + subscriptionId: subscription.id, + title: 'Item A', + url: 'https://example.com/post', + userId: USER_A_ID + }); + + await expect(items.dismiss(item.id, USER_B_ID)).resolves.toBeNull(); + const dismissed = await items.dismiss(item.id, USER_A_ID, new Date('2026-06-28T12:00:00Z')); + expect(dismissed).toMatchObject({ id: item.id, state: 'dismissed' }); + expect(dismissed?.dismissedAt?.toISOString()).toBe('2026-06-28T12:00:00.000Z'); + + await expect(items.save(item.id, USER_B_ID, linkId)).resolves.toBeNull(); + const saved = await items.save(item.id, USER_A_ID, linkId, new Date('2026-06-28T13:00:00Z')); + expect(saved).toMatchObject({ id: item.id, linkId, state: 'saved' }); + expect(saved?.savedAt?.toISOString()).toBe('2026-06-28T13:00:00.000Z'); + }); + + it('reconciles matching staged items to an existing saved link by normalized URL', async () => { + const items = createDrizzleFeedItemsAdapter(db); + await seedUser(USER_A_ID, 'feed-items-a@example.com'); + await seedUser(USER_B_ID, 'feed-items-b@example.com'); + const subscriptionA = await seedSubscription(USER_A_ID); + const subscriptionB = await seedSubscription(USER_B_ID, 'https://other.example/feed.xml'); + const linkId = await seedLink(USER_A_ID); + + const itemA = await items.create({ + normalizedUrl: 'https://example.com/post', + subscriptionId: subscriptionA.id, + title: 'Item A', + url: 'https://example.com/post', + userId: USER_A_ID + }); + await items.create({ + normalizedUrl: 'https://example.com/post', + subscriptionId: subscriptionB.id, + title: 'Item B', + url: 'https://example.com/post', + userId: USER_B_ID + }); + + const reconciled = await items.reconcileSavedByUrl({ + linkId, + normalizedUrl: 'https://example.com/post', + savedAt: new Date('2026-06-28T12:00:00Z'), + userId: USER_A_ID + }); + + expect(reconciled.map((item) => item.id)).toEqual([itemA.id]); + expect(reconciled[0]).toMatchObject({ linkId, state: 'saved' }); + await expect( + items.findManyForReview({ state: 'new', userId: USER_B_ID }) + ).resolves.toHaveLength(1); + }); + + it('prunes old and excess unsaved items while keeping saved items', async () => { + const items = createDrizzleFeedItemsAdapter(db); + await seedUser(USER_A_ID, 'feed-items-a@example.com'); + const subscription = await seedSubscription(USER_A_ID); + const linkId = await seedLink(USER_A_ID); + + const oldUnsaved = await items.create({ + discoveredAt: new Date('2026-01-01T00:00:00Z'), + normalizedUrl: 'https://example.com/old-unsaved', + subscriptionId: subscription.id, + title: 'Old unsaved', + url: 'https://example.com/old-unsaved', + userId: USER_A_ID + }); + const oldSaved = await items.create({ + discoveredAt: new Date('2026-01-02T00:00:00Z'), + linkId, + normalizedUrl: 'https://example.com/old-saved', + state: 'saved', + subscriptionId: subscription.id, + title: 'Old saved', + url: 'https://example.com/old-saved', + userId: USER_A_ID + }); + const newest = await items.create({ + discoveredAt: new Date('2026-06-28T00:00:00Z'), + normalizedUrl: 'https://example.com/newest', + subscriptionId: subscription.id, + title: 'Newest', + url: 'https://example.com/newest', + userId: USER_A_ID + }); + const secondNewest = await items.create({ + discoveredAt: new Date('2026-06-27T00:00:00Z'), + normalizedUrl: 'https://example.com/second-newest', + subscriptionId: subscription.id, + title: 'Second newest', + url: 'https://example.com/second-newest', + userId: USER_A_ID + }); + + const removed = await items.pruneForSubscription({ + before: new Date('2026-04-01T00:00:00Z'), + keepLatest: 1, + subscriptionId: subscription.id, + userId: USER_A_ID + }); + + expect(removed).toBe(2); + await expect(items.findById(oldUnsaved.id, USER_A_ID)).resolves.toBeNull(); + await expect(items.findById(oldSaved.id, USER_A_ID)).resolves.toMatchObject({ + id: oldSaved.id + }); + await expect(items.findById(newest.id, USER_A_ID)).resolves.toMatchObject({ id: newest.id }); + await expect(items.findById(secondNewest.id, USER_A_ID)).resolves.toBeNull(); + }); +}); diff --git a/apps/server/src/repositories/feed-items.repository.ts b/apps/server/src/repositories/feed-items.repository.ts new file mode 100644 index 0000000..1ca150e --- /dev/null +++ b/apps/server/src/repositories/feed-items.repository.ts @@ -0,0 +1,294 @@ +import { and, desc, eq, inArray, lt, ne, or } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +import type * as schema from '@/db/schemas/index.js'; +import { feedItemsTable } from '@/db/schemas/index.js'; + +type DrizzleClient = NodePgDatabase; + +export type FeedItemState = 'new' | 'dismissed' | 'saved'; + +export interface FeedItemData { + id: string; + subscriptionId: string; + userId: string; + linkId: string | null; + guid: string | null; + url: string; + normalizedUrl: string; + title: string; + excerpt: string | null; + author: string | null; + publishedAt: Date | null; + imageUrl: string | null; + state: FeedItemState; + discoveredAt: Date; + savedAt: Date | null; + dismissedAt: Date | null; + createdAt: Date; + updatedAt: Date; +} + +export type CreateFeedItemData = Pick< + FeedItemData, + 'normalizedUrl' | 'subscriptionId' | 'title' | 'url' | 'userId' +> & + Partial< + Pick< + FeedItemData, + | 'author' + | 'discoveredAt' + | 'excerpt' + | 'guid' + | 'imageUrl' + | 'linkId' + | 'publishedAt' + | 'state' + > + >; + +type UpdateFeedItemData = Partial< + Pick< + FeedItemData, + | 'author' + | 'dismissedAt' + | 'discoveredAt' + | 'excerpt' + | 'guid' + | 'imageUrl' + | 'linkId' + | 'normalizedUrl' + | 'publishedAt' + | 'savedAt' + | 'state' + | 'title' + | 'url' + > +>; + +const itemColumns = { + id: feedItemsTable.id, + subscriptionId: feedItemsTable.subscriptionId, + userId: feedItemsTable.userId, + linkId: feedItemsTable.linkId, + guid: feedItemsTable.guid, + url: feedItemsTable.url, + normalizedUrl: feedItemsTable.normalizedUrl, + title: feedItemsTable.title, + excerpt: feedItemsTable.excerpt, + author: feedItemsTable.author, + publishedAt: feedItemsTable.publishedAt, + imageUrl: feedItemsTable.imageUrl, + state: feedItemsTable.state, + discoveredAt: feedItemsTable.discoveredAt, + savedAt: feedItemsTable.savedAt, + dismissedAt: feedItemsTable.dismissedAt, + createdAt: feedItemsTable.createdAt, + updatedAt: feedItemsTable.updatedAt +}; + +export interface FeedItemsRepository { + create(data: CreateFeedItemData): Promise; + dismiss(id: string, userId: string, dismissedAt?: Date): Promise; + findById(id: string, userId: string): Promise; + findBySubscriptionAndIdentity(input: { + guid?: string | null; + normalizedUrl: string; + subscriptionId: string; + userId: string; + }): Promise; + findManyForReview(input: { + state?: FeedItemState; + subscriptionId?: string; + userId: string; + }): Promise; + pruneForSubscription(input: { + before: Date; + keepLatest: number; + subscriptionId: string; + userId: string; + }): Promise; + reconcileSavedByUrl(input: { + linkId: string; + normalizedUrl: string; + savedAt?: Date; + userId: string; + }): Promise; + save(id: string, userId: string, linkId: string, savedAt?: Date): Promise; + upsertByIdentity(data: CreateFeedItemData): Promise; +} + +export function createDrizzleFeedItemsAdapter(db: DrizzleClient): FeedItemsRepository { + async function findById(id: string, userId: string): Promise { + const [row] = await db + .select(itemColumns) + .from(feedItemsTable) + .where(and(eq(feedItemsTable.id, id), eq(feedItemsTable.userId, userId))) + .limit(1); + + return (row as FeedItemData | undefined) ?? null; + } + + async function update( + id: string, + userId: string, + updates: UpdateFeedItemData + ): Promise { + const [row] = await db + .update(feedItemsTable) + .set({ ...updates, updatedAt: new Date() }) + .where(and(eq(feedItemsTable.id, id), eq(feedItemsTable.userId, userId))) + .returning(itemColumns); + + return (row as FeedItemData | undefined) ?? null; + } + + return { + findById, + + async create(data) { + const [row] = await db + .insert(feedItemsTable) + .values({ + author: data.author ?? null, + discoveredAt: data.discoveredAt ?? new Date(), + excerpt: data.excerpt ?? null, + guid: data.guid ?? null, + imageUrl: data.imageUrl ?? null, + linkId: data.linkId ?? null, + normalizedUrl: data.normalizedUrl, + publishedAt: data.publishedAt ?? null, + state: data.state ?? 'new', + subscriptionId: data.subscriptionId, + title: data.title, + url: data.url, + userId: data.userId + }) + .returning(itemColumns); + + if (!row) throw new Error('Failed to create feed item'); + return row as FeedItemData; + }, + + async dismiss(id, userId, dismissedAt = new Date()) { + return update(id, userId, { dismissedAt, state: 'dismissed' }); + }, + + async findBySubscriptionAndIdentity({ guid, normalizedUrl, subscriptionId, userId }) { + const identityCondition = guid + ? or(eq(feedItemsTable.guid, guid), eq(feedItemsTable.normalizedUrl, normalizedUrl)) + : eq(feedItemsTable.normalizedUrl, normalizedUrl); + + const [row] = await db + .select(itemColumns) + .from(feedItemsTable) + .where( + and( + eq(feedItemsTable.subscriptionId, subscriptionId), + eq(feedItemsTable.userId, userId), + identityCondition + ) + ) + .limit(1); + + return (row as FeedItemData | undefined) ?? null; + }, + + async findManyForReview({ state, subscriptionId, userId }) { + const conditions = [eq(feedItemsTable.userId, userId)]; + + if (state) conditions.push(eq(feedItemsTable.state, state)); + if (subscriptionId) conditions.push(eq(feedItemsTable.subscriptionId, subscriptionId)); + + const rows = await db + .select(itemColumns) + .from(feedItemsTable) + .where(and(...conditions)) + .orderBy(desc(feedItemsTable.publishedAt), desc(feedItemsTable.discoveredAt)); + + return rows as FeedItemData[]; + }, + + async pruneForSubscription({ before, keepLatest, subscriptionId, userId }) { + const oldRows = await db + .delete(feedItemsTable) + .where( + and( + eq(feedItemsTable.subscriptionId, subscriptionId), + eq(feedItemsTable.userId, userId), + ne(feedItemsTable.state, 'saved'), + lt(feedItemsTable.discoveredAt, before) + ) + ) + .returning({ id: feedItemsTable.id }); + + const excessRows = await db + .select({ id: feedItemsTable.id }) + .from(feedItemsTable) + .where( + and( + eq(feedItemsTable.subscriptionId, subscriptionId), + eq(feedItemsTable.userId, userId), + ne(feedItemsTable.state, 'saved') + ) + ) + .orderBy(desc(feedItemsTable.discoveredAt), desc(feedItemsTable.createdAt)) + .offset(keepLatest); + + if (excessRows.length === 0) return oldRows.length; + + const removedExcess = await db + .delete(feedItemsTable) + .where( + inArray( + feedItemsTable.id, + excessRows.map((row) => row.id) + ) + ) + .returning({ id: feedItemsTable.id }); + + return oldRows.length + removedExcess.length; + }, + + async reconcileSavedByUrl({ linkId, normalizedUrl, savedAt = new Date(), userId }) { + const rows = await db + .update(feedItemsTable) + .set({ linkId, savedAt, state: 'saved', updatedAt: new Date() }) + .where( + and(eq(feedItemsTable.userId, userId), eq(feedItemsTable.normalizedUrl, normalizedUrl)) + ) + .returning(itemColumns); + + return rows as FeedItemData[]; + }, + + async save(id, userId, linkId, savedAt = new Date()) { + return update(id, userId, { linkId, savedAt, state: 'saved' }); + }, + + async upsertByIdentity(data) { + const existing = await this.findBySubscriptionAndIdentity({ + guid: data.guid, + normalizedUrl: data.normalizedUrl, + subscriptionId: data.subscriptionId, + userId: data.userId + }); + + if (!existing) return this.create(data); + + const updated = await update(existing.id, data.userId, { + author: data.author ?? existing.author, + excerpt: data.excerpt ?? existing.excerpt, + guid: data.guid ?? existing.guid, + imageUrl: data.imageUrl ?? existing.imageUrl, + normalizedUrl: data.normalizedUrl, + publishedAt: data.publishedAt ?? existing.publishedAt, + title: data.title, + url: data.url + }); + + if (!updated) throw new Error('Failed to update feed item'); + return updated; + } + }; +} diff --git a/apps/server/src/repositories/feed-subscriptions.repository.test.ts b/apps/server/src/repositories/feed-subscriptions.repository.test.ts new file mode 100644 index 0000000..abf9bd9 --- /dev/null +++ b/apps/server/src/repositories/feed-subscriptions.repository.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from 'vitest'; + +import { db } from '@/db/index.js'; +import { usersTable } from '@/db/schemas/index.js'; + +import { createDrizzleFeedSubscriptionsAdapter } from './feed-subscriptions.repository.js'; + +const USER_A_ID = '10000000-0000-0000-0000-000000000001'; +const USER_B_ID = '10000000-0000-0000-0000-000000000002'; + +async function seedUser(id: string, email: string) { + await db.insert(usersTable).values({ + id, + email, + passwordHash: 'hashed-password', + name: email, + settings: {} + }); +} + +describe('feed subscriptions repository', () => { + it('creates and scopes subscriptions by user', async () => { + const repo = createDrizzleFeedSubscriptionsAdapter(db); + await seedUser(USER_A_ID, 'feeds-a@example.com'); + await seedUser(USER_B_ID, 'feeds-b@example.com'); + + const subscription = await repo.create({ + feedUrl: 'https://example.com/feed.xml', + normalizedFeedUrl: 'https://example.com/feed.xml', + siteUrl: 'https://example.com', + title: 'Example Feed', + userId: USER_A_ID + }); + + expect(subscription.id).toBeTruthy(); + expect(subscription.autoSave).toBe(false); + expect(subscription.status).toBe('active'); + await expect(repo.findById(subscription.id, USER_A_ID)).resolves.toMatchObject({ + id: subscription.id, + title: 'Example Feed' + }); + await expect(repo.findById(subscription.id, USER_B_ID)).resolves.toBeNull(); + }); + + it('finds duplicate normalized feed URLs per user while allowing another user', async () => { + const repo = createDrizzleFeedSubscriptionsAdapter(db); + await seedUser(USER_A_ID, 'feeds-a@example.com'); + await seedUser(USER_B_ID, 'feeds-b@example.com'); + + await repo.create({ + feedUrl: 'https://example.com/feed.xml', + normalizedFeedUrl: 'https://example.com/feed.xml', + title: 'Example Feed', + userId: USER_A_ID + }); + await repo.create({ + feedUrl: 'https://example.com/feed.xml', + normalizedFeedUrl: 'https://example.com/feed.xml', + title: 'Example Feed For B', + userId: USER_B_ID + }); + + const duplicateForA = await repo.findByNormalizedUrl('https://example.com/feed.xml', USER_A_ID); + const duplicateForB = await repo.findByNormalizedUrl('https://example.com/feed.xml', USER_B_ID); + + expect(duplicateForA?.title).toBe('Example Feed'); + expect(duplicateForB?.title).toBe('Example Feed For B'); + await expect( + repo.create({ + feedUrl: 'https://example.com/feed.xml', + normalizedFeedUrl: 'https://example.com/feed.xml', + title: 'Duplicate Feed', + userId: USER_A_ID + }) + ).rejects.toThrow(); + }); + + it('returns active due subscriptions and skips paused or future subscriptions', async () => { + const repo = createDrizzleFeedSubscriptionsAdapter(db); + await seedUser(USER_A_ID, 'feeds-a@example.com'); + + const now = new Date('2026-06-28T12:00:00Z'); + const due = await repo.create({ + feedUrl: 'https://due.example/feed.xml', + normalizedFeedUrl: 'https://due.example/feed.xml', + nextFetchAfter: new Date('2026-06-28T11:00:00Z'), + title: 'Due Feed', + userId: USER_A_ID + }); + const neverFetched = await repo.create({ + feedUrl: 'https://new.example/feed.xml', + normalizedFeedUrl: 'https://new.example/feed.xml', + title: 'New Feed', + userId: USER_A_ID + }); + const future = await repo.create({ + feedUrl: 'https://future.example/feed.xml', + normalizedFeedUrl: 'https://future.example/feed.xml', + nextFetchAfter: new Date('2026-06-28T13:00:00Z'), + title: 'Future Feed', + userId: USER_A_ID + }); + const paused = await repo.create({ + feedUrl: 'https://paused.example/feed.xml', + normalizedFeedUrl: 'https://paused.example/feed.xml', + title: 'Paused Feed', + userId: USER_A_ID + }); + await repo.update(paused.id, USER_A_ID, { status: 'paused' }); + + const dueFeeds = await repo.findDue(now); + const dueIds = dueFeeds.map((feed) => feed.id); + + expect(dueIds).toContain(due.id); + expect(dueIds).toContain(neverFetched.id); + expect(dueIds).not.toContain(future.id); + expect(dueIds).not.toContain(paused.id); + }); + + it('updates fetch metadata only for the owning user', async () => { + const repo = createDrizzleFeedSubscriptionsAdapter(db); + await seedUser(USER_A_ID, 'feeds-a@example.com'); + await seedUser(USER_B_ID, 'feeds-b@example.com'); + + const subscription = await repo.create({ + feedUrl: 'https://example.com/feed.xml', + normalizedFeedUrl: 'https://example.com/feed.xml', + title: 'Example Feed', + userId: USER_A_ID + }); + + await expect( + repo.updateFetchMetadata(subscription.id, USER_B_ID, { + failureCount: 1, + lastError: 'Wrong user' + }) + ).resolves.toBeNull(); + + const updated = await repo.updateFetchMetadata(subscription.id, USER_A_ID, { + etag: 'abc123', + failureCount: 0, + lastError: null, + lastFetchedAt: new Date('2026-06-28T12:00:00Z'), + lastModified: 'Sun, 28 Jun 2026 12:00:00 GMT', + lastSuccessfulFetchAt: new Date('2026-06-28T12:00:00Z'), + nextFetchAfter: new Date('2026-06-28T18:00:00Z') + }); + + expect(updated).toMatchObject({ + etag: 'abc123', + failureCount: 0, + lastError: null, + lastModified: 'Sun, 28 Jun 2026 12:00:00 GMT' + }); + }); +}); diff --git a/apps/server/src/repositories/feed-subscriptions.repository.ts b/apps/server/src/repositories/feed-subscriptions.repository.ts new file mode 100644 index 0000000..5f5f544 --- /dev/null +++ b/apps/server/src/repositories/feed-subscriptions.repository.ts @@ -0,0 +1,231 @@ +import { and, desc, eq, isNull, lte, or } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +import type * as schema from '@/db/schemas/index.js'; +import { feedSubscriptionsTable } from '@/db/schemas/index.js'; + +type DrizzleClient = NodePgDatabase; + +export type FeedSubscriptionStatus = 'active' | 'paused'; + +export interface FeedSubscriptionData { + id: string; + userId: string; + feedUrl: string; + normalizedFeedUrl: string; + siteUrl: string | null; + title: string; + description: string | null; + imageUrl: string | null; + autoSave: boolean; + status: FeedSubscriptionStatus; + lastFetchedAt: Date | null; + lastSuccessfulFetchAt: Date | null; + nextFetchAfter: Date | null; + lastError: string | null; + failureCount: number; + etag: string | null; + lastModified: string | null; + createdAt: Date; + updatedAt: Date; +} + +type CreateFeedSubscriptionData = Pick< + FeedSubscriptionData, + 'feedUrl' | 'normalizedFeedUrl' | 'title' | 'userId' +> & + Partial< + Pick< + FeedSubscriptionData, + | 'autoSave' + | 'description' + | 'etag' + | 'imageUrl' + | 'lastModified' + | 'nextFetchAfter' + | 'siteUrl' + > + >; + +type UpdateFeedSubscriptionData = Partial< + Pick< + FeedSubscriptionData, + | 'autoSave' + | 'description' + | 'etag' + | 'failureCount' + | 'feedUrl' + | 'imageUrl' + | 'lastError' + | 'lastFetchedAt' + | 'lastModified' + | 'lastSuccessfulFetchAt' + | 'nextFetchAfter' + | 'siteUrl' + | 'status' + | 'title' + > +>; + +const subscriptionColumns = { + id: feedSubscriptionsTable.id, + userId: feedSubscriptionsTable.userId, + feedUrl: feedSubscriptionsTable.feedUrl, + normalizedFeedUrl: feedSubscriptionsTable.normalizedFeedUrl, + siteUrl: feedSubscriptionsTable.siteUrl, + title: feedSubscriptionsTable.title, + description: feedSubscriptionsTable.description, + imageUrl: feedSubscriptionsTable.imageUrl, + autoSave: feedSubscriptionsTable.autoSave, + status: feedSubscriptionsTable.status, + lastFetchedAt: feedSubscriptionsTable.lastFetchedAt, + lastSuccessfulFetchAt: feedSubscriptionsTable.lastSuccessfulFetchAt, + nextFetchAfter: feedSubscriptionsTable.nextFetchAfter, + lastError: feedSubscriptionsTable.lastError, + failureCount: feedSubscriptionsTable.failureCount, + etag: feedSubscriptionsTable.etag, + lastModified: feedSubscriptionsTable.lastModified, + createdAt: feedSubscriptionsTable.createdAt, + updatedAt: feedSubscriptionsTable.updatedAt +}; + +export interface FeedSubscriptionsRepository { + create(data: CreateFeedSubscriptionData): Promise; + delete(id: string, userId: string): Promise; + findById(id: string, userId: string): Promise; + findByNormalizedUrl( + normalizedFeedUrl: string, + userId: string + ): Promise; + findDue(now: Date, limit?: number): Promise; + findManyByUserId(userId: string): Promise; + update( + id: string, + userId: string, + updates: UpdateFeedSubscriptionData + ): Promise; + updateFetchMetadata( + id: string, + userId: string, + updates: Pick< + UpdateFeedSubscriptionData, + | 'etag' + | 'failureCount' + | 'lastError' + | 'lastFetchedAt' + | 'lastModified' + | 'lastSuccessfulFetchAt' + | 'nextFetchAfter' + > + ): Promise; +} + +export function createDrizzleFeedSubscriptionsAdapter( + db: DrizzleClient +): FeedSubscriptionsRepository { + async function findById(id: string, userId: string): Promise { + const [row] = await db + .select(subscriptionColumns) + .from(feedSubscriptionsTable) + .where(and(eq(feedSubscriptionsTable.id, id), eq(feedSubscriptionsTable.userId, userId))) + .limit(1); + + return (row as FeedSubscriptionData | undefined) ?? null; + } + + async function update( + id: string, + userId: string, + updates: UpdateFeedSubscriptionData + ): Promise { + const [row] = await db + .update(feedSubscriptionsTable) + .set({ ...updates, updatedAt: new Date() }) + .where(and(eq(feedSubscriptionsTable.id, id), eq(feedSubscriptionsTable.userId, userId))) + .returning(subscriptionColumns); + + return (row as FeedSubscriptionData | undefined) ?? null; + } + + return { + findById, + update, + + async create(data) { + const [row] = await db + .insert(feedSubscriptionsTable) + .values({ + autoSave: data.autoSave ?? false, + description: data.description ?? null, + etag: data.etag ?? null, + feedUrl: data.feedUrl, + imageUrl: data.imageUrl ?? null, + lastModified: data.lastModified ?? null, + nextFetchAfter: data.nextFetchAfter ?? null, + normalizedFeedUrl: data.normalizedFeedUrl, + siteUrl: data.siteUrl ?? null, + title: data.title, + userId: data.userId + }) + .returning(subscriptionColumns); + + if (!row) throw new Error('Failed to create feed subscription'); + return row as FeedSubscriptionData; + }, + + async delete(id, userId) { + const rows = await db + .delete(feedSubscriptionsTable) + .where(and(eq(feedSubscriptionsTable.id, id), eq(feedSubscriptionsTable.userId, userId))) + .returning({ id: feedSubscriptionsTable.id }); + + return rows.length > 0; + }, + + async findByNormalizedUrl(normalizedFeedUrl, userId) { + const [row] = await db + .select(subscriptionColumns) + .from(feedSubscriptionsTable) + .where( + and( + eq(feedSubscriptionsTable.normalizedFeedUrl, normalizedFeedUrl), + eq(feedSubscriptionsTable.userId, userId) + ) + ) + .limit(1); + + return (row as FeedSubscriptionData | undefined) ?? null; + }, + + async findDue(now, limit = 50) { + const rows = await db + .select(subscriptionColumns) + .from(feedSubscriptionsTable) + .where( + and( + eq(feedSubscriptionsTable.status, 'active'), + or( + isNull(feedSubscriptionsTable.nextFetchAfter), + lte(feedSubscriptionsTable.nextFetchAfter, now) + ) + ) + ) + .orderBy(feedSubscriptionsTable.nextFetchAfter, feedSubscriptionsTable.createdAt) + .limit(limit); + + return rows as FeedSubscriptionData[]; + }, + + async findManyByUserId(userId) { + const rows = await db + .select(subscriptionColumns) + .from(feedSubscriptionsTable) + .where(eq(feedSubscriptionsTable.userId, userId)) + .orderBy(desc(feedSubscriptionsTable.createdAt)); + + return rows as FeedSubscriptionData[]; + }, + + updateFetchMetadata: update + }; +} From 4615080d03c80cbf89b109b40abbab78585a8ec1 Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sun, 28 Jun 2026 06:12:23 +0700 Subject: [PATCH 03/18] feat(server): add safe RSS feed parsing Add guarded feed fetching and RSS/Atom normalization so future ingestion can accept user-provided feed URLs without bypassing Loreo's public HTTP(S) URL safety posture. --- apps/server/src/lib/feed-url-guard.test.ts | 39 +++ apps/server/src/lib/feed-url-guard.ts | 11 + .../src/services/feed-fetch.service.test.ts | 107 ++++++++ .../server/src/services/feed-fetch.service.ts | 143 +++++++++++ .../src/services/feed-parser.service.test.ts | 133 ++++++++++ .../src/services/feed-parser.service.ts | 238 ++++++++++++++++++ 6 files changed, 671 insertions(+) create mode 100644 apps/server/src/lib/feed-url-guard.test.ts create mode 100644 apps/server/src/lib/feed-url-guard.ts create mode 100644 apps/server/src/services/feed-fetch.service.test.ts create mode 100644 apps/server/src/services/feed-fetch.service.ts create mode 100644 apps/server/src/services/feed-parser.service.test.ts create mode 100644 apps/server/src/services/feed-parser.service.ts diff --git a/apps/server/src/lib/feed-url-guard.test.ts b/apps/server/src/lib/feed-url-guard.test.ts new file mode 100644 index 0000000..382c98d --- /dev/null +++ b/apps/server/src/lib/feed-url-guard.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { assertSafeFeedUrl, isSafeFeedEntryUrl } from './feed-url-guard.js'; + +const lookupMock = vi.hoisted(() => vi.fn()); + +vi.mock('node:dns/promises', () => ({ + lookup: lookupMock +})); + +describe('feed URL guard', () => { + beforeEach(() => { + lookupMock.mockReset(); + }); + + it('rejects non-http feed URLs', async () => { + await expect(assertSafeFeedUrl('file:///etc/passwd')).rejects.toThrow(/feed url/i); + expect(lookupMock).not.toHaveBeenCalled(); + }); + + it('rejects localhost feed URLs without DNS lookup', async () => { + await expect(assertSafeFeedUrl('http://localhost/feed.xml')).rejects.toThrow(/feed url/i); + expect(lookupMock).not.toHaveBeenCalled(); + }); + + it('rejects hostnames that resolve to private addresses', async () => { + lookupMock.mockResolvedValue([{ address: '10.0.0.10', family: 4 }] as never); + + await expect(assertSafeFeedUrl('https://feeds.example.com/rss')).rejects.toThrow(/feed url/i); + expect(lookupMock).toHaveBeenCalledWith('feeds.example.com', { all: true }); + }); + + it('allows public feed and entry URLs', async () => { + lookupMock.mockResolvedValue([{ address: '93.184.216.34', family: 4 }] as never); + + await expect(assertSafeFeedUrl('https://feeds.example.com/rss')).resolves.toBeUndefined(); + await expect(isSafeFeedEntryUrl('https://example.com/article')).resolves.toBe(true); + }); +}); diff --git a/apps/server/src/lib/feed-url-guard.ts b/apps/server/src/lib/feed-url-guard.ts new file mode 100644 index 0000000..2e67de1 --- /dev/null +++ b/apps/server/src/lib/feed-url-guard.ts @@ -0,0 +1,11 @@ +import { isValidUrl } from './url-validator.js'; + +export async function assertSafeFeedUrl(url: string): Promise { + if (!(await isValidUrl(url))) { + throw new Error('Feed URL is not allowed'); + } +} + +export async function isSafeFeedEntryUrl(url: string): Promise { + return isValidUrl(url); +} diff --git a/apps/server/src/services/feed-fetch.service.test.ts b/apps/server/src/services/feed-fetch.service.test.ts new file mode 100644 index 0000000..06526a7 --- /dev/null +++ b/apps/server/src/services/feed-fetch.service.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FeedFetchError, fetchFeed } from './feed-fetch.service.js'; + +const assertSafeFeedUrlMock = vi.hoisted(() => vi.fn()); +const fetchWithValidatedRedirectsMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/feed-url-guard.js', () => ({ + assertSafeFeedUrl: assertSafeFeedUrlMock +})); + +vi.mock('@/lib/api-client.js', () => ({ + fetchWithValidatedRedirects: fetchWithValidatedRedirectsMock, + rotatedUserAgent: 'test-agent' +})); + +describe('fetchFeed', () => { + beforeEach(() => { + assertSafeFeedUrlMock.mockReset().mockResolvedValue(undefined); + fetchWithValidatedRedirectsMock.mockReset(); + }); + + it('sends conditional request headers and returns feed text', async () => { + fetchWithValidatedRedirectsMock.mockResolvedValue( + new Response('', { + headers: { + etag: 'abc123', + 'last-modified': 'Sun, 28 Jun 2026 12:00:00 GMT' + }, + status: 200 + }) + ); + + const result = await fetchFeed('https://example.com/feed.xml', { + etag: 'previous-etag', + lastModified: 'Sat, 27 Jun 2026 12:00:00 GMT' + }); + + expect(result).toMatchObject({ + body: '', + headers: { + etag: 'abc123', + lastModified: 'Sun, 28 Jun 2026 12:00:00 GMT' + }, + status: 'ok' + }); + expect(fetchWithValidatedRedirectsMock).toHaveBeenCalledWith( + 'https://example.com/feed.xml', + expect.objectContaining({ + headers: expect.objectContaining({ + 'If-Modified-Since': 'Sat, 27 Jun 2026 12:00:00 GMT', + 'If-None-Match': 'previous-etag', + 'User-Agent': 'test-agent' + }) + }) + ); + }); + + it('returns not-modified for 304 responses without reading a body', async () => { + fetchWithValidatedRedirectsMock.mockResolvedValue(new Response(null, { status: 304 })); + + await expect(fetchFeed('https://example.com/feed.xml')).resolves.toEqual({ + headers: { etag: null, lastModified: null }, + status: 'not-modified' + }); + }); + + it('rejects unsafe feed URLs before fetching', async () => { + assertSafeFeedUrlMock.mockRejectedValue(new Error('Feed URL is not allowed')); + + await expect(fetchFeed('http://127.0.0.1/feed.xml')).rejects.toMatchObject({ + code: 'feed-url-not-allowed' + } satisfies Partial); + expect(fetchWithValidatedRedirectsMock).not.toHaveBeenCalled(); + }); + + it('maps aborts to timeout errors', async () => { + fetchWithValidatedRedirectsMock.mockRejectedValue( + new DOMException('Timed out', 'TimeoutError') + ); + + await expect(fetchFeed('https://example.com/feed.xml')).rejects.toMatchObject({ + code: 'feed-timeout' + } satisfies Partial); + }); + + it('rejects oversized responses using content-length', async () => { + fetchWithValidatedRedirectsMock.mockResolvedValue( + new Response('too large', { + headers: { 'content-length': '10' }, + status: 200 + }) + ); + + await expect(fetchFeed('https://example.com/feed.xml', { maxBytes: 5 })).rejects.toMatchObject({ + code: 'feed-too-large' + } satisfies Partial); + }); + + it('rejects oversized streamed responses', async () => { + fetchWithValidatedRedirectsMock.mockResolvedValue(new Response('123456', { status: 200 })); + + await expect(fetchFeed('https://example.com/feed.xml', { maxBytes: 5 })).rejects.toMatchObject({ + code: 'feed-too-large' + } satisfies Partial); + }); +}); diff --git a/apps/server/src/services/feed-fetch.service.ts b/apps/server/src/services/feed-fetch.service.ts new file mode 100644 index 0000000..7273d28 --- /dev/null +++ b/apps/server/src/services/feed-fetch.service.ts @@ -0,0 +1,143 @@ +import { fetchWithValidatedRedirects, rotatedUserAgent } from '@/lib/api-client.js'; +import { assertSafeFeedUrl } from '@/lib/feed-url-guard.js'; + +const DEFAULT_FEED_FETCH_TIMEOUT_MS = 10_000; +const DEFAULT_MAX_FEED_BYTES = 1024 * 1024; + +export type FeedFetchOptions = { + etag?: string | null; + lastModified?: string | null; + maxBytes?: number; + timeoutMs?: number; +}; + +export type FeedFetchResult = + | { + headers: { + etag: string | null; + lastModified: string | null; + }; + status: 'not-modified'; + } + | { + body: string; + headers: { + etag: string | null; + lastModified: string | null; + }; + status: 'ok'; + }; + +export class FeedFetchError extends Error { + constructor( + message: string, + public readonly code: + | 'feed-url-not-allowed' + | 'feed-fetch-failed' + | 'feed-timeout' + | 'feed-too-large' + | 'feed-unexpected-status' + ) { + super(message); + this.name = 'FeedFetchError'; + } +} + +function conditionalHeaders({ etag, lastModified }: FeedFetchOptions): Record { + return { + Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml;q=0.9, */*;q=0.5', + 'User-Agent': rotatedUserAgent ?? 'Mozilla/5.0', + ...(etag ? { 'If-None-Match': etag } : {}), + ...(lastModified ? { 'If-Modified-Since': lastModified } : {}) + }; +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'); +} + +function safeErrorMessage(error: unknown): string { + if (isAbortError(error)) return 'Feed fetch timed out'; + if (error instanceof FeedFetchError) return error.message; + if (error instanceof Error && /Rejected URL|Unsafe redirect/i.test(error.message)) { + return 'Feed URL is not allowed'; + } + return 'Feed fetch failed'; +} + +async function readBoundedText(response: Response, maxBytes: number): Promise { + const contentLength = response.headers.get('content-length'); + if (contentLength && Number.parseInt(contentLength, 10) > maxBytes) { + throw new FeedFetchError('Feed response is too large', 'feed-too-large'); + } + + if (!response.body) { + const body = await response.text(); + if (Buffer.byteLength(body, 'utf8') > maxBytes) { + throw new FeedFetchError('Feed response is too large', 'feed-too-large'); + } + return body; + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + + totalBytes += value.byteLength; + if (totalBytes > maxBytes) { + await reader.cancel().catch(() => undefined); + throw new FeedFetchError('Feed response is too large', 'feed-too-large'); + } + chunks.push(value); + } + + return new TextDecoder().decode(Buffer.concat(chunks)); +} + +export async function fetchFeed( + url: string, + options: FeedFetchOptions = {} +): Promise { + const maxBytes = options.maxBytes ?? DEFAULT_MAX_FEED_BYTES; + const timeoutMs = options.timeoutMs ?? DEFAULT_FEED_FETCH_TIMEOUT_MS; + + try { + await assertSafeFeedUrl(url); + + const response = await fetchWithValidatedRedirects(url, { + headers: conditionalHeaders(options), + timeoutMs + }); + + const headers = { + etag: response.headers.get('etag'), + lastModified: response.headers.get('last-modified') + }; + + if (response.status === 304) { + return { headers, status: 'not-modified' }; + } + + if (!response.ok) { + throw new FeedFetchError('Feed returned an unexpected status', 'feed-unexpected-status'); + } + + return { + body: await readBoundedText(response, maxBytes), + headers, + status: 'ok' + }; + } catch (error) { + if (error instanceof FeedFetchError) throw error; + if (isAbortError(error)) throw new FeedFetchError('Feed fetch timed out', 'feed-timeout'); + if (error instanceof Error && /Rejected URL|Unsafe redirect|not allowed/i.test(error.message)) { + throw new FeedFetchError('Feed URL is not allowed', 'feed-url-not-allowed'); + } + throw new FeedFetchError(safeErrorMessage(error), 'feed-fetch-failed'); + } +} diff --git a/apps/server/src/services/feed-parser.service.test.ts b/apps/server/src/services/feed-parser.service.test.ts new file mode 100644 index 0000000..3268cff --- /dev/null +++ b/apps/server/src/services/feed-parser.service.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { parseFeedXml } from './feed-parser.service.js'; + +const isSafeFeedEntryUrlMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/lib/feed-url-guard.js', () => ({ + isSafeFeedEntryUrl: isSafeFeedEntryUrlMock +})); + +describe('parseFeedXml', () => { + beforeEach(() => { + isSafeFeedEntryUrlMock.mockReset().mockResolvedValue(true); + }); + + it('maps RSS feed metadata and items into normalized records', async () => { + const feed = await parseFeedXml( + ` + + + Example RSS + A <strong>calm</strong> feed + https://example.com/ + /logo.png + + First article + https://example.com/articles/one?b=2&a=1#section + item-1 + Useful summary

]]>
+ Jane Author + Sun, 28 Jun 2026 12:00:00 GMT + +
+
+
`, + 'https://example.com/feed.xml' + ); + + expect(feed).toMatchObject({ + description: 'A calm feed', + imageUrl: 'https://example.com/logo.png', + siteUrl: 'https://example.com/', + title: 'Example RSS' + }); + expect(feed.items).toHaveLength(1); + expect(feed.items[0]).toMatchObject({ + author: 'Jane Author', + excerpt: 'Useful summary', + guid: 'item-1', + imageUrl: 'https://example.com/one.jpg', + normalizedUrl: 'https://example.com/articles/one?a=1&b=2', + title: 'First article', + url: 'https://example.com/articles/one?b=2&a=1#section' + }); + expect(feed.items[0]?.publishedAt?.toISOString()).toBe('2026-06-28T12:00:00.000Z'); + }); + + it('maps Atom feed metadata and entries into normalized records', async () => { + const feed = await parseFeedXml( + ` + + Example Atom + Atom summary + + + tag:example.com,2026:one + Atom article + + Atom excerpt

]]>
+ Ada Writer + 2026-06-28T13:00:00Z +
+
`, + 'https://example.com/feed.atom' + ); + + expect(feed).toMatchObject({ + description: 'Atom summary', + siteUrl: 'https://example.com/', + title: 'Example Atom' + }); + expect(feed.items).toHaveLength(1); + expect(feed.items[0]).toMatchObject({ + author: 'Ada Writer', + excerpt: 'Atom excerpt', + guid: 'tag:example.com,2026:one', + normalizedUrl: 'https://example.com/articles/atom-one', + title: 'Atom article', + url: 'https://example.com/articles/atom-one' + }); + }); + + it('skips entries with blocked or invalid URLs', async () => { + isSafeFeedEntryUrlMock.mockImplementation(async (url: string) => !url.includes('internal')); + + const feed = await parseFeedXml( + ` + Mixed feed + Unsafehttp://127.0.0.1/internal + Safehttps://example.com/safe + `, + 'https://example.com/feed.xml' + ); + + expect(feed.items).toHaveLength(1); + expect(feed.items[0]?.title).toBe('Safe'); + }); + + it('bounds noisy external text fields', async () => { + const longTitle = 'A'.repeat(400); + const longDescription = 'B'.repeat(1200); + + const feed = await parseFeedXml( + ` + ${longTitle} + ${longDescription} + ${longTitle}${longDescription}https://example.com/post + `, + 'https://example.com/feed.xml' + ); + + expect(feed.title).toHaveLength(300); + expect(feed.description).toHaveLength(1000); + expect(feed.items[0]?.title).toHaveLength(300); + expect(feed.items[0]?.excerpt).toHaveLength(1000); + }); + + it('rejects unsupported or invalid XML documents', async () => { + await expect( + parseFeedXml('not a feed', 'https://example.com') + ).rejects.toThrow(/unsupported feed/i); + }); +}); diff --git a/apps/server/src/services/feed-parser.service.ts b/apps/server/src/services/feed-parser.service.ts new file mode 100644 index 0000000..23111c4 --- /dev/null +++ b/apps/server/src/services/feed-parser.service.ts @@ -0,0 +1,238 @@ +import { DOMParser } from 'linkedom'; + +import { isSafeFeedEntryUrl } from '@/lib/feed-url-guard.js'; + +type ParsedFeedDocument = ReturnType; + +const MAX_TITLE_LENGTH = 300; +const MAX_DESCRIPTION_LENGTH = 1000; +const MAX_EXCERPT_LENGTH = 1000; +const MAX_AUTHOR_LENGTH = 200; + +export type NormalizedFeedItem = { + author: string | null; + excerpt: string | null; + guid: string | null; + imageUrl: string | null; + normalizedUrl: string; + publishedAt: Date | null; + title: string; + url: string; +}; + +export type NormalizedFeed = { + description: string | null; + imageUrl: string | null; + items: NormalizedFeedItem[]; + siteUrl: string | null; + title: string; +}; + +export class FeedParseError extends Error { + constructor(message: string) { + super(message); + this.name = 'FeedParseError'; + } +} + +function textFromHtml(value: string): string { + return value.replace(/<[^>]*>/g, ' '); +} + +function sanitizeText(value: string | null | undefined, maxLength: number): string | null { + const normalized = textFromHtml(value ?? '') + .replace(/\s+/g, ' ') + .trim(); + + if (!normalized) return null; + return normalized.length > maxLength ? normalized.slice(0, maxLength).trim() : normalized; +} + +function firstText(parent: Element | ParsedFeedDocument, tagNames: string[]): string | null { + for (const tagName of tagNames) { + const element = parent.querySelector(tagName) ?? parent.getElementsByTagName(tagName).item(0); + const text = sanitizeText(element?.textContent, Number.MAX_SAFE_INTEGER); + if (text) return text; + } + return null; +} + +function resolveUrl(value: string | null | undefined, baseUrl: string): string | null { + const sanitized = sanitizeText(value, 2048); + if (!sanitized) return null; + + try { + return new URL(sanitized, baseUrl).toString(); + } catch { + return null; + } +} + +function normalizeUrl(url: string): string { + const parsed = new URL(url); + parsed.hash = ''; + parsed.searchParams.sort(); + return parsed.toString(); +} + +function parseDate(value: string | null | undefined): Date | null { + const sanitized = sanitizeText(value, 200); + if (!sanitized) return null; + + const timestamp = Date.parse(sanitized); + return Number.isNaN(timestamp) ? null : new Date(timestamp); +} + +async function safeUrl(value: string | null, baseUrl: string): Promise { + const url = resolveUrl(value, baseUrl); + if (!url) return null; + return (await isSafeFeedEntryUrl(url)) ? url : null; +} + +function rssChannel(document: ParsedFeedDocument): Element { + const channel = document.querySelector('rss channel') ?? document.querySelector('channel'); + if (!channel) { + throw new FeedParseError('Unsupported feed XML'); + } + return channel; +} + +function atomFeed(document: ParsedFeedDocument): Element { + const feed = document.querySelector('feed'); + if (!feed) { + throw new FeedParseError('Unsupported feed XML'); + } + return feed; +} + +function atomLink(parent: Element | ParsedFeedDocument): string | null { + const links = Array.from(parent.querySelectorAll('link')) as Element[]; + const alternate = + links.find((link) => !link.getAttribute('rel') || link.getAttribute('rel') === 'alternate') ?? + links[0]; + + return alternate?.getAttribute('href') ?? alternate?.textContent ?? null; +} + +function rssImage(parent: Element): string | null { + return ( + parent.querySelector('enclosure[type^="image/"]')?.getAttribute('url') ?? + parent.getElementsByTagName('media:content').item(0)?.getAttribute('url') ?? + parent.getElementsByTagName('media:thumbnail').item(0)?.getAttribute('url') ?? + firstText(parent, ['image url']) + ); +} + +function rssChannelImage(channel: Element): string | null { + const image = Array.from(channel.children).find( + (child) => child.tagName.toLowerCase() === 'image' + ); + return image ? firstText(image, ['url']) : null; +} + +function atomImage(parent: Element): string | null { + const links = Array.from(parent.querySelectorAll('link')) as Element[]; + return ( + links + .find((link) => ['enclosure', 'image'].includes(link.getAttribute('rel') ?? '')) + ?.getAttribute('href') ?? null + ); +} + +async function parseRss(document: ParsedFeedDocument, feedUrl: string): Promise { + const channel = rssChannel(document); + const rawSiteUrl = firstText(channel, ['link']); + const siteUrl = await safeUrl(rawSiteUrl, feedUrl); + const imageUrl = await safeUrl(rssChannelImage(channel), feedUrl); + const title = sanitizeText(firstText(channel, ['title']), MAX_TITLE_LENGTH) ?? 'Untitled feed'; + const items: NormalizedFeedItem[] = []; + + for (const item of Array.from(channel.querySelectorAll('item'))) { + const url = await safeUrl(firstText(item, ['link']), siteUrl ?? feedUrl); + if (!url) continue; + + const image = await safeUrl(rssImage(item), url); + const titleText = sanitizeText(firstText(item, ['title']), MAX_TITLE_LENGTH) ?? url; + + items.push({ + author: sanitizeText( + firstText(item, ['author', 'dc\\:creator', 'creator']), + MAX_AUTHOR_LENGTH + ), + excerpt: sanitizeText( + firstText(item, ['description', 'content\\:encoded', 'summary']), + MAX_EXCERPT_LENGTH + ), + guid: sanitizeText(firstText(item, ['guid']), 500), + imageUrl: image, + normalizedUrl: normalizeUrl(url), + publishedAt: parseDate(firstText(item, ['pubDate', 'published', 'updated'])), + title: titleText, + url + }); + } + + return { + description: sanitizeText( + firstText(channel, ['description', 'subtitle']), + MAX_DESCRIPTION_LENGTH + ), + imageUrl, + items, + siteUrl, + title + }; +} + +async function parseAtom(document: ParsedFeedDocument, feedUrl: string): Promise { + const feed = atomFeed(document); + const siteUrl = await safeUrl(atomLink(feed), feedUrl); + const imageUrl = await safeUrl(firstText(feed, ['icon', 'logo']), feedUrl); + const title = sanitizeText(firstText(feed, ['title']), MAX_TITLE_LENGTH) ?? 'Untitled feed'; + const items: NormalizedFeedItem[] = []; + + for (const entry of Array.from(feed.querySelectorAll('entry'))) { + const url = await safeUrl(atomLink(entry), siteUrl ?? feedUrl); + if (!url) continue; + + const image = await safeUrl(atomImage(entry), url); + const titleText = sanitizeText(firstText(entry, ['title']), MAX_TITLE_LENGTH) ?? url; + + items.push({ + author: sanitizeText( + firstText(entry, ['author name', 'author', 'creator']), + MAX_AUTHOR_LENGTH + ), + excerpt: sanitizeText(firstText(entry, ['summary', 'content']), MAX_EXCERPT_LENGTH), + guid: sanitizeText(firstText(entry, ['id']), 500), + imageUrl: image, + normalizedUrl: normalizeUrl(url), + publishedAt: parseDate(firstText(entry, ['published', 'updated'])), + title: titleText, + url + }); + } + + return { + description: sanitizeText(firstText(feed, ['subtitle', 'description']), MAX_DESCRIPTION_LENGTH), + imageUrl, + items, + siteUrl, + title + }; +} + +export async function parseFeedXml(xml: string, feedUrl: string): Promise { + const document = new DOMParser().parseFromString(xml, 'text/xml'); + const rootName = document.documentElement?.tagName.toLowerCase(); + + if (rootName === 'rss' || rootName === 'rdf:rdf') { + return parseRss(document, feedUrl); + } + + if (rootName === 'feed') { + return parseAtom(document, feedUrl); + } + + throw new FeedParseError('Unsupported feed XML'); +} From 82949835e53f63e3647711bc81b9234a7eb536d4 Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sun, 28 Jun 2026 06:18:19 +0700 Subject: [PATCH 04/18] feat(server): add RSS link save seam Route manual article saves through a reusable link-save service so RSS ingestion can reuse saved links, enqueue extraction only for new links, and reconcile matching staged feed items without changing duplicate URL responses. --- .../src/repositories/links.repository.ts | 16 +- apps/server/src/routes/files/files.test.ts | 1 + .../server/src/routes/links/links.handlers.ts | 46 +----- .../src/services/link-save.service.test.ts | 154 ++++++++++++++++++ apps/server/src/services/link-save.service.ts | 108 ++++++++++++ apps/server/src/tests/in-memory/links.ts | 3 + 6 files changed, 287 insertions(+), 41 deletions(-) create mode 100644 apps/server/src/services/link-save.service.test.ts create mode 100644 apps/server/src/services/link-save.service.ts diff --git a/apps/server/src/repositories/links.repository.ts b/apps/server/src/repositories/links.repository.ts index c8f8d19..b342841 100644 --- a/apps/server/src/repositories/links.repository.ts +++ b/apps/server/src/repositories/links.repository.ts @@ -84,6 +84,7 @@ export interface LinksRepository { ): Promise>; getTagsForLink(linkId: string, userId: string): Promise; findAllUrls(userId: string): Promise; + findByUrl(url: string, userId: string): Promise; existsByUrl(url: string, userId: string): Promise; } @@ -866,12 +867,21 @@ export function createDrizzleLinksAdapter(db: DrizzleClient): LinksRepository { } }, - async existsByUrl(url, userId) { + async findByUrl(url, userId) { try { const result = await db.query.linksTable.findFirst({ - where: and(eq(linksTable.url, url), eq(linksTable.userId, userId)), - columns: { id: true } + where: and(eq(linksTable.url, url), eq(linksTable.userId, userId)) }); + return (result as LinkData | undefined) ?? null; + } catch (error) { + logger.error(`Error finding URL for user ${userId}: ${error}`); + throw error; + } + }, + + async existsByUrl(url, userId) { + try { + const result = await this.findByUrl(url, userId); return result != null; } catch (error) { logger.error(`Error checking URL existence for user ${userId}: ${error}`); diff --git a/apps/server/src/routes/files/files.test.ts b/apps/server/src/routes/files/files.test.ts index d69261a..8b82577 100644 --- a/apps/server/src/routes/files/files.test.ts +++ b/apps/server/src/routes/files/files.test.ts @@ -136,6 +136,7 @@ function createFakeLinksRepository(): LinksRepository { }), getTagsForLink: async () => [], findAllUrls: async () => [], + findByUrl: async () => null, existsByUrl: async () => false } satisfies LinksRepository; } diff --git a/apps/server/src/routes/links/links.handlers.ts b/apps/server/src/routes/links/links.handlers.ts index af7903b..d0db6b7 100644 --- a/apps/server/src/routes/links/links.handlers.ts +++ b/apps/server/src/routes/links/links.handlers.ts @@ -8,6 +8,8 @@ import { errorResponse, HttpStatus, successResponse } from '@/lib/response.js'; import type { AppRouteHandler } from '@/lib/types.js'; import { isValidUrl } from '@/lib/url-validator.js'; +import { saveLink } from '@/services/link-save.service.js'; + import type { CursorPaginationOptions } from '@/types/pagination.js'; import type { Tag } from '@/types/tags.js'; @@ -156,7 +158,7 @@ export const createLink: AppRouteHandler = async (c) => { } const user = c.get('user'); - const { links } = c.get('repos'); + const repos = c.get('repos'); try { const { tags = [], url } = c.req.valid('json'); @@ -171,13 +173,6 @@ export const createLink: AppRouteHandler = async (c) => { return c.json(response, response.status); } - const urlExists = await links.existsByUrl(url, user.id); - - if (urlExists) { - const response = errorResponse('URL already exists in your library', HttpStatus.CONFLICT); - return c.json(response, response.status); - } - const processedTags: Omit[] = []; for (const tag of tags) { @@ -191,32 +186,15 @@ export const createLink: AppRouteHandler = async (c) => { } } - const newLink = await links.create({ - author: null, - content: null, - excerpt: null, - isArchived: false, - isFavorite: false, - isPaywalled: false, - isRead: false, - lastReadAt: null, - priority: 'none', - processingStatus: 'pending', - publishedAt: null, - readingProgress: 0, - readingTime: 0, - textContent: null, - timeSpentReading: 0, - title: url, - url, - userId: user.id - }); + const saveResult = await saveLink({ repos, user, url }); - if (!newLink) { - const response = errorResponse('Failed to create link'); + if (!saveResult.created) { + const response = errorResponse('URL already exists in your library', HttpStatus.CONFLICT); return c.json(response, response.status); } + const newLink = saveResult.link; + if (processedTags.length > 0) { const { tags } = c.get('repos'); const existingTags = await tags.findTagsByUserId(user.id); @@ -237,14 +215,6 @@ export const createLink: AppRouteHandler = async (c) => { if (tagIds.length > 0) await tags.addTagsToLink(newLink.id as string, tagIds, user.id); } - const jobData: ContentExtractionJobData = { - linkId: newLink.id as string, - url, - user - }; - const job = await enqueueContentExtraction.add('process-new-article', jobData); - - logger.info(`Article processing job added to queue: ${job.id}`); logger.info(`Link created with id: ${newLink.id}`); const response = successResponse( diff --git a/apps/server/src/services/link-save.service.test.ts b/apps/server/src/services/link-save.service.test.ts new file mode 100644 index 0000000..f90ccfd --- /dev/null +++ b/apps/server/src/services/link-save.service.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { Repos } from '@/lib/types.js'; + +import type { FeedItemsRepository } from '@/repositories/feed-items.repository.js'; +import type { LinksRepository } from '@/repositories/links.repository.js'; + +import type { UserWithoutPassword } from '@/types/auth.js'; +import type { LinkData } from '@/types/links.js'; + +import { saveLink } from './link-save.service.js'; + +const TEST_USER: UserWithoutPassword = { + id: '00000000-0000-0000-0000-000000000001', + email: 'reader@example.com', + name: 'Reader', + avatar: null, + role: 'user', + settings: {}, + deletedAt: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() +}; + +function linkFixture(overrides: Partial = {}): LinkData { + return { + author: null, + content: null, + coverImage: null, + errorMessage: null, + excerpt: null, + favicon: null, + id: crypto.randomUUID(), + isArchived: false, + isFavorite: false, + isPaywalled: false, + isRead: false, + lastReadAt: null, + priority: 'none', + processingStartedAt: null, + processingStatus: 'pending', + publishedAt: null, + readingProgress: 0, + readingTime: 0, + textContent: null, + timeSpentReading: 0, + title: overrides.url ?? 'https://example.com/article', + url: 'https://example.com/article', + userId: TEST_USER.id, + ...overrides + }; +} + +function buildRepos(existingLink: LinkData | null = null) { + const createdLink = linkFixture({ id: '00000000-0000-0000-0000-000000000100' }); + const links = { + create: vi.fn(async () => createdLink), + findByUrl: vi.fn(async () => existingLink) + } as unknown as LinksRepository; + const feedItems = { + reconcileSavedByUrl: vi.fn(async () => []) + } as unknown as FeedItemsRepository; + + return { createdLink, repos: { feedItems, links } as Pick }; +} + +describe('saveLink', () => { + it('creates a pending link, enqueues extraction, and reconciles matching feed items', async () => { + const { createdLink, repos } = buildRepos(); + const enqueueExtraction = vi.fn(async () => ({ id: 'job-1' })); + expect(repos.feedItems).toBeDefined(); + vi.mocked(repos.feedItems!.reconcileSavedByUrl).mockResolvedValue([ + { id: 'feed-item-1' } + ] as never); + + const result = await saveLink({ + enqueueExtraction, + repos, + user: TEST_USER, + url: 'https://example.com/article?b=2&a=1#section' + }); + + expect(result).toEqual({ created: true, link: createdLink, reconciledFeedItems: 1 }); + expect(repos.links.create).toHaveBeenCalledWith( + expect.objectContaining({ + processingStatus: 'pending', + title: 'https://example.com/article?b=2&a=1#section', + url: 'https://example.com/article?b=2&a=1#section', + userId: TEST_USER.id + }) + ); + expect(enqueueExtraction).toHaveBeenCalledWith('process-new-article', { + linkId: createdLink.id, + url: 'https://example.com/article?b=2&a=1#section', + user: TEST_USER + }); + expect(repos.feedItems?.reconcileSavedByUrl).toHaveBeenCalledWith({ + linkId: createdLink.id, + normalizedUrl: 'https://example.com/article?a=1&b=2', + userId: TEST_USER.id + }); + }); + + it('reuses an existing user link without enqueueing extraction', async () => { + const existingLink = linkFixture({ id: '00000000-0000-0000-0000-000000000200' }); + const { repos } = buildRepos(existingLink); + const enqueueExtraction = vi.fn(async () => ({ id: 'job-1' })); + + const result = await saveLink({ + enqueueExtraction, + repos, + user: TEST_USER, + url: existingLink.url + }); + + expect(result).toEqual({ created: false, link: existingLink, reconciledFeedItems: 0 }); + expect(repos.links.create).not.toHaveBeenCalled(); + expect(enqueueExtraction).not.toHaveBeenCalled(); + expect(repos.feedItems?.reconcileSavedByUrl).toHaveBeenCalledWith({ + linkId: existingLink.id, + normalizedUrl: existingLink.url, + userId: TEST_USER.id + }); + }); + + it('does not reconcile feed items when link creation fails', async () => { + const { repos } = buildRepos(); + vi.mocked(repos.links.create).mockResolvedValue(null); + + await expect( + saveLink({ + enqueueExtraction: vi.fn(), + repos, + user: TEST_USER, + url: 'https://example.com/fails' + }) + ).rejects.toThrow(/failed to create link/i); + + expect(repos.feedItems?.reconcileSavedByUrl).not.toHaveBeenCalled(); + }); + + it('keeps duplicate checks scoped to the current user', async () => { + const { repos } = buildRepos(); + + await saveLink({ + enqueueExtraction: vi.fn(async () => ({ id: 'job-1' })), + repos, + user: TEST_USER, + url: 'https://example.com/scoped' + }); + + expect(repos.links.findByUrl).toHaveBeenCalledWith('https://example.com/scoped', TEST_USER.id); + }); +}); diff --git a/apps/server/src/services/link-save.service.ts b/apps/server/src/services/link-save.service.ts new file mode 100644 index 0000000..1ac8c42 --- /dev/null +++ b/apps/server/src/services/link-save.service.ts @@ -0,0 +1,108 @@ +import type { ContentExtractionJobData } from '@/queues/content-extraction.queue.js'; +import { enqueueContentExtraction } from '@/queues/content-extraction.queue.js'; + +import type { Repos } from '@/lib/types.js'; + +import type { UserWithoutPassword } from '@/types/auth.js'; +import type { LinkData } from '@/types/links.js'; + +export type LinkSaveResult = { + created: boolean; + link: LinkData; + reconciledFeedItems: number; +}; + +type EnqueueExtraction = ( + jobName: string, + data: ContentExtractionJobData +) => Promise<{ id?: string }>; + +export type SaveLinkInput = { + enqueueExtraction?: EnqueueExtraction; + reconcileFeedItems?: boolean; + repos: Pick; + user: UserWithoutPassword; + url: string; +}; + +function normalizedLinkUrl(url: string): string { + const parsed = new URL(url); + parsed.hash = ''; + parsed.searchParams.sort(); + return parsed.toString(); +} + +function newPendingLink( + url: string, + userId: string +): Omit { + return { + author: null, + content: null, + excerpt: null, + isArchived: false, + isFavorite: false, + isPaywalled: false, + isRead: false, + lastReadAt: null, + priority: 'none', + processingStatus: 'pending', + publishedAt: null, + readingProgress: 0, + readingTime: 0, + textContent: null, + timeSpentReading: 0, + title: url, + url, + userId + }; +} + +export async function saveLink(input: SaveLinkInput): Promise { + const { repos, user, url } = input; + const existing = await repos.links.findByUrl(url, user.id); + const normalizedUrl = normalizedLinkUrl(url); + + const shouldReconcileFeedItems = input.reconcileFeedItems ?? true; + + if (existing) { + const reconciled = shouldReconcileFeedItems + ? await repos.feedItems?.reconcileSavedByUrl({ + linkId: existing.id, + normalizedUrl, + userId: user.id + }) + : undefined; + + return { + created: false, + link: existing, + reconciledFeedItems: reconciled?.length ?? 0 + }; + } + + const link = await repos.links.create(newPendingLink(url, user.id)); + if (!link) throw new Error('Failed to create link'); + + const enqueueExtraction = + input.enqueueExtraction ?? enqueueContentExtraction.add.bind(enqueueContentExtraction); + await enqueueExtraction('process-new-article', { + linkId: link.id, + url, + user + }); + + const reconciled = shouldReconcileFeedItems + ? await repos.feedItems?.reconcileSavedByUrl({ + linkId: link.id, + normalizedUrl, + userId: user.id + }) + : undefined; + + return { + created: true, + link, + reconciledFeedItems: reconciled?.length ?? 0 + }; +} diff --git a/apps/server/src/tests/in-memory/links.ts b/apps/server/src/tests/in-memory/links.ts index d8aaac1..5fb447b 100644 --- a/apps/server/src/tests/in-memory/links.ts +++ b/apps/server/src/tests/in-memory/links.ts @@ -95,6 +95,9 @@ export function createInMemoryLinksAdapter( findAllUrls: async (userId) => [...store.values()].filter((l) => l.userId === userId).map((l) => l.url), + findByUrl: async (url, userId) => + [...store.values()].find((l) => l.url === url && l.userId === userId) ?? null, + existsByUrl: async (url, userId) => [...store.values()].some((l) => l.url === url && l.userId === userId) }; From d822b8be85fd4fe87316819514d9c163ee91f6eb Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sun, 28 Jun 2026 12:52:01 +0700 Subject: [PATCH 05/18] feat(server): add RSS feed ingestion service Add the service layer for subscribing to feeds and polling existing feeds, including initial staging, auto-save, conditional metadata updates, failure backoff, and feed item retention pruning. --- .../services/feed-ingestion.service.test.ts | 273 +++++++++++++++ .../src/services/feed-ingestion.service.ts | 331 ++++++++++++++++++ 2 files changed, 604 insertions(+) create mode 100644 apps/server/src/services/feed-ingestion.service.test.ts create mode 100644 apps/server/src/services/feed-ingestion.service.ts diff --git a/apps/server/src/services/feed-ingestion.service.test.ts b/apps/server/src/services/feed-ingestion.service.test.ts new file mode 100644 index 0000000..97d9e7d --- /dev/null +++ b/apps/server/src/services/feed-ingestion.service.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { FeedItemsRepository } from '@/repositories/feed-items.repository.js'; +import type { + FeedSubscriptionData, + FeedSubscriptionsRepository +} from '@/repositories/feed-subscriptions.repository.js'; +import type { LinksRepository } from '@/repositories/links.repository.js'; + +import type { UserWithoutPassword } from '@/types/auth.js'; + +import { createFeedIngestionService } from './feed-ingestion.service.js'; +import type { NormalizedFeed } from './feed-parser.service.js'; + +const NOW = new Date('2026-06-28T12:00:00.000Z'); +const USER: UserWithoutPassword = { + avatar: null, + createdAt: NOW.toISOString(), + deletedAt: null, + email: 'reader@example.com', + id: '00000000-0000-0000-0000-000000000001', + name: 'Reader', + role: 'user', + settings: {}, + updatedAt: NOW.toISOString() +}; + +function subscriptionFixture(overrides: Partial = {}): FeedSubscriptionData { + return { + autoSave: false, + createdAt: NOW, + description: null, + etag: null, + failureCount: 0, + feedUrl: 'https://example.com/feed.xml', + id: '00000000-0000-0000-0000-000000000100', + imageUrl: null, + lastError: null, + lastFetchedAt: null, + lastModified: null, + lastSuccessfulFetchAt: null, + nextFetchAfter: null, + normalizedFeedUrl: 'https://example.com/feed.xml', + siteUrl: null, + status: 'active', + title: 'Example Feed', + updatedAt: NOW, + userId: USER.id, + ...overrides + }; +} + +function feedFixture(itemCount = 3): NormalizedFeed { + return { + description: 'A useful feed', + imageUrl: 'https://example.com/feed.png', + items: Array.from({ length: itemCount }, (_, index) => ({ + author: `Author ${index}`, + excerpt: `Excerpt ${index}`, + guid: `guid-${index}`, + imageUrl: null, + normalizedUrl: `https://example.com/articles/${index}`, + publishedAt: new Date(NOW.getTime() - index * 60_000), + title: `Article ${index}`, + url: `https://example.com/articles/${index}` + })), + siteUrl: 'https://example.com/', + title: 'Example Feed' + }; +} + +function createRepos(options: { existingSubscription?: FeedSubscriptionData | null } = {}) { + const subscription = subscriptionFixture(); + const createdItems: unknown[] = []; + + const feedSubscriptions = { + create: vi.fn(async () => subscription), + findByNormalizedUrl: vi.fn(async () => options.existingSubscription ?? null), + update: vi.fn(async (_id, _userId, updates) => ({ ...subscription, ...updates })), + updateFetchMetadata: vi.fn(async (_id, _userId, updates) => ({ ...subscription, ...updates })) + } as unknown as FeedSubscriptionsRepository; + + const feedItems = { + pruneForSubscription: vi.fn(async () => 0), + upsertByIdentity: vi.fn(async (data) => { + const item = { ...data, id: crypto.randomUUID() }; + createdItems.push(item); + return item; + }) + } as unknown as FeedItemsRepository; + + const links = { + create: vi.fn(), + findByUrl: vi.fn() + } as unknown as LinksRepository; + + return { + createdItems, + repos: { feedItems, feedSubscriptions, links } + }; +} + +describe('createFeedIngestionService', () => { + it('adds a subscription and stages the latest 50 eligible items', async () => { + const { repos } = createRepos(); + const oldPublishedAt = new Date(NOW.getTime() - 91 * 24 * 60 * 60 * 1000); + const feed = feedFixture(55); + feed.items[54] = { ...feed.items[54]!, publishedAt: oldPublishedAt }; + const service = createFeedIngestionService({ + fetchFeed: vi.fn(async () => ({ + body: '', + headers: { etag: 'etag-1', lastModified: 'Sun, 28 Jun 2026 12:00:00 GMT' }, + status: 'ok' as const + })), + now: () => NOW, + parseFeedXml: vi.fn(async () => feed), + repos + }); + + const result = await service.addSubscription({ + feedUrl: 'https://example.com/feed.xml', + user: USER + }); + + expect(result).toMatchObject({ createdSubscription: true, fetched: true, staged: 50 }); + expect(repos.feedSubscriptions.create).toHaveBeenCalledWith( + expect.objectContaining({ + etag: 'etag-1', + normalizedFeedUrl: 'https://example.com/feed.xml', + title: 'Example Feed', + userId: USER.id + }) + ); + expect(repos.feedItems.upsertByIdentity).toHaveBeenCalledTimes(50); + expect(repos.feedItems.pruneForSubscription).toHaveBeenCalledWith( + expect.objectContaining({ + keepLatest: 500, + subscriptionId: expect.any(String), + userId: USER.id + }) + ); + }); + + it('returns an existing subscription without fetching on duplicate add', async () => { + const existingSubscription = subscriptionFixture({ id: 'existing-feed' }); + const { repos } = createRepos({ existingSubscription }); + const fetchFeed = vi.fn(); + const service = createFeedIngestionService({ fetchFeed, now: () => NOW, repos }); + + const result = await service.addSubscription({ + feedUrl: 'https://example.com/feed.xml', + user: USER + }); + + expect(result).toMatchObject({ + createdSubscription: false, + fetched: false, + subscription: existingSubscription + }); + expect(fetchFeed).not.toHaveBeenCalled(); + }); + + it('polls a subscription and inserts only parsed items through identity upsert', async () => { + const { repos } = createRepos(); + const subscription = subscriptionFixture({ etag: 'old-etag', lastModified: 'old-date' }); + const fetchFeed = vi.fn(async () => ({ + body: '', + headers: { etag: 'new-etag', lastModified: 'new-date' }, + status: 'ok' as const + })); + const service = createFeedIngestionService({ + fetchFeed, + now: () => NOW, + parseFeedXml: vi.fn(async () => feedFixture(2)), + repos + }); + + const result = await service.pollSubscription({ subscription, user: USER }); + + expect(fetchFeed).toHaveBeenCalledWith(subscription.feedUrl, { + etag: 'old-etag', + lastModified: 'old-date' + }); + expect(result).toMatchObject({ fetched: true, staged: 2 }); + expect(repos.feedItems.upsertByIdentity).toHaveBeenCalledTimes(2); + expect(repos.feedSubscriptions.update).toHaveBeenCalledWith( + subscription.id, + USER.id, + expect.objectContaining({ failureCount: 0, lastError: null, lastSuccessfulFetchAt: NOW }) + ); + }); + + it('handles not-modified polls without staging items', async () => { + const { repos } = createRepos(); + const subscription = subscriptionFixture(); + const service = createFeedIngestionService({ + fetchFeed: vi.fn(async () => ({ + headers: { etag: null, lastModified: null }, + status: 'not-modified' as const + })), + now: () => NOW, + repos + }); + + const result = await service.pollSubscription({ subscription, user: USER }); + + expect(result).toMatchObject({ fetched: false, staged: 0 }); + expect(repos.feedItems.upsertByIdentity).not.toHaveBeenCalled(); + expect(repos.feedSubscriptions.updateFetchMetadata).toHaveBeenCalledWith( + subscription.id, + USER.id, + expect.objectContaining({ failureCount: 0, lastError: null, lastSuccessfulFetchAt: NOW }) + ); + }); + + it('auto-saves feed items when the subscription enables auto-save', async () => { + const { repos } = createRepos(); + const saveLink = vi.fn(async ({ url }) => ({ + created: true, + link: { id: `link-for-${url}` }, + reconciledFeedItems: 0 + })); + const service = createFeedIngestionService({ + fetchFeed: vi.fn(async () => ({ + body: '', + headers: { etag: null, lastModified: null }, + status: 'ok' as const + })), + now: () => NOW, + parseFeedXml: vi.fn(async () => feedFixture(2)), + repos, + saveLink: saveLink as never + }); + + const result = await service.pollSubscription({ + subscription: subscriptionFixture({ autoSave: true }), + user: USER + }); + + expect(result).toMatchObject({ autoSaved: 2, staged: 0 }); + expect(saveLink).toHaveBeenCalledTimes(2); + expect(repos.feedItems.upsertByIdentity).toHaveBeenCalledWith( + expect.objectContaining({ state: 'saved' }) + ); + }); + + it('records failure metadata and backoff when polling fails', async () => { + const { repos } = createRepos(); + const subscription = subscriptionFixture({ failureCount: 1 }); + const service = createFeedIngestionService({ + fetchFeed: vi.fn(async () => { + throw new Error('network exploded with details'); + }), + now: () => NOW, + repos + }); + + await expect(service.pollSubscription({ subscription, user: USER })).rejects.toThrow( + /network exploded/ + ); + + expect(repos.feedSubscriptions.updateFetchMetadata).toHaveBeenCalledWith( + subscription.id, + USER.id, + expect.objectContaining({ + failureCount: 2, + lastError: 'network exploded with details', + lastFetchedAt: NOW, + nextFetchAfter: new Date('2026-06-28T12:30:00.000Z') + }) + ); + }); +}); diff --git a/apps/server/src/services/feed-ingestion.service.ts b/apps/server/src/services/feed-ingestion.service.ts new file mode 100644 index 0000000..a5ffd0a --- /dev/null +++ b/apps/server/src/services/feed-ingestion.service.ts @@ -0,0 +1,331 @@ +import type { Repos } from '@/lib/types.js'; + +import type { FeedItemsRepository } from '@/repositories/feed-items.repository.js'; +import type { + FeedSubscriptionData, + FeedSubscriptionsRepository +} from '@/repositories/feed-subscriptions.repository.js'; + +import type { UserWithoutPassword } from '@/types/auth.js'; + +import type { FeedFetchResult } from './feed-fetch.service.js'; +import { fetchFeed } from './feed-fetch.service.js'; +import type { NormalizedFeed, NormalizedFeedItem } from './feed-parser.service.js'; +import { parseFeedXml } from './feed-parser.service.js'; +import { saveLink } from './link-save.service.js'; + +const INITIAL_ITEM_LIMIT = 50; +const RETENTION_DAYS = 90; +const RETENTION_ITEM_LIMIT = 500; +const DEFAULT_SUCCESS_INTERVAL_MS = 60 * 60 * 1000; +const MAX_FAILURE_BACKOFF_MS = 24 * 60 * 60 * 1000; + +export type FeedIngestionRepos = Pick & { + feedItems: FeedItemsRepository; + feedSubscriptions: FeedSubscriptionsRepository; +}; + +export type FeedIngestionDependencies = { + fetchFeed?: typeof fetchFeed; + now?: () => Date; + parseFeedXml?: typeof parseFeedXml; + repos: FeedIngestionRepos; + saveLink?: typeof saveLink; +}; + +export type AddFeedInput = { + autoSave?: boolean; + feedUrl: string; + user: UserWithoutPassword; +}; + +export type PollFeedInput = { + subscription: FeedSubscriptionData; + user: UserWithoutPassword; +}; + +export type FeedIngestionResult = { + autoSaved: number; + createdSubscription: boolean; + fetched: boolean; + pruned: number; + staged: number; + subscription: FeedSubscriptionData; +}; + +function normalizeUrl(url: string): string { + const parsed = new URL(url); + parsed.hash = ''; + parsed.searchParams.sort(); + return parsed.toString(); +} + +function nextSuccessfulFetchAfter(now: Date): Date { + return new Date(now.getTime() + DEFAULT_SUCCESS_INTERVAL_MS); +} + +function nextFailureFetchAfter(now: Date, failureCount: number): Date { + const backoffMs = Math.min( + 2 ** Math.max(failureCount - 1, 0) * 15 * 60 * 1000, + MAX_FAILURE_BACKOFF_MS + ); + return new Date(now.getTime() + backoffMs); +} + +function retentionCutoff(now: Date): Date { + return new Date(now.getTime() - RETENTION_DAYS * 24 * 60 * 60 * 1000); +} + +function itemsForInitialIngest(items: NormalizedFeedItem[], now: Date): NormalizedFeedItem[] { + const cutoff = retentionCutoff(now); + return items + .filter((item) => !item.publishedAt || item.publishedAt >= cutoff) + .slice(0, INITIAL_ITEM_LIMIT); +} + +function sanitizedError(error: unknown): string { + if (error instanceof Error && error.message) return error.message.slice(0, 500); + return 'Feed ingestion failed'; +} + +export function createFeedIngestionService(dependencies: FeedIngestionDependencies) { + const fetchFeedDependency = dependencies.fetchFeed ?? fetchFeed; + const parseFeedXmlDependency = dependencies.parseFeedXml ?? parseFeedXml; + const saveLinkDependency = dependencies.saveLink ?? saveLink; + const now = dependencies.now ?? (() => new Date()); + const { repos } = dependencies; + + async function stageItems(input: { + items: NormalizedFeedItem[]; + subscription: FeedSubscriptionData; + user: UserWithoutPassword; + }): Promise<{ autoSaved: number; staged: number }> { + let autoSaved = 0; + let staged = 0; + + for (const item of input.items) { + const saved = input.subscription.autoSave + ? await saveLinkDependency({ + repos, + user: input.user, + url: item.url + }) + : null; + + const feedItem = await repos.feedItems.upsertByIdentity({ + author: item.author, + excerpt: item.excerpt, + guid: item.guid, + imageUrl: item.imageUrl, + linkId: saved?.link.id ?? null, + normalizedUrl: item.normalizedUrl, + publishedAt: item.publishedAt, + state: saved ? 'saved' : 'new', + subscriptionId: input.subscription.id, + title: item.title, + url: item.url, + userId: input.user.id + }); + + if (feedItem.state === 'saved') autoSaved += 1; + else staged += 1; + } + + return { autoSaved, staged }; + } + + async function markSuccess(input: { + feed: NormalizedFeed; + fetchedAt: Date; + headers: FeedFetchResult['headers']; + subscription: FeedSubscriptionData; + }): Promise { + const updated = await repos.feedSubscriptions.update( + input.subscription.id, + input.subscription.userId, + { + description: input.feed.description, + etag: input.headers.etag, + failureCount: 0, + imageUrl: input.feed.imageUrl, + lastError: null, + lastFetchedAt: input.fetchedAt, + lastModified: input.headers.lastModified, + lastSuccessfulFetchAt: input.fetchedAt, + nextFetchAfter: nextSuccessfulFetchAfter(input.fetchedAt), + siteUrl: input.feed.siteUrl, + title: input.feed.title + } + ); + + if (!updated) throw new Error('Failed to update feed subscription after fetch'); + return updated; + } + + async function markNotModified(input: { + fetchedAt: Date; + headers: FeedFetchResult['headers']; + subscription: FeedSubscriptionData; + }): Promise { + const updated = await repos.feedSubscriptions.updateFetchMetadata( + input.subscription.id, + input.subscription.userId, + { + etag: input.headers.etag ?? input.subscription.etag, + failureCount: 0, + lastError: null, + lastFetchedAt: input.fetchedAt, + lastModified: input.headers.lastModified ?? input.subscription.lastModified, + lastSuccessfulFetchAt: input.fetchedAt, + nextFetchAfter: nextSuccessfulFetchAfter(input.fetchedAt) + } + ); + + if (!updated) throw new Error('Failed to update feed subscription after no-change fetch'); + return updated; + } + + async function markFailure( + subscription: FeedSubscriptionData, + fetchedAt: Date, + error: unknown + ): Promise { + const failureCount = subscription.failureCount + 1; + await repos.feedSubscriptions.updateFetchMetadata(subscription.id, subscription.userId, { + failureCount, + lastError: sanitizedError(error), + lastFetchedAt: fetchedAt, + nextFetchAfter: nextFailureFetchAfter(fetchedAt, failureCount) + }); + } + + async function pollSubscription(input: PollFeedInput): Promise { + const fetchedAt = now(); + + try { + const fetched = await fetchFeedDependency(input.subscription.feedUrl, { + etag: input.subscription.etag, + lastModified: input.subscription.lastModified + }); + + if (fetched.status === 'not-modified') { + const subscription = await markNotModified({ + fetchedAt, + headers: fetched.headers, + subscription: input.subscription + }); + return { + autoSaved: 0, + createdSubscription: false, + fetched: false, + pruned: 0, + staged: 0, + subscription + }; + } + + const feed = await parseFeedXmlDependency(fetched.body, input.subscription.feedUrl); + const stagedResult = await stageItems({ + items: feed.items, + subscription: input.subscription, + user: input.user + }); + const pruned = await repos.feedItems.pruneForSubscription({ + before: retentionCutoff(fetchedAt), + keepLatest: RETENTION_ITEM_LIMIT, + subscriptionId: input.subscription.id, + userId: input.user.id + }); + const subscription = await markSuccess({ + feed, + fetchedAt, + headers: fetched.headers, + subscription: input.subscription + }); + + return { + ...stagedResult, + createdSubscription: false, + fetched: true, + pruned, + subscription + }; + } catch (error) { + await markFailure(input.subscription, fetchedAt, error); + throw error; + } + } + + async function addSubscription(input: AddFeedInput): Promise { + const normalizedFeedUrl = normalizeUrl(input.feedUrl); + const existing = await repos.feedSubscriptions.findByNormalizedUrl( + normalizedFeedUrl, + input.user.id + ); + if (existing) { + return { + autoSaved: 0, + createdSubscription: false, + fetched: false, + pruned: 0, + staged: 0, + subscription: existing + }; + } + + const fetchedAt = now(); + const fetched = await fetchFeedDependency(input.feedUrl); + if (fetched.status === 'not-modified') { + throw new Error('Feed returned not modified before subscription was created'); + } + + const feed = await parseFeedXmlDependency(fetched.body, input.feedUrl); + let subscription = await repos.feedSubscriptions.create({ + autoSave: input.autoSave ?? false, + description: feed.description, + etag: fetched.headers.etag, + feedUrl: input.feedUrl, + imageUrl: feed.imageUrl, + lastModified: fetched.headers.lastModified, + nextFetchAfter: nextSuccessfulFetchAfter(fetchedAt), + normalizedFeedUrl, + siteUrl: feed.siteUrl, + title: feed.title, + userId: input.user.id + }); + + subscription = + (await repos.feedSubscriptions.updateFetchMetadata(subscription.id, input.user.id, { + failureCount: 0, + lastError: null, + lastFetchedAt: fetchedAt, + lastSuccessfulFetchAt: fetchedAt, + nextFetchAfter: nextSuccessfulFetchAfter(fetchedAt) + })) ?? subscription; + + const stagedResult = await stageItems({ + items: itemsForInitialIngest(feed.items, fetchedAt), + subscription, + user: input.user + }); + const pruned = await repos.feedItems.pruneForSubscription({ + before: retentionCutoff(fetchedAt), + keepLatest: RETENTION_ITEM_LIMIT, + subscriptionId: subscription.id, + userId: input.user.id + }); + + return { + ...stagedResult, + createdSubscription: true, + fetched: true, + pruned, + subscription + }; + } + + return { + addSubscription, + pollSubscription + }; +} From 64c14341d9bb55e6b7146a259f695b3279c6737f Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sun, 28 Jun 2026 12:58:28 +0700 Subject: [PATCH 06/18] feat(server): add RSS feed poll worker Add the feed poll queue and worker for scheduled and manual feed refreshes, including due-subscription enqueueing, not-yet-due skips, ingestion delegation, and graceful server shutdown registration. --- apps/server/src/index.ts | 4 + .../server/src/queues/feed-poll.queue.test.ts | 55 ++++++++ apps/server/src/queues/feed-poll.queue.ts | 52 +++++++ .../src/workers/feed-poll.worker.test.ts | 132 ++++++++++++++++++ apps/server/src/workers/feed-poll.worker.ts | 119 ++++++++++++++++ 5 files changed, 362 insertions(+) create mode 100644 apps/server/src/queues/feed-poll.queue.test.ts create mode 100644 apps/server/src/queues/feed-poll.queue.ts create mode 100644 apps/server/src/workers/feed-poll.worker.test.ts create mode 100644 apps/server/src/workers/feed-poll.worker.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 65ef136..368e38f 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -10,8 +10,10 @@ import { browserService } from './services/browser.service.js'; import app from './app.js'; import { enqueueContentExtraction } from './queues/content-extraction.queue.js'; +import { enqueueFeedPoll } from './queues/feed-poll.queue.js'; import contentExtractionWorker from './workers/content-extraction.worker.js'; import csvImportWorker from './workers/csv-import.worker.js'; +import feedPollWorker from './workers/feed-poll.worker.js'; if (env.isDevelopment) { logger.info('Available routes:'); @@ -41,7 +43,9 @@ function onCloseSignal() { await Promise.all([ contentExtractionWorker.close(), csvImportWorker.close(), + feedPollWorker.close(), enqueueContentExtraction.close(), + enqueueFeedPoll.close(), browserService.close() ]); diff --git a/apps/server/src/queues/feed-poll.queue.test.ts b/apps/server/src/queues/feed-poll.queue.test.ts new file mode 100644 index 0000000..918718b --- /dev/null +++ b/apps/server/src/queues/feed-poll.queue.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const queueMock = vi.hoisted(() => ({ + add: vi.fn(async (_name, data) => ({ data, id: 'job-1' })), + on: vi.fn() +})); + +vi.mock('@/lib/job-queue.js', () => ({ + createQueue: vi.fn(() => queueMock) +})); + +const { enqueueDueFeedPolls, enqueueFeedPollJob } = await import('./feed-poll.queue.js'); + +describe('feed poll queue', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('enqueues a single feed poll job with stable identity', async () => { + await enqueueFeedPollJob({ + reason: 'manual', + subscriptionId: 'feed-1', + userId: 'user-1' + }); + + expect(queueMock.add).toHaveBeenCalledWith( + 'poll-feed', + { reason: 'manual', subscriptionId: 'feed-1', userId: 'user-1' }, + { jobId: 'feed-poll:feed-1:manual' } + ); + }); + + it('enqueues due subscriptions as scheduled jobs', async () => { + const now = new Date('2026-06-28T12:00:00.000Z'); + const feedSubscriptions = { + findDue: vi.fn(async () => [ + { id: 'feed-1', userId: 'user-1' }, + { id: 'feed-2', userId: 'user-2' } + ]) + }; + + await expect( + enqueueDueFeedPolls({ feedSubscriptions: feedSubscriptions as never, limit: 2, now }) + ).resolves.toBe(2); + + expect(feedSubscriptions.findDue).toHaveBeenCalledWith(now, 2); + expect(queueMock.add).toHaveBeenCalledTimes(2); + expect(queueMock.add).toHaveBeenNthCalledWith( + 2, + 'poll-feed', + { reason: 'scheduled', subscriptionId: 'feed-2', userId: 'user-2' }, + { jobId: 'feed-poll:feed-2:scheduled' } + ); + }); +}); diff --git a/apps/server/src/queues/feed-poll.queue.ts b/apps/server/src/queues/feed-poll.queue.ts new file mode 100644 index 0000000..8546a27 --- /dev/null +++ b/apps/server/src/queues/feed-poll.queue.ts @@ -0,0 +1,52 @@ +import type { Job } from 'bullmq'; + +import { createQueue } from '@/lib/job-queue.js'; +import { logger } from '@/lib/logger.js'; + +import type { FeedSubscriptionsRepository } from '@/repositories/feed-subscriptions.repository.js'; + +export type FeedPollReason = 'scheduled' | 'manual'; + +export interface FeedPollJobData { + force?: boolean; + reason: FeedPollReason; + subscriptionId: string; + userId: string; +} + +const enqueueFeedPoll = createQueue('feed-poll'); + +enqueueFeedPoll.on('waiting', (job: Job) => { + logger.info(`[Queue] Feed poll job ${job.id} is waiting`); +}); + +enqueueFeedPoll.on('error', (error: Error) => { + logger.error(`[Queue] Feed poll queue error: ${JSON.stringify(error)}`); +}); + +export async function enqueueFeedPollJob(data: FeedPollJobData): Promise> { + return enqueueFeedPoll.add('poll-feed', data, { + jobId: `feed-poll:${data.subscriptionId}:${data.reason}` + }); +} + +export async function enqueueDueFeedPolls(input: { + feedSubscriptions: FeedSubscriptionsRepository; + limit?: number; + now?: Date; +}): Promise { + const now = input.now ?? new Date(); + const dueSubscriptions = await input.feedSubscriptions.findDue(now, input.limit); + + for (const subscription of dueSubscriptions) { + await enqueueFeedPollJob({ + reason: 'scheduled', + subscriptionId: subscription.id, + userId: subscription.userId + }); + } + + return dueSubscriptions.length; +} + +export { enqueueFeedPoll }; diff --git a/apps/server/src/workers/feed-poll.worker.test.ts b/apps/server/src/workers/feed-poll.worker.test.ts new file mode 100644 index 0000000..870014f --- /dev/null +++ b/apps/server/src/workers/feed-poll.worker.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const createWorkerMock = vi.hoisted(() => vi.fn(() => ({ on: vi.fn(), close: vi.fn() }))); +const feedSubscriptionsAdapterMock = vi.hoisted(() => ({ findById: vi.fn() })); +const feedItemsAdapterMock = vi.hoisted(() => ({})); +const linksAdapterMock = vi.hoisted(() => ({})); +const pollSubscriptionMock = vi.hoisted(() => vi.fn()); +const createFeedIngestionServiceMock = vi.hoisted(() => + vi.fn(() => ({ pollSubscription: pollSubscriptionMock })) +); + +vi.mock('@/db/index.js', () => ({ db: {} })); +vi.mock('@/lib/job-queue.js', () => ({ createWorker: createWorkerMock })); +vi.mock('@/repositories/feed-subscriptions.repository.js', () => ({ + createDrizzleFeedSubscriptionsAdapter: vi.fn(() => feedSubscriptionsAdapterMock) +})); +vi.mock('@/repositories/feed-items.repository.js', () => ({ + createDrizzleFeedItemsAdapter: vi.fn(() => feedItemsAdapterMock) +})); +vi.mock('@/repositories/links.repository.js', () => ({ + createDrizzleLinksAdapter: vi.fn(() => linksAdapterMock) +})); +vi.mock('@/services/feed-ingestion.service.js', () => ({ + createFeedIngestionService: createFeedIngestionServiceMock +})); + +const { feedPollJob } = await import('./feed-poll.worker.js'); + +function job(data: { + force?: boolean; + reason: 'manual' | 'scheduled'; + subscriptionId: string; + userId: string; +}) { + return { data, id: 'job-1' } as never; +} + +function subscription(overrides: Record = {}) { + return { + autoSave: false, + etag: null, + failureCount: 0, + feedUrl: 'https://example.com/feed.xml', + id: 'feed-1', + lastModified: null, + nextFetchAfter: null, + status: 'active', + userId: 'user-1', + ...overrides + }; +} + +describe('feed poll worker', () => { + beforeEach(() => { + vi.clearAllMocks(); + pollSubscriptionMock.mockResolvedValue({ autoSaved: 0, fetched: true, pruned: 0, staged: 2 }); + }); + + it('polls a due subscription through the ingestion service', async () => { + const feed = subscription(); + feedSubscriptionsAdapterMock.findById.mockResolvedValue(feed); + + await expect( + feedPollJob(job({ reason: 'scheduled', subscriptionId: 'feed-1', userId: 'user-1' })) + ).resolves.toEqual({ autoSaved: 0, fetched: true, pruned: 0, staged: 2, status: 'completed' }); + + expect(createFeedIngestionServiceMock).toHaveBeenCalledWith({ + repos: { + feedItems: feedItemsAdapterMock, + feedSubscriptions: feedSubscriptionsAdapterMock, + links: linksAdapterMock + } + }); + expect(pollSubscriptionMock).toHaveBeenCalledWith({ + subscription: feed, + user: expect.objectContaining({ id: 'user-1' }) + }); + }); + + it('skips subscriptions that are not due unless forced', async () => { + feedSubscriptionsAdapterMock.findById.mockResolvedValue( + subscription({ nextFetchAfter: new Date(Date.now() + 60_000) }) + ); + + await expect( + feedPollJob(job({ reason: 'scheduled', subscriptionId: 'feed-1', userId: 'user-1' })) + ).resolves.toEqual({ status: 'skipped' }); + + expect(pollSubscriptionMock).not.toHaveBeenCalled(); + }); + + it('allows forced manual refresh even when the feed is not due', async () => { + feedSubscriptionsAdapterMock.findById.mockResolvedValue( + subscription({ nextFetchAfter: new Date(Date.now() + 60_000) }) + ); + + await expect( + feedPollJob( + job({ force: true, reason: 'manual', subscriptionId: 'feed-1', userId: 'user-1' }) + ) + ).resolves.toMatchObject({ status: 'completed' }); + + expect(pollSubscriptionMock).toHaveBeenCalledOnce(); + }); + + it('skips paused subscriptions', async () => { + feedSubscriptionsAdapterMock.findById.mockResolvedValue(subscription({ status: 'paused' })); + + await expect( + feedPollJob(job({ reason: 'scheduled', subscriptionId: 'feed-1', userId: 'user-1' })) + ).resolves.toEqual({ status: 'skipped' }); + + expect(pollSubscriptionMock).not.toHaveBeenCalled(); + }); + + it('aborts missing subscriptions', async () => { + feedSubscriptionsAdapterMock.findById.mockResolvedValue(null); + + await expect( + feedPollJob(job({ reason: 'scheduled', subscriptionId: 'missing', userId: 'user-1' })) + ).resolves.toEqual({ error: 'Feed subscription not found', status: 'aborted' }); + }); + + it('propagates ingestion failures after ingestion records backoff metadata', async () => { + feedSubscriptionsAdapterMock.findById.mockResolvedValue(subscription({ failureCount: 2 })); + pollSubscriptionMock.mockRejectedValue(new Error('fetch failed')); + + await expect( + feedPollJob(job({ reason: 'scheduled', subscriptionId: 'feed-1', userId: 'user-1' })) + ).rejects.toThrow(/fetch failed/); + }); +}); diff --git a/apps/server/src/workers/feed-poll.worker.ts b/apps/server/src/workers/feed-poll.worker.ts new file mode 100644 index 0000000..0f703a0 --- /dev/null +++ b/apps/server/src/workers/feed-poll.worker.ts @@ -0,0 +1,119 @@ +import type { Job } from 'bullmq'; + +import type { FeedPollJobData } from '@/queues/feed-poll.queue.js'; + +import { db } from '@/db/index.js'; + +import { isDemoMode } from '@/lib/demo-mode.js'; +import { createWorker } from '@/lib/job-queue.js'; +import { logger } from '@/lib/logger.js'; + +import { createDrizzleFeedItemsAdapter } from '@/repositories/feed-items.repository.js'; +import { createDrizzleFeedSubscriptionsAdapter } from '@/repositories/feed-subscriptions.repository.js'; +import { createDrizzleLinksAdapter } from '@/repositories/links.repository.js'; + +import { createFeedIngestionService } from '@/services/feed-ingestion.service.js'; + +import type { UserWithoutPassword } from '@/types/auth.js'; + +const workerName = 'feed-poll-worker'; + +const feedItems = createDrizzleFeedItemsAdapter(db); +const feedSubscriptions = createDrizzleFeedSubscriptionsAdapter(db); +const links = createDrizzleLinksAdapter(db); + +const repos = { feedItems, feedSubscriptions, links }; + +function minimalUser(userId: string): UserWithoutPassword { + return { + avatar: null, + createdAt: new Date().toISOString(), + deletedAt: null, + email: '', + id: userId, + name: '', + role: 'user', + settings: {}, + updatedAt: new Date().toISOString() + }; +} + +function isDue(subscription: { nextFetchAfter: Date | null }, now: Date): boolean { + return !subscription.nextFetchAfter || subscription.nextFetchAfter <= now; +} + +export async function feedPollJob(job: Job): Promise<{ + autoSaved?: number; + error?: string; + fetched?: boolean; + pruned?: number; + staged?: number; + status: string; +}> { + if (isDemoMode()) { + logger.info(`[${workerName}] Skipping feed poll job ${job.id} in demo mode`); + return { status: 'skipped' }; + } + + const { force = false, subscriptionId, userId } = job.data; + const subscription = await feedSubscriptions.findById(subscriptionId, userId); + + if (!subscription) { + logger.warn(`[${workerName}] Feed subscription ${subscriptionId} not found`); + return { error: 'Feed subscription not found', status: 'aborted' }; + } + + if (subscription.status !== 'active') { + logger.info(`[${workerName}] Feed subscription ${subscriptionId} is not active`); + return { status: 'skipped' }; + } + + if (!force && !isDue(subscription, new Date())) { + logger.info(`[${workerName}] Feed subscription ${subscriptionId} is not due yet`); + return { status: 'skipped' }; + } + + const ingestion = createFeedIngestionService({ repos }); + const result = await ingestion.pollSubscription({ + subscription, + user: minimalUser(userId) + }); + + logger.info( + `[${workerName}] Feed poll ${job.id} completed. Staged: ${result.staged}, auto-saved: ${result.autoSaved}, pruned: ${result.pruned}` + ); + + return { + autoSaved: result.autoSaved, + fetched: result.fetched, + pruned: result.pruned, + staged: result.staged, + status: 'completed' + }; +} + +const feedPollWorker = createWorker('feed-poll', feedPollJob, { + concurrency: 2, + limiter: { + duration: 60_000, + max: 60 + } +}); + +feedPollWorker.on('completed', (job: Job) => { + logger.info(`[${workerName}] Job ${job.id} completed`); +}); + +feedPollWorker.on('failed', (job: Job | undefined, error: Error) => { + logger.error(`[${workerName}] Job ${job?.id ?? 'unknown'} failed: ${error.message}`); +}); + +feedPollWorker.on('error', (error: Error) => { + logger.error(`[${workerName}] Error in feed poll worker: ${JSON.stringify(error)}`); +}); + +logger.info( + `[${workerName}] Feed poll worker started and listening for jobs on 'feed-poll' queue.` +); + +export default feedPollWorker; From e650ee460084f608e5bf0c012ee394bfb5ef472a Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sun, 28 Jun 2026 13:11:04 +0700 Subject: [PATCH 07/18] feat(server): add RSS feed API routes Expose protected feed subscription and item endpoints for adding feeds, refreshing subscriptions, reviewing staged items, and saving or dismissing feed items through the existing ingestion and link-save seams. --- apps/server/src/app.ts | 2 + apps/server/src/middlewares/rate-limit.ts | 14 + .../server/src/routes/feeds/feeds.handlers.ts | 205 ++++++++++ apps/server/src/routes/feeds/feeds.index.ts | 15 + apps/server/src/routes/feeds/feeds.routes.ts | 198 +++++++++ apps/server/src/routes/feeds/feeds.test.ts | 386 ++++++++++++++++++ apps/server/src/routes/feeds/feeds.types.ts | 17 + 7 files changed, 837 insertions(+) create mode 100644 apps/server/src/routes/feeds/feeds.handlers.ts create mode 100644 apps/server/src/routes/feeds/feeds.index.ts create mode 100644 apps/server/src/routes/feeds/feeds.routes.ts create mode 100644 apps/server/src/routes/feeds/feeds.test.ts create mode 100644 apps/server/src/routes/feeds/feeds.types.ts diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 3c006e5..d9020ea 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -14,6 +14,7 @@ import { createDrizzleTagsAdapter } from './repositories/tags.repository.js'; import admin from './routes/admin/admin.index.js'; import auth from './routes/auth/auth.index.js'; +import feeds from './routes/feeds/feeds.index.js'; import files from './routes/files/files.index.js'; import health from './routes/health/health.index.js'; import highlights from './routes/highlights/highlights.index.js'; @@ -47,6 +48,7 @@ const router = app .route('/', auth) .route('/', home) .route('/', links) + .route('/', feeds) .route('/', highlights) .route('/', tags) .route('/', files) diff --git a/apps/server/src/middlewares/rate-limit.ts b/apps/server/src/middlewares/rate-limit.ts index dc41bbc..4693bd5 100644 --- a/apps/server/src/middlewares/rate-limit.ts +++ b/apps/server/src/middlewares/rate-limit.ts @@ -81,6 +81,20 @@ export const createLinkRateLimit = rateLimiter({ store: makeStore('rl:links:create:') }); +export const addFeedRateLimit = rateLimiter({ + windowMs: ONE_HOUR_WINDOW, + limit: 20, + keyGenerator: keyByUser, + store: makeStore('rl:feeds:add:') +}); + +export const refreshFeedRateLimit = rateLimiter({ + windowMs: ONE_HOUR_WINDOW, + limit: 60, + keyGenerator: keyByUser, + store: makeStore('rl:feeds:refresh:') +}); + export const importUploadRateLimit = rateLimiter({ windowMs: ONE_HOUR_WINDOW, limit: 10, diff --git a/apps/server/src/routes/feeds/feeds.handlers.ts b/apps/server/src/routes/feeds/feeds.handlers.ts new file mode 100644 index 0000000..91de654 --- /dev/null +++ b/apps/server/src/routes/feeds/feeds.handlers.ts @@ -0,0 +1,205 @@ +import { enqueueFeedPollJob } from '@/queues/feed-poll.queue.js'; + +import { demoModeForbiddenResponse, isDemoMode } from '@/lib/demo-mode.js'; +import { HttpStatus, errorResponse, successResponse } from '@/lib/response.js'; +import type { AppRouteHandler } from '@/lib/types.js'; + +import { createFeedIngestionService } from '@/services/feed-ingestion.service.js'; +import { saveLink } from '@/services/link-save.service.js'; + +import type { + CreateFeedSubscriptionRoute, + DismissFeedItemRoute, + ListFeedItemsRoute, + ListFeedSubscriptionsRoute, + RefreshFeedSubscriptionRoute, + SaveFeedItemRoute, + UpdateFeedSubscriptionRoute +} from './feeds.types.js'; + +function requireFeedRepos(c: Parameters>[0]) { + const repos = c.get('repos'); + if (!repos.feedItems || !repos.feedSubscriptions) { + throw new Error('Feed repositories are not configured'); + } + return { ...repos, feedItems: repos.feedItems, feedSubscriptions: repos.feedSubscriptions }; +} + +export const listFeedSubscriptions: AppRouteHandler = async (c) => { + const user = c.get('user'); + const repos = requireFeedRepos(c); + + try { + const subscriptions = await repos.feedSubscriptions.findManyByUserId(user.id); + const response = successResponse(subscriptions, 'Feed subscriptions fetched successfully'); + return c.json(response, response.status); + } catch { + const response = errorResponse( + 'An error occurred when fetching feed subscriptions', + HttpStatus.BAD_REQUEST + ); + return c.json(response, response.status); + } +}; + +export const createFeedSubscription: AppRouteHandler = async (c) => { + if (isDemoMode()) return c.json(demoModeForbiddenResponse(), HttpStatus.FORBIDDEN); + + const user = c.get('user'); + const repos = requireFeedRepos(c); + const { autoSave, feedUrl } = c.req.valid('json'); + + try { + const ingestion = createFeedIngestionService({ repos }); + const result = await ingestion.addSubscription({ autoSave, feedUrl, user }); + const response = successResponse(result, 'Feed subscription added', HttpStatus.ACCEPTED); + return c.json(response, response.status); + } catch (error) { + const message = error instanceof Error ? error.message : 'An error occurred when adding feed'; + const response = errorResponse(message, HttpStatus.BAD_REQUEST); + return c.json(response, response.status); + } +}; + +export const updateFeedSubscription: AppRouteHandler = async (c) => { + if (isDemoMode()) return c.json(demoModeForbiddenResponse(), HttpStatus.FORBIDDEN); + + const user = c.get('user'); + const repos = requireFeedRepos(c); + const { id } = c.req.valid('param'); + const updates = c.req.valid('json'); + + try { + const subscription = await repos.feedSubscriptions.update(id, user.id, updates); + if (!subscription) { + const response = errorResponse('Feed subscription not found', HttpStatus.NOT_FOUND); + return c.json(response, response.status); + } + + const response = successResponse(subscription, 'Feed subscription updated'); + return c.json(response, response.status); + } catch { + const response = errorResponse( + 'An error occurred when updating feed subscription', + HttpStatus.BAD_REQUEST + ); + return c.json(response, response.status); + } +}; + +export const refreshFeedSubscription: AppRouteHandler = async (c) => { + if (isDemoMode()) return c.json(demoModeForbiddenResponse(), HttpStatus.FORBIDDEN); + + const user = c.get('user'); + const repos = requireFeedRepos(c); + const { id } = c.req.valid('param'); + + try { + const subscription = await repos.feedSubscriptions.findById(id, user.id); + if (!subscription) { + const response = errorResponse('Feed subscription not found', HttpStatus.NOT_FOUND); + return c.json(response, response.status); + } + + const job = await enqueueFeedPollJob({ + force: true, + reason: 'manual', + subscriptionId: id, + userId: user.id + }); + const response = successResponse( + { jobId: job.id?.toString(), subscriptionId: id }, + 'Feed refresh queued', + HttpStatus.ACCEPTED + ); + return c.json(response, response.status); + } catch { + const response = errorResponse( + 'An error occurred when refreshing feed subscription', + HttpStatus.BAD_REQUEST + ); + return c.json(response, response.status); + } +}; + +export const listFeedItems: AppRouteHandler = async (c) => { + const user = c.get('user'); + const repos = requireFeedRepos(c); + const { state, subscriptionId } = c.req.valid('query'); + + try { + const items = await repos.feedItems.findManyForReview({ + state, + subscriptionId, + userId: user.id + }); + const response = successResponse(items, 'Feed items fetched successfully'); + return c.json(response, response.status); + } catch { + const response = errorResponse( + 'An error occurred when fetching feed items', + HttpStatus.BAD_REQUEST + ); + return c.json(response, response.status); + } +}; + +export const saveFeedItem: AppRouteHandler = async (c) => { + if (isDemoMode()) return c.json(demoModeForbiddenResponse(), HttpStatus.FORBIDDEN); + + const user = c.get('user'); + const repos = requireFeedRepos(c); + const { id } = c.req.valid('param'); + + try { + const item = await repos.feedItems.findById(id, user.id); + if (!item) { + const response = errorResponse('Feed item not found', HttpStatus.NOT_FOUND); + return c.json(response, response.status); + } + + const saved = await saveLink({ repos, user, url: item.url }); + const updated = await repos.feedItems.save(item.id, user.id, saved.link.id); + if (!updated) { + const response = errorResponse('Feed item not found', HttpStatus.NOT_FOUND); + return c.json(response, response.status); + } + + const response = successResponse( + { item: updated, linkId: saved.link.id, reusedLink: !saved.created }, + 'Feed item saved' + ); + return c.json(response, response.status); + } catch { + const response = errorResponse( + 'An error occurred when saving feed item', + HttpStatus.BAD_REQUEST + ); + return c.json(response, response.status); + } +}; + +export const dismissFeedItem: AppRouteHandler = async (c) => { + if (isDemoMode()) return c.json(demoModeForbiddenResponse(), HttpStatus.FORBIDDEN); + + const user = c.get('user'); + const repos = requireFeedRepos(c); + const { id } = c.req.valid('param'); + + try { + const item = await repos.feedItems.dismiss(id, user.id); + if (!item) { + const response = errorResponse('Feed item not found', HttpStatus.NOT_FOUND); + return c.json(response, response.status); + } + + const response = successResponse(item, 'Feed item dismissed'); + return c.json(response, response.status); + } catch { + const response = errorResponse( + 'An error occurred when dismissing feed item', + HttpStatus.BAD_REQUEST + ); + return c.json(response, response.status); + } +}; diff --git a/apps/server/src/routes/feeds/feeds.index.ts b/apps/server/src/routes/feeds/feeds.index.ts new file mode 100644 index 0000000..4881e7a --- /dev/null +++ b/apps/server/src/routes/feeds/feeds.index.ts @@ -0,0 +1,15 @@ +import { createRouter } from '@/lib/create-app.js'; + +import * as handlers from './feeds.handlers.js'; +import * as routes from './feeds.routes.js'; + +const router = createRouter() + .openapi(routes.listFeedSubscriptions, handlers.listFeedSubscriptions) + .openapi(routes.createFeedSubscription, handlers.createFeedSubscription) + .openapi(routes.updateFeedSubscription, handlers.updateFeedSubscription) + .openapi(routes.refreshFeedSubscription, handlers.refreshFeedSubscription) + .openapi(routes.listFeedItems, handlers.listFeedItems) + .openapi(routes.saveFeedItem, handlers.saveFeedItem) + .openapi(routes.dismissFeedItem, handlers.dismissFeedItem); + +export default router; diff --git a/apps/server/src/routes/feeds/feeds.routes.ts b/apps/server/src/routes/feeds/feeds.routes.ts new file mode 100644 index 0000000..f9d915f --- /dev/null +++ b/apps/server/src/routes/feeds/feeds.routes.ts @@ -0,0 +1,198 @@ +import { createRoute, z } from '@hono/zod-openapi'; + +import { jsonContent, jsonContentRequired } from '@/lib/openapi.js'; +import { errorResponseSchema, HttpStatus, successResponseSchema } from '@/lib/response.js'; + +import { currentUser } from '@/middlewares/current-user.js'; +import { addFeedRateLimit, refreshFeedRateLimit } from '@/middlewares/rate-limit.js'; + +const tags = ['Feeds']; + +const feedSubscriptionSchema = z.object({ + autoSave: z.boolean(), + createdAt: z.date(), + description: z.string().nullable(), + etag: z.string().nullable(), + failureCount: z.number(), + feedUrl: z.string(), + id: z.string(), + imageUrl: z.string().nullable(), + lastError: z.string().nullable(), + lastFetchedAt: z.date().nullable(), + lastModified: z.string().nullable(), + lastSuccessfulFetchAt: z.date().nullable(), + nextFetchAfter: z.date().nullable(), + normalizedFeedUrl: z.string(), + siteUrl: z.string().nullable(), + status: z.enum(['active', 'paused']), + title: z.string(), + updatedAt: z.date(), + userId: z.string() +}); + +const feedItemSchema = z.object({ + author: z.string().nullable(), + createdAt: z.date(), + discoveredAt: z.date(), + dismissedAt: z.date().nullable(), + excerpt: z.string().nullable(), + guid: z.string().nullable(), + id: z.string(), + imageUrl: z.string().nullable(), + linkId: z.string().nullable(), + normalizedUrl: z.string(), + publishedAt: z.date().nullable(), + savedAt: z.date().nullable(), + state: z.enum(['new', 'dismissed', 'saved']), + subscriptionId: z.string(), + title: z.string(), + updatedAt: z.date(), + url: z.string(), + userId: z.string() +}); + +export const listFeedSubscriptions = createRoute({ + tags, + method: 'get', + path: '/feeds/subscriptions', + middleware: [currentUser], + responses: { + [HttpStatus.OK]: jsonContent( + successResponseSchema(z.array(feedSubscriptionSchema)), + 'Feed subscriptions fetched successfully' + ), + [HttpStatus.BAD_REQUEST]: jsonContent(errorResponseSchema(HttpStatus.BAD_REQUEST), '') + } +}); + +export const createFeedSubscription = createRoute({ + tags, + method: 'post', + path: '/feeds/subscriptions', + middleware: [currentUser, addFeedRateLimit], + request: { + body: jsonContentRequired( + z.object({ autoSave: z.boolean().optional(), feedUrl: z.string().url() }), + 'Add feed subscription' + ) + }, + responses: { + [HttpStatus.ACCEPTED]: jsonContent( + successResponseSchema( + z.object({ + autoSaved: z.number(), + createdSubscription: z.boolean(), + fetched: z.boolean(), + pruned: z.number(), + staged: z.number(), + subscription: feedSubscriptionSchema + }), + HttpStatus.ACCEPTED + ), + 'Feed subscription added' + ), + [HttpStatus.FORBIDDEN]: jsonContent(errorResponseSchema(HttpStatus.FORBIDDEN), ''), + [HttpStatus.BAD_REQUEST]: jsonContent(errorResponseSchema(HttpStatus.BAD_REQUEST), '') + } +}); + +export const updateFeedSubscription = createRoute({ + tags, + method: 'patch', + path: '/feeds/subscriptions/:id', + middleware: [currentUser], + request: { + params: z.object({ id: z.string() }), + body: jsonContentRequired( + z.object({ + autoSave: z.boolean().optional(), + status: z.enum(['active', 'paused']).optional() + }), + 'Update feed subscription' + ) + }, + responses: { + [HttpStatus.OK]: jsonContent( + successResponseSchema(feedSubscriptionSchema), + 'Feed subscription updated' + ), + [HttpStatus.FORBIDDEN]: jsonContent(errorResponseSchema(HttpStatus.FORBIDDEN), ''), + [HttpStatus.NOT_FOUND]: jsonContent(errorResponseSchema(HttpStatus.NOT_FOUND), ''), + [HttpStatus.BAD_REQUEST]: jsonContent(errorResponseSchema(HttpStatus.BAD_REQUEST), '') + } +}); + +export const refreshFeedSubscription = createRoute({ + tags, + method: 'post', + path: '/feeds/subscriptions/:id/refresh', + middleware: [currentUser, refreshFeedRateLimit], + request: { + params: z.object({ id: z.string() }) + }, + responses: { + [HttpStatus.ACCEPTED]: jsonContent( + successResponseSchema( + z.object({ jobId: z.string().optional(), subscriptionId: z.string() }), + HttpStatus.ACCEPTED + ), + 'Feed refresh queued' + ), + [HttpStatus.FORBIDDEN]: jsonContent(errorResponseSchema(HttpStatus.FORBIDDEN), ''), + [HttpStatus.NOT_FOUND]: jsonContent(errorResponseSchema(HttpStatus.NOT_FOUND), ''), + [HttpStatus.BAD_REQUEST]: jsonContent(errorResponseSchema(HttpStatus.BAD_REQUEST), '') + } +}); + +export const listFeedItems = createRoute({ + tags, + method: 'get', + path: '/feeds/items', + middleware: [currentUser], + request: { + query: z.object({ + state: z.enum(['new', 'dismissed', 'saved']).optional(), + subscriptionId: z.string().optional() + }) + }, + responses: { + [HttpStatus.OK]: jsonContent( + successResponseSchema(z.array(feedItemSchema)), + 'Feed items fetched successfully' + ), + [HttpStatus.BAD_REQUEST]: jsonContent(errorResponseSchema(HttpStatus.BAD_REQUEST), '') + } +}); + +export const saveFeedItem = createRoute({ + tags, + method: 'post', + path: '/feeds/items/:id/save', + middleware: [currentUser], + request: { params: z.object({ id: z.string() }) }, + responses: { + [HttpStatus.OK]: jsonContent( + successResponseSchema( + z.object({ item: feedItemSchema, linkId: z.string(), reusedLink: z.boolean() }) + ), + 'Feed item saved' + ), + [HttpStatus.FORBIDDEN]: jsonContent(errorResponseSchema(HttpStatus.FORBIDDEN), ''), + [HttpStatus.NOT_FOUND]: jsonContent(errorResponseSchema(HttpStatus.NOT_FOUND), ''), + [HttpStatus.BAD_REQUEST]: jsonContent(errorResponseSchema(HttpStatus.BAD_REQUEST), '') + } +}); + +export const dismissFeedItem = createRoute({ + tags, + method: 'post', + path: '/feeds/items/:id/dismiss', + middleware: [currentUser], + request: { params: z.object({ id: z.string() }) }, + responses: { + [HttpStatus.OK]: jsonContent(successResponseSchema(feedItemSchema), 'Feed item dismissed'), + [HttpStatus.FORBIDDEN]: jsonContent(errorResponseSchema(HttpStatus.FORBIDDEN), ''), + [HttpStatus.NOT_FOUND]: jsonContent(errorResponseSchema(HttpStatus.NOT_FOUND), ''), + [HttpStatus.BAD_REQUEST]: jsonContent(errorResponseSchema(HttpStatus.BAD_REQUEST), '') + } +}); diff --git a/apps/server/src/routes/feeds/feeds.test.ts b/apps/server/src/routes/feeds/feeds.test.ts new file mode 100644 index 0000000..a7c28d5 --- /dev/null +++ b/apps/server/src/routes/feeds/feeds.test.ts @@ -0,0 +1,386 @@ +import { testClient } from 'hono/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createInMemoryAuthAdapter } from '@/tests/in-memory/auth.js'; +import { createInMemoryHighlightsAdapter } from '@/tests/in-memory/highlights.js'; +import { createInMemoryImportSessionsAdapter } from '@/tests/in-memory/import-sessions.js'; +import { createInMemoryLinksAdapter } from '@/tests/in-memory/links.js'; +import { createInMemoryTagsAdapter } from '@/tests/in-memory/tags.js'; + +import { createTestApp } from '@/lib/create-app.js'; +import { generateToken } from '@/lib/jwt.js'; +import { HttpStatus } from '@/lib/response.js'; +import type { Repos } from '@/lib/types.js'; + +import type { FeedItemData, FeedItemsRepository } from '@/repositories/feed-items.repository.js'; +import type { + FeedSubscriptionData, + FeedSubscriptionsRepository +} from '@/repositories/feed-subscriptions.repository.js'; + +import type { UserWithoutPassword } from '@/types/auth.js'; +import type { Tag } from '@/types/tags.js'; + +import router from './feeds.index.js'; + +const addSubscriptionMock = vi.hoisted(() => vi.fn()); +const enqueueFeedPollJobMock = vi.hoisted(() => vi.fn(async () => ({ id: 'feed-job-1' }))); +let demoMode = false; + +vi.mock('@/services/feed-ingestion.service.js', () => ({ + createFeedIngestionService: vi.fn(() => ({ addSubscription: addSubscriptionMock })) +})); + +vi.mock('@/queues/feed-poll.queue.js', () => ({ + enqueueFeedPollJob: enqueueFeedPollJobMock +})); + +vi.mock('@/queues/content-extraction.queue.js', () => ({ + enqueueContentExtraction: { add: vi.fn(async () => ({ id: 'extract-job-1' })), on: vi.fn() } +})); + +vi.mock('@/middlewares/rate-limit.js', () => ({ + addFeedRateLimit: async (_c: unknown, next: () => Promise) => next(), + rateLimit: async (_c: unknown, next: () => Promise) => next(), + refreshFeedRateLimit: async (_c: unknown, next: () => Promise) => next() +})); + +vi.mock('@/lib/demo-mode.js', () => ({ + demoModeForbiddenResponse: () => ({ message: 'Demo mode is read-only', status: 403 }), + isDemoMode: () => demoMode +})); + +const NOW = new Date('2026-06-28T12:00:00.000Z'); +const TEST_USER: UserWithoutPassword = { + id: '00000000-0000-0000-0000-000000000001', + email: 'feeds-test@example.com', + name: 'Test User', + avatar: null, + role: 'user', + settings: {}, + deletedAt: null, + createdAt: NOW.toISOString(), + updatedAt: NOW.toISOString() +}; +const OTHER_USER_ID = '00000000-0000-0000-0000-000000000002'; +const authCookie = `token=${await generateToken(TEST_USER.id, TEST_USER.email)}`; + +function subscriptionFixture(overrides: Partial = {}): FeedSubscriptionData { + return { + autoSave: false, + createdAt: NOW, + description: null, + etag: null, + failureCount: 0, + feedUrl: 'https://example.com/feed.xml', + id: crypto.randomUUID(), + imageUrl: null, + lastError: null, + lastFetchedAt: null, + lastModified: null, + lastSuccessfulFetchAt: null, + nextFetchAfter: null, + normalizedFeedUrl: 'https://example.com/feed.xml', + siteUrl: 'https://example.com/', + status: 'active', + title: 'Example Feed', + updatedAt: NOW, + userId: TEST_USER.id, + ...overrides + }; +} + +function itemFixture(overrides: Partial = {}): FeedItemData { + return { + author: null, + createdAt: NOW, + discoveredAt: NOW, + dismissedAt: null, + excerpt: 'Excerpt', + guid: 'guid-1', + id: crypto.randomUUID(), + imageUrl: null, + linkId: null, + normalizedUrl: 'https://example.com/article', + publishedAt: NOW, + savedAt: null, + state: 'new', + subscriptionId: 'feed-1', + title: 'Article', + updatedAt: NOW, + url: 'https://example.com/article', + userId: TEST_USER.id, + ...overrides + }; +} + +function buildFeedRepos() { + const subscriptions = new Map(); + const items = new Map(); + + const feedSubscriptions: FeedSubscriptionsRepository = { + create: async (data) => { + const subscription = subscriptionFixture(data); + subscriptions.set(subscription.id, subscription); + return subscription; + }, + delete: async (id, userId) => + subscriptions.delete( + [...subscriptions.values()].find((s) => s.id === id && s.userId === userId)?.id ?? '' + ), + findById: async (id, userId) => + [...subscriptions.values()].find((s) => s.id === id && s.userId === userId) ?? null, + findByNormalizedUrl: async (normalizedFeedUrl, userId) => + [...subscriptions.values()].find( + (s) => s.normalizedFeedUrl === normalizedFeedUrl && s.userId === userId + ) ?? null, + findDue: async () => [], + findManyByUserId: async (userId) => + [...subscriptions.values()].filter((s) => s.userId === userId), + update: async (id, userId, updates) => { + const existing = [...subscriptions.values()].find((s) => s.id === id && s.userId === userId); + if (!existing) return null; + const updated = { ...existing, ...updates, updatedAt: NOW }; + subscriptions.set(id, updated); + return updated; + }, + updateFetchMetadata: async (id, userId, updates) => + feedSubscriptions.update(id, userId, updates) + }; + + const feedItems: FeedItemsRepository = { + create: async (data) => { + const item = itemFixture(data); + items.set(item.id, item); + return item; + }, + dismiss: async (id, userId, dismissedAt = NOW) => { + const existing = await feedItems.findById(id, userId); + if (!existing) return null; + const updated = { ...existing, dismissedAt, state: 'dismissed' as const, updatedAt: NOW }; + items.set(id, updated); + return updated; + }, + findById: async (id, userId) => + [...items.values()].find((item) => item.id === id && item.userId === userId) ?? null, + findBySubscriptionAndIdentity: async () => null, + findManyForReview: async ({ state, subscriptionId, userId }) => + [...items.values()].filter( + (item) => + item.userId === userId && + (!state || item.state === state) && + (!subscriptionId || item.subscriptionId === subscriptionId) + ), + pruneForSubscription: async () => 0, + reconcileSavedByUrl: async ({ linkId, normalizedUrl, savedAt = NOW, userId }) => { + const matching = [...items.values()].filter( + (item) => item.userId === userId && item.normalizedUrl === normalizedUrl + ); + return matching.map((item) => { + const updated = { ...item, linkId, savedAt, state: 'saved' as const, updatedAt: NOW }; + items.set(item.id, updated); + return updated; + }); + }, + save: async (id, userId, linkId, savedAt = NOW) => { + const existing = await feedItems.findById(id, userId); + if (!existing) return null; + const updated = { ...existing, linkId, savedAt, state: 'saved' as const, updatedAt: NOW }; + items.set(id, updated); + return updated; + }, + upsertByIdentity: async (data) => feedItems.create(data) + }; + + return { feedItems, feedSubscriptions, items, subscriptions }; +} + +function buildClient() { + const tagLinkRelations = new Map(); + const auth = createInMemoryAuthAdapter(); + auth.findById = async (id) => (id === TEST_USER.id ? TEST_USER : null); + const feedRepos = buildFeedRepos(); + + const repos: Repos = { + auth, + feedItems: feedRepos.feedItems, + feedSubscriptions: feedRepos.feedSubscriptions, + highlights: createInMemoryHighlightsAdapter(), + importSessions: createInMemoryImportSessionsAdapter().repo, + links: createInMemoryLinksAdapter(tagLinkRelations), + tags: createInMemoryTagsAdapter(tagLinkRelations).repo + }; + + const client = testClient( + createTestApp(router, (app) => { + app.use('*', async (c, next) => { + c.set('repos', repos); + return next(); + }); + }) + ); + + return { client, ...feedRepos }; +} + +let built: ReturnType; + +beforeEach(() => { + demoMode = false; + vi.clearAllMocks(); + built = buildClient(); +}); + +describe('feed routes', () => { + it('requires auth', async () => { + const response = await built.client.feeds.subscriptions.$get(); + expect(response.status).toBe(HttpStatus.UNAUTHORIZED); + }); + + it('adds a valid feed subscription', async () => { + const subscription = subscriptionFixture(); + addSubscriptionMock.mockResolvedValue({ + autoSaved: 0, + createdSubscription: true, + fetched: true, + pruned: 0, + staged: 2, + subscription + }); + + const response = await built.client.feeds.subscriptions.$post( + { json: { feedUrl: subscription.feedUrl } }, + { headers: { Cookie: authCookie } } + ); + + expect(response.status).toBe(HttpStatus.ACCEPTED); + expect(addSubscriptionMock).toHaveBeenCalledWith({ + autoSave: undefined, + feedUrl: subscription.feedUrl, + user: TEST_USER + }); + }); + + it('returns existing subscription behavior from duplicate add', async () => { + const subscription = subscriptionFixture(); + addSubscriptionMock.mockResolvedValue({ + autoSaved: 0, + createdSubscription: false, + fetched: false, + pruned: 0, + staged: 0, + subscription + }); + + const response = await built.client.feeds.subscriptions.$post( + { json: { feedUrl: subscription.feedUrl } }, + { headers: { Cookie: authCookie } } + ); + expect(response.status).toBe(HttpStatus.ACCEPTED); + const json = (await response.json()) as { result: { createdSubscription: boolean } }; + + expect(json.result.createdSubscription).toBe(false); + }); + + it('returns a bad request for blocked or private feed URLs', async () => { + addSubscriptionMock.mockRejectedValue(new Error('Feed URL is not allowed')); + + const response = await built.client.feeds.subscriptions.$post( + { json: { feedUrl: 'https://example.com/feed.xml' } }, + { headers: { Cookie: authCookie } } + ); + const json = await response.json(); + + expect(response.status).toBe(HttpStatus.BAD_REQUEST); + expect(json.message).toMatch(/not allowed/i); + }); + + it('lists user-scoped subscriptions and items', async () => { + const userFeed = subscriptionFixture({ id: 'feed-user', userId: TEST_USER.id }); + const otherFeed = subscriptionFixture({ id: 'feed-other', userId: OTHER_USER_ID }); + built.subscriptions.set(userFeed.id, userFeed); + built.subscriptions.set(otherFeed.id, otherFeed); + built.items.set( + 'item-user', + itemFixture({ id: 'item-user', subscriptionId: userFeed.id, userId: TEST_USER.id }) + ); + built.items.set( + 'item-other', + itemFixture({ id: 'item-other', subscriptionId: otherFeed.id, userId: OTHER_USER_ID }) + ); + + const subscriptionsResponse = await built.client.feeds.subscriptions.$get(undefined, { + headers: { Cookie: authCookie } + }); + const itemsResponse = await built.client.feeds.items.$get( + { query: { state: 'new' } }, + { headers: { Cookie: authCookie } } + ); + + const subscriptionsJson = (await subscriptionsResponse.json()) as { result: unknown[] }; + const itemsJson = (await itemsResponse.json()) as { result: unknown[] }; + + expect(subscriptionsJson.result).toHaveLength(1); + expect(itemsJson.result).toHaveLength(1); + }); + + it('updates and refreshes user-owned subscriptions only', async () => { + const subscription = subscriptionFixture({ id: 'feed-user' }); + built.subscriptions.set(subscription.id, subscription); + + const updateResponse = await built.client.feeds.subscriptions[':id'].$patch( + { param: { id: subscription.id }, json: { autoSave: true } }, + { headers: { Cookie: authCookie } } + ); + const refreshResponse = await built.client.feeds.subscriptions[':id'].refresh.$post( + { param: { id: subscription.id } }, + { headers: { Cookie: authCookie } } + ); + const deniedResponse = await built.client.feeds.subscriptions[':id'].refresh.$post( + { param: { id: 'missing-feed' } }, + { headers: { Cookie: authCookie } } + ); + + expect(updateResponse.status).toBe(HttpStatus.OK); + expect(refreshResponse.status).toBe(HttpStatus.ACCEPTED); + expect(enqueueFeedPollJobMock).toHaveBeenCalledWith({ + force: true, + reason: 'manual', + subscriptionId: subscription.id, + userId: TEST_USER.id + }); + expect(deniedResponse.status).toBe(HttpStatus.NOT_FOUND); + }); + + it('saves and dismisses feed items', async () => { + const item = itemFixture({ id: 'item-user', url: 'https://example.com/feed-item' }); + built.items.set(item.id, item); + + const saveResponse = await built.client.feeds.items[':id'].save.$post( + { param: { id: item.id } }, + { headers: { Cookie: authCookie } } + ); + const saveJson = (await saveResponse.json()) as { result: { item: { state: string } } }; + const dismissItem = itemFixture({ id: 'item-dismiss' }); + built.items.set(dismissItem.id, dismissItem); + const dismissResponse = await built.client.feeds.items[':id'].dismiss.$post( + { param: { id: dismissItem.id } }, + { headers: { Cookie: authCookie } } + ); + + expect(saveResponse.status).toBe(HttpStatus.OK); + expect(saveJson.result.item.state).toBe('saved'); + expect(dismissResponse.status).toBe(HttpStatus.OK); + }); + + it('blocks feed mutations in demo mode', async () => { + demoMode = true; + + const response = await built.client.feeds.subscriptions.$post( + { json: { feedUrl: 'https://example.com/feed.xml' } }, + { headers: { Cookie: authCookie } } + ); + + expect(response.status).toBe(HttpStatus.FORBIDDEN); + }); +}); diff --git a/apps/server/src/routes/feeds/feeds.types.ts b/apps/server/src/routes/feeds/feeds.types.ts new file mode 100644 index 0000000..3ce5e28 --- /dev/null +++ b/apps/server/src/routes/feeds/feeds.types.ts @@ -0,0 +1,17 @@ +import type { + createFeedSubscription, + dismissFeedItem, + listFeedItems, + listFeedSubscriptions, + refreshFeedSubscription, + saveFeedItem, + updateFeedSubscription +} from './feeds.routes.js'; + +export type ListFeedSubscriptionsRoute = typeof listFeedSubscriptions; +export type CreateFeedSubscriptionRoute = typeof createFeedSubscription; +export type UpdateFeedSubscriptionRoute = typeof updateFeedSubscription; +export type RefreshFeedSubscriptionRoute = typeof refreshFeedSubscription; +export type ListFeedItemsRoute = typeof listFeedItems; +export type SaveFeedItemRoute = typeof saveFeedItem; +export type DismissFeedItemRoute = typeof dismissFeedItem; From c30a4717ae25f2202d59caa8dc5f3baa11c21c7d Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sun, 28 Jun 2026 13:26:06 +0700 Subject: [PATCH 08/18] feat(web): add RSS feeds route foundation Add the typed web API layer, feed query keys, protected route skeleton, and localized copy for the Feeds review surface. --- .../feeds/api/create-feed-subscription.ts | 36 +++ .../features/feeds/api/dismiss-feed-item.ts | 26 ++ .../src/features/feeds/api/get-feed-items.ts | 35 +++ .../feeds/api/get-feed-subscriptions.ts | 31 +++ apps/web/src/features/feeds/api/query-keys.ts | 8 + .../feeds/api/refresh-feed-subscription.ts | 30 +++ .../src/features/feeds/api/save-feed-item.ts | 28 +++ .../feeds/api/update-feed-subscription.ts | 36 +++ apps/web/src/pages/feeds.tsx | 225 ++++++++++++++++++ apps/web/src/routeTree.gen.ts | 22 ++ .../_protected/_with-layout/feeds/index.tsx | 17 ++ apps/web/src/translations/en.json | 46 +++- apps/web/src/translations/id.json | 46 +++- apps/web/src/types/feeds.ts | 77 ++++++ 14 files changed, 659 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/features/feeds/api/create-feed-subscription.ts create mode 100644 apps/web/src/features/feeds/api/dismiss-feed-item.ts create mode 100644 apps/web/src/features/feeds/api/get-feed-items.ts create mode 100644 apps/web/src/features/feeds/api/get-feed-subscriptions.ts create mode 100644 apps/web/src/features/feeds/api/query-keys.ts create mode 100644 apps/web/src/features/feeds/api/refresh-feed-subscription.ts create mode 100644 apps/web/src/features/feeds/api/save-feed-item.ts create mode 100644 apps/web/src/features/feeds/api/update-feed-subscription.ts create mode 100644 apps/web/src/pages/feeds.tsx create mode 100644 apps/web/src/routes/_protected/_with-layout/feeds/index.tsx create mode 100644 apps/web/src/types/feeds.ts diff --git a/apps/web/src/features/feeds/api/create-feed-subscription.ts b/apps/web/src/features/feeds/api/create-feed-subscription.ts new file mode 100644 index 0000000..b59c381 --- /dev/null +++ b/apps/web/src/features/feeds/api/create-feed-subscription.ts @@ -0,0 +1,36 @@ +import { useMutation } from '@tanstack/react-query'; +import { z } from 'zod'; + +import { apiClient } from '@/lib/api-client'; +import type { MutationConfig } from '@/lib/react-query'; + +import type { ApiResult } from '@/types/api'; +import type { CreateFeedSubscriptionBody, CreateFeedSubscriptionResult } from '@/types/feeds'; + +import { feedKeys } from './query-keys'; + +export const createFeedSubscriptionBodySchema = z.object({ + autoSave: z.boolean().optional(), + feedUrl: z.url() +}); + +export const createFeedSubscription = async ( + body: CreateFeedSubscriptionBody +): Promise> => { + const response = await apiClient.post('feeds/subscriptions', body); + + return response.json(); +}; + +type UseCreateFeedSubscriptionOptions = { + mutationConfig?: MutationConfig; +}; + +export const useCreateFeedSubscription = ({ + mutationConfig +}: UseCreateFeedSubscriptionOptions = {}) => + useMutation({ + ...mutationConfig, + mutationFn: createFeedSubscription, + meta: { invalidates: [feedKeys.all] } + }); diff --git a/apps/web/src/features/feeds/api/dismiss-feed-item.ts b/apps/web/src/features/feeds/api/dismiss-feed-item.ts new file mode 100644 index 0000000..211eef0 --- /dev/null +++ b/apps/web/src/features/feeds/api/dismiss-feed-item.ts @@ -0,0 +1,26 @@ +import { useMutation } from '@tanstack/react-query'; + +import { apiClient } from '@/lib/api-client'; +import type { MutationConfig } from '@/lib/react-query'; + +import type { ApiResult } from '@/types/api'; +import type { FeedItem } from '@/types/feeds'; + +import { feedKeys } from './query-keys'; + +export const dismissFeedItem = async (itemId: string): Promise> => { + const response = await apiClient.post(`feeds/items/${itemId}/dismiss`); + + return response.json(); +}; + +type UseDismissFeedItemOptions = { + mutationConfig?: MutationConfig; +}; + +export const useDismissFeedItem = ({ mutationConfig }: UseDismissFeedItemOptions = {}) => + useMutation({ + ...mutationConfig, + mutationFn: dismissFeedItem, + meta: { invalidates: [feedKeys.all] } + }); diff --git a/apps/web/src/features/feeds/api/get-feed-items.ts b/apps/web/src/features/feeds/api/get-feed-items.ts new file mode 100644 index 0000000..593b29a --- /dev/null +++ b/apps/web/src/features/feeds/api/get-feed-items.ts @@ -0,0 +1,35 @@ +import { queryOptions, useQuery } from '@tanstack/react-query'; + +import { apiClient } from '@/lib/api-client'; +import type { QueryConfig } from '@/lib/react-query'; + +import type { ApiResult } from '@/types/api'; +import type { FeedItem, FeedItemFilters } from '@/types/feeds'; + +import { feedKeys } from './query-keys'; + +const getFeedItems = async (filters: FeedItemFilters = {}): Promise> => { + const params = Object.fromEntries( + Object.entries(filters).filter((entry): entry is [string, string] => Boolean(entry[1])) + ); + const response = await apiClient.get('feeds/items', {}, params); + + return response.json(); +}; + +export const getFeedItemsQueryOptions = (filters: FeedItemFilters = {}) => + queryOptions({ + queryKey: feedKeys.itemList(filters), + queryFn: () => getFeedItems(filters) + }); + +type UseFeedItemsOptions = { + filters?: FeedItemFilters; + queryConfig?: QueryConfig; +}; + +export const useFeedItems = ({ filters = {}, queryConfig }: UseFeedItemsOptions = {}) => + useQuery({ + ...getFeedItemsQueryOptions(filters), + ...queryConfig + }); diff --git a/apps/web/src/features/feeds/api/get-feed-subscriptions.ts b/apps/web/src/features/feeds/api/get-feed-subscriptions.ts new file mode 100644 index 0000000..18524e1 --- /dev/null +++ b/apps/web/src/features/feeds/api/get-feed-subscriptions.ts @@ -0,0 +1,31 @@ +import { queryOptions, useQuery } from '@tanstack/react-query'; + +import { apiClient } from '@/lib/api-client'; +import type { QueryConfig } from '@/lib/react-query'; + +import type { ApiResult } from '@/types/api'; +import type { FeedSubscription } from '@/types/feeds'; + +import { feedKeys } from './query-keys'; + +const getFeedSubscriptions = async (): Promise> => { + const response = await apiClient.get('feeds/subscriptions'); + + return response.json(); +}; + +export const getFeedSubscriptionsQueryOptions = () => + queryOptions({ + queryKey: feedKeys.subscriptions(), + queryFn: getFeedSubscriptions + }); + +type UseFeedSubscriptionsOptions = { + queryConfig?: QueryConfig; +}; + +export const useFeedSubscriptions = ({ queryConfig }: UseFeedSubscriptionsOptions = {}) => + useQuery({ + ...getFeedSubscriptionsQueryOptions(), + ...queryConfig + }); diff --git a/apps/web/src/features/feeds/api/query-keys.ts b/apps/web/src/features/feeds/api/query-keys.ts new file mode 100644 index 0000000..6b4b468 --- /dev/null +++ b/apps/web/src/features/feeds/api/query-keys.ts @@ -0,0 +1,8 @@ +import type { FeedItemFilters } from '@/types/feeds'; + +export const feedKeys = { + all: ['feeds'] as const, + subscriptions: () => [...feedKeys.all, 'subscriptions'] as const, + items: () => [...feedKeys.all, 'items'] as const, + itemList: (filters?: FeedItemFilters) => [...feedKeys.items(), { filters }] as const +}; diff --git a/apps/web/src/features/feeds/api/refresh-feed-subscription.ts b/apps/web/src/features/feeds/api/refresh-feed-subscription.ts new file mode 100644 index 0000000..d5562de --- /dev/null +++ b/apps/web/src/features/feeds/api/refresh-feed-subscription.ts @@ -0,0 +1,30 @@ +import { useMutation } from '@tanstack/react-query'; + +import { apiClient } from '@/lib/api-client'; +import type { MutationConfig } from '@/lib/react-query'; + +import type { ApiResult } from '@/types/api'; +import type { RefreshFeedSubscriptionResult } from '@/types/feeds'; + +import { feedKeys } from './query-keys'; + +export const refreshFeedSubscription = async ( + subscriptionId: string +): Promise> => { + const response = await apiClient.post(`feeds/subscriptions/${subscriptionId}/refresh`); + + return response.json(); +}; + +type UseRefreshFeedSubscriptionOptions = { + mutationConfig?: MutationConfig; +}; + +export const useRefreshFeedSubscription = ({ + mutationConfig +}: UseRefreshFeedSubscriptionOptions = {}) => + useMutation({ + ...mutationConfig, + mutationFn: refreshFeedSubscription, + meta: { invalidates: [feedKeys.all] } + }); diff --git a/apps/web/src/features/feeds/api/save-feed-item.ts b/apps/web/src/features/feeds/api/save-feed-item.ts new file mode 100644 index 0000000..ab9a576 --- /dev/null +++ b/apps/web/src/features/feeds/api/save-feed-item.ts @@ -0,0 +1,28 @@ +import { useMutation } from '@tanstack/react-query'; + +import { homeKeys } from '@/features/home/api/query-keys'; + +import { apiClient } from '@/lib/api-client'; +import type { MutationConfig } from '@/lib/react-query'; + +import type { ApiResult } from '@/types/api'; +import type { SaveFeedItemResult } from '@/types/feeds'; + +import { feedKeys } from './query-keys'; + +export const saveFeedItem = async (itemId: string): Promise> => { + const response = await apiClient.post(`feeds/items/${itemId}/save`); + + return response.json(); +}; + +type UseSaveFeedItemOptions = { + mutationConfig?: MutationConfig; +}; + +export const useSaveFeedItem = ({ mutationConfig }: UseSaveFeedItemOptions = {}) => + useMutation({ + ...mutationConfig, + mutationFn: saveFeedItem, + meta: { invalidates: [feedKeys.all, homeKeys.all] } + }); diff --git a/apps/web/src/features/feeds/api/update-feed-subscription.ts b/apps/web/src/features/feeds/api/update-feed-subscription.ts new file mode 100644 index 0000000..febb229 --- /dev/null +++ b/apps/web/src/features/feeds/api/update-feed-subscription.ts @@ -0,0 +1,36 @@ +import { useMutation } from '@tanstack/react-query'; + +import { apiClient } from '@/lib/api-client'; +import type { MutationConfig } from '@/lib/react-query'; + +import type { ApiResult } from '@/types/api'; +import type { FeedSubscription, UpdateFeedSubscriptionBody } from '@/types/feeds'; + +import { feedKeys } from './query-keys'; + +type UpdateFeedSubscriptionVariables = { + body: UpdateFeedSubscriptionBody; + subscriptionId: string; +}; + +export const updateFeedSubscription = async ({ + body, + subscriptionId +}: UpdateFeedSubscriptionVariables): Promise> => { + const response = await apiClient.patch(`feeds/subscriptions/${subscriptionId}`, body); + + return response.json(); +}; + +type UseUpdateFeedSubscriptionOptions = { + mutationConfig?: MutationConfig; +}; + +export const useUpdateFeedSubscription = ({ + mutationConfig +}: UseUpdateFeedSubscriptionOptions = {}) => + useMutation({ + ...mutationConfig, + mutationFn: updateFeedSubscription, + meta: { invalidates: [feedKeys.all] } + }); diff --git a/apps/web/src/pages/feeds.tsx b/apps/web/src/pages/feeds.tsx new file mode 100644 index 0000000..4e40ae1 --- /dev/null +++ b/apps/web/src/pages/feeds.tsx @@ -0,0 +1,225 @@ +import { ArrowsClockwise, CheckCircle, RssSimple, WarningCircle } from '@phosphor-icons/react'; +import { Link, useSearch } from '@tanstack/react-router'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; + +import { useFeedItems } from '@/features/feeds/api/get-feed-items'; +import { useFeedSubscriptions } from '@/features/feeds/api/get-feed-subscriptions'; + +import { cn } from '@/lib/utils'; + +import type { FeedItemState, FeedSubscription } from '@/types/feeds'; + +type FeedTab = 'new' | 'saved' | 'dismissed' | 'feeds'; + +const tabStates: Array<{ state?: FeedItemState; value: FeedTab }> = [ + { value: 'new', state: 'new' }, + { value: 'saved', state: 'saved' }, + { value: 'dismissed', state: 'dismissed' }, + { value: 'feeds' } +]; + +function FeedsSkeleton() { + return ( +
+ + + +
+ ); +} + +function FeedStatusBadge({ subscription }: { subscription: FeedSubscription }) { + const { t } = useTranslation(); + + if (subscription.lastError) { + return ( + + + {t('feeds.status.warning')} + + ); + } + + if (subscription.status === 'paused') { + return {t('feeds.status.paused')}; + } + + return ( + + + {t('feeds.status.active')} + + ); +} + +function FeedsPage() { + const { t } = useTranslation(); + const search = useSearch({ strict: false }) as { tab?: FeedTab }; + const activeTab: FeedTab = search.tab ?? 'new'; + const activeState = tabStates.find((tab) => tab.value === activeTab)?.state; + + const subscriptionsQuery = useFeedSubscriptions(); + const itemsQuery = useFeedItems({ filters: activeState ? { state: activeState } : {} }); + + const subscriptions = subscriptionsQuery.data?.result ?? []; + const items = itemsQuery.data?.result ?? []; + const itemsBySubscription = useMemo(() => { + const grouped = new Map(); + + for (const item of items) { + grouped.set(item.subscriptionId, [...(grouped.get(item.subscriptionId) ?? []), item]); + } + + return grouped; + }, [items]); + + const isLoading = subscriptionsQuery.isLoading || itemsQuery.isLoading; + const isError = subscriptionsQuery.isError || itemsQuery.isError; + + return ( +
+
+
+
+
+ + {t('feeds.eyebrow')} +
+
+

+ {t('feeds.title')} +

+

+ {t('feeds.description')} +

+
+
+ +
+ + +
+ + {isLoading ? : null} + + {isError ? ( + + + {t('feeds.error.title')} + + + {t('feeds.error.description')} + + + ) : null} + + {!isLoading && !isError && subscriptions.length === 0 ? ( + + + {t('feeds.empty.title')} + + + {t('feeds.empty.description')} + + + ) : null} + + {!isLoading && !isError && subscriptions.length > 0 ? ( +
+ {subscriptions.map((subscription) => { + const subscriptionItems = itemsBySubscription.get(subscription.id) ?? []; + + return ( + + +
+
+ {subscription.title} +

+ {subscription.siteUrl ?? subscription.feedUrl} +

+
+ +
+
+ + {subscription.lastError ? ( +

+ {subscription.lastError} +

+ ) : null} + + {activeTab === 'feeds' ? ( +
+
+
{t('feeds.fields.autoSave')}
+
{subscription.autoSave ? t('feeds.yes') : t('feeds.no')}
+
+
+
{t('feeds.fields.failures')}
+
{subscription.failureCount}
+
+
+ ) : subscriptionItems.length > 0 ? ( +
+ {subscriptionItems.map((item) => ( +
+

{item.title}

+ {item.excerpt ? ( +

+ {item.excerpt} +

+ ) : null} +
+ + +
+
+ ))} +
+ ) : ( +

{t('feeds.emptyFeed')}

+ )} + + +
+
+ ); + })} +
+ ) : null} +
+ ); +} + +export default FeedsPage; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 4799440..6921b0d 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -22,6 +22,7 @@ import { Route as ProtectedAdminConnectionsRouteImport } from './routes/_protect import { Route as ProtectedWithLayoutPrototypeFeedsRouteImport } from './routes/_protected/_with-layout/prototype-feeds' import { Route as ProtectedWithLayoutManageTagsRouteImport } from './routes/_protected/_with-layout/manage-tags' import { Route as ProtectedWithLayoutSettingsIndexRouteImport } from './routes/_protected/_with-layout/settings/index' +import { Route as ProtectedWithLayoutFeedsIndexRouteImport } from './routes/_protected/_with-layout/feeds/index' import { Route as ProtectedWithLayoutArticlesIndexRouteImport } from './routes/_protected/_with-layout/articles/index' import { Route as ProtectedWithLayoutSettingsImportArticlesIndexRouteImport } from './routes/_protected/_with-layout/settings/import-articles/index' import { Route as ProtectedWithLayoutSettingsImportArticlesSessionIdRouteImport } from './routes/_protected/_with-layout/settings/import-articles/$sessionId' @@ -93,6 +94,12 @@ const ProtectedWithLayoutSettingsIndexRoute = path: '/settings/', getParentRoute: () => ProtectedWithLayoutRoute, } as any) +const ProtectedWithLayoutFeedsIndexRoute = + ProtectedWithLayoutFeedsIndexRouteImport.update({ + id: '/feeds/', + path: '/feeds/', + getParentRoute: () => ProtectedWithLayoutRoute, + } as any) const ProtectedWithLayoutArticlesIndexRoute = ProtectedWithLayoutArticlesIndexRouteImport.update({ id: '/articles/', @@ -123,6 +130,7 @@ export interface FileRoutesByFullPath { '/articles/$id': typeof ProtectedArticlesIdRoute '/admin/': typeof ProtectedAdminIndexRoute '/articles/': typeof ProtectedWithLayoutArticlesIndexRoute + '/feeds/': typeof ProtectedWithLayoutFeedsIndexRoute '/settings/': typeof ProtectedWithLayoutSettingsIndexRoute '/settings/import-articles/$sessionId': typeof ProtectedWithLayoutSettingsImportArticlesSessionIdRoute '/settings/import-articles/': typeof ProtectedWithLayoutSettingsImportArticlesIndexRoute @@ -137,6 +145,7 @@ export interface FileRoutesByTo { '/articles/$id': typeof ProtectedArticlesIdRoute '/admin': typeof ProtectedAdminIndexRoute '/articles': typeof ProtectedWithLayoutArticlesIndexRoute + '/feeds': typeof ProtectedWithLayoutFeedsIndexRoute '/settings': typeof ProtectedWithLayoutSettingsIndexRoute '/settings/import-articles/$sessionId': typeof ProtectedWithLayoutSettingsImportArticlesSessionIdRoute '/settings/import-articles': typeof ProtectedWithLayoutSettingsImportArticlesIndexRoute @@ -156,6 +165,7 @@ export interface FileRoutesById { '/_protected/_with-layout/': typeof ProtectedWithLayoutIndexRoute '/_protected/admin/': typeof ProtectedAdminIndexRoute '/_protected/_with-layout/articles/': typeof ProtectedWithLayoutArticlesIndexRoute + '/_protected/_with-layout/feeds/': typeof ProtectedWithLayoutFeedsIndexRoute '/_protected/_with-layout/settings/': typeof ProtectedWithLayoutSettingsIndexRoute '/_protected/_with-layout/settings/import-articles/$sessionId': typeof ProtectedWithLayoutSettingsImportArticlesSessionIdRoute '/_protected/_with-layout/settings/import-articles/': typeof ProtectedWithLayoutSettingsImportArticlesIndexRoute @@ -173,6 +183,7 @@ export interface FileRouteTypes { | '/articles/$id' | '/admin/' | '/articles/' + | '/feeds/' | '/settings/' | '/settings/import-articles/$sessionId' | '/settings/import-articles/' @@ -187,6 +198,7 @@ export interface FileRouteTypes { | '/articles/$id' | '/admin' | '/articles' + | '/feeds' | '/settings' | '/settings/import-articles/$sessionId' | '/settings/import-articles' @@ -205,6 +217,7 @@ export interface FileRouteTypes { | '/_protected/_with-layout/' | '/_protected/admin/' | '/_protected/_with-layout/articles/' + | '/_protected/_with-layout/feeds/' | '/_protected/_with-layout/settings/' | '/_protected/_with-layout/settings/import-articles/$sessionId' | '/_protected/_with-layout/settings/import-articles/' @@ -308,6 +321,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedWithLayoutSettingsIndexRouteImport parentRoute: typeof ProtectedWithLayoutRoute } + '/_protected/_with-layout/feeds/': { + id: '/_protected/_with-layout/feeds/' + path: '/feeds' + fullPath: '/feeds/' + preLoaderRoute: typeof ProtectedWithLayoutFeedsIndexRouteImport + parentRoute: typeof ProtectedWithLayoutRoute + } '/_protected/_with-layout/articles/': { id: '/_protected/_with-layout/articles/' path: '/articles' @@ -349,6 +369,7 @@ interface ProtectedWithLayoutRouteChildren { ProtectedWithLayoutPrototypeFeedsRoute: typeof ProtectedWithLayoutPrototypeFeedsRoute ProtectedWithLayoutIndexRoute: typeof ProtectedWithLayoutIndexRoute ProtectedWithLayoutArticlesIndexRoute: typeof ProtectedWithLayoutArticlesIndexRoute + ProtectedWithLayoutFeedsIndexRoute: typeof ProtectedWithLayoutFeedsIndexRoute ProtectedWithLayoutSettingsIndexRoute: typeof ProtectedWithLayoutSettingsIndexRoute ProtectedWithLayoutSettingsImportArticlesSessionIdRoute: typeof ProtectedWithLayoutSettingsImportArticlesSessionIdRoute ProtectedWithLayoutSettingsImportArticlesIndexRoute: typeof ProtectedWithLayoutSettingsImportArticlesIndexRoute @@ -360,6 +381,7 @@ const ProtectedWithLayoutRouteChildren: ProtectedWithLayoutRouteChildren = { ProtectedWithLayoutPrototypeFeedsRoute, ProtectedWithLayoutIndexRoute: ProtectedWithLayoutIndexRoute, ProtectedWithLayoutArticlesIndexRoute: ProtectedWithLayoutArticlesIndexRoute, + ProtectedWithLayoutFeedsIndexRoute: ProtectedWithLayoutFeedsIndexRoute, ProtectedWithLayoutSettingsIndexRoute: ProtectedWithLayoutSettingsIndexRoute, ProtectedWithLayoutSettingsImportArticlesSessionIdRoute: ProtectedWithLayoutSettingsImportArticlesSessionIdRoute, diff --git a/apps/web/src/routes/_protected/_with-layout/feeds/index.tsx b/apps/web/src/routes/_protected/_with-layout/feeds/index.tsx new file mode 100644 index 0000000..a881b4a --- /dev/null +++ b/apps/web/src/routes/_protected/_with-layout/feeds/index.tsx @@ -0,0 +1,17 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { zodValidator } from '@tanstack/zod-adapter'; +import { z } from 'zod'; + +import i18n from '@/lib/i18n'; + +import FeedsPage from '@/pages/feeds'; + +const searchSchema = z.object({ + tab: z.enum(['new', 'saved', 'dismissed', 'feeds']).optional() +}); + +export const Route = createFileRoute('/_protected/_with-layout/feeds/')({ + head: () => ({ meta: [{ title: i18n.t('routes.feeds.metaTitle') }] }), + component: FeedsPage, + validateSearch: zodValidator(searchSchema) +}); diff --git a/apps/web/src/translations/en.json b/apps/web/src/translations/en.json index 4d90007..66b454d 100644 --- a/apps/web/src/translations/en.json +++ b/apps/web/src/translations/en.json @@ -94,7 +94,8 @@ "nav": { "home": "Home", "articles": "Articles", - "moreActionsAria": "More actions" + "moreActionsAria": "More actions", + "feeds": "Feeds" }, "userMenu": { "fallbackName": "User", @@ -154,6 +155,9 @@ }, "importProgress": { "metaTitle": "Import Progress | Loreo" + }, + "feeds": { + "metaTitle": "Feeds | Loreo" } }, "demo": { @@ -460,7 +464,7 @@ "manageTagsAria": "Manage tags for {{name}}" }, "moreCount": "+{{count}} more", - "manageTags": "Manage tags \u2192", + "manageTags": "Manage tags →", "addFirstTag": "Add First Tag" }, "tagInput": { @@ -810,5 +814,43 @@ }, "latency": "{{ms}} ms" } + }, + "feeds": { + "eyebrow": "User-curated discovery", + "title": "Feeds", + "description": "Follow trusted sources and review new articles before they enter your library.", + "addFeed": "Add feed", + "emptyFeed": "No items for this filter.", + "yes": "Yes", + "no": "No", + "tabs": { + "aria": "Feed review filters", + "new": "New", + "saved": "Saved", + "dismissed": "Dismissed", + "feeds": "Feeds" + }, + "status": { + "active": "Active", + "paused": "Paused", + "warning": "Needs attention" + }, + "fields": { + "autoSave": "Auto-save", + "failures": "Failures" + }, + "actions": { + "save": "Save", + "dismiss": "Dismiss", + "refresh": "Refresh" + }, + "empty": { + "title": "No feeds yet", + "description": "Add one trusted RSS source to start reviewing fresh articles here." + }, + "error": { + "title": "Could not load feeds", + "description": "Refresh the page or try again in a moment." + } } } diff --git a/apps/web/src/translations/id.json b/apps/web/src/translations/id.json index c1a7049..520d2d3 100644 --- a/apps/web/src/translations/id.json +++ b/apps/web/src/translations/id.json @@ -94,7 +94,8 @@ "nav": { "home": "Beranda", "articles": "Artikel", - "moreActionsAria": "Aksi lainnya" + "moreActionsAria": "Aksi lainnya", + "feeds": "Feed" }, "userMenu": { "fallbackName": "Pengguna", @@ -149,6 +150,9 @@ }, "importProgress": { "metaTitle": "Progres Impor | Loreo" + }, + "feeds": { + "metaTitle": "Feed | Loreo" } }, "demo": { @@ -455,7 +459,7 @@ "manageTagsAria": "Kelola tag untuk {{name}}" }, "moreCount": "+{{count}} lainnya", - "manageTags": "Kelola tag \u2192", + "manageTags": "Kelola tag →", "addFirstTag": "Tambah Tag Pertama" }, "tagInput": { @@ -740,5 +744,43 @@ "failedLoadMore": "Gagal memuat lebih banyak item", "processingQueue": "Antrean pemrosesan" } + }, + "feeds": { + "eyebrow": "Discovery pilihanmu", + "title": "Feed", + "description": "Ikuti sumber tepercaya dan tinjau artikel baru sebelum masuk ke perpustakaanmu.", + "addFeed": "Tambah feed", + "emptyFeed": "Tidak ada item untuk filter ini.", + "yes": "Ya", + "no": "Tidak", + "tabs": { + "aria": "Filter tinjauan feed", + "new": "Baru", + "saved": "Disimpan", + "dismissed": "Dilewati", + "feeds": "Feed" + }, + "status": { + "active": "Aktif", + "paused": "Dijeda", + "warning": "Perlu perhatian" + }, + "fields": { + "autoSave": "Simpan otomatis", + "failures": "Kegagalan" + }, + "actions": { + "save": "Simpan", + "dismiss": "Lewati", + "refresh": "Segarkan" + }, + "empty": { + "title": "Belum ada feed", + "description": "Tambahkan satu sumber RSS tepercaya untuk mulai meninjau artikel baru di sini." + }, + "error": { + "title": "Feed tidak dapat dimuat", + "description": "Segarkan halaman atau coba lagi sebentar lagi." + } } } diff --git a/apps/web/src/types/feeds.ts b/apps/web/src/types/feeds.ts new file mode 100644 index 0000000..82bd51f --- /dev/null +++ b/apps/web/src/types/feeds.ts @@ -0,0 +1,77 @@ +export type FeedSubscriptionStatus = 'active' | 'paused'; +export type FeedItemState = 'new' | 'dismissed' | 'saved'; + +export type FeedSubscription = { + autoSave: boolean; + createdAt: string; + description: string | null; + etag: string | null; + failureCount: number; + feedUrl: string; + id: string; + imageUrl: string | null; + lastError: string | null; + lastFetchedAt: string | null; + lastModified: string | null; + lastSuccessfulFetchAt: string | null; + nextFetchAfter: string | null; + normalizedFeedUrl: string; + siteUrl: string | null; + status: FeedSubscriptionStatus; + title: string; + updatedAt: string; + userId: string; +}; + +export type FeedItem = { + author: string | null; + createdAt: string; + discoveredAt: string; + dismissedAt: string | null; + excerpt: string | null; + guid: string | null; + id: string; + imageUrl: string | null; + linkId: string | null; + normalizedUrl: string; + publishedAt: string | null; + savedAt: string | null; + state: FeedItemState; + subscriptionId: string; + title: string; + updatedAt: string; + url: string; + userId: string; +}; + +export type CreateFeedSubscriptionBody = { + autoSave?: boolean; + feedUrl: string; +}; + +export type UpdateFeedSubscriptionBody = Partial>; + +export type CreateFeedSubscriptionResult = { + autoSaved: number; + createdSubscription: boolean; + fetched: boolean; + pruned: number; + staged: number; + subscription: FeedSubscription; +}; + +export type RefreshFeedSubscriptionResult = { + jobId?: string; + subscriptionId: string; +}; + +export type SaveFeedItemResult = { + item: FeedItem; + linkId: string; + reusedLink: boolean; +}; + +export type FeedItemFilters = { + state?: FeedItemState; + subscriptionId?: string; +}; From e085b022d376fa72b76efe68e40cdcf69a7f9537 Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sun, 28 Jun 2026 13:38:51 +0700 Subject: [PATCH 09/18] feat(web): build RSS feeds review UI Replace the feeds skeleton with the production review surface: add-feed form, source-grouped shelves, responsive masonry layout, and feed item actions for save, dismiss, and refresh. --- apps/web/src/pages/feeds.test.tsx | 196 +++++++++++++ apps/web/src/pages/feeds.tsx | 467 ++++++++++++++++++++++++------ apps/web/src/translations/en.json | 29 +- apps/web/src/translations/id.json | 29 +- 4 files changed, 624 insertions(+), 97 deletions(-) create mode 100644 apps/web/src/pages/feeds.test.tsx diff --git a/apps/web/src/pages/feeds.test.tsx b/apps/web/src/pages/feeds.test.tsx new file mode 100644 index 0000000..6a21713 --- /dev/null +++ b/apps/web/src/pages/feeds.test.tsx @@ -0,0 +1,196 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const createFeedMutate = vi.hoisted(() => vi.fn()); +const saveFeedMutate = vi.hoisted(() => vi.fn()); +const dismissFeedMutate = vi.hoisted(() => vi.fn()); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, values?: Record) => { + if (values?.count !== undefined) return `${key} ${values.count}`; + return key; + } + }) +})); + +vi.mock('@/features/feeds/api/create-feed-subscription', async (importOriginal) => { + const actual = + await importOriginal(); + + return { + ...actual, + useCreateFeedSubscription: () => ({ + error: null, + isPending: false, + mutate: createFeedMutate + }) + }; +}); + +vi.mock('@/features/feeds/api/save-feed-item', () => ({ + useSaveFeedItem: () => ({ + error: null, + isPending: false, + mutate: saveFeedMutate + }) +})); + +vi.mock('@/features/feeds/api/dismiss-feed-item', () => ({ + useDismissFeedItem: () => ({ + error: null, + isPending: false, + mutate: dismissFeedMutate + }) +})); + +import type { FeedItem, FeedSubscription } from '@/types/feeds'; + +import { AddFeedForm, FeedItemCard, buildFeedShelves, groupFeedItemsBySubscription } from './feeds'; + +const NOW = '2026-06-28T12:00:00.000Z'; + +function subscription(overrides: Partial = {}): FeedSubscription { + return { + autoSave: false, + createdAt: NOW, + description: null, + etag: null, + failureCount: 0, + feedUrl: 'https://example.com/feed.xml', + id: 'feed-1', + imageUrl: null, + lastError: null, + lastFetchedAt: null, + lastModified: null, + lastSuccessfulFetchAt: null, + nextFetchAfter: null, + normalizedFeedUrl: 'https://example.com/feed.xml', + siteUrl: 'https://example.com', + status: 'active', + title: 'Example', + updatedAt: NOW, + userId: 'user-1', + ...overrides + }; +} + +function item(overrides: Partial = {}): FeedItem { + return { + author: null, + createdAt: NOW, + discoveredAt: NOW, + dismissedAt: null, + excerpt: null, + guid: null, + id: 'item-1', + imageUrl: null, + linkId: null, + normalizedUrl: 'https://example.com/article', + publishedAt: NOW, + savedAt: null, + state: 'new', + subscriptionId: 'feed-1', + title: 'Article', + updatedAt: NOW, + url: 'https://example.com/article', + userId: 'user-1', + ...overrides + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('feeds page helpers', () => { + it('groups items by subscription id', () => { + const grouped = groupFeedItemsBySubscription([ + item({ id: 'one', subscriptionId: 'feed-a' }), + item({ id: 'two', subscriptionId: 'feed-b' }), + item({ id: 'three', subscriptionId: 'feed-a' }) + ]); + + expect(grouped.get('feed-a')?.map((feedItem) => feedItem.id)).toEqual(['one', 'three']); + expect(grouped.get('feed-b')?.map((feedItem) => feedItem.id)).toEqual(['two']); + }); + + it('orders shelves by new items, warnings, then quiet feeds', () => { + const newFeed = subscription({ id: 'new-feed', title: 'New Feed' }); + const warningFeed = subscription({ + id: 'warning-feed', + lastError: 'Fetch failed', + title: 'Warning Feed' + }); + const quietFeed = subscription({ id: 'quiet-feed', title: 'Quiet Feed' }); + const grouped = groupFeedItemsBySubscription([ + item({ id: 'new-item', state: 'new', subscriptionId: newFeed.id }) + ]); + + const shelves = buildFeedShelves([quietFeed, warningFeed, newFeed], grouped); + + expect(shelves.map((shelf) => shelf.subscription.id)).toEqual([ + 'new-feed', + 'warning-feed', + 'quiet-feed' + ]); + }); + + it('opens shelves with new items or warnings by default', () => { + const newFeed = subscription({ id: 'new-feed' }); + const warningFeed = subscription({ id: 'warning-feed', lastError: 'Fetch failed' }); + const quietFeed = subscription({ id: 'quiet-feed' }); + const grouped = groupFeedItemsBySubscription([ + item({ id: 'new-item', subscriptionId: newFeed.id }) + ]); + + const shelves = buildFeedShelves([newFeed, warningFeed, quietFeed], grouped); + + expect( + Object.fromEntries(shelves.map((shelf) => [shelf.subscription.id, shelf.defaultOpen])) + ).toEqual({ + 'new-feed': true, + 'warning-feed': true, + 'quiet-feed': false + }); + }); +}); + +describe('feed page components', () => { + it('validates add feed input before submitting', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('feeds.form.urlLabel'), 'not a url'); + await user.click(screen.getByRole('button', { name: /feeds.addFeed/i })); + + expect(screen.getByText('feeds.form.invalidUrl')).toBeInTheDocument(); + expect(createFeedMutate).not.toHaveBeenCalled(); + }); + + it('submits a valid feed URL with auto-save preference', async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText('feeds.form.urlLabel'), 'https://example.com/feed.xml'); + await user.click(screen.getByRole('checkbox')); + await user.click(screen.getByRole('button', { name: /feeds.addFeed/i })); + + expect(createFeedMutate).toHaveBeenCalledWith({ + autoSave: true, + feedUrl: 'https://example.com/feed.xml' + }); + }); + + it('wires save and dismiss actions for new feed items', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /feeds.actions.save/i })); + await user.click(screen.getByRole('button', { name: /feeds.actions.dismiss/i })); + + expect(saveFeedMutate).toHaveBeenCalledWith('item-save'); + expect(dismissFeedMutate).toHaveBeenCalledWith('item-save'); + }); +}); diff --git a/apps/web/src/pages/feeds.tsx b/apps/web/src/pages/feeds.tsx index 4e40ae1..0dd6da8 100644 --- a/apps/web/src/pages/feeds.tsx +++ b/apps/web/src/pages/feeds.tsx @@ -1,21 +1,45 @@ -import { ArrowsClockwise, CheckCircle, RssSimple, WarningCircle } from '@phosphor-icons/react'; +import { + ArrowsClockwise, + CheckCircle, + FloppyDisk, + Plus, + RssSimple, + WarningCircle, + XCircle +} from '@phosphor-icons/react'; import { Link, useSearch } from '@tanstack/react-router'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import { Skeleton } from '@/components/ui/skeleton'; +import { + createFeedSubscriptionBodySchema, + useCreateFeedSubscription +} from '@/features/feeds/api/create-feed-subscription'; +import { useDismissFeedItem } from '@/features/feeds/api/dismiss-feed-item'; import { useFeedItems } from '@/features/feeds/api/get-feed-items'; import { useFeedSubscriptions } from '@/features/feeds/api/get-feed-subscriptions'; +import { useRefreshFeedSubscription } from '@/features/feeds/api/refresh-feed-subscription'; +import { useSaveFeedItem } from '@/features/feeds/api/save-feed-item'; import { cn } from '@/lib/utils'; -import type { FeedItemState, FeedSubscription } from '@/types/feeds'; +import type { FeedItem, FeedItemState, FeedSubscription } from '@/types/feeds'; type FeedTab = 'new' | 'saved' | 'dismissed' | 'feeds'; +type FeedShelf = { + defaultOpen: boolean; + items: FeedItem[]; + priority: number; + subscription: FeedSubscription; +}; const tabStates: Array<{ state?: FeedItemState; value: FeedTab }> = [ { value: 'new', state: 'new' }, @@ -24,12 +48,62 @@ const tabStates: Array<{ state?: FeedItemState; value: FeedTab }> = [ { value: 'feeds' } ]; +const formatDate = (value: string | null) => { + if (!value) return null; + + return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(value)); +}; + +const getActionError = (error: unknown) => { + if (error instanceof Error) return error.message; + + return null; +}; + +export const groupFeedItemsBySubscription = (items: FeedItem[]) => { + const grouped = new Map(); + + for (const item of items) { + grouped.set(item.subscriptionId, [...(grouped.get(item.subscriptionId) ?? []), item]); + } + + return grouped; +}; + +export const buildFeedShelves = ( + subscriptions: FeedSubscription[], + itemsBySubscription: Map +): FeedShelf[] => + subscriptions + .map((subscription) => { + const items = itemsBySubscription.get(subscription.id) ?? []; + const hasWarning = Boolean(subscription.lastError); + const hasNewItems = items.some((item) => item.state === 'new'); + const priority = hasNewItems ? 0 : hasWarning ? 1 : 2; + + return { + defaultOpen: hasNewItems || hasWarning, + items, + priority, + subscription + }; + }) + .sort((a, b) => { + if (a.priority !== b.priority) return a.priority - b.priority; + if (b.items.length !== a.items.length) return b.items.length - a.items.length; + + return a.subscription.title.localeCompare(b.subscription.title); + }); + function FeedsSkeleton() { return (
- - - + +
+ + + +
); } @@ -58,6 +132,275 @@ function FeedStatusBadge({ subscription }: { subscription: FeedSubscription }) { ); } +export function AddFeedForm() { + const { t } = useTranslation(); + const [feedUrl, setFeedUrl] = useState(''); + const [autoSave, setAutoSave] = useState(false); + const [validationError, setValidationError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const createFeed = useCreateFeedSubscription({ + mutationConfig: { + onSuccess: (response) => { + setFeedUrl(''); + setAutoSave(false); + setValidationError(null); + setSuccessMessage( + response.result.createdSubscription + ? t('feeds.form.success', { count: response.result.staged }) + : t('feeds.form.alreadyAdded') + ); + }, + onError: () => { + setSuccessMessage(null); + } + } + }); + + const errorMessage = validationError ?? getActionError(createFeed.error); + + return ( +
{ + event.preventDefault(); + setSuccessMessage(null); + + const parsed = createFeedSubscriptionBodySchema.safeParse({ feedUrl, autoSave }); + if (!parsed.success) { + setValidationError(t('feeds.form.invalidUrl')); + return; + } + + setValidationError(null); + createFeed.mutate(parsed.data); + }} + > +
+
+ + setFeedUrl(event.target.value)} + placeholder={t('feeds.form.urlPlaceholder')} + type="text" + value={feedUrl} + /> +

+ {t('feeds.form.help')} +

+
+ +
+ + + + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} + {successMessage ? ( +

+ {successMessage} +

+ ) : null} +
+ ); +} + +export function FeedItemCard({ item }: { item: FeedItem }) { + const { t } = useTranslation(); + const saveItem = useSaveFeedItem(); + const dismissItem = useDismissFeedItem(); + const isMutating = saveItem.isPending || dismissItem.isPending; + const canSave = item.state === 'new'; + const canDismiss = item.state === 'new'; + const publishedAt = formatDate(item.publishedAt); + const errorMessage = getActionError(saveItem.error) ?? getActionError(dismissItem.error); + + return ( +
+
+
+ {item.imageUrl ? ( + + ) : null} +
+

+ + {item.title} + +

+ {publishedAt || item.author ? ( +

+ {[publishedAt, item.author].filter(Boolean).join(' · ')} +

+ ) : null} +
+
+ + {item.excerpt ? ( +

{item.excerpt}

+ ) : null} +
+ + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} + +
+ + +
+
+ ); +} + +export function FeedShelfCard({ activeTab, shelf }: { activeTab: FeedTab; shelf: FeedShelf }) { + const { t } = useTranslation(); + const refreshFeed = useRefreshFeedSubscription(); + const { items, subscription } = shelf; + const lastFetchedAt = formatDate( + subscription.lastSuccessfulFetchAt ?? subscription.lastFetchedAt + ); + const nextFetchAfter = formatDate(subscription.nextFetchAfter); + const refreshError = getActionError(refreshFeed.error); + + return ( +
+ +
+
+

+ {subscription.title} +

+ +
+

+ {subscription.siteUrl ?? subscription.feedUrl} +

+

+ {t('feeds.shelf.itemCount', { count: items.length })} + {lastFetchedAt ? ` · ${t('feeds.shelf.lastFetched', { date: lastFetchedAt })}` : ''} +

+
+ + {t('feeds.shelf.toggle')} + +
+ +
+ {subscription.lastError ? ( +
+
+ + {t('feeds.warning.title')} +
+

{subscription.lastError}

+ {nextFetchAfter ? ( +

+ {t('feeds.warning.nextTry', { date: nextFetchAfter })} +

+ ) : null} +
+ ) : null} + + {activeTab === 'feeds' ? ( +
+
+
{t('feeds.fields.autoSave')}
+
+ {subscription.autoSave ? t('feeds.yes') : t('feeds.no')} +
+
+
+
{t('feeds.fields.failures')}
+
{subscription.failureCount}
+
+
+ ) : items.length > 0 ? ( +
+ {items.map((item) => ( + + ))} +
+ ) : ( +

+ {t('feeds.emptyFeed')} +

+ )} + + {refreshError ? ( +

+ {refreshError} +

+ ) : null} + + +
+
+ ); +} + function FeedsPage() { const { t } = useTranslation(); const search = useSearch({ strict: false }) as { tab?: FeedTab }; @@ -69,49 +412,48 @@ function FeedsPage() { const subscriptions = subscriptionsQuery.data?.result ?? []; const items = itemsQuery.data?.result ?? []; - const itemsBySubscription = useMemo(() => { - const grouped = new Map(); - - for (const item of items) { - grouped.set(item.subscriptionId, [...(grouped.get(item.subscriptionId) ?? []), item]); - } - - return grouped; - }, [items]); + const itemsBySubscription = useMemo(() => groupFeedItemsBySubscription(items), [items]); + const shelves = useMemo( + () => buildFeedShelves(subscriptions, itemsBySubscription), + [itemsBySubscription, subscriptions] + ); const isLoading = subscriptionsQuery.isLoading || itemsQuery.isLoading; const isError = subscriptionsQuery.isError || itemsQuery.isError; return (
-
-
+
+
{t('feeds.eyebrow')}
-

+

{t('feeds.title')}

-

+

{t('feeds.description')}

- +
+ {t('feeds.reviewNote')} +
+ +
- - ))} - - ) : ( -

{t('feeds.emptyFeed')}

- )} - - - - - ); - })} +
+ {shelves.map((shelf) => ( + + ))}
) : null}
diff --git a/apps/web/src/translations/en.json b/apps/web/src/translations/en.json index 66b454d..f19cdfa 100644 --- a/apps/web/src/translations/en.json +++ b/apps/web/src/translations/en.json @@ -840,9 +840,12 @@ "failures": "Failures" }, "actions": { - "save": "Save", + "save": "Save article", "dismiss": "Dismiss", - "refresh": "Refresh" + "refresh": "Refresh feed", + "saving": "Saving...", + "dismissing": "Dismissing...", + "refreshing": "Refreshing..." }, "empty": { "title": "No feeds yet", @@ -851,6 +854,28 @@ "error": { "title": "Could not load feeds", "description": "Refresh the page or try again in a moment." + }, + "reviewNote": "New feed items stay here until you save them.", + "form": { + "urlLabel": "Feed URL", + "urlPlaceholder": "https://example.com/feed.xml", + "help": "Use a public RSS or Atom feed. Loreo checks the feed before adding it.", + "autoSaveHelp": "Auto-save new items from this feed instead of staging them for review.", + "invalidUrl": "Enter a valid public feed URL.", + "adding": "Adding feed...", + "success": "{{count}} new items staged for review.", + "alreadyAdded": "This feed is already on your shelf." + }, + "shelf": { + "aria": "Feed shelves", + "toggle": "Toggle", + "itemCount": "{{count}} item", + "itemCount_plural": "{{count}} items", + "lastFetched": "Fetched {{date}}" + }, + "warning": { + "title": "Feed needs attention", + "nextTry": "Loreo will try again after {{date}}." } } } diff --git a/apps/web/src/translations/id.json b/apps/web/src/translations/id.json index 520d2d3..486edd8 100644 --- a/apps/web/src/translations/id.json +++ b/apps/web/src/translations/id.json @@ -770,9 +770,12 @@ "failures": "Kegagalan" }, "actions": { - "save": "Simpan", + "save": "Simpan artikel", "dismiss": "Lewati", - "refresh": "Segarkan" + "refresh": "Segarkan feed", + "saving": "Menyimpan...", + "dismissing": "Melewati...", + "refreshing": "Menyegarkan..." }, "empty": { "title": "Belum ada feed", @@ -781,6 +784,28 @@ "error": { "title": "Feed tidak dapat dimuat", "description": "Segarkan halaman atau coba lagi sebentar lagi." + }, + "reviewNote": "Item feed baru tetap di sini sampai kamu menyimpannya.", + "form": { + "urlLabel": "URL feed", + "urlPlaceholder": "https://example.com/feed.xml", + "help": "Gunakan feed RSS atau Atom publik. Loreo akan memeriksanya sebelum ditambahkan.", + "autoSaveHelp": "Simpan otomatis item baru dari feed ini, bukan menaruhnya di tinjauan.", + "invalidUrl": "Masukkan URL feed publik yang valid.", + "adding": "Menambahkan feed...", + "success": "{{count}} item baru siap ditinjau.", + "alreadyAdded": "Feed ini sudah ada di rakmu." + }, + "shelf": { + "aria": "Rak feed", + "toggle": "Buka/tutup", + "itemCount": "{{count}} item", + "itemCount_plural": "{{count}} item", + "lastFetched": "Diambil {{date}}" + }, + "warning": { + "title": "Feed perlu perhatian", + "nextTry": "Loreo akan mencoba lagi setelah {{date}}." } } } From c1630a5a141a1afe58a962b13398f4bd59ca73cb Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sat, 18 Jul 2026 16:00:07 +0700 Subject: [PATCH 10/18] feat(server): extend RSS feed review APIs Add cursor pagination and per-subscription summaries for scalable feed review, including forward migrations for existing installations and pagination indexes. --- .../0002_smooth_rss_feed_tables.sql | 86 ++++++++++ .../0003_feed_item_pagination_indexes.sql | 16 ++ .../src/db/migrations/meta/_journal.json | 14 ++ apps/server/src/db/schemas/feed-items.ts | 14 ++ apps/server/src/lib/response.ts | 1 + .../feed-items.repository.test.ts | 118 ++++++++++++- .../src/repositories/feed-items.repository.ts | 100 +++++++++-- .../server/src/routes/feeds/feeds.handlers.ts | 52 +++++- apps/server/src/routes/feeds/feeds.index.ts | 1 + apps/server/src/routes/feeds/feeds.routes.ts | 30 +++- apps/server/src/routes/feeds/feeds.test.ts | 155 +++++++++++++++++- apps/server/src/routes/feeds/feeds.types.ts | 2 + apps/server/src/types/pagination.ts | 1 + 13 files changed, 560 insertions(+), 30 deletions(-) create mode 100644 apps/server/src/db/migrations/0002_smooth_rss_feed_tables.sql create mode 100644 apps/server/src/db/migrations/0003_feed_item_pagination_indexes.sql diff --git a/apps/server/src/db/migrations/0002_smooth_rss_feed_tables.sql b/apps/server/src/db/migrations/0002_smooth_rss_feed_tables.sql new file mode 100644 index 0000000..62eab87 --- /dev/null +++ b/apps/server/src/db/migrations/0002_smooth_rss_feed_tables.sql @@ -0,0 +1,86 @@ +CREATE TABLE IF NOT EXISTS "feed_subscriptions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "feed_url" text NOT NULL, + "normalized_feed_url" text NOT NULL, + "site_url" text, + "title" text NOT NULL, + "description" text, + "image_url" text, + "auto_save" boolean DEFAULT false NOT NULL, + "status" varchar(20) DEFAULT 'active' NOT NULL, + "last_fetched_at" timestamp with time zone, + "last_successful_fetch_at" timestamp with time zone, + "next_fetch_after" timestamp with time zone, + "last_error" text, + "failure_count" integer DEFAULT 0 NOT NULL, + "etag" text, + "last_modified" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "feed_items" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "subscription_id" uuid NOT NULL, + "user_id" uuid NOT NULL, + "link_id" uuid, + "guid" text, + "url" text NOT NULL, + "normalized_url" text NOT NULL, + "title" text NOT NULL, + "excerpt" text, + "author" text, + "published_at" timestamp with time zone, + "image_url" text, + "state" varchar(20) DEFAULT 'new' NOT NULL, + "discovered_at" timestamp with time zone DEFAULT now() NOT NULL, + "saved_at" timestamp with time zone, + "dismissed_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "feed_subscriptions" ADD CONSTRAINT "feed_subscriptions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "feed_items" ADD CONSTRAINT "feed_items_subscription_id_feed_subscriptions_id_fk" FOREIGN KEY ("subscription_id") REFERENCES "public"."feed_subscriptions"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "feed_items" ADD CONSTRAINT "feed_items_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "feed_items" ADD CONSTRAINT "feed_items_link_id_links_id_fk" FOREIGN KEY ("link_id") REFERENCES "public"."links"("id") ON DELETE set null ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "uq_feed_subscriptions_user_normalized_url" ON "feed_subscriptions" USING btree ("user_id","normalized_feed_url"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_feed_subscriptions_user_created" ON "feed_subscriptions" USING btree ("user_id","created_at" DESC NULLS LAST); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_feed_subscriptions_status_next_fetch" ON "feed_subscriptions" USING btree ("status","next_fetch_after"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_feed_subscriptions_user_status" ON "feed_subscriptions" USING btree ("user_id","status"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "uq_feed_items_subscription_guid" ON "feed_items" USING btree ("subscription_id","guid"); +--> statement-breakpoint +CREATE UNIQUE INDEX IF NOT EXISTS "uq_feed_items_subscription_normalized_url" ON "feed_items" USING btree ("subscription_id","normalized_url"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_feed_items_user_state_published" ON "feed_items" USING btree ("user_id","state","published_at" DESC NULLS LAST); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_feed_items_user_normalized_url" ON "feed_items" USING btree ("user_id","normalized_url"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_feed_items_subscription_discovered" ON "feed_items" USING btree ("subscription_id","discovered_at" DESC NULLS LAST); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_feed_items_link_id" ON "feed_items" USING btree ("link_id"); diff --git a/apps/server/src/db/migrations/0003_feed_item_pagination_indexes.sql b/apps/server/src/db/migrations/0003_feed_item_pagination_indexes.sql new file mode 100644 index 0000000..198dd27 --- /dev/null +++ b/apps/server/src/db/migrations/0003_feed_item_pagination_indexes.sql @@ -0,0 +1,16 @@ +CREATE INDEX IF NOT EXISTS "idx_feed_items_user_state_effective_date_id" + ON "feed_items" USING btree ( + "user_id", + "state", + (coalesce("published_at", "discovered_at")), + "id" + ); + +CREATE INDEX IF NOT EXISTS "idx_feed_items_user_state_subscription_effective_date_id" + ON "feed_items" USING btree ( + "user_id", + "state", + "subscription_id", + (coalesce("published_at", "discovered_at")), + "id" + ); diff --git a/apps/server/src/db/migrations/meta/_journal.json b/apps/server/src/db/migrations/meta/_journal.json index d866b64..ea1f444 100644 --- a/apps/server/src/db/migrations/meta/_journal.json +++ b/apps/server/src/db/migrations/meta/_journal.json @@ -15,6 +15,20 @@ "when": 1782600940254, "tag": "0001_married_sabretooth", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1782648000000, + "tag": "0002_smooth_rss_feed_tables", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1783706400000, + "tag": "0003_feed_item_pagination_indexes", + "breakpoints": true } ] } diff --git a/apps/server/src/db/schemas/feed-items.ts b/apps/server/src/db/schemas/feed-items.ts index 9ee7f6e..004e162 100644 --- a/apps/server/src/db/schemas/feed-items.ts +++ b/apps/server/src/db/schemas/feed-items.ts @@ -1,3 +1,4 @@ +import { sql } from 'drizzle-orm'; import { index, pgTable, text, timestamp, uniqueIndex, uuid, varchar } from 'drizzle-orm/pg-core'; import { createInsertSchema, createSelectSchema } from 'drizzle-zod'; @@ -44,6 +45,19 @@ export const feedItemsTable = pgTable( table.state, table.publishedAt.desc() ), + index('idx_feed_items_user_state_effective_date_id').on( + table.userId, + table.state, + sql`coalesce(${table.publishedAt}, ${table.discoveredAt})`, + table.id + ), + index('idx_feed_items_user_state_subscription_effective_date_id').on( + table.userId, + table.state, + table.subscriptionId, + sql`coalesce(${table.publishedAt}, ${table.discoveredAt})`, + table.id + ), index('idx_feed_items_user_normalized_url').on(table.userId, table.normalizedUrl), index('idx_feed_items_subscription_discovered').on( table.subscriptionId, diff --git a/apps/server/src/lib/response.ts b/apps/server/src/lib/response.ts index 3586f00..b1093e2 100644 --- a/apps/server/src/lib/response.ts +++ b/apps/server/src/lib/response.ts @@ -78,6 +78,7 @@ export const paginationMetadataSchema = z.object({ nextCursor: z.string().optional(), previousCursor: z.string().optional(), limit: z.number(), + total: z.number().optional(), totalReturned: z.number() }); diff --git a/apps/server/src/repositories/feed-items.repository.test.ts b/apps/server/src/repositories/feed-items.repository.test.ts index 216380f..a6d194e 100644 --- a/apps/server/src/repositories/feed-items.repository.test.ts +++ b/apps/server/src/repositories/feed-items.repository.test.ts @@ -90,10 +90,117 @@ describe('feed items repository', () => { await expect(items.findById(itemA.id, USER_B_ID)).resolves.toBeNull(); await expect( items.findManyForReview({ state: 'new', userId: USER_A_ID }) - ).resolves.toMatchObject([{ id: itemA.id, title: 'Item A' }]); + ).resolves.toMatchObject({ items: [{ id: itemA.id, title: 'Item A' }], total: 1 }); await expect( items.findManyForReview({ state: 'new', userId: USER_B_ID }) - ).resolves.toHaveLength(1); + ).resolves.toMatchObject({ items: [expect.any(Object)], total: 1 }); + }); + + it('paginates review items with stable newest and oldest cursors', async () => { + const items = createDrizzleFeedItemsAdapter(db); + await seedUser(USER_A_ID, 'feed-items-a@example.com'); + const subscription = await seedSubscription(USER_A_ID); + + const oldest = await items.create({ + discoveredAt: new Date('2026-06-20T12:00:00Z'), + normalizedUrl: 'https://example.com/oldest', + subscriptionId: subscription.id, + title: 'Oldest', + url: 'https://example.com/oldest', + userId: USER_A_ID + }); + const middle = await items.create({ + discoveredAt: new Date('2026-06-24T12:00:00Z'), + normalizedUrl: 'https://example.com/middle', + publishedAt: new Date('2026-06-24T12:00:00Z'), + subscriptionId: subscription.id, + title: 'Middle', + url: 'https://example.com/middle', + userId: USER_A_ID + }); + const newest = await items.create({ + discoveredAt: new Date('2026-06-28T12:00:00Z'), + normalizedUrl: 'https://example.com/newest', + subscriptionId: subscription.id, + title: 'Newest', + url: 'https://example.com/newest', + userId: USER_A_ID + }); + + const firstPage = await items.findManyForReview({ + limit: 2, + sort: 'newest', + userId: USER_A_ID + }); + expect(firstPage.items.map((item) => item.id)).toEqual([newest.id, middle.id]); + expect(firstPage).toMatchObject({ hasMore: true, total: 3 }); + expect(firstPage.nextCursor).toBeDefined(); + + const secondPage = await items.findManyForReview({ + cursor: firstPage.nextCursor, + limit: 2, + sort: 'newest', + userId: USER_A_ID + }); + expect(secondPage.items.map((item) => item.id)).toEqual([oldest.id]); + expect(secondPage).toMatchObject({ hasMore: false, total: 3 }); + + const oldestFirst = await items.findManyForReview({ + limit: 2, + sort: 'oldest', + userId: USER_A_ID + }); + expect(oldestFirst.items.map((item) => item.id)).toEqual([oldest.id, middle.id]); + }); + + it('summarizes item states for one user-owned subscription', async () => { + const items = createDrizzleFeedItemsAdapter(db); + await seedUser(USER_A_ID, 'feed-items-a@example.com'); + await seedUser(USER_B_ID, 'feed-items-b@example.com'); + const subscriptionA = await seedSubscription(USER_A_ID); + const subscriptionB = await seedSubscription(USER_B_ID, 'https://other.example/feed.xml'); + + await items.create({ + normalizedUrl: 'https://example.com/new', + subscriptionId: subscriptionA.id, + title: 'New item', + url: 'https://example.com/new', + userId: USER_A_ID + }); + await items.create({ + normalizedUrl: 'https://example.com/saved', + state: 'saved', + subscriptionId: subscriptionA.id, + title: 'Saved item', + url: 'https://example.com/saved', + userId: USER_A_ID + }); + await items.create({ + normalizedUrl: 'https://example.com/dismissed', + state: 'dismissed', + subscriptionId: subscriptionA.id, + title: 'Dismissed item', + url: 'https://example.com/dismissed', + userId: USER_A_ID + }); + await items.create({ + normalizedUrl: 'https://other.example/new', + subscriptionId: subscriptionB.id, + title: 'Other user item', + url: 'https://other.example/new', + userId: USER_B_ID + }); + + await expect(items.summarizeBySubscription(subscriptionA.id, USER_A_ID)).resolves.toEqual({ + dismissed: 1, + new: 1, + saved: 1 + }); + await expect(items.summarizeBySubscription(subscriptionA.id, USER_B_ID)).resolves.toEqual({ + dismissed: 0, + new: 0, + saved: 0 + }); }); it('upserts by GUID or normalized URL within a subscription', async () => { @@ -129,7 +236,10 @@ describe('feed items repository', () => { expect(updatedByGuid.title).toBe('Updated title'); expect(updatedByUrl.id).toBe(created.id); expect(updatedByUrl.title).toBe('Updated by URL'); - await expect(items.findManyForReview({ userId: USER_A_ID })).resolves.toHaveLength(1); + await expect(items.findManyForReview({ userId: USER_A_ID })).resolves.toMatchObject({ + items: [expect.any(Object)], + total: 1 + }); }); it('marks items dismissed and saved only for the owning user', async () => { @@ -193,7 +303,7 @@ describe('feed items repository', () => { expect(reconciled[0]).toMatchObject({ linkId, state: 'saved' }); await expect( items.findManyForReview({ state: 'new', userId: USER_B_ID }) - ).resolves.toHaveLength(1); + ).resolves.toMatchObject({ items: [expect.any(Object)], total: 1 }); }); it('prunes old and excess unsaved items while keeping saved items', async () => { diff --git a/apps/server/src/repositories/feed-items.repository.ts b/apps/server/src/repositories/feed-items.repository.ts index 1ca150e..5b5f0a8 100644 --- a/apps/server/src/repositories/feed-items.repository.ts +++ b/apps/server/src/repositories/feed-items.repository.ts @@ -1,13 +1,19 @@ -import { and, desc, eq, inArray, lt, ne, or } from 'drizzle-orm'; +import { and, asc, desc, eq, gt, inArray, lt, ne, or, sql } from 'drizzle-orm'; import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; import type * as schema from '@/db/schemas/index.js'; import { feedItemsTable } from '@/db/schemas/index.js'; +import { decodeCursor, encodeCursor } from '@/lib/cursor.js'; + +import type { CursorQueryResult } from '@/types/pagination.js'; + type DrizzleClient = NodePgDatabase; export type FeedItemState = 'new' | 'dismissed' | 'saved'; +export type FeedItemStateSummary = Record; + export interface FeedItemData { id: string; subscriptionId: string; @@ -98,10 +104,13 @@ export interface FeedItemsRepository { userId: string; }): Promise; findManyForReview(input: { + cursor?: string; + limit?: number; + sort?: 'newest' | 'oldest'; state?: FeedItemState; subscriptionId?: string; userId: string; - }): Promise; + }): Promise & { total: number }>; pruneForSubscription(input: { before: Date; keepLatest: number; @@ -115,6 +124,7 @@ export interface FeedItemsRepository { userId: string; }): Promise; save(id: string, userId: string, linkId: string, savedAt?: Date): Promise; + summarizeBySubscription(subscriptionId: string, userId: string): Promise; upsertByIdentity(data: CreateFeedItemData): Promise; } @@ -194,19 +204,68 @@ export function createDrizzleFeedItemsAdapter(db: DrizzleClient): FeedItemsRepos return (row as FeedItemData | undefined) ?? null; }, - async findManyForReview({ state, subscriptionId, userId }) { - const conditions = [eq(feedItemsTable.userId, userId)]; - - if (state) conditions.push(eq(feedItemsTable.state, state)); - if (subscriptionId) conditions.push(eq(feedItemsTable.subscriptionId, subscriptionId)); - - const rows = await db + async findManyForReview({ + cursor, + limit: requestedLimit, + sort = 'newest', + state, + subscriptionId, + userId + }) { + const limit = Math.max(1, Math.min(requestedLimit ?? 24, 60)); + const ascending = sort === 'oldest'; + const effectiveDate = sql`coalesce(${feedItemsTable.publishedAt}, ${feedItemsTable.discoveredAt})`; + const baseConditions = [eq(feedItemsTable.userId, userId)]; + + if (state) baseConditions.push(eq(feedItemsTable.state, state)); + if (subscriptionId) { + baseConditions.push(eq(feedItemsTable.subscriptionId, subscriptionId)); + } + + const [{ count: total = 0 } = {}] = await db + .select({ count: sql`count(*)::int` }) + .from(feedItemsTable) + .where(and(...baseConditions)); + + const pageConditions = [...baseConditions]; + if (cursor) { + const cursorData = decodeCursor(cursor); + const cursorDate = new Date(cursorData.createdAt); + const cursorCondition = ascending + ? or( + gt(effectiveDate, cursorDate), + and(eq(effectiveDate, cursorDate), gt(feedItemsTable.id, cursorData.id)) + ) + : or( + lt(effectiveDate, cursorDate), + and(eq(effectiveDate, cursorDate), lt(feedItemsTable.id, cursorData.id)) + ); + + if (cursorCondition) pageConditions.push(cursorCondition); + } + + const rows = (await db .select(itemColumns) .from(feedItemsTable) - .where(and(...conditions)) - .orderBy(desc(feedItemsTable.publishedAt), desc(feedItemsTable.discoveredAt)); - - return rows as FeedItemData[]; + .where(and(...pageConditions)) + .orderBy( + ascending ? asc(effectiveDate) : desc(effectiveDate), + ascending ? asc(feedItemsTable.id) : desc(feedItemsTable.id) + ) + .limit(limit + 1)) as FeedItemData[]; + + const hasMore = rows.length > limit; + const items = hasMore ? rows.slice(0, limit) : rows; + const lastItem = items.at(-1); + const nextCursor = + hasMore && lastItem + ? encodeCursor({ + createdAt: (lastItem.publishedAt ?? lastItem.discoveredAt).toISOString(), + id: lastItem.id + }) + : undefined; + + return { hasMore, items, nextCursor, total }; }, async pruneForSubscription({ before, keepLatest, subscriptionId, userId }) { @@ -266,6 +325,21 @@ export function createDrizzleFeedItemsAdapter(db: DrizzleClient): FeedItemsRepos return update(id, userId, { linkId, savedAt, state: 'saved' }); }, + async summarizeBySubscription(subscriptionId, userId) { + const [summary] = await db + .select({ + dismissed: sql`count(*) filter (where ${feedItemsTable.state} = 'dismissed')::int`, + new: sql`count(*) filter (where ${feedItemsTable.state} = 'new')::int`, + saved: sql`count(*) filter (where ${feedItemsTable.state} = 'saved')::int` + }) + .from(feedItemsTable) + .where( + and(eq(feedItemsTable.subscriptionId, subscriptionId), eq(feedItemsTable.userId, userId)) + ); + + return summary ?? { dismissed: 0, new: 0, saved: 0 }; + }, + async upsertByIdentity(data) { const existing = await this.findBySubscriptionAndIdentity({ guid: data.guid, diff --git a/apps/server/src/routes/feeds/feeds.handlers.ts b/apps/server/src/routes/feeds/feeds.handlers.ts index 91de654..7064a2d 100644 --- a/apps/server/src/routes/feeds/feeds.handlers.ts +++ b/apps/server/src/routes/feeds/feeds.handlers.ts @@ -10,6 +10,7 @@ import { saveLink } from '@/services/link-save.service.js'; import type { CreateFeedSubscriptionRoute, DismissFeedItemRoute, + GetFeedSubscriptionSummaryRoute, ListFeedItemsRoute, ListFeedSubscriptionsRoute, RefreshFeedSubscriptionRoute, @@ -61,6 +62,32 @@ export const createFeedSubscription: AppRouteHandler = async ( + c +) => { + const user = c.get('user'); + const repos = requireFeedRepos(c); + const { id } = c.req.valid('param'); + + try { + const subscription = await repos.feedSubscriptions.findById(id, user.id); + if (!subscription) { + const response = errorResponse('Feed subscription not found', HttpStatus.NOT_FOUND); + return c.json(response, response.status); + } + + const summary = await repos.feedItems.summarizeBySubscription(id, user.id); + const response = successResponse(summary, 'Feed subscription summary fetched successfully'); + return c.json(response, response.status); + } catch { + const response = errorResponse( + 'An error occurred when fetching feed subscription summary', + HttpStatus.BAD_REQUEST + ); + return c.json(response, response.status); + } +}; + export const updateFeedSubscription: AppRouteHandler = async (c) => { if (isDemoMode()) return c.json(demoModeForbiddenResponse(), HttpStatus.FORBIDDEN); @@ -125,16 +152,33 @@ export const refreshFeedSubscription: AppRouteHandler = async (c) => { const user = c.get('user'); const repos = requireFeedRepos(c); - const { state, subscriptionId } = c.req.valid('query'); + const { cursor, limit, sort, state, subscriptionId } = c.req.valid('query'); + const pageLimit = Math.max(1, Math.min(limit ? Number(limit) : 24, 60)); try { - const items = await repos.feedItems.findManyForReview({ + const page = await repos.feedItems.findManyForReview({ + cursor, + limit: pageLimit, + sort, state, subscriptionId, userId: user.id }); - const response = successResponse(items, 'Feed items fetched successfully'); - return c.json(response, response.status); + return c.json( + { + message: 'Feed items fetched successfully', + pagination: { + hasMore: page.hasMore, + limit: pageLimit, + nextCursor: page.nextCursor, + total: page.total, + totalReturned: page.items.length + }, + result: page.items, + status: HttpStatus.OK + }, + HttpStatus.OK + ); } catch { const response = errorResponse( 'An error occurred when fetching feed items', diff --git a/apps/server/src/routes/feeds/feeds.index.ts b/apps/server/src/routes/feeds/feeds.index.ts index 4881e7a..d18a3a2 100644 --- a/apps/server/src/routes/feeds/feeds.index.ts +++ b/apps/server/src/routes/feeds/feeds.index.ts @@ -6,6 +6,7 @@ import * as routes from './feeds.routes.js'; const router = createRouter() .openapi(routes.listFeedSubscriptions, handlers.listFeedSubscriptions) .openapi(routes.createFeedSubscription, handlers.createFeedSubscription) + .openapi(routes.getFeedSubscriptionSummary, handlers.getFeedSubscriptionSummary) .openapi(routes.updateFeedSubscription, handlers.updateFeedSubscription) .openapi(routes.refreshFeedSubscription, handlers.refreshFeedSubscription) .openapi(routes.listFeedItems, handlers.listFeedItems) diff --git a/apps/server/src/routes/feeds/feeds.routes.ts b/apps/server/src/routes/feeds/feeds.routes.ts index f9d915f..0b137be 100644 --- a/apps/server/src/routes/feeds/feeds.routes.ts +++ b/apps/server/src/routes/feeds/feeds.routes.ts @@ -1,7 +1,12 @@ import { createRoute, z } from '@hono/zod-openapi'; import { jsonContent, jsonContentRequired } from '@/lib/openapi.js'; -import { errorResponseSchema, HttpStatus, successResponseSchema } from '@/lib/response.js'; +import { + errorResponseSchema, + HttpStatus, + paginatedSuccessResponseSchema, + successResponseSchema +} from '@/lib/response.js'; import { currentUser } from '@/middlewares/current-user.js'; import { addFeedRateLimit, refreshFeedRateLimit } from '@/middlewares/rate-limit.js'; @@ -96,6 +101,24 @@ export const createFeedSubscription = createRoute({ } }); +export const getFeedSubscriptionSummary = createRoute({ + tags, + method: 'get', + path: '/feeds/subscriptions/:id/summary', + middleware: [currentUser], + request: { params: z.object({ id: z.string() }) }, + responses: { + [HttpStatus.OK]: jsonContent( + successResponseSchema( + z.object({ dismissed: z.number(), new: z.number(), saved: z.number() }) + ), + 'Feed subscription summary fetched successfully' + ), + [HttpStatus.NOT_FOUND]: jsonContent(errorResponseSchema(HttpStatus.NOT_FOUND), ''), + [HttpStatus.BAD_REQUEST]: jsonContent(errorResponseSchema(HttpStatus.BAD_REQUEST), '') + } +}); + export const updateFeedSubscription = createRoute({ tags, method: 'patch', @@ -151,13 +174,16 @@ export const listFeedItems = createRoute({ middleware: [currentUser], request: { query: z.object({ + cursor: z.string().optional(), + limit: z.string().regex(/^\d+$/).optional(), + sort: z.enum(['newest', 'oldest']).optional(), state: z.enum(['new', 'dismissed', 'saved']).optional(), subscriptionId: z.string().optional() }) }, responses: { [HttpStatus.OK]: jsonContent( - successResponseSchema(z.array(feedItemSchema)), + paginatedSuccessResponseSchema(feedItemSchema), 'Feed items fetched successfully' ), [HttpStatus.BAD_REQUEST]: jsonContent(errorResponseSchema(HttpStatus.BAD_REQUEST), '') diff --git a/apps/server/src/routes/feeds/feeds.test.ts b/apps/server/src/routes/feeds/feeds.test.ts index a7c28d5..fe56f56 100644 --- a/apps/server/src/routes/feeds/feeds.test.ts +++ b/apps/server/src/routes/feeds/feeds.test.ts @@ -8,6 +8,7 @@ import { createInMemoryLinksAdapter } from '@/tests/in-memory/links.js'; import { createInMemoryTagsAdapter } from '@/tests/in-memory/tags.js'; import { createTestApp } from '@/lib/create-app.js'; +import { decodeCursor, encodeCursor } from '@/lib/cursor.js'; import { generateToken } from '@/lib/jwt.js'; import { HttpStatus } from '@/lib/response.js'; import type { Repos } from '@/lib/types.js'; @@ -164,13 +165,47 @@ function buildFeedRepos() { findById: async (id, userId) => [...items.values()].find((item) => item.id === id && item.userId === userId) ?? null, findBySubscriptionAndIdentity: async () => null, - findManyForReview: async ({ state, subscriptionId, userId }) => - [...items.values()].filter( - (item) => - item.userId === userId && - (!state || item.state === state) && - (!subscriptionId || item.subscriptionId === subscriptionId) - ), + findManyForReview: async ({ + cursor, + limit = 24, + sort = 'newest', + state, + subscriptionId, + userId + }) => { + const matching = [...items.values()] + .filter( + (item) => + item.userId === userId && + (!state || item.state === state) && + (!subscriptionId || item.subscriptionId === subscriptionId) + ) + .sort((a, b) => { + const aDate = (a.publishedAt ?? a.discoveredAt).getTime(); + const bDate = (b.publishedAt ?? b.discoveredAt).getTime(); + const difference = bDate - aDate || b.id.localeCompare(a.id); + return sort === 'newest' ? difference : -difference; + }); + const cursorData = cursor ? decodeCursor(cursor) : null; + const cursorIndex = cursorData ? matching.findIndex((item) => item.id === cursorData.id) : -1; + const remaining = matching.slice(cursorIndex + 1); + const pageItems = remaining.slice(0, limit); + const hasMore = remaining.length > limit; + const lastItem = pageItems.at(-1); + + return { + hasMore, + items: pageItems, + nextCursor: + hasMore && lastItem + ? encodeCursor({ + createdAt: (lastItem.publishedAt ?? lastItem.discoveredAt).toISOString(), + id: lastItem.id + }) + : undefined, + total: matching.length + }; + }, pruneForSubscription: async () => 0, reconcileSavedByUrl: async ({ linkId, normalizedUrl, savedAt = NOW, userId }) => { const matching = [...items.values()].filter( @@ -189,6 +224,16 @@ function buildFeedRepos() { items.set(id, updated); return updated; }, + summarizeBySubscription: async (subscriptionId, userId) => { + const matching = [...items.values()].filter( + (item) => item.subscriptionId === subscriptionId && item.userId === userId + ); + return { + dismissed: matching.filter((item) => item.state === 'dismissed').length, + new: matching.filter((item) => item.state === 'new').length, + saved: matching.filter((item) => item.state === 'saved').length + }; + }, upsertByIdentity: async (data) => feedItems.create(data) }; @@ -324,6 +369,102 @@ describe('feed routes', () => { expect(itemsJson.result).toHaveLength(1); }); + it('paginates feed items and exposes total matching count', async () => { + const feed = subscriptionFixture({ id: 'feed-user', userId: TEST_USER.id }); + built.subscriptions.set(feed.id, feed); + for (let index = 0; index < 3; index += 1) { + const item = itemFixture({ + discoveredAt: new Date(`2026-06-2${index + 1}T12:00:00.000Z`), + id: `00000000-0000-0000-0000-00000000000${index + 3}`, + publishedAt: null, + subscriptionId: feed.id, + title: `Article ${index + 1}`, + userId: TEST_USER.id + }); + built.items.set(item.id, item); + } + + const firstResponse = await built.client.feeds.items.$get( + { query: { limit: '2', sort: 'newest', state: 'new' } }, + { headers: { Cookie: authCookie } } + ); + const firstJson = (await firstResponse.json()) as { + pagination: { hasMore: boolean; nextCursor?: string; total: number; totalReturned: number }; + result: Array<{ title: string }>; + }; + + expect(firstJson.result.map((item) => item.title)).toEqual(['Article 3', 'Article 2']); + expect(firstJson.pagination).toMatchObject({ + hasMore: true, + total: 3, + totalReturned: 2 + }); + + const secondResponse = await built.client.feeds.items.$get( + { + query: { + cursor: firstJson.pagination.nextCursor, + limit: '2', + sort: 'newest', + state: 'new' + } + }, + { headers: { Cookie: authCookie } } + ); + const secondJson = (await secondResponse.json()) as { + pagination: { hasMore: boolean; total: number }; + result: Array<{ title: string }>; + }; + + expect(secondJson.result.map((item) => item.title)).toEqual(['Article 1']); + expect(secondJson.pagination).toMatchObject({ hasMore: false, total: 3 }); + }); + + it('returns a user-scoped subscription item summary', async () => { + const userFeed = subscriptionFixture({ id: 'feed-user', userId: TEST_USER.id }); + const otherFeed = subscriptionFixture({ id: 'feed-other', userId: OTHER_USER_ID }); + built.subscriptions.set(userFeed.id, userFeed); + built.subscriptions.set(otherFeed.id, otherFeed); + built.items.set( + 'item-new', + itemFixture({ id: 'item-new', subscriptionId: userFeed.id, userId: TEST_USER.id }) + ); + built.items.set( + 'item-saved', + itemFixture({ + id: 'item-saved', + state: 'saved', + subscriptionId: userFeed.id, + userId: TEST_USER.id + }) + ); + built.items.set( + 'item-dismissed', + itemFixture({ + id: 'item-dismissed', + state: 'dismissed', + subscriptionId: userFeed.id, + userId: TEST_USER.id + }) + ); + + const response = await built.client.feeds.subscriptions[':id'].summary.$get( + { param: { id: userFeed.id } }, + { headers: { Cookie: authCookie } } + ); + const missingResponse = await built.client.feeds.subscriptions[':id'].summary.$get( + { param: { id: otherFeed.id } }, + { headers: { Cookie: authCookie } } + ); + const json = (await response.json()) as { + result: { dismissed: number; new: number; saved: number }; + }; + + expect(response.status).toBe(HttpStatus.OK); + expect(json.result).toEqual({ dismissed: 1, new: 1, saved: 1 }); + expect(missingResponse.status).toBe(HttpStatus.NOT_FOUND); + }); + it('updates and refreshes user-owned subscriptions only', async () => { const subscription = subscriptionFixture({ id: 'feed-user' }); built.subscriptions.set(subscription.id, subscription); diff --git a/apps/server/src/routes/feeds/feeds.types.ts b/apps/server/src/routes/feeds/feeds.types.ts index 3ce5e28..43a5523 100644 --- a/apps/server/src/routes/feeds/feeds.types.ts +++ b/apps/server/src/routes/feeds/feeds.types.ts @@ -1,6 +1,7 @@ import type { createFeedSubscription, dismissFeedItem, + getFeedSubscriptionSummary, listFeedItems, listFeedSubscriptions, refreshFeedSubscription, @@ -10,6 +11,7 @@ import type { export type ListFeedSubscriptionsRoute = typeof listFeedSubscriptions; export type CreateFeedSubscriptionRoute = typeof createFeedSubscription; +export type GetFeedSubscriptionSummaryRoute = typeof getFeedSubscriptionSummary; export type UpdateFeedSubscriptionRoute = typeof updateFeedSubscription; export type RefreshFeedSubscriptionRoute = typeof refreshFeedSubscription; export type ListFeedItemsRoute = typeof listFeedItems; diff --git a/apps/server/src/types/pagination.ts b/apps/server/src/types/pagination.ts index 6b697f4..3552295 100644 --- a/apps/server/src/types/pagination.ts +++ b/apps/server/src/types/pagination.ts @@ -26,6 +26,7 @@ export interface PaginationMetadata { nextCursor?: string; previousCursor?: string; limit: number; + total?: number; totalReturned: number; } From 3fc87e95af96938bb2f4d2982da92b98aa7ad0e0 Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sat, 18 Jul 2026 16:00:21 +0700 Subject: [PATCH 11/18] feat(web): complete RSS feed review experience Integrate feeds into navigation, add scalable mixed-source review collections, and provide responsive Manage Feeds and Add Feed workflows. --- apps/web/package.json | 1 + apps/web/src/components/layouts/header.tsx | 17 +- .../navigation/mobile-bottom-nav.tsx | 18 +- .../prototype/prototype-switcher.tsx | 98 --- apps/web/src/components/ui/button.tsx | 2 +- apps/web/src/components/ui/dialog.tsx | 4 +- apps/web/src/components/ui/switch.tsx | 23 + .../features/feeds/api/get-feed-items.test.ts | 81 ++ .../src/features/feeds/api/get-feed-items.ts | 38 +- .../api/get-feed-subscription-summary.test.ts | 31 + .../api/get-feed-subscription-summary.ts | 37 + apps/web/src/features/feeds/api/query-keys.ts | 2 + .../feeds/components/add-feed-dialog.test.tsx | 111 +++ .../feeds/components/add-feed-dialog.tsx | 96 +++ .../feeds/components/add-feed-form.tsx | 215 +++++ .../feeds/components/feed-collection.tsx | 131 +++ .../feed-infinite-scroll-loader.tsx | 41 + .../feeds/components/feed-manager-dialog.tsx | 665 ++++++++++++++++ .../components/virtualized-feed-grid.tsx | 139 ++++ .../web/src/features/feeds/prototype/NOTES.md | 13 - .../prototype/feeds-review-prototype.tsx | 747 ------------------ apps/web/src/pages/feeds.test.tsx | 167 ++-- apps/web/src/pages/feeds.tsx | 733 +++++++---------- apps/web/src/routeTree.gen.ts | 23 - .../_protected/_with-layout/feeds/index.tsx | 6 + .../_with-layout/prototype-feeds.tsx | 43 - apps/web/src/translations/en.json | 131 ++- apps/web/src/translations/id.json | 123 ++- apps/web/src/types/api.ts | 1 + apps/web/src/types/feeds.ts | 7 + pnpm-lock.yaml | 25 +- 31 files changed, 2299 insertions(+), 1470 deletions(-) delete mode 100644 apps/web/src/components/prototype/prototype-switcher.tsx create mode 100644 apps/web/src/components/ui/switch.tsx create mode 100644 apps/web/src/features/feeds/api/get-feed-items.test.ts create mode 100644 apps/web/src/features/feeds/api/get-feed-subscription-summary.test.ts create mode 100644 apps/web/src/features/feeds/api/get-feed-subscription-summary.ts create mode 100644 apps/web/src/features/feeds/components/add-feed-dialog.test.tsx create mode 100644 apps/web/src/features/feeds/components/add-feed-dialog.tsx create mode 100644 apps/web/src/features/feeds/components/add-feed-form.tsx create mode 100644 apps/web/src/features/feeds/components/feed-collection.tsx create mode 100644 apps/web/src/features/feeds/components/feed-infinite-scroll-loader.tsx create mode 100644 apps/web/src/features/feeds/components/feed-manager-dialog.tsx create mode 100644 apps/web/src/features/feeds/components/virtualized-feed-grid.tsx delete mode 100644 apps/web/src/features/feeds/prototype/NOTES.md delete mode 100644 apps/web/src/features/feeds/prototype/feeds-review-prototype.tsx delete mode 100644 apps/web/src/routes/_protected/_with-layout/prototype-feeds.tsx diff --git a/apps/web/package.json b/apps/web/package.json index 0ada79b..4f3283b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -33,6 +33,7 @@ "@tanstack/react-query-devtools": "^5.95.0", "@tanstack/react-router": "^1.168.0", "@tanstack/react-router-devtools": "^1.166.0", + "@tanstack/react-virtual": "^3.14.5", "@tanstack/router-plugin": "^1.167.0", "@tanstack/zod-adapter": "^1.166.9", "class-variance-authority": "^0.7.1", diff --git a/apps/web/src/components/layouts/header.tsx b/apps/web/src/components/layouts/header.tsx index b74d40a..eff10c3 100644 --- a/apps/web/src/components/layouts/header.tsx +++ b/apps/web/src/components/layouts/header.tsx @@ -1,4 +1,4 @@ -import { BookmarksIcon, HouseIcon, UserIcon } from '@phosphor-icons/react'; +import { BookmarksIcon, HouseIcon, RssIcon, UserIcon } from '@phosphor-icons/react'; import { Link, useLocation } from '@tanstack/react-router'; import { useTranslation } from 'react-i18next'; @@ -49,6 +49,21 @@ export function Header() { {t('nav.articles')} + +
+ + {t('nav.feeds')} +
+ diff --git a/apps/web/src/components/navigation/mobile-bottom-nav.tsx b/apps/web/src/components/navigation/mobile-bottom-nav.tsx index d50f604..a6ec0ff 100644 --- a/apps/web/src/components/navigation/mobile-bottom-nav.tsx +++ b/apps/web/src/components/navigation/mobile-bottom-nav.tsx @@ -1,31 +1,39 @@ -import { BookmarksIcon, GearIcon, HouseIcon } from '@phosphor-icons/react'; +import { BookmarksIcon, GearIcon, HouseIcon, RssIcon } from '@phosphor-icons/react'; +import { useTranslation } from 'react-i18next'; import { BottomNav, BottomNavItem } from './bottom-nav'; interface MobileBottomNavProps { activeTab: string; - onTabChange: (tab: '/' | '/articles' | '/settings') => void; + onTabChange: (tab: '/' | '/articles' | '/feeds' | '/settings') => void; unreadCount?: number; } export const MobileBottomNav = ({ activeTab, onTabChange }: MobileBottomNavProps) => { + const { t } = useTranslation(); const navItems = [ { icon: HouseIcon, id: '/' as const, - label: 'Home', + label: t('nav.home'), onClick: () => onTabChange('/') }, { icon: BookmarksIcon, id: '/articles' as const, - label: 'Articles', + label: t('nav.articles'), onClick: () => onTabChange('/articles') }, + { + icon: RssIcon, + id: '/feeds' as const, + label: t('nav.feeds'), + onClick: () => onTabChange('/feeds') + }, { icon: GearIcon, id: '/settings' as const, - label: 'Settings', + label: t('userMenu.settings'), onClick: () => onTabChange('/settings') } ]; diff --git a/apps/web/src/components/prototype/prototype-switcher.tsx b/apps/web/src/components/prototype/prototype-switcher.tsx deleted file mode 100644 index bf8511b..0000000 --- a/apps/web/src/components/prototype/prototype-switcher.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react'; -import { useEffect } from 'react'; - -import { Button } from '@/components/ui/button'; - -import { cn } from '@/lib/utils'; - -interface PrototypeVariantOption { - key: string; - name: string; -} - -interface PrototypeSwitcherProps { - className?: string; - current: string; - onChange: (variant: string) => void; - variants: readonly PrototypeVariantOption[]; -} - -function isTextEntryElement(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) return false; - - const tagName = target.tagName.toLowerCase(); - return tagName === 'input' || tagName === 'textarea' || target.isContentEditable; -} - -export function PrototypeSwitcher({ - className, - current, - onChange, - variants -}: PrototypeSwitcherProps) { - const currentIndex = Math.max( - variants.findIndex((variant) => variant.key === current), - 0 - ); - const currentVariant = variants[currentIndex] ?? variants[0]; - - const move = (direction: -1 | 1) => { - const nextIndex = (currentIndex + direction + variants.length) % variants.length; - const nextVariant = variants[nextIndex]; - if (nextVariant) onChange(nextVariant.key); - }; - - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (isTextEntryElement(event.target)) return; - if (event.key === 'ArrowLeft') { - event.preventDefault(); - move(-1); - } - if (event.key === 'ArrowRight') { - event.preventDefault(); - move(1); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }); - - if (import.meta.env.PROD || !currentVariant) return null; - - return ( -
-
- -
- {currentVariant.key} — {currentVariant.name} -
- -
-
- ); -} diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx index e53e2f2..635da5f 100644 --- a/apps/web/src/components/ui/button.tsx +++ b/apps/web/src/components/ui/button.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98] duration-150", + "inline-flex items-center justify-center gap-2 touch-manipulation whitespace-nowrap rounded-full text-sm font-medium transition-[color,background-color,border-color,box-shadow,transform,opacity] motion-reduce:transform-none motion-reduce:transition-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-[0.98] duration-150", { defaultVariants: { size: 'default', diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index f0473c5..24436ba 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -28,7 +28,7 @@ function DialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) + + + ); +} + +export { Switch }; diff --git a/apps/web/src/features/feeds/api/get-feed-items.test.ts b/apps/web/src/features/feeds/api/get-feed-items.test.ts new file mode 100644 index 0000000..4502660 --- /dev/null +++ b/apps/web/src/features/feeds/api/get-feed-items.test.ts @@ -0,0 +1,81 @@ +import { waitFor } from '@testing-library/react'; +import { HttpResponse, http } from 'msw'; +import { describe, expect, it } from 'vitest'; + +import { server } from '@/tests/mocks/server'; +import { renderHookWithProviders } from '@/tests/test-utils'; + +import type { PaginatedResponseOptions } from '@/types/api'; +import type { FeedItem } from '@/types/feeds'; + +import { useFeedItems } from './get-feed-items'; + +const API_URL = 'http://localhost:3000'; +const NOW = '2026-06-28T12:00:00.000Z'; + +function item(id: string): FeedItem { + return { + author: null, + createdAt: NOW, + discoveredAt: NOW, + dismissedAt: null, + excerpt: null, + guid: null, + id, + imageUrl: null, + linkId: null, + normalizedUrl: `https://example.com/${id}`, + publishedAt: NOW, + savedAt: null, + state: 'new', + subscriptionId: 'feed-1', + title: id, + updatedAt: NOW, + url: `https://example.com/${id}`, + userId: 'user-1' + }; +} + +describe('useFeedItems', () => { + it('loads and flattens cursor-paginated feed items', async () => { + const firstPage = [item('item-1'), item('item-2')]; + const secondPage = [item('item-3')]; + + server.use( + http.get(`${API_URL}/feeds/items`, ({ request }) => { + const url = new URL(request.url); + const cursor = url.searchParams.get('cursor'); + const response: PaginatedResponseOptions = { + message: 'ok', + pagination: { + hasMore: !cursor, + limit: 24, + nextCursor: cursor ? undefined : 'cursor-2', + total: 3, + totalReturned: cursor ? 1 : 2 + }, + result: cursor ? secondPage : firstPage, + status: 200 + }; + + expect(url.searchParams.get('sort')).toBe('newest'); + expect(url.searchParams.get('state')).toBe('new'); + expect(url.searchParams.get('limit')).toBe('24'); + return HttpResponse.json(response); + }) + ); + + const { result } = renderHookWithProviders(() => + useFeedItems({ filters: { sort: 'newest', state: 'new' } }) + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.data).toEqual(firstPage); + expect(result.current.total).toBe(3); + expect(result.current.hasNextPage).toBe(true); + + await result.current.fetchNextPage(); + await waitFor(() => expect(result.current.data).toEqual([...firstPage, ...secondPage])); + expect(result.current.hasNextPage).toBe(false); + }); +}); diff --git a/apps/web/src/features/feeds/api/get-feed-items.ts b/apps/web/src/features/feeds/api/get-feed-items.ts index 593b29a..a36cec1 100644 --- a/apps/web/src/features/feeds/api/get-feed-items.ts +++ b/apps/web/src/features/feeds/api/get-feed-items.ts @@ -1,16 +1,23 @@ -import { queryOptions, useQuery } from '@tanstack/react-query'; +import { infiniteQueryOptions, useInfiniteQuery } from '@tanstack/react-query'; import { apiClient } from '@/lib/api-client'; import type { QueryConfig } from '@/lib/react-query'; -import type { ApiResult } from '@/types/api'; +import type { PaginatedResponseOptions } from '@/types/api'; import type { FeedItem, FeedItemFilters } from '@/types/feeds'; import { feedKeys } from './query-keys'; -const getFeedItems = async (filters: FeedItemFilters = {}): Promise> => { +const DEFAULT_PAGE_SIZE = 24; + +const getFeedItems = async ( + filters: FeedItemFilters = {}, + cursor?: string +): Promise> => { const params = Object.fromEntries( - Object.entries(filters).filter((entry): entry is [string, string] => Boolean(entry[1])) + Object.entries({ ...filters, cursor, limit: String(DEFAULT_PAGE_SIZE) }).filter( + (entry): entry is [string, string] => Boolean(entry[1]) + ) ); const response = await apiClient.get('feeds/items', {}, params); @@ -18,9 +25,12 @@ const getFeedItems = async (filters: FeedItemFilters = {}): Promise - queryOptions({ + infiniteQueryOptions({ + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage: PaginatedResponseOptions) => + lastPage.pagination.nextCursor, queryKey: feedKeys.itemList(filters), - queryFn: () => getFeedItems(filters) + queryFn: ({ pageParam }) => getFeedItems(filters, pageParam) }); type UseFeedItemsOptions = { @@ -28,8 +38,20 @@ type UseFeedItemsOptions = { queryConfig?: QueryConfig; }; -export const useFeedItems = ({ filters = {}, queryConfig }: UseFeedItemsOptions = {}) => - useQuery({ +export const useFeedItems = ({ filters = {}, queryConfig }: UseFeedItemsOptions = {}) => { + const infiniteQuery = useInfiniteQuery({ ...getFeedItemsQueryOptions(filters), ...queryConfig }); + + return { + data: infiniteQuery.data?.pages.flatMap((page) => page.result), + error: infiniteQuery.error, + fetchNextPage: infiniteQuery.fetchNextPage, + hasNextPage: infiniteQuery.hasNextPage, + isError: infiniteQuery.isError, + isFetchingNextPage: infiniteQuery.isFetchingNextPage, + isLoading: infiniteQuery.isLoading, + total: infiniteQuery.data?.pages[0]?.pagination.total ?? 0 + }; +}; diff --git a/apps/web/src/features/feeds/api/get-feed-subscription-summary.test.ts b/apps/web/src/features/feeds/api/get-feed-subscription-summary.test.ts new file mode 100644 index 0000000..d6d0db7 --- /dev/null +++ b/apps/web/src/features/feeds/api/get-feed-subscription-summary.test.ts @@ -0,0 +1,31 @@ +import { waitFor } from '@testing-library/react'; +import { HttpResponse, http } from 'msw'; +import { describe, expect, it } from 'vitest'; + +import { server } from '@/tests/mocks/server'; +import { renderHookWithProviders } from '@/tests/test-utils'; + +import { useFeedSubscriptionSummary } from './get-feed-subscription-summary'; + +const API_URL = 'http://localhost:3000'; + +describe('useFeedSubscriptionSummary', () => { + it('loads grouped item totals for one subscription', async () => { + server.use( + http.get(`${API_URL}/feeds/subscriptions/feed-1/summary`, () => + HttpResponse.json({ + message: 'ok', + result: { dismissed: 2, new: 7, saved: 4 }, + status: 200 + }) + ) + ); + + const { result } = renderHookWithProviders(() => + useFeedSubscriptionSummary({ subscriptionId: 'feed-1' }) + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(result.current.data?.result).toEqual({ dismissed: 2, new: 7, saved: 4 }); + }); +}); diff --git a/apps/web/src/features/feeds/api/get-feed-subscription-summary.ts b/apps/web/src/features/feeds/api/get-feed-subscription-summary.ts new file mode 100644 index 0000000..533a393 --- /dev/null +++ b/apps/web/src/features/feeds/api/get-feed-subscription-summary.ts @@ -0,0 +1,37 @@ +import { queryOptions, useQuery } from '@tanstack/react-query'; + +import { apiClient } from '@/lib/api-client'; +import type { QueryConfig } from '@/lib/react-query'; + +import type { ApiResult } from '@/types/api'; +import type { FeedSubscriptionSummary } from '@/types/feeds'; + +import { feedKeys } from './query-keys'; + +const getFeedSubscriptionSummary = async ( + subscriptionId: string +): Promise> => { + const response = await apiClient.get(`feeds/subscriptions/${subscriptionId}/summary`); + + return response.json(); +}; + +export const getFeedSubscriptionSummaryQueryOptions = (subscriptionId: string) => + queryOptions({ + queryKey: feedKeys.subscriptionSummary(subscriptionId), + queryFn: () => getFeedSubscriptionSummary(subscriptionId) + }); + +type UseFeedSubscriptionSummaryOptions = { + queryConfig?: QueryConfig; + subscriptionId: string; +}; + +export const useFeedSubscriptionSummary = ({ + queryConfig, + subscriptionId +}: UseFeedSubscriptionSummaryOptions) => + useQuery({ + ...getFeedSubscriptionSummaryQueryOptions(subscriptionId), + ...queryConfig + }); diff --git a/apps/web/src/features/feeds/api/query-keys.ts b/apps/web/src/features/feeds/api/query-keys.ts index 6b4b468..5dc2002 100644 --- a/apps/web/src/features/feeds/api/query-keys.ts +++ b/apps/web/src/features/feeds/api/query-keys.ts @@ -3,6 +3,8 @@ import type { FeedItemFilters } from '@/types/feeds'; export const feedKeys = { all: ['feeds'] as const, subscriptions: () => [...feedKeys.all, 'subscriptions'] as const, + subscriptionSummary: (subscriptionId: string) => + [...feedKeys.subscriptions(), subscriptionId, 'summary'] as const, items: () => [...feedKeys.all, 'items'] as const, itemList: (filters?: FeedItemFilters) => [...feedKeys.items(), { filters }] as const }; diff --git a/apps/web/src/features/feeds/components/add-feed-dialog.test.tsx b/apps/web/src/features/feeds/components/add-feed-dialog.test.tsx new file mode 100644 index 0000000..2049925 --- /dev/null +++ b/apps/web/src/features/feeds/components/add-feed-dialog.test.tsx @@ -0,0 +1,111 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { HttpResponse, http } from 'msw'; +import { describe, expect, it, vi } from 'vitest'; + +import { server } from '@/tests/mocks/server'; +import { render } from '@/tests/test-utils'; + +import type { FeedSubscription } from '@/types/feeds'; + +import { AddFeedDialog } from './add-feed-dialog'; + +const API_URL = 'http://localhost:3000'; +const NOW = '2026-07-12T00:00:00.000Z'; + +const subscription: FeedSubscription = { + autoSave: false, + createdAt: NOW, + description: null, + etag: null, + failureCount: 0, + feedUrl: 'https://example.com/feed.xml', + id: 'feed-1', + imageUrl: null, + lastError: null, + lastFetchedAt: NOW, + lastModified: null, + lastSuccessfulFetchAt: NOW, + nextFetchAfter: null, + normalizedFeedUrl: 'https://example.com/feed.xml', + siteUrl: 'https://example.com', + status: 'active', + title: 'Example Feed', + updatedAt: NOW, + userId: 'user-1' +}; + +describe('AddFeedDialog', () => { + it('updates guidance when auto-save changes', async () => { + const user = userEvent.setup(); + render(); + + expect(screen.getByText(/will appear in Review first/i)).toBeInTheDocument(); + + await user.click(screen.getByRole('switch', { name: /Auto-save New Items/i })); + + expect(screen.getByText(/saved directly to your library/i)).toBeInTheDocument(); + }); + + it('keeps server errors inline and returns focus to the URL field', async () => { + const user = userEvent.setup(); + server.use( + http.post(`${API_URL}/feeds/subscriptions`, () => + HttpResponse.json( + { message: 'This URL did not return a valid RSS or Atom feed', status: 400 }, + { status: 400 } + ) + ) + ); + + render(); + const urlInput = screen.getByLabelText(/Feed URL/i); + await user.type(urlInput, subscription.feedUrl); + await user.click(screen.getByRole('button', { name: /^Add Feed$/i })); + + expect(await screen.findByText(/Check the feed URL and try again/i)).toBeInTheDocument(); + await waitFor(() => expect(urlInput).toHaveFocus()); + expect(urlInput).toHaveValue(subscription.feedUrl); + }); + + it('locks dismissal while checking and closes after a successful add', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + let releaseRequest: () => void = () => undefined; + const requestGate = new Promise((resolve) => { + releaseRequest = resolve; + }); + + server.use( + http.post(`${API_URL}/feeds/subscriptions`, async () => { + await requestGate; + return HttpResponse.json({ + message: 'Feed subscription created successfully', + result: { + autoSaved: 0, + createdSubscription: true, + fetched: true, + pruned: 0, + staged: 3, + subscription + }, + status: 202 + }); + }) + ); + + render(); + await user.type(screen.getByLabelText(/Feed URL/i), subscription.feedUrl); + await user.click(screen.getByRole('button', { name: /^Add Feed$/i })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Checking and Adding/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /^Cancel$/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /^Close$/i })).toBeDisabled(); + }); + + releaseRequest(); + + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)); + }); +}); diff --git a/apps/web/src/features/feeds/components/add-feed-dialog.tsx b/apps/web/src/features/feeds/components/add-feed-dialog.tsx new file mode 100644 index 0000000..d734c47 --- /dev/null +++ b/apps/web/src/features/feeds/components/add-feed-dialog.tsx @@ -0,0 +1,96 @@ +import { XIcon } from '@phosphor-icons/react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'sonner'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog'; + +import type { CreateFeedSubscriptionResult } from '@/types/feeds'; + +import { AddFeedForm } from './add-feed-form'; + +type AddFeedDialogProps = { + onOpenChange: (open: boolean) => void; + open: boolean; +}; + +export function AddFeedDialog({ onOpenChange, open }: AddFeedDialogProps) { + const { t } = useTranslation(); + const [isPending, setIsPending] = useState(false); + + const closeDialog = () => { + if (isPending) return; + onOpenChange(false); + }; + + const handleSuccess = (result: CreateFeedSubscriptionResult) => { + setIsPending(false); + onOpenChange(false); + + if (!result.createdSubscription) { + toast.success(t('feeds.form.alreadyAddedTitle', { title: result.subscription.title }), { + description: t('feeds.form.alreadyAddedDescription'), + richColors: true + }); + return; + } + + toast.success(t('feeds.form.addedTitle', { title: result.subscription.title }), { + description: + result.autoSaved > 0 + ? t('feeds.form.addedAutoSaveDescription', { count: result.autoSaved }) + : t('feeds.form.addedReviewDescription', { count: result.staged }), + richColors: true + }); + }; + + return ( + { + if (!nextOpen && isPending) return; + onOpenChange(nextOpen); + }} + open={open} + > + + + + {t('feeds.dialog.title')} + + + {t('feeds.dialog.description')} + + + + + + + + + ); +} diff --git a/apps/web/src/features/feeds/components/add-feed-form.tsx b/apps/web/src/features/feeds/components/add-feed-form.tsx new file mode 100644 index 0000000..4bce050 --- /dev/null +++ b/apps/web/src/features/feeds/components/add-feed-form.tsx @@ -0,0 +1,215 @@ +import { InfoIcon, PlusIcon } from '@phosphor-icons/react'; +import { useId, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Spinner } from '@/components/ui/spinner'; +import { Switch } from '@/components/ui/switch'; + +import { + createFeedSubscriptionBodySchema, + useCreateFeedSubscription +} from '@/features/feeds/api/create-feed-subscription'; + +import { cn } from '@/lib/utils'; + +import type { CreateFeedSubscriptionResult } from '@/types/feeds'; + +const getActionError = (error: unknown) => (error instanceof Error ? error.message : null); + +type AddFeedFormProps = { + onCancel?: () => void; + onPendingChange?: (pending: boolean) => void; + onSuccess?: (result: CreateFeedSubscriptionResult) => void; + presentation?: 'dialog' | 'embedded'; +}; + +export function AddFeedForm({ + onCancel, + onPendingChange, + onSuccess, + presentation = 'embedded' +}: AddFeedFormProps = {}) { + const { t } = useTranslation(); + const fieldId = useId(); + const inputRef = useRef(null); + const [feedUrl, setFeedUrl] = useState(''); + const [autoSave, setAutoSave] = useState(false); + const [validationError, setValidationError] = useState(null); + const createFeed = useCreateFeedSubscription({ + mutationConfig: { + onMutate: () => { + onPendingChange?.(true); + }, + onSuccess: (response) => { + onPendingChange?.(false); + setValidationError(null); + onSuccess?.(response.result); + }, + onError: () => { + onPendingChange?.(false); + globalThis.setTimeout(() => inputRef.current?.focus(), 0); + } + } + }); + + const inputId = `${fieldId}-url`; + const helpId = `${fieldId}-help`; + const errorId = `${fieldId}-error`; + const normalizedFeedUrl = feedUrl.trim(); + const serverError = getActionError(createFeed.error); + const errorMessage = + validationError ?? (serverError ? t('feeds.form.serverError', { message: serverError }) : null); + + const validateUrl = () => { + if (normalizedFeedUrl.length === 0) { + setValidationError(null); + return true; + } + + const parsed = createFeedSubscriptionBodySchema.safeParse({ + autoSave, + feedUrl: normalizedFeedUrl + }); + if (parsed.success) { + setValidationError(null); + return true; + } + + setValidationError(t('feeds.form.invalidUrl')); + return false; + }; + + return ( +
{ + event.preventDefault(); + + if (!validateUrl() || normalizedFeedUrl.length === 0) { + setValidationError(t('feeds.form.invalidUrl')); + inputRef.current?.focus(); + return; + } + + createFeed.mutate({ autoSave, feedUrl: normalizedFeedUrl }); + }} + > +
+
+ + { + setFeedUrl(event.target.value); + setValidationError(null); + createFeed.reset(); + }} + placeholder={t('feeds.form.urlPlaceholder')} + ref={inputRef} + spellCheck={false} + type="url" + value={feedUrl} + /> +

+ {t('feeds.form.help')} +

+ {errorMessage ? ( + + ) : null} +
+ +
+ +
+ +

+ {createFeed.isPending ? t('feeds.form.checking') : ''} +

+ + + +
+ +
+ {presentation === 'dialog' ? ( + + ) : null} + +
+
+ ); +} diff --git a/apps/web/src/features/feeds/components/feed-collection.tsx b/apps/web/src/features/feeds/components/feed-collection.tsx new file mode 100644 index 0000000..f728e36 --- /dev/null +++ b/apps/web/src/features/feeds/components/feed-collection.tsx @@ -0,0 +1,131 @@ +import { BookmarkSimpleIcon } from '@phosphor-icons/react'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '@/components/ui/button'; + +import { useDismissFeedItem } from '@/features/feeds/api/dismiss-feed-item'; +import { useSaveFeedItem } from '@/features/feeds/api/save-feed-item'; + +import type { FeedItem } from '@/types/feeds'; + +type FeedItemCardProps = { + item: FeedItem; + showActions?: boolean; + sourceTitle?: string; +}; + +const formatPublishedDate = (value: string | null) => { + if (!value) return null; + + return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(new Date(value)); +}; + +const getActionError = (error: unknown) => (error instanceof Error ? error.message : null); + +function FeedItemActions({ item }: { item: FeedItem }) { + const { t } = useTranslation(); + const saveItem = useSaveFeedItem(); + const dismissItem = useDismissFeedItem(); + const isMutating = saveItem.isPending || dismissItem.isPending; + const errorMessage = getActionError(saveItem.error) ?? getActionError(dismissItem.error); + + return ( +
+ {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +
+ + +
+
+ ); +} + +function FeedThumbnail({ item }: { item: FeedItem }) { + return ( + + {item.imageUrl ? ( + + ) : ( + + + )} + + ); +} + +function GridFeedItemCard({ item, showActions, sourceTitle }: FeedItemCardProps) { + const publishedAt = formatPublishedDate(item.publishedAt); + + return ( +
+ +
+
+ {sourceTitle ? ( +

{sourceTitle}

+ ) : null} +

+ + {item.title} + +

+ {item.excerpt ? ( +

{item.excerpt}

+ ) : null} +
+
+ {publishedAt ?

{publishedAt}

: null} + {showActions ? : null} +
+
+
+ ); +} + +export function FeedItemCard({ + item, + showActions = item.state === 'new', + sourceTitle = '' +}: FeedItemCardProps) { + return ; +} diff --git a/apps/web/src/features/feeds/components/feed-infinite-scroll-loader.tsx b/apps/web/src/features/feeds/components/feed-infinite-scroll-loader.tsx new file mode 100644 index 0000000..45552ec --- /dev/null +++ b/apps/web/src/features/feeds/components/feed-infinite-scroll-loader.tsx @@ -0,0 +1,41 @@ +import { useTranslation } from 'react-i18next'; + +import { Skeleton } from '@/components/ui/skeleton'; + +import { useIntersectionObserver } from '@/hooks/use-intersection-observer'; + +type FeedInfiniteScrollLoaderProps = { + hasNextPage?: boolean; + isFetchingNextPage: boolean; + onLoadMore: () => void; +}; + +export function FeedInfiniteScrollLoader({ + hasNextPage, + isFetchingNextPage, + onLoadMore +}: FeedInfiniteScrollLoaderProps) { + const { t } = useTranslation(); + const { targetRef } = useIntersectionObserver({ + onIntersect: (isIntersecting) => { + if (isIntersecting && hasNextPage && !isFetchingNextPage) { + onLoadMore(); + } + }, + rootMargin: '600px' + }); + + return ( + <> + ); } -export function FeedShelfCard({ activeTab, shelf }: { activeTab: FeedTab; shelf: FeedShelf }) { - const { t } = useTranslation(); - const refreshFeed = useRefreshFeedSubscription(); - const { items, subscription } = shelf; - const lastFetchedAt = formatDate( - subscription.lastSuccessfulFetchAt ?? subscription.lastFetchedAt - ); - const nextFetchAfter = formatDate(subscription.nextFetchAfter); - const refreshError = getActionError(refreshFeed.error); - - return ( -
- -
-
-

- {subscription.title} -

- -
-

- {subscription.siteUrl ?? subscription.feedUrl} -

-

- {t('feeds.shelf.itemCount', { count: items.length })} - {lastFetchedAt ? ` · ${t('feeds.shelf.lastFetched', { date: lastFetchedAt })}` : ''} -

-
- - {t('feeds.shelf.toggle')} - -
- -
- {subscription.lastError ? ( -
-
- - {t('feeds.warning.title')} -
-

{subscription.lastError}

- {nextFetchAfter ? ( -

- {t('feeds.warning.nextTry', { date: nextFetchAfter })} -

- ) : null} -
- ) : null} - - {activeTab === 'feeds' ? ( -
-
-
{t('feeds.fields.autoSave')}
-
- {subscription.autoSave ? t('feeds.yes') : t('feeds.no')} -
-
-
-
{t('feeds.fields.failures')}
-
{subscription.failureCount}
-
-
- ) : items.length > 0 ? ( -
- {items.map((item) => ( - - ))} -
- ) : ( -

- {t('feeds.emptyFeed')} -

- )} - - {refreshError ? ( -

- {refreshError} -

- ) : null} +export { AddFeedForm } from '@/features/feeds/components/add-feed-form'; - -
-
- ); -} +export { FeedItemCard } from '@/features/feeds/components/feed-collection'; function FeedsPage() { const { t } = useTranslation(); - const search = useSearch({ strict: false }) as { tab?: FeedTab }; - const activeTab: FeedTab = search.tab ?? 'new'; - const activeState = tabStates.find((tab) => tab.value === activeTab)?.state; + const navigate = useNavigate(); + const [isAddFeedOpen, setIsAddFeedOpen] = useState(false); + const search = useSearch({ strict: false }) as FeedSearch; + const activeTab: FeedTab = search.tab && search.tab !== 'feeds' ? search.tab : 'new'; + const sort: FeedSort = search.sort ?? 'newest'; + const selectedSubscriptionId = search.subscriptionId ?? 'all'; + const activeState = tabStates.find((tab) => tab.value === activeTab)?.state ?? 'new'; + const selectedSubscriptionFilter = + selectedSubscriptionId === 'all' ? undefined : selectedSubscriptionId; const subscriptionsQuery = useFeedSubscriptions(); - const itemsQuery = useFeedItems({ filters: activeState ? { state: activeState } : {} }); + const itemsQuery = useFeedItems({ + filters: { + sort, + state: activeState, + ...(selectedSubscriptionFilter ? { subscriptionId: selectedSubscriptionFilter } : {}) + } + }); + const pendingItemsQuery = useFeedItems({ filters: { state: 'new' } }); const subscriptions = subscriptionsQuery.data?.result ?? []; - const items = itemsQuery.data?.result ?? []; - const itemsBySubscription = useMemo(() => groupFeedItemsBySubscription(items), [items]); - const shelves = useMemo( - () => buildFeedShelves(subscriptions, itemsBySubscription), - [itemsBySubscription, subscriptions] + const items = itemsQuery.data ?? []; + const activeFeeds = subscriptions.filter((subscription) => subscription.status === 'active'); + const sourceTitleBySubscriptionId = useMemo( + () => new Map(subscriptions.map((subscription) => [subscription.id, subscription.title])), + [subscriptions] ); - const isLoading = subscriptionsQuery.isLoading || itemsQuery.isLoading; const isError = subscriptionsQuery.isError || itemsQuery.isError; + const isManageOpen = search.manage === true || search.tab === 'feeds'; + const managerStatus = search.manageStatus ?? 'all'; + + const updateSearch = (next: Partial, replace = false) => { + navigate({ + replace, + search: (previous) => + ({ + ...previous, + ...next + }) as FeedSearch, + to: '/feeds' + }); + }; + + useEffect(() => { + if (search.tab !== 'feeds') return; + updateSearch({ manage: true, tab: undefined }, true); + }, [search.tab]); + + const updateSubscriptionFilter = (subscriptionId: string) => { + updateSearch({ subscriptionId: subscriptionId === 'all' ? undefined : subscriptionId }); + }; return ( -
-
-
-
-
- - {t('feeds.eyebrow')} -
-
-

+ <> +
+
+
+
+

{t('feeds.title')}

-

+

{t('feeds.description')}

-
-

-
- {t('feeds.reviewNote')} + + + +
-
- +
+
+
+
+
+
+
+
+
+
+
- -
+ + + + + + updateSearch({ tab: value as FeedTab })} + value={activeTab} + > + + {tabStates.map((tab) => ( + + {t(`feeds.tabs.${tab.value}`)} + + ))} + + + +
+
+ + +
+
+ + {subscriptions.map((subscription) => ( + + ))} +
+
+
+
- {isLoading ? : null} + {isLoading ? : null} + + {isError ? ( + + + {t('feeds.error.title')} + + + {t('feeds.error.description')} + + + ) : null} - {isError ? ( - - - {t('feeds.error.title')} - - - {t('feeds.error.description')} - - - ) : null} + {!isLoading && !isError && subscriptions.length === 0 ? ( + + + {t('feeds.empty.title')} + + + {t('feeds.empty.description')} + + + ) : null} - {!isLoading && !isError && subscriptions.length === 0 ? ( - - - {t('feeds.empty.title')} - - - {t('feeds.empty.description')} - - - ) : null} + {!isLoading && !isError && subscriptions.length > 0 && items.length > 0 ? ( + <> + + void itemsQuery.fetchNextPage()} + /> + + ) : null} - {!isLoading && !isError && subscriptions.length > 0 ? ( -
- {shelves.map((shelf) => ( - - ))} -
- ) : null} -
+ {!isLoading && !isError && subscriptions.length > 0 && items.length === 0 ? ( + + + {t('feeds.emptyFeed')} + + + ) : null} + + + { + if (open) { + updateSearch({ manage: true }); + return; + } + updateSearch( + { + feed: undefined, + manage: undefined, + manageQuery: undefined, + manageStatus: undefined, + tab: search.tab === 'feeds' ? undefined : search.tab + }, + true + ); + }} + onQueryChange={(query) => + updateSearch({ manageQuery: query.length > 0 ? query : undefined }, true) + } + onSelectFeed={(feed) => updateSearch({ feed }, true)} + onStartAdd={() => + updateSearch({ feed: 'add', manageQuery: undefined, manageStatus: undefined }, true) + } + onStatusFilterChange={(status) => + updateSearch({ manageStatus: status === 'all' ? undefined : status }, true) + } + open={isManageOpen} + query={search.manageQuery ?? ''} + selectedFeedId={search.feed} + statusFilter={managerStatus} + subscriptions={subscriptions} + subscriptionsLoading={subscriptionsQuery.isLoading} + /> + ); } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 6921b0d..0f49f78 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -19,7 +19,6 @@ import { Route as ProtectedAdminIndexRouteImport } from './routes/_protected/adm import { Route as ProtectedWithLayoutIndexRouteImport } from './routes/_protected/_with-layout/index' import { Route as ProtectedArticlesIdRouteImport } from './routes/_protected/articles/$id' import { Route as ProtectedAdminConnectionsRouteImport } from './routes/_protected/admin/connections' -import { Route as ProtectedWithLayoutPrototypeFeedsRouteImport } from './routes/_protected/_with-layout/prototype-feeds' import { Route as ProtectedWithLayoutManageTagsRouteImport } from './routes/_protected/_with-layout/manage-tags' import { Route as ProtectedWithLayoutSettingsIndexRouteImport } from './routes/_protected/_with-layout/settings/index' import { Route as ProtectedWithLayoutFeedsIndexRouteImport } from './routes/_protected/_with-layout/feeds/index' @@ -76,12 +75,6 @@ const ProtectedAdminConnectionsRoute = path: '/connections', getParentRoute: () => ProtectedAdminRoute, } as any) -const ProtectedWithLayoutPrototypeFeedsRoute = - ProtectedWithLayoutPrototypeFeedsRouteImport.update({ - id: '/prototype-feeds', - path: '/prototype-feeds', - getParentRoute: () => ProtectedWithLayoutRoute, - } as any) const ProtectedWithLayoutManageTagsRoute = ProtectedWithLayoutManageTagsRouteImport.update({ id: '/manage-tags', @@ -125,7 +118,6 @@ export interface FileRoutesByFullPath { '/register': typeof AuthRegisterRoute '/admin': typeof ProtectedAdminRouteWithChildren '/manage-tags': typeof ProtectedWithLayoutManageTagsRoute - '/prototype-feeds': typeof ProtectedWithLayoutPrototypeFeedsRoute '/admin/connections': typeof ProtectedAdminConnectionsRoute '/articles/$id': typeof ProtectedArticlesIdRoute '/admin/': typeof ProtectedAdminIndexRoute @@ -140,7 +132,6 @@ export interface FileRoutesByTo { '/login': typeof AuthLoginRoute '/register': typeof AuthRegisterRoute '/manage-tags': typeof ProtectedWithLayoutManageTagsRoute - '/prototype-feeds': typeof ProtectedWithLayoutPrototypeFeedsRoute '/admin/connections': typeof ProtectedAdminConnectionsRoute '/articles/$id': typeof ProtectedArticlesIdRoute '/admin': typeof ProtectedAdminIndexRoute @@ -159,7 +150,6 @@ export interface FileRoutesById { '/_protected/_with-layout': typeof ProtectedWithLayoutRouteWithChildren '/_protected/admin': typeof ProtectedAdminRouteWithChildren '/_protected/_with-layout/manage-tags': typeof ProtectedWithLayoutManageTagsRoute - '/_protected/_with-layout/prototype-feeds': typeof ProtectedWithLayoutPrototypeFeedsRoute '/_protected/admin/connections': typeof ProtectedAdminConnectionsRoute '/_protected/articles/$id': typeof ProtectedArticlesIdRoute '/_protected/_with-layout/': typeof ProtectedWithLayoutIndexRoute @@ -178,7 +168,6 @@ export interface FileRouteTypes { | '/register' | '/admin' | '/manage-tags' - | '/prototype-feeds' | '/admin/connections' | '/articles/$id' | '/admin/' @@ -193,7 +182,6 @@ export interface FileRouteTypes { | '/login' | '/register' | '/manage-tags' - | '/prototype-feeds' | '/admin/connections' | '/articles/$id' | '/admin' @@ -211,7 +199,6 @@ export interface FileRouteTypes { | '/_protected/_with-layout' | '/_protected/admin' | '/_protected/_with-layout/manage-tags' - | '/_protected/_with-layout/prototype-feeds' | '/_protected/admin/connections' | '/_protected/articles/$id' | '/_protected/_with-layout/' @@ -300,13 +287,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedAdminConnectionsRouteImport parentRoute: typeof ProtectedAdminRoute } - '/_protected/_with-layout/prototype-feeds': { - id: '/_protected/_with-layout/prototype-feeds' - path: '/prototype-feeds' - fullPath: '/prototype-feeds' - preLoaderRoute: typeof ProtectedWithLayoutPrototypeFeedsRouteImport - parentRoute: typeof ProtectedWithLayoutRoute - } '/_protected/_with-layout/manage-tags': { id: '/_protected/_with-layout/manage-tags' path: '/manage-tags' @@ -366,7 +346,6 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) interface ProtectedWithLayoutRouteChildren { ProtectedWithLayoutManageTagsRoute: typeof ProtectedWithLayoutManageTagsRoute - ProtectedWithLayoutPrototypeFeedsRoute: typeof ProtectedWithLayoutPrototypeFeedsRoute ProtectedWithLayoutIndexRoute: typeof ProtectedWithLayoutIndexRoute ProtectedWithLayoutArticlesIndexRoute: typeof ProtectedWithLayoutArticlesIndexRoute ProtectedWithLayoutFeedsIndexRoute: typeof ProtectedWithLayoutFeedsIndexRoute @@ -377,8 +356,6 @@ interface ProtectedWithLayoutRouteChildren { const ProtectedWithLayoutRouteChildren: ProtectedWithLayoutRouteChildren = { ProtectedWithLayoutManageTagsRoute: ProtectedWithLayoutManageTagsRoute, - ProtectedWithLayoutPrototypeFeedsRoute: - ProtectedWithLayoutPrototypeFeedsRoute, ProtectedWithLayoutIndexRoute: ProtectedWithLayoutIndexRoute, ProtectedWithLayoutArticlesIndexRoute: ProtectedWithLayoutArticlesIndexRoute, ProtectedWithLayoutFeedsIndexRoute: ProtectedWithLayoutFeedsIndexRoute, diff --git a/apps/web/src/routes/_protected/_with-layout/feeds/index.tsx b/apps/web/src/routes/_protected/_with-layout/feeds/index.tsx index a881b4a..779e6cb 100644 --- a/apps/web/src/routes/_protected/_with-layout/feeds/index.tsx +++ b/apps/web/src/routes/_protected/_with-layout/feeds/index.tsx @@ -7,6 +7,12 @@ import i18n from '@/lib/i18n'; import FeedsPage from '@/pages/feeds'; const searchSchema = z.object({ + feed: z.string().optional(), + manage: z.boolean().optional(), + manageQuery: z.string().optional(), + manageStatus: z.enum(['all', 'active', 'paused', 'attention']).optional(), + sort: z.enum(['newest', 'oldest']).optional(), + subscriptionId: z.string().optional(), tab: z.enum(['new', 'saved', 'dismissed', 'feeds']).optional() }); diff --git a/apps/web/src/routes/_protected/_with-layout/prototype-feeds.tsx b/apps/web/src/routes/_protected/_with-layout/prototype-feeds.tsx deleted file mode 100644 index 5248eea..0000000 --- a/apps/web/src/routes/_protected/_with-layout/prototype-feeds.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router'; - -import { PrototypeSwitcher } from '@/components/prototype/prototype-switcher'; -import { - VariantAReviewDesk, - VariantBQuietRiver, - VariantCFeedShelves -} from '@/features/feeds/prototype/feeds-review-prototype'; - -const variants = [ - { key: 'A', name: 'Review desk' }, - { key: 'B', name: 'Quiet river' }, - { key: 'C', name: 'Feed shelves' } -] as const; - -export const Route = createFileRoute('/_protected/_with-layout/prototype-feeds')({ - head: () => ({ meta: [{ title: 'RSS feed prototype · Loreo' }] }), - validateSearch: (search) => ({ - variant: typeof search.variant === 'string' ? search.variant : undefined - }), - component: PrototypeFeedsRoute -}); - -function PrototypeFeedsRoute() { - const search = Route.useSearch(); - const navigate = Route.useNavigate(); - const variant = variants.some((option) => option.key === search.variant) - ? (search.variant as string) - : 'A'; - - const setVariant = (nextVariant: string) => { - void navigate({ replace: true, search: { variant: nextVariant } }); - }; - - return ( - <> - {variant === 'A' && } - {variant === 'B' && } - {variant === 'C' && } - - - ); -} diff --git a/apps/web/src/translations/en.json b/apps/web/src/translations/en.json index f19cdfa..0ca873f 100644 --- a/apps/web/src/translations/en.json +++ b/apps/web/src/translations/en.json @@ -818,14 +818,16 @@ "feeds": { "eyebrow": "User-curated discovery", "title": "Feeds", - "description": "Follow trusted sources and review new articles before they enter your library.", + "description": "Follow trusted sources and review new articles before they enter your library", "addFeed": "Add feed", - "emptyFeed": "No items for this filter.", + "manageFeeds": "Manage feeds", + "emptyFeed": "No items for this filter", + "loadingMore": "Loading more feed items…", "yes": "Yes", "no": "No", "tabs": { "aria": "Feed review filters", - "new": "New", + "new": "Review", "saved": "Saved", "dismissed": "Dismissed", "feeds": "Feeds" @@ -839,13 +841,31 @@ "autoSave": "Auto-save", "failures": "Failures" }, + "overview": { + "total": "Total feeds", + "active": "Active feeds", + "pending": "Pending review" + }, + "filters": { + "aria": "Feed item filters", + "sortAria": "Change feed item sort order", + "newestFirst": "Newest first", + "oldestFirst": "Oldest first", + "all": "All" + }, "actions": { "save": "Save article", "dismiss": "Dismiss", - "refresh": "Refresh feed", - "saving": "Saving...", - "dismissing": "Dismissing...", - "refreshing": "Refreshing..." + "saving": "Saving…", + "dismissing": "Dismissing…", + "pause": "Pause", + "resume": "Resume" + }, + "collection": { + "aria": "Feed articles" + }, + "management": { + "aria": "Feed subscriptions" }, "empty": { "title": "No feeds yet", @@ -853,18 +873,95 @@ }, "error": { "title": "Could not load feeds", - "description": "Refresh the page or try again in a moment." + "description": "Refresh the page or try again in a moment" + }, + "reviewNote": "New feed items stay here until you save them", + "dialog": { + "title": "Add Feed", + "description": "Add a public RSS or Atom feed, then choose how new articles enter Loreo." }, - "reviewNote": "New feed items stay here until you save them.", "form": { "urlLabel": "Feed URL", - "urlPlaceholder": "https://example.com/feed.xml", - "help": "Use a public RSS or Atom feed. Loreo checks the feed before adding it.", - "autoSaveHelp": "Auto-save new items from this feed instead of staging them for review.", - "invalidUrl": "Enter a valid public feed URL.", - "adding": "Adding feed...", - "success": "{{count}} new items staged for review.", - "alreadyAdded": "This feed is already on your shelf." + "urlPlaceholder": "e.g. https://example.com/feed.xml…", + "help": "Loreo checks that the URL is public, reachable, and a valid feed before adding it.", + "invalidUrl": "Enter a complete URL beginning with http:// or https://.", + "serverError": "{{message}} Check the feed URL and try again.", + "autoSaveLabel": "Auto-save New Items", + "enabled": "Enabled", + "disabled": "Disabled", + "autoSaveEnabledHelp": "New articles go directly to your library.", + "autoSaveDisabledHelp": "New articles wait in Review until you save or dismiss them.", + "nextTitle": "What Happens Next", + "nextReview": "New items from this feed will appear in Review first.", + "nextAutoSave": "New items from this feed will be saved directly to your library.", + "nextManage": "You can change this behavior later in Manage Feeds.", + "cancel": "Cancel", + "checking": "Checking and Adding…", + "addedTitle": "{{title}} added", + "addedReviewDescription": "New articles will wait in Review.", + "addedAutoSaveDescription": "New articles will be saved to your library.", + "alreadyAddedTitle": "{{title}} is already in your feeds", + "alreadyAddedDescription": "No duplicate subscription was created." + }, + "manager": { + "title": "Manage Feeds", + "description": "Manage trusted sources, review behavior, and feed health.", + "detailLabel": "Selected feed details", + "loading": "Loading feeds…", + "back": "Back to feeds", + "selectPrompt": "Select a feed to view its settings and health.", + "feedCount": "{{count}} feeds", + "lastFetched": "Fetched {{date}}", + "neverFetched": "Not fetched yet", + "feedUrl": "Feed URL", + "website": "Website", + "description": "Description", + "notAvailable": "Not available", + "updateError": "Couldn’t update this feed: {{message}}. Try again.", + "search": { + "label": "Search feeds", + "placeholder": "Search feeds by name or URL…" + }, + "filters": { + "label": "Filter feeds by status", + "all": "All", + "active": "Active", + "paused": "Paused", + "attention": "Needs attention" + }, + "status": { + "active": "Active", + "paused": "Paused", + "attention": "Needs attention" + }, + "controls": { + "status": "Feed status", + "activeHelp": "Active feeds continue polling for new items.", + "pausedHelp": "Paused feeds remain available but do not poll.", + "autoSave": "Auto-save new items", + "autoSaveHelp": "Save new articles directly to your library.", + "updating": "Updating feed settings…", + "updated": "Feed settings updated." + }, + "summary": { + "title": "Feed Overview", + "pending": "Pending review", + "saved": "Saved", + "dismissed": "Dismissed", + "failures": "Failures", + "lastFetched": "Last fetched", + "viewItems": "View items", + "viewItemsLabel": "View {{label}} items", + "error": "Couldn’t load this feed’s item totals. Try again in a moment." + }, + "empty": { + "title": "No feeds yet", + "description": "Add your first trusted source to start curating articles." + }, + "noResults": { + "title": "No matching feeds", + "description": "No feeds match “{{query}}”. Try another search or status filter." + } }, "shelf": { "aria": "Feed shelves", @@ -875,7 +972,7 @@ }, "warning": { "title": "Feed needs attention", - "nextTry": "Loreo will try again after {{date}}." + "nextTry": "Loreo will try again after {{date}}" } } } diff --git a/apps/web/src/translations/id.json b/apps/web/src/translations/id.json index 486edd8..3051d0e 100644 --- a/apps/web/src/translations/id.json +++ b/apps/web/src/translations/id.json @@ -750,12 +750,14 @@ "title": "Feed", "description": "Ikuti sumber tepercaya dan tinjau artikel baru sebelum masuk ke perpustakaanmu.", "addFeed": "Tambah feed", + "manageFeeds": "Kelola feed", "emptyFeed": "Tidak ada item untuk filter ini.", + "loadingMore": "Memuat item feed lainnya…", "yes": "Ya", "no": "Tidak", "tabs": { "aria": "Filter tinjauan feed", - "new": "Baru", + "new": "Tinjau", "saved": "Disimpan", "dismissed": "Dilewati", "feeds": "Feed" @@ -769,13 +771,31 @@ "autoSave": "Simpan otomatis", "failures": "Kegagalan" }, + "overview": { + "total": "Total feed", + "active": "Feed aktif", + "pending": "Menunggu tinjauan" + }, + "filters": { + "aria": "Filter item feed", + "sortAria": "Ubah urutan item feed", + "newestFirst": "Terbaru dulu", + "oldestFirst": "Terlama dulu", + "all": "Semua" + }, "actions": { "save": "Simpan artikel", "dismiss": "Lewati", - "refresh": "Segarkan feed", - "saving": "Menyimpan...", - "dismissing": "Melewati...", - "refreshing": "Menyegarkan..." + "saving": "Menyimpan…", + "dismissing": "Melewati…", + "pause": "Jeda", + "resume": "Lanjutkan" + }, + "collection": { + "aria": "Artikel feed" + }, + "management": { + "aria": "Langganan feed" }, "empty": { "title": "Belum ada feed", @@ -786,15 +806,92 @@ "description": "Segarkan halaman atau coba lagi sebentar lagi." }, "reviewNote": "Item feed baru tetap di sini sampai kamu menyimpannya.", + "dialog": { + "title": "Tambah Feed", + "description": "Tambahkan feed RSS atau Atom publik, lalu pilih cara artikel baru masuk ke Loreo." + }, "form": { - "urlLabel": "URL feed", - "urlPlaceholder": "https://example.com/feed.xml", - "help": "Gunakan feed RSS atau Atom publik. Loreo akan memeriksanya sebelum ditambahkan.", - "autoSaveHelp": "Simpan otomatis item baru dari feed ini, bukan menaruhnya di tinjauan.", - "invalidUrl": "Masukkan URL feed publik yang valid.", - "adding": "Menambahkan feed...", - "success": "{{count}} item baru siap ditinjau.", - "alreadyAdded": "Feed ini sudah ada di rakmu." + "urlLabel": "URL Feed", + "urlPlaceholder": "mis. https://example.com/feed.xml…", + "help": "Loreo memeriksa bahwa URL bersifat publik, dapat dijangkau, dan merupakan feed yang valid sebelum menambahkannya.", + "invalidUrl": "Masukkan URL lengkap yang diawali dengan http:// atau https://.", + "serverError": "{{message}} Periksa URL feed dan coba lagi.", + "autoSaveLabel": "Simpan Otomatis Item Baru", + "enabled": "Aktif", + "disabled": "Nonaktif", + "autoSaveEnabledHelp": "Artikel baru langsung masuk ke perpustakaanmu.", + "autoSaveDisabledHelp": "Artikel baru menunggu di Tinjauan sampai kamu menyimpan atau melewatinya.", + "nextTitle": "Yang Terjadi Selanjutnya", + "nextReview": "Item baru dari feed ini akan muncul di Tinjauan terlebih dahulu.", + "nextAutoSave": "Item baru dari feed ini akan langsung disimpan ke perpustakaanmu.", + "nextManage": "Kamu dapat mengubah perilaku ini nanti di Kelola Feed.", + "cancel": "Batal", + "checking": "Memeriksa dan Menambahkan…", + "addedTitle": "{{title}} ditambahkan", + "addedReviewDescription": "Artikel baru akan menunggu di Tinjauan.", + "addedAutoSaveDescription": "Artikel baru akan disimpan ke perpustakaanmu.", + "alreadyAddedTitle": "{{title}} sudah ada di feed-mu", + "alreadyAddedDescription": "Tidak ada langganan duplikat yang dibuat." + }, + "manager": { + "title": "Kelola Feed", + "description": "Kelola sumber tepercaya, perilaku tinjauan, dan kesehatan feed.", + "detailLabel": "Detail feed terpilih", + "loading": "Memuat feed…", + "back": "Kembali ke feed", + "selectPrompt": "Pilih feed untuk melihat pengaturan dan kesehatannya.", + "feedCount": "{{count}} feed", + "lastFetched": "Diambil {{date}}", + "neverFetched": "Belum pernah diambil", + "feedUrl": "URL feed", + "website": "Situs web", + "description": "Deskripsi", + "notAvailable": "Tidak tersedia", + "updateError": "Feed ini tidak dapat diperbarui: {{message}}. Coba lagi.", + "search": { + "label": "Cari feed", + "placeholder": "Cari feed berdasarkan nama atau URL…" + }, + "filters": { + "label": "Filter feed berdasarkan status", + "all": "Semua", + "active": "Aktif", + "paused": "Dijeda", + "attention": "Perlu perhatian" + }, + "status": { + "active": "Aktif", + "paused": "Dijeda", + "attention": "Perlu perhatian" + }, + "controls": { + "status": "Status feed", + "activeHelp": "Feed aktif terus memeriksa item baru.", + "pausedHelp": "Feed yang dijeda tetap tersedia tetapi tidak diperiksa.", + "autoSave": "Simpan otomatis item baru", + "autoSaveHelp": "Simpan artikel baru langsung ke perpustakaanmu.", + "updating": "Memperbarui pengaturan feed…", + "updated": "Pengaturan feed diperbarui." + }, + "summary": { + "title": "Ringkasan Feed", + "pending": "Menunggu tinjauan", + "saved": "Disimpan", + "dismissed": "Dilewati", + "failures": "Kegagalan", + "lastFetched": "Terakhir diambil", + "viewItems": "Lihat item", + "viewItemsLabel": "Lihat item {{label}}", + "error": "Jumlah item feed ini tidak dapat dimuat. Coba lagi sebentar lagi." + }, + "empty": { + "title": "Belum ada feed", + "description": "Tambahkan sumber tepercaya pertamamu untuk mulai memilih artikel." + }, + "noResults": { + "title": "Tidak ada feed yang cocok", + "description": "Tidak ada feed yang cocok dengan “{{query}}”. Coba pencarian atau filter status lain." + } }, "shelf": { "aria": "Rak feed", diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts index ac63636..8c87437 100644 --- a/apps/web/src/types/api.ts +++ b/apps/web/src/types/api.ts @@ -18,6 +18,7 @@ export type Pagination = { hasMore: boolean; limit: number; nextCursor?: string; + total?: number; totalReturned: number; }; diff --git a/apps/web/src/types/feeds.ts b/apps/web/src/types/feeds.ts index 82bd51f..fdb1987 100644 --- a/apps/web/src/types/feeds.ts +++ b/apps/web/src/types/feeds.ts @@ -60,6 +60,12 @@ export type CreateFeedSubscriptionResult = { subscription: FeedSubscription; }; +export type FeedSubscriptionSummary = { + dismissed: number; + new: number; + saved: number; +}; + export type RefreshFeedSubscriptionResult = { jobId?: string; subscriptionId: string; @@ -72,6 +78,7 @@ export type SaveFeedItemResult = { }; export type FeedItemFilters = { + sort?: 'newest' | 'oldest'; state?: FeedItemState; subscriptionId?: string; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b36f45..d5b12f7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,6 +249,9 @@ importers: '@tanstack/react-router-devtools': specifier: ^1.166.0 version: 1.166.11(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.168.9)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-virtual': + specifier: ^3.14.5 + version: 3.14.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-plugin': specifier: ^1.167.0 version: 1.167.12(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) @@ -2549,6 +2552,12 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/react-virtual@3.14.5': + resolution: {integrity: sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/router-core@1.168.9': resolution: {integrity: sha512-18oeEwEDyXOIuO1VBP9ACaK7tYHZUjynGDCoUh/5c/BNhia9vCJCp9O0LfhZXOorDc/PmLSgvmweFhVmIxF10g==} engines: {node: '>=20.19'} @@ -2597,6 +2606,9 @@ packages: '@tanstack/store@0.9.3': resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@tanstack/virtual-core@3.17.3': + resolution: {integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==} + '@tanstack/virtual-file-routes@1.161.7': resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==} engines: {node: '>=20.19'} @@ -2760,6 +2772,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vitejs/plugin-react@6.0.1': resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} @@ -3950,7 +3963,7 @@ packages: git-raw-commits@4.0.0: resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} engines: {node: '>=16'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true glob-parent@5.1.2: @@ -8532,6 +8545,12 @@ snapshots: react-dom: 19.2.4(react@19.2.4) use-sync-external-store: 1.6.0(react@19.2.4) + '@tanstack/react-virtual@3.14.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/virtual-core': 3.17.3 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@tanstack/router-core@1.168.9': dependencies: '@tanstack/history': 1.161.6 @@ -8597,6 +8616,8 @@ snapshots: '@tanstack/store@0.9.3': {} + '@tanstack/virtual-core@3.17.3': {} + '@tanstack/virtual-file-routes@1.161.7': {} '@tanstack/zod-adapter@1.166.9(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(zod@4.3.6)': @@ -8854,7 +8875,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vitest: 4.1.2(@types/node@22.19.15)(@vitest/ui@4.1.2)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@22.19.15)(typescript@5.9.3))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.2(@types/node@25.5.0)(@vitest/ui@4.1.2)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@25.5.0)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/utils@4.1.2': dependencies: From 15fc89df17d98f9ab8c01975b1045d5ca619bd7f Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sat, 18 Jul 2026 16:00:28 +0700 Subject: [PATCH 12/18] docs: document RSS feed support Describe feed review, polling, retention, and self-hosting behavior for the new subscription workflow. --- README.md | 2 ++ docs/SELF_HOSTING.md | 18 +++++++++--------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4884e49..355e500 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Loreo is a read-it-later app for saving articles worth revisiting, built with se ### Saving & Organization - Save links with automatic article extraction +- Subscribe to RSS/Atom feeds and review new items by source before saving - Favorite articles for quick access - Archive articles to hide them from your reading list - Organize with tag groups and tags @@ -43,6 +44,7 @@ Loreo is a read-it-later app for saving articles worth revisiting, built with se ### Utilities - CSV import with field mapping +- RSS feed polling through the existing Redis/BullMQ worker stack - Docker Compose support, run locally or as separate web/server processes ## Why Loreo? diff --git a/docs/SELF_HOSTING.md b/docs/SELF_HOSTING.md index 016e72f..1d006e3 100644 --- a/docs/SELF_HOSTING.md +++ b/docs/SELF_HOSTING.md @@ -8,13 +8,13 @@ This guide walks through deploying Loreo with Docker Compose, including domain s Loreo's production stack consists of five containers: -| Service | Image | Role | -| ---------------- | ------------------------------------ | --------------------------------------- | -| `loreo-postgres` | `postgres:17-alpine` | Database | -| `loreo-redis` | `redis:7-alpine` | Job queue (BullMQ) | -| `loreo-browser` | `ghcr.io/technowizard/loreo-browser` | Headless browser for article extraction | -| `loreo-server` | `ghcr.io/technowizard/loreo-server` | Hono API, background jobs | -| `loreo-web` | `ghcr.io/technowizard/loreo-web` | Nginx + static React app | +| Service | Image | Role | +| ---------------- | ------------------------------------ | ----------------------------------------------------------- | +| `loreo-postgres` | `postgres:17-alpine` | Database | +| `loreo-redis` | `redis:7-alpine` | Job queue (BullMQ) for extraction, imports, and RSS polling | +| `loreo-browser` | `ghcr.io/technowizard/loreo-browser` | Headless browser for article extraction | +| `loreo-server` | `ghcr.io/technowizard/loreo-server` | Hono API, background jobs | +| `loreo-web` | `ghcr.io/technowizard/loreo-web` | Nginx + static React app | The web container serves the React app through nginx and proxies API requests to the server. @@ -103,7 +103,7 @@ Data is persisted in the `postgres_data` Docker volume. The database runs on an ### Redis -Used by BullMQ for background job processing (article extraction, image downloads). Data is persisted in the `redis_data` volume. +Used by BullMQ for background job processing (article extraction, image downloads, CSV imports, and RSS feed polling/manual refresh). Data is persisted in the `redis_data` volume. ### Browser Service @@ -111,7 +111,7 @@ The browser container runs a Camoufox-compatible Playwright server. It requires ### Server -The API server handles authentication, article management, and background job scheduling. It automatically runs database migrations on startup via `docker-entrypoint.sh`. +The API server handles authentication, article management, RSS feed subscriptions, and background job scheduling. It automatically runs database migrations on startup via `docker-entrypoint.sh`. The server uses `STORAGE_PROVIDER: local-docker` by default, storing uploaded files in the `storage_data` volume. See the Storage section for S3 configuration. From 3ad1f185dde1b7740f84ee4919f8da1c439a3276 Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sat, 18 Jul 2026 18:40:46 +0700 Subject: [PATCH 13/18] fix(server): harden RSS feed polling Schedule due-feed scans autonomously, pin validated DNS addresses across redirects, and preserve exact cursor ordering. Keep migration and retention behavior safe for existing installations. --- apps/server/.env.example | 4 + ...0004_feed_item_pagination_query_shapes.sql | 2 + .../src/db/migrations/meta/0003_snapshot.json | 1942 ++++++++++++++++ .../src/db/migrations/meta/0004_snapshot.json | 2002 +++++++++++++++++ .../src/db/migrations/meta/_journal.json | 7 + .../db/migrations/rss-feed-upgrade.test.ts | 52 + apps/server/src/db/schemas/feed-items.ts | 11 + apps/server/src/index.ts | 12 + apps/server/src/lib/api-client.test.ts | 198 +- apps/server/src/lib/api-client.ts | 213 +- apps/server/src/lib/env-config.ts | 4 + apps/server/src/lib/job-queue.ts | 4 + apps/server/src/lib/url-validator.test.ts | 17 + apps/server/src/lib/url-validator.ts | 52 +- .../queues/feed-poll-scheduler.queue.test.ts | 37 + .../src/queues/feed-poll-scheduler.queue.ts | 44 + .../server/src/queues/feed-poll.queue.test.ts | 16 +- apps/server/src/queues/feed-poll.queue.ts | 5 +- .../feed-items.repository.test.ts | 52 + .../src/repositories/feed-items.repository.ts | 30 +- apps/server/src/services/storage.service.ts | 4 +- .../workers/content-extraction.worker.test.ts | 6 +- .../src/workers/csv-import.worker.test.ts | 4 +- .../feed-poll-scheduler.worker.test.ts | 35 + .../src/workers/feed-poll-scheduler.worker.ts | 46 + docker-compose.prod.yml | 2 + docker-compose.yml | 2 + docs/SELF_HOSTING.md | 11 + 28 files changed, 4697 insertions(+), 117 deletions(-) create mode 100644 apps/server/src/db/migrations/0004_feed_item_pagination_query_shapes.sql create mode 100644 apps/server/src/db/migrations/meta/0003_snapshot.json create mode 100644 apps/server/src/db/migrations/meta/0004_snapshot.json create mode 100644 apps/server/src/db/migrations/rss-feed-upgrade.test.ts create mode 100644 apps/server/src/queues/feed-poll-scheduler.queue.test.ts create mode 100644 apps/server/src/queues/feed-poll-scheduler.queue.ts create mode 100644 apps/server/src/workers/feed-poll-scheduler.worker.test.ts create mode 100644 apps/server/src/workers/feed-poll-scheduler.worker.ts diff --git a/apps/server/.env.example b/apps/server/.env.example index f674623..8b8dfce 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -34,6 +34,10 @@ RATE_LIMIT_MAX=50 # Body size limit (in bytes) BODY_SIZE_LIMIT=102400 +# RSS polling scheduler +FEED_POLL_SCAN_INTERVAL_MS=60000 +FEED_POLL_SCAN_BATCH_SIZE=100 + # Public/storage URLs PUBLIC_URL=http://localhost:3000 STORAGE_PROVIDER=local diff --git a/apps/server/src/db/migrations/0004_feed_item_pagination_query_shapes.sql b/apps/server/src/db/migrations/0004_feed_item_pagination_query_shapes.sql new file mode 100644 index 0000000..11f6771 --- /dev/null +++ b/apps/server/src/db/migrations/0004_feed_item_pagination_query_shapes.sql @@ -0,0 +1,2 @@ +CREATE INDEX "idx_feed_items_user_effective_date_id" ON "feed_items" USING btree ("user_id",coalesce("published_at", "discovered_at"),"id");--> statement-breakpoint +CREATE INDEX "idx_feed_items_user_subscription_effective_date_id" ON "feed_items" USING btree ("user_id","subscription_id",coalesce("published_at", "discovered_at"),"id"); \ No newline at end of file diff --git a/apps/server/src/db/migrations/meta/0003_snapshot.json b/apps/server/src/db/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..d0e3f45 --- /dev/null +++ b/apps/server/src/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,1942 @@ +{ + "id": "2f8bb313-f535-462b-a510-1c2b71ab6c3c", + "prevId": "54310944-236e-44a4-9def-127912959169", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.feed_items": { + "name": "feed_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "link_id": { + "name": "link_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "guid": { + "name": "guid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_url": { + "name": "normalized_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "discovered_at": { + "name": "discovered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "saved_at": { + "name": "saved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_feed_items_subscription_guid": { + "name": "uq_feed_items_subscription_guid", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "guid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_feed_items_subscription_normalized_url": { + "name": "uq_feed_items_subscription_normalized_url", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_items_user_state_published": { + "name": "idx_feed_items_user_state_published", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_items_user_state_effective_date_id": { + "name": "idx_feed_items_user_state_effective_date_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"published_at\", \"discovered_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_items_user_state_subscription_effective_date_id": { + "name": "idx_feed_items_user_state_subscription_effective_date_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"published_at\", \"discovered_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_items_user_normalized_url": { + "name": "idx_feed_items_user_normalized_url", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_items_subscription_discovered": { + "name": "idx_feed_items_subscription_discovered", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "discovered_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_items_link_id": { + "name": "idx_feed_items_link_id", + "columns": [ + { + "expression": "link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feed_items_subscription_id_feed_subscriptions_id_fk": { + "name": "feed_items_subscription_id_feed_subscriptions_id_fk", + "tableFrom": "feed_items", + "tableTo": "feed_subscriptions", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feed_items_user_id_users_id_fk": { + "name": "feed_items_user_id_users_id_fk", + "tableFrom": "feed_items", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feed_items_link_id_links_id_fk": { + "name": "feed_items_link_id_links_id_fk", + "tableFrom": "feed_items", + "tableTo": "links", + "columnsFrom": ["link_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_subscriptions": { + "name": "feed_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "feed_url": { + "name": "feed_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_feed_url": { + "name": "normalized_feed_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_save": { + "name": "auto_save", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_successful_fetch_at": { + "name": "last_successful_fetch_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_fetch_after": { + "name": "next_fetch_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_modified": { + "name": "last_modified", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_feed_subscriptions_user_normalized_url": { + "name": "uq_feed_subscriptions_user_normalized_url", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_feed_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_subscriptions_user_created": { + "name": "idx_feed_subscriptions_user_created", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_subscriptions_status_next_fetch": { + "name": "idx_feed_subscriptions_status_next_fetch", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_fetch_after", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_subscriptions_user_status": { + "name": "idx_feed_subscriptions_user_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feed_subscriptions_user_id_users_id_fk": { + "name": "feed_subscriptions_user_id_users_id_fk", + "tableFrom": "feed_subscriptions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.highlights": { + "name": "highlights", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "link_id": { + "name": "link_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_highlights_link_id": { + "name": "idx_highlights_link_id", + "columns": [ + { + "expression": "link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_highlights_user_id": { + "name": "idx_highlights_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_highlights_link_user": { + "name": "idx_highlights_link_user", + "columns": [ + { + "expression": "link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_highlights_start_offset": { + "name": "idx_highlights_start_offset", + "columns": [ + { + "expression": "start_offset", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "highlights_link_id_links_id_fk": { + "name": "highlights_link_id_links_id_fk", + "tableFrom": "highlights", + "tableTo": "links", + "columnsFrom": ["link_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "highlights_user_id_users_id_fk": { + "name": "highlights_user_id_users_id_fk", + "tableFrom": "highlights", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.import_sessions": { + "name": "import_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "total_rows": { + "name": "total_rows", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "imported_count": { + "name": "imported_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "job_id": { + "name": "job_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "extraction_status": { + "name": "extraction_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "extraction_progress": { + "name": "extraction_progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "extraction_completed": { + "name": "extraction_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "extraction_failed": { + "name": "extraction_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_import_sessions_user_id": { + "name": "idx_import_sessions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_import_sessions_status": { + "name": "idx_import_sessions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_import_sessions_created_at": { + "name": "idx_import_sessions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "import_sessions_user_id_users_id_fk": { + "name": "import_sessions_user_id_users_id_fk", + "tableFrom": "import_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link_tags": { + "name": "link_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "link_id": { + "name": "link_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_link_tags_link_id": { + "name": "idx_link_tags_link_id", + "columns": [ + { + "expression": "link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_link_tags_tag_id": { + "name": "idx_link_tags_tag_id", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_link_tags_user_id": { + "name": "idx_link_tags_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_link_tags_link_tag": { + "name": "uq_link_tags_link_tag", + "columns": [ + { + "expression": "link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "link_tags_link_id_links_id_fk": { + "name": "link_tags_link_id_links_id_fk", + "tableFrom": "link_tags", + "tableTo": "links", + "columnsFrom": ["link_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "link_tags_tag_id_tags_id_fk": { + "name": "link_tags_tag_id_tags_id_fk", + "tableFrom": "link_tags", + "tableTo": "tags", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "link_tags_user_id_users_id_fk": { + "name": "link_tags_user_id_users_id_fk", + "tableFrom": "link_tags", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.links": { + "name": "links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text_content": { + "name": "text_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image": { + "name": "cover_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reading_time": { + "name": "reading_time", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reading_progress": { + "name": "reading_progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "time_spent_reading": { + "name": "time_spent_reading", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_favorite": { + "name": "is_favorite", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_paywalled": { + "name": "is_paywalled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "processing_status": { + "name": "processing_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_session_id": { + "name": "import_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_links_user_id": { + "name": "idx_links_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_url": { + "name": "idx_links_url", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_title": { + "name": "idx_links_title", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_is_read": { + "name": "idx_links_is_read", + "columns": [ + { + "expression": "is_read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_is_favorite": { + "name": "idx_links_is_favorite", + "columns": [ + { + "expression": "is_favorite", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_is_archived": { + "name": "idx_links_is_archived", + "columns": [ + { + "expression": "is_archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_priority": { + "name": "idx_links_priority", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_processing_status": { + "name": "idx_links_processing_status", + "columns": [ + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_created_at": { + "name": "idx_links_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_last_read_at": { + "name": "idx_links_last_read_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_read_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_user_created": { + "name": "idx_links_user_created", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_user_last_read_at": { + "name": "idx_links_user_last_read_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_read_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_import_session_id": { + "name": "idx_links_import_session_id", + "columns": [ + { + "expression": "import_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_processing_started_at": { + "name": "idx_links_processing_started_at", + "columns": [ + { + "expression": "processing_started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "links_user_id_users_id_fk": { + "name": "links_user_id_users_id_fk", + "tableFrom": "links", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "links_import_session_id_import_sessions_id_fk": { + "name": "links_import_session_id_import_sessions_id_fk", + "tableFrom": "links", + "tableTo": "import_sessions", + "columnsFrom": ["import_session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag_groups": { + "name": "tag_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_tag_groups_name": { + "name": "idx_tag_groups_name", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tag_groups_user_id": { + "name": "idx_tag_groups_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_tag_groups_user_id_name": { + "name": "uq_tag_groups_user_id_name", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tag_groups_user_id_users_id_fk": { + "name": "tag_groups_user_id_users_id_fk", + "tableFrom": "tag_groups", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_tags_user_id": { + "name": "idx_tags_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tags_group_id": { + "name": "idx_tags_group_id", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tags_name": { + "name": "idx_tags_name", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tags_user_group": { + "name": "idx_tags_user_group", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_tags_user_group_name": { + "name": "uq_tags_user_group_name", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tags_group_id_tag_groups_id_fk": { + "name": "tags_group_id_tag_groups_id_fk", + "tableFrom": "tags", + "tableTo": "tag_groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tags_user_id_users_id_fk": { + "name": "tags_user_id_users_id_fk", + "tableFrom": "tags", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_settings": { + "name": "idx_users_settings", + "columns": [ + { + "expression": "settings", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_users_role": { + "name": "idx_users_role", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/server/src/db/migrations/meta/0004_snapshot.json b/apps/server/src/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..fc57034 --- /dev/null +++ b/apps/server/src/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,2002 @@ +{ + "id": "db71409b-99e9-449b-ae09-9e34f87b19e6", + "prevId": "2f8bb313-f535-462b-a510-1c2b71ab6c3c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.feed_items": { + "name": "feed_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "link_id": { + "name": "link_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "guid": { + "name": "guid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_url": { + "name": "normalized_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'new'" + }, + "discovered_at": { + "name": "discovered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "saved_at": { + "name": "saved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_feed_items_subscription_guid": { + "name": "uq_feed_items_subscription_guid", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "guid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_feed_items_subscription_normalized_url": { + "name": "uq_feed_items_subscription_normalized_url", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_items_user_state_published": { + "name": "idx_feed_items_user_state_published", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "published_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_items_user_effective_date_id": { + "name": "idx_feed_items_user_effective_date_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"published_at\", \"discovered_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_items_user_state_effective_date_id": { + "name": "idx_feed_items_user_state_effective_date_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"published_at\", \"discovered_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_items_user_subscription_effective_date_id": { + "name": "idx_feed_items_user_subscription_effective_date_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"published_at\", \"discovered_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_items_user_state_subscription_effective_date_id": { + "name": "idx_feed_items_user_state_subscription_effective_date_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"published_at\", \"discovered_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_items_user_normalized_url": { + "name": "idx_feed_items_user_normalized_url", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_items_subscription_discovered": { + "name": "idx_feed_items_subscription_discovered", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "discovered_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_items_link_id": { + "name": "idx_feed_items_link_id", + "columns": [ + { + "expression": "link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feed_items_subscription_id_feed_subscriptions_id_fk": { + "name": "feed_items_subscription_id_feed_subscriptions_id_fk", + "tableFrom": "feed_items", + "tableTo": "feed_subscriptions", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feed_items_user_id_users_id_fk": { + "name": "feed_items_user_id_users_id_fk", + "tableFrom": "feed_items", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feed_items_link_id_links_id_fk": { + "name": "feed_items_link_id_links_id_fk", + "tableFrom": "feed_items", + "tableTo": "links", + "columnsFrom": ["link_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_subscriptions": { + "name": "feed_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "feed_url": { + "name": "feed_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_feed_url": { + "name": "normalized_feed_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "site_url": { + "name": "site_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_save": { + "name": "auto_save", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_successful_fetch_at": { + "name": "last_successful_fetch_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_fetch_after": { + "name": "next_fetch_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_count": { + "name": "failure_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_modified": { + "name": "last_modified", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_feed_subscriptions_user_normalized_url": { + "name": "uq_feed_subscriptions_user_normalized_url", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_feed_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_subscriptions_user_created": { + "name": "idx_feed_subscriptions_user_created", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_subscriptions_status_next_fetch": { + "name": "idx_feed_subscriptions_status_next_fetch", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_fetch_after", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_feed_subscriptions_user_status": { + "name": "idx_feed_subscriptions_user_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feed_subscriptions_user_id_users_id_fk": { + "name": "feed_subscriptions_user_id_users_id_fk", + "tableFrom": "feed_subscriptions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.highlights": { + "name": "highlights", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "link_id": { + "name": "link_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_highlights_link_id": { + "name": "idx_highlights_link_id", + "columns": [ + { + "expression": "link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_highlights_user_id": { + "name": "idx_highlights_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_highlights_link_user": { + "name": "idx_highlights_link_user", + "columns": [ + { + "expression": "link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_highlights_start_offset": { + "name": "idx_highlights_start_offset", + "columns": [ + { + "expression": "start_offset", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "highlights_link_id_links_id_fk": { + "name": "highlights_link_id_links_id_fk", + "tableFrom": "highlights", + "tableTo": "links", + "columnsFrom": ["link_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "highlights_user_id_users_id_fk": { + "name": "highlights_user_id_users_id_fk", + "tableFrom": "highlights", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.import_sessions": { + "name": "import_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "total_rows": { + "name": "total_rows", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "imported_count": { + "name": "imported_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "job_id": { + "name": "job_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "extraction_status": { + "name": "extraction_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "extraction_progress": { + "name": "extraction_progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "extraction_completed": { + "name": "extraction_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "extraction_failed": { + "name": "extraction_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_import_sessions_user_id": { + "name": "idx_import_sessions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_import_sessions_status": { + "name": "idx_import_sessions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_import_sessions_created_at": { + "name": "idx_import_sessions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "import_sessions_user_id_users_id_fk": { + "name": "import_sessions_user_id_users_id_fk", + "tableFrom": "import_sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link_tags": { + "name": "link_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "link_id": { + "name": "link_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_link_tags_link_id": { + "name": "idx_link_tags_link_id", + "columns": [ + { + "expression": "link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_link_tags_tag_id": { + "name": "idx_link_tags_tag_id", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_link_tags_user_id": { + "name": "idx_link_tags_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_link_tags_link_tag": { + "name": "uq_link_tags_link_tag", + "columns": [ + { + "expression": "link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "link_tags_link_id_links_id_fk": { + "name": "link_tags_link_id_links_id_fk", + "tableFrom": "link_tags", + "tableTo": "links", + "columnsFrom": ["link_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "link_tags_tag_id_tags_id_fk": { + "name": "link_tags_tag_id_tags_id_fk", + "tableFrom": "link_tags", + "tableTo": "tags", + "columnsFrom": ["tag_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "link_tags_user_id_users_id_fk": { + "name": "link_tags_user_id_users_id_fk", + "tableFrom": "link_tags", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.links": { + "name": "links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text_content": { + "name": "text_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_image": { + "name": "cover_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reading_time": { + "name": "reading_time", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reading_progress": { + "name": "reading_progress", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "time_spent_reading": { + "name": "time_spent_reading", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_favorite": { + "name": "is_favorite", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_paywalled": { + "name": "is_paywalled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "processing_status": { + "name": "processing_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "import_session_id": { + "name": "import_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_links_user_id": { + "name": "idx_links_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_url": { + "name": "idx_links_url", + "columns": [ + { + "expression": "url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_title": { + "name": "idx_links_title", + "columns": [ + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_is_read": { + "name": "idx_links_is_read", + "columns": [ + { + "expression": "is_read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_is_favorite": { + "name": "idx_links_is_favorite", + "columns": [ + { + "expression": "is_favorite", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_is_archived": { + "name": "idx_links_is_archived", + "columns": [ + { + "expression": "is_archived", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_priority": { + "name": "idx_links_priority", + "columns": [ + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_processing_status": { + "name": "idx_links_processing_status", + "columns": [ + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_created_at": { + "name": "idx_links_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_last_read_at": { + "name": "idx_links_last_read_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_read_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_user_created": { + "name": "idx_links_user_created", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_user_last_read_at": { + "name": "idx_links_user_last_read_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_read_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_import_session_id": { + "name": "idx_links_import_session_id", + "columns": [ + { + "expression": "import_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_links_processing_started_at": { + "name": "idx_links_processing_started_at", + "columns": [ + { + "expression": "processing_started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "links_user_id_users_id_fk": { + "name": "links_user_id_users_id_fk", + "tableFrom": "links", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "links_import_session_id_import_sessions_id_fk": { + "name": "links_import_session_id_import_sessions_id_fk", + "tableFrom": "links", + "tableTo": "import_sessions", + "columnsFrom": ["import_session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tag_groups": { + "name": "tag_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_tag_groups_name": { + "name": "idx_tag_groups_name", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tag_groups_user_id": { + "name": "idx_tag_groups_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_tag_groups_user_id_name": { + "name": "uq_tag_groups_user_id_name", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tag_groups_user_id_users_id_fk": { + "name": "tag_groups_user_id_users_id_fk", + "tableFrom": "tag_groups", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tags": { + "name": "tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_tags_user_id": { + "name": "idx_tags_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tags_group_id": { + "name": "idx_tags_group_id", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tags_name": { + "name": "idx_tags_name", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tags_user_group": { + "name": "idx_tags_user_group", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_tags_user_group_name": { + "name": "uq_tags_user_group_name", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tags_group_id_tag_groups_id_fk": { + "name": "tags_group_id_tag_groups_id_fk", + "tableFrom": "tags", + "tableTo": "tag_groups", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tags_user_id_users_id_fk": { + "name": "tags_user_id_users_id_fk", + "tableFrom": "tags", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar": { + "name": "avatar", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_settings": { + "name": "idx_users_settings", + "columns": [ + { + "expression": "settings", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_users_role": { + "name": "idx_users_role", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/server/src/db/migrations/meta/_journal.json b/apps/server/src/db/migrations/meta/_journal.json index ea1f444..2fedd2d 100644 --- a/apps/server/src/db/migrations/meta/_journal.json +++ b/apps/server/src/db/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1783706400000, "tag": "0003_feed_item_pagination_indexes", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1784366874258, + "tag": "0004_feed_item_pagination_query_shapes", + "breakpoints": true } ] } diff --git a/apps/server/src/db/migrations/rss-feed-upgrade.test.ts b/apps/server/src/db/migrations/rss-feed-upgrade.test.ts new file mode 100644 index 0000000..e14af9f --- /dev/null +++ b/apps/server/src/db/migrations/rss-feed-upgrade.test.ts @@ -0,0 +1,52 @@ +import { readFile } from 'node:fs/promises'; + +import { sql } from 'drizzle-orm'; +import { describe, expect, it } from 'vitest'; + +import { db } from '@/db/index.js'; + +async function applyMigration(filename: string) { + const migration = await readFile(new URL(filename, import.meta.url), 'utf8'); + await db.execute(sql.raw(migration.replaceAll('--> statement-breakpoint', ''))); +} + +describe('RSS feed forward migrations', () => { + it('upgrades a database where migration 0001 was recorded before feed tables existed', async () => { + await db.execute(sql`drop schema if exists rss_feed_upgrade_test cascade`); + await db.execute(sql`create schema rss_feed_upgrade_test`); + await db.execute(sql`set local search_path to rss_feed_upgrade_test, public`); + + await applyMigration('./0002_smooth_rss_feed_tables.sql'); + await applyMigration('./0003_feed_item_pagination_indexes.sql'); + await applyMigration('./0004_feed_item_pagination_query_shapes.sql'); + + const tableResult = await db.execute<{ + feedItems: string | null; + feedSubscriptions: string | null; + }>(sql` + select + to_regclass('rss_feed_upgrade_test.feed_items')::text as "feedItems", + to_regclass('rss_feed_upgrade_test.feed_subscriptions')::text as "feedSubscriptions" + `); + expect(tableResult.rows[0]).toEqual({ + feedItems: 'feed_items', + feedSubscriptions: 'feed_subscriptions' + }); + + const indexResult = await db.execute<{ indexname: string }>(sql` + select indexname + from pg_indexes + where schemaname = 'rss_feed_upgrade_test' and tablename = 'feed_items' + `); + const indexes = indexResult.rows.map(({ indexname }) => indexname); + + expect(indexes).toEqual( + expect.arrayContaining([ + 'idx_feed_items_user_effective_date_id', + 'idx_feed_items_user_state_effective_date_id', + 'idx_feed_items_user_subscription_effective_date_id', + 'idx_feed_items_user_state_subscription_effective_date_id' + ]) + ); + }); +}); diff --git a/apps/server/src/db/schemas/feed-items.ts b/apps/server/src/db/schemas/feed-items.ts index 004e162..38292cc 100644 --- a/apps/server/src/db/schemas/feed-items.ts +++ b/apps/server/src/db/schemas/feed-items.ts @@ -45,12 +45,23 @@ export const feedItemsTable = pgTable( table.state, table.publishedAt.desc() ), + index('idx_feed_items_user_effective_date_id').on( + table.userId, + sql`coalesce(${table.publishedAt}, ${table.discoveredAt})`, + table.id + ), index('idx_feed_items_user_state_effective_date_id').on( table.userId, table.state, sql`coalesce(${table.publishedAt}, ${table.discoveredAt})`, table.id ), + index('idx_feed_items_user_subscription_effective_date_id').on( + table.userId, + table.subscriptionId, + sql`coalesce(${table.publishedAt}, ${table.discoveredAt})`, + table.id + ), index('idx_feed_items_user_state_subscription_effective_date_id').on( table.userId, table.state, diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 368e38f..4693881 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -10,9 +10,14 @@ import { browserService } from './services/browser.service.js'; import app from './app.js'; import { enqueueContentExtraction } from './queues/content-extraction.queue.js'; +import { + feedPollSchedulerQueue, + registerFeedPollScheduler +} from './queues/feed-poll-scheduler.queue.js'; import { enqueueFeedPoll } from './queues/feed-poll.queue.js'; import contentExtractionWorker from './workers/content-extraction.worker.js'; import csvImportWorker from './workers/csv-import.worker.js'; +import feedPollSchedulerWorker from './workers/feed-poll-scheduler.worker.js'; import feedPollWorker from './workers/feed-poll.worker.js'; if (env.isDevelopment) { @@ -30,6 +35,11 @@ const server = serve( } ); +void registerFeedPollScheduler().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + logger.error(`[Queue] Failed to register feed poll scheduler: ${message}`); +}); + let isShuttingDown = false; function onCloseSignal() { @@ -43,8 +53,10 @@ function onCloseSignal() { await Promise.all([ contentExtractionWorker.close(), csvImportWorker.close(), + feedPollSchedulerWorker.close(), feedPollWorker.close(), enqueueContentExtraction.close(), + feedPollSchedulerQueue.close(), enqueueFeedPoll.close(), browserService.close() ]); diff --git a/apps/server/src/lib/api-client.test.ts b/apps/server/src/lib/api-client.test.ts index bc7031a..8db017c 100644 --- a/apps/server/src/lib/api-client.test.ts +++ b/apps/server/src/lib/api-client.test.ts @@ -1,81 +1,189 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createServer } from 'node:http'; +import { gzipSync } from 'node:zlib'; -import { fetchWithValidatedRedirects } from './api-client.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -const isValidUrlMock = vi.hoisted(() => vi.fn()); +import { createValidatedRedirectFetcher } from './api-client.js'; -vi.mock('./url-validator.js', () => ({ - isValidUrl: isValidUrlMock -})); +const openServers: ReturnType[] = []; + +afterEach(async () => { + await Promise.all( + openServers.splice(0).map( + (server) => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }) + ) + ); +}); + +function redirectResponse(location: string) { + return new Response(null, { + status: 302, + headers: { + Location: location + } + }); +} describe('fetchWithValidatedRedirects', () => { - beforeEach(() => { - isValidUrlMock.mockReset(); - vi.restoreAllMocks(); + it('connects through the approved address without resolving the hostname again', async () => { + let receivedHost: string | undefined; + const server = createServer((request, response) => { + receivedHost = request.headers.host; + response.writeHead(200, { 'content-type': 'text/plain' }); + response.end('ok'); + }); + openServers.push(server); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected an ephemeral TCP port'); + + const fetchValidated = createValidatedRedirectFetcher({ + resolvePublicAddresses: vi.fn(async () => [{ address: '127.0.0.1', family: 4 as const }]) + }); + const url = `http://does-not-resolve.invalid:${address.port}/feed.xml`; + + const response = await fetchValidated(url); + + await expect(response.text()).resolves.toBe('ok'); + expect(receivedHost).toBe(`does-not-resolve.invalid:${address.port}`); }); - function redirectResponse(location: string) { - return new Response(null, { - status: 302, - headers: { - Location: location - } + it('decodes compressed response bodies before returning them', async () => { + const server = createServer((_request, response) => { + const compressed = gzipSync('compressed feed'); + response.writeHead(200, { + 'content-encoding': 'gzip', + 'content-length': String(compressed.byteLength), + 'content-type': 'application/rss+xml' + }); + response.end(compressed); }); - } + openServers.push(server); - it('rejects redirects to private targets', async () => { - isValidUrlMock.mockImplementation( - async (url: string) => url === 'https://example.com/image.jpg' - ); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected an ephemeral TCP port'); - vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( - redirectResponse('http://127.0.0.1/internal') + const fetchValidated = createValidatedRedirectFetcher({ + resolvePublicAddresses: vi.fn(async () => [{ address: '127.0.0.1', family: 4 as const }]) + }); + const response = await fetchValidated( + `http://compressed-feed.invalid:${address.port}/feed.xml` ); - await expect(fetchWithValidatedRedirects('https://example.com/image.jpg')).rejects.toThrow( - /redirect/i + await expect(response.text()).resolves.toBe('compressed feed'); + expect(response.headers.has('content-encoding')).toBe(false); + expect(response.headers.has('content-length')).toBe(false); + }); + + it('tries the next approved address when a connection fails', async () => { + const addresses = [ + { address: '2606:4700:4700::1111', family: 6 as const }, + { address: '1.1.1.1', family: 4 as const } + ]; + const requestPinned = vi + .fn() + .mockRejectedValueOnce( + Object.assign(new Error('Network unreachable'), { code: 'ENETUNREACH' }) + ) + .mockResolvedValueOnce(new Response('ok', { status: 200 })); + const fetchValidated = createValidatedRedirectFetcher({ + requestPinned, + resolvePublicAddresses: vi.fn(async () => addresses) + }); + + const response = await fetchValidated('https://dual-stack.example/feed.xml'); + + await expect(response.text()).resolves.toBe('ok'); + expect(requestPinned).toHaveBeenNthCalledWith( + 1, + new URL('https://dual-stack.example/feed.xml'), + addresses[0], + expect.any(Object) + ); + expect(requestPinned).toHaveBeenNthCalledWith( + 2, + new URL('https://dual-stack.example/feed.xml'), + addresses[1], + expect.any(Object) ); }); - it('follows safe redirects until the final response', async () => { - isValidUrlMock.mockImplementation( - async (url: string) => - url === 'https://example.com/image.jpg' || url === 'https://cdn.example.com/image.jpg' + it('rejects redirects to private targets before making the next request', async () => { + const resolvePublicAddresses = vi.fn(async (url: string) => + url === 'https://example.com/image.jpg' + ? [{ address: '93.184.216.34', family: 4 as const }] + : null ); + const requestPinned = vi.fn(async () => redirectResponse('http://127.0.0.1/internal')); + const fetchValidated = createValidatedRedirectFetcher({ + requestPinned, + resolvePublicAddresses + }); - const fetchMock = vi.spyOn(globalThis, 'fetch'); - fetchMock.mockResolvedValueOnce(redirectResponse('https://cdn.example.com/image.jpg')); - fetchMock.mockResolvedValueOnce(new Response('ok', { status: 200 })); + await expect(fetchValidated('https://example.com/image.jpg')).rejects.toThrow(/redirect/i); + expect(requestPinned).toHaveBeenCalledTimes(1); + }); - const response = await fetchWithValidatedRedirects('https://example.com/image.jpg'); + it('pins a separately approved address for every safe redirect hop', async () => { + const resolvePublicAddresses = vi.fn(async (url: string) => { + if (url === 'https://example.com/image.jpg') { + return [{ address: '93.184.216.34', family: 4 as const }]; + } + if (url === 'https://cdn.example.com/image.jpg') { + return [{ address: '203.0.113.10', family: 4 as const }]; + } + return null; + }); + const requestPinned = vi + .fn() + .mockResolvedValueOnce(redirectResponse('https://cdn.example.com/image.jpg')) + .mockResolvedValueOnce(new Response('ok', { status: 200 })); + const fetchValidated = createValidatedRedirectFetcher({ + requestPinned, + resolvePublicAddresses + }); + + const response = await fetchValidated('https://example.com/image.jpg'); expect(response.status).toBe(200); - expect(fetchMock).toHaveBeenNthCalledWith( + expect(requestPinned).toHaveBeenNthCalledWith( 1, - 'https://example.com/image.jpg', - expect.objectContaining({ redirect: 'manual' }) + new URL('https://example.com/image.jpg'), + { address: '93.184.216.34', family: 4 }, + expect.any(Object) ); - expect(fetchMock).toHaveBeenNthCalledWith( + expect(requestPinned).toHaveBeenNthCalledWith( 2, - 'https://cdn.example.com/image.jpg', - expect.objectContaining({ redirect: 'manual' }) + new URL('https://cdn.example.com/image.jpg'), + { address: '203.0.113.10', family: 4 }, + expect.any(Object) ); }); it('rejects redirects that exceed the limit', async () => { - isValidUrlMock.mockResolvedValue(true); - - const fetchMock = vi.spyOn(globalThis, 'fetch'); - fetchMock + const resolvePublicAddresses = vi.fn(async () => [ + { address: '93.184.216.34', family: 4 as const } + ]); + const requestPinned = vi + .fn() .mockResolvedValueOnce(redirectResponse('https://one.example.com/image.jpg')) .mockResolvedValueOnce(redirectResponse('https://two.example.com/image.jpg')) .mockResolvedValueOnce(redirectResponse('https://three.example.com/image.jpg')) .mockResolvedValueOnce(redirectResponse('https://four.example.com/image.jpg')) .mockResolvedValueOnce(redirectResponse('https://five.example.com/image.jpg')) .mockResolvedValueOnce(redirectResponse('https://six.example.com/image.jpg')); + const fetchValidated = createValidatedRedirectFetcher({ + requestPinned, + resolvePublicAddresses + }); - await expect(fetchWithValidatedRedirects('https://example.com/image.jpg')).rejects.toThrow( - /redirect/i - ); + await expect( + fetchValidated('https://example.com/image.jpg', { maxRedirects: 5 }) + ).rejects.toThrow(/redirect/i); }); }); diff --git a/apps/server/src/lib/api-client.ts b/apps/server/src/lib/api-client.ts index a18daa1..08ccf07 100644 --- a/apps/server/src/lib/api-client.ts +++ b/apps/server/src/lib/api-client.ts @@ -1,14 +1,28 @@ -import { isValidUrl } from './url-validator.js'; +import { type IncomingMessage, request as httpRequest, type RequestOptions } from 'node:http'; +import { request as httpsRequest } from 'node:https'; +import { isIP } from 'node:net'; +import { Readable } from 'node:stream'; +import { createBrotliDecompress, createGunzip, createInflate } from 'node:zlib'; + +import { resolvePublicAddresses, type PublicAddress } from './url-validator.js'; type METHODS = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; type RequestBody = File | string | URLSearchParams | FormData | object | undefined; type RedirectFetchOptions = { - headers?: object; + headers?: Record; timeoutMs?: number; maxRedirects?: number; }; +type PinnedRequestOptions = Required>; +type PinnedRequest = ( + url: URL, + address: PublicAddress, + options: PinnedRequestOptions +) => Promise; +type ResolvePublicAddresses = (url: string) => Promise; + // used for user agent rotation const userAgents = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36', @@ -45,48 +59,179 @@ function isRedirectStatus(status: number): boolean { return [301, 302, 303, 307, 308].includes(status); } -export async function fetchWithValidatedRedirects( - url: string, - { headers = {}, timeoutMs = 30_000, maxRedirects = 5 }: RedirectFetchOptions = {} -): Promise { - let currentUrl = url; - let redirectCount = 0; +function responseHeaders(headers: Record): Headers { + const result = new Headers(); - while (true) { - if (!(await isValidUrl(currentUrl))) { - throw new Error(`Rejected URL: ${currentUrl}`); + for (const [name, value] of Object.entries(headers)) { + if (Array.isArray(value)) { + value.forEach((entry) => result.append(name, entry)); + } else if (value !== undefined) { + result.set(name, value); } + } - const payload = buildPayload('GET', undefined, headers); - const response = await fetch(currentUrl, { - ...payload, - redirect: 'manual', - signal: AbortSignal.timeout(timeoutMs) - }); + return result; +} - if (!isRedirectStatus(response.status)) { - return response; - } +function decodedResponseBody(incoming: IncomingMessage, headers: Headers) { + const encoding = headers.get('content-encoding')?.trim().toLowerCase(); + if (!encoding || encoding === 'identity') { + return Readable.toWeb(incoming) as ReadableStream; + } - redirectCount += 1; - if (redirectCount > maxRedirects) { - throw new Error(`Too many redirects while fetching ${url}`); - } + const decoder = + encoding === 'gzip' || encoding === 'x-gzip' + ? createGunzip() + : encoding === 'deflate' + ? createInflate() + : encoding === 'br' + ? createBrotliDecompress() + : null; - const location = response.headers.get('location'); - if (!location) { - throw new Error(`Redirect response missing Location header for ${currentUrl}`); - } + if (!decoder) { + incoming.resume(); + throw new Error(`Unsupported Content-Encoding: ${encoding}`); + } - const nextUrl = new URL(location, currentUrl).toString(); - if (!(await isValidUrl(nextUrl))) { - throw new Error(`Unsafe redirect target rejected: ${nextUrl}`); - } + incoming.on('error', (error) => decoder.destroy(error)); + incoming.pipe(decoder); + headers.delete('content-encoding'); + headers.delete('content-length'); - currentUrl = nextUrl; - } + return Readable.toWeb(decoder) as ReadableStream; +} + +const requestPinnedUrl: PinnedRequest = (url, address, { headers, timeoutMs }) => + new Promise((resolve, reject) => { + const requestHostname = + url.hostname.startsWith('[') && url.hostname.endsWith(']') + ? url.hostname.slice(1, -1) + : url.hostname; + const lookup = (( + _hostname: string, + options: { all?: boolean }, + callback: ( + error: NodeJS.ErrnoException | null, + result: PublicAddress[] | string, + family?: number + ) => void + ) => { + if (options.all) { + callback(null, [address]); + return; + } + + callback(null, address.address, address.family); + }) as NonNullable; + + const request = (url.protocol === 'https:' ? httpsRequest : httpRequest)( + url, + { + headers: { + ...headers, + 'Accept-Encoding': 'gzip, deflate, br', + Host: url.host + }, + lookup, + method: 'GET', + servername: isIP(requestHostname) === 0 ? requestHostname : undefined, + signal: AbortSignal.timeout(timeoutMs) + }, + (incoming) => { + const status = incoming.statusCode ?? 500; + const hasNoBody = status === 101 || status === 204 || status === 205 || status === 304; + const responseHeaderValues = responseHeaders(incoming.headers); + + try { + const body = hasNoBody ? null : decodedResponseBody(incoming, responseHeaderValues); + resolve( + new Response(body, { + headers: responseHeaderValues, + status, + statusText: incoming.statusMessage + }) + ); + } catch (error) { + reject(error); + } + } + ); + + request.on('error', reject); + request.end(); + }); + +export function createValidatedRedirectFetcher( + dependencies: { + requestPinned?: PinnedRequest; + resolvePublicAddresses?: ResolvePublicAddresses; + } = {} +) { + const requestPinned = dependencies.requestPinned ?? requestPinnedUrl; + const resolveAddresses = dependencies.resolvePublicAddresses ?? resolvePublicAddresses; + + return async function fetchValidatedRedirects( + url: string, + { headers = {}, timeoutMs = 30_000, maxRedirects = 5 }: RedirectFetchOptions = {} + ): Promise { + let currentUrl = url; + let redirectCount = 0; + const deadline = Date.now() + timeoutMs; + + while (true) { + const addresses = await resolveAddresses(currentUrl); + if (!addresses?.[0]) { + const prefix = redirectCount === 0 ? 'Rejected URL' : 'Unsafe redirect target rejected'; + throw new Error(`${prefix}: ${currentUrl}`); + } + + let response: Response | undefined; + let lastConnectionError: unknown; + + for (const address of addresses) { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + throw lastConnectionError ?? new Error(`Request timed out while fetching ${url}`); + } + + try { + response = await requestPinned(new URL(currentUrl), address, { + headers, + timeoutMs: remainingMs + }); + break; + } catch (error) { + lastConnectionError = error; + } + } + + if (!response) { + throw lastConnectionError ?? new Error(`Could not connect to ${currentUrl}`); + } + + if (!isRedirectStatus(response.status)) { + return response; + } + + redirectCount += 1; + if (redirectCount > maxRedirects) { + await response.body?.cancel(); + throw new Error(`Too many redirects while fetching ${url}`); + } + + const location = response.headers.get('location'); + await response.body?.cancel(); + if (!location) { + throw new Error(`Redirect response missing Location header for ${currentUrl}`); + } + + currentUrl = new URL(location, currentUrl).toString(); + } + }; } +export const fetchWithValidatedRedirects = createValidatedRedirectFetcher(); + export const apiClient = { get: async (url: string, headers: object = {}) => { const payload = buildPayload('GET', undefined, headers); diff --git a/apps/server/src/lib/env-config.ts b/apps/server/src/lib/env-config.ts index b4d0192..cb2342a 100644 --- a/apps/server/src/lib/env-config.ts +++ b/apps/server/src/lib/env-config.ts @@ -50,6 +50,10 @@ const envSchema = z BODY_SIZE_LIMIT: z.coerce.number().int().positive().default(4_194_304), + FEED_POLL_SCAN_INTERVAL_MS: z.coerce.number().int().min(10_000).default(60_000), + + FEED_POLL_SCAN_BATCH_SIZE: z.coerce.number().int().min(1).max(1_000).default(100), + PUBLIC_URL: z.string().default(''), STORAGE_PATH: z.string().default(''), diff --git a/apps/server/src/lib/job-queue.ts b/apps/server/src/lib/job-queue.ts index b35d61d..b62fd43 100644 --- a/apps/server/src/lib/job-queue.ts +++ b/apps/server/src/lib/job-queue.ts @@ -21,6 +21,10 @@ class NoopQueue { return null; } + upsertJobScheduler(): Promise { + return Promise.resolve({ id: `${this.name}:demo-scheduler-job` }); + } + on(): this { return this; } diff --git a/apps/server/src/lib/url-validator.test.ts b/apps/server/src/lib/url-validator.test.ts index a20e592..909c105 100644 --- a/apps/server/src/lib/url-validator.test.ts +++ b/apps/server/src/lib/url-validator.test.ts @@ -47,8 +47,25 @@ describe('isValidUrl', () => { expect(lookupMock).toHaveBeenCalledWith('example.com', { all: true }); }); + it('rejects a hostname when any resolved address is private', async () => { + lookupMock.mockResolvedValue([ + { address: '93.184.216.34', family: 4 }, + { address: '169.254.169.254', family: 4 } + ] as never); + + expect(await isValidUrl('https://example.com/article')).toBe(false); + }); + it('rejects a private IPv4 literal', async () => { expect(await isValidUrl('https://192.168.0.10/article')).toBe(false); expect(lookupMock).not.toHaveBeenCalled(); }); + + it.each(['https://[::ffff:7f00:1]/feed.xml', 'https://[::ffff:a9fe:a9fe]/feed.xml'])( + 'rejects hexadecimal IPv4-mapped IPv6 literals: %s', + async (url) => { + expect(await isValidUrl(url)).toBe(false); + expect(lookupMock).not.toHaveBeenCalled(); + } + ); }); diff --git a/apps/server/src/lib/url-validator.ts b/apps/server/src/lib/url-validator.ts index 9cdaa90..a39243b 100644 --- a/apps/server/src/lib/url-validator.ts +++ b/apps/server/src/lib/url-validator.ts @@ -42,9 +42,11 @@ function isBlockedIpv6Address(address: string): boolean { return true; } + // IPv4-mapped IPv6 literals may encode the final 32 bits in hexadecimal + // (for example ::ffff:7f00:1), which dotted-IPv4 parsers misclassify. + // Reject the entire mapped range rather than decoding only one spelling. if (normalized.startsWith('::ffff:')) { - const mappedAddress = normalized.slice('::ffff:'.length); - return isBlockedIpv4Address(mappedAddress); + return true; } if ( @@ -77,33 +79,51 @@ function isBlockedAddress(address: string): boolean { return false; } -async function resolvesToBlockedAddress(hostname: string): Promise { - const results = await lookup(hostname, { all: true }); - return results.some(({ address }) => isBlockedAddress(address)); +export interface PublicAddress { + address: string; + family: 4 | 6; } -export async function isValidUrl(url: string): Promise { +function withoutIpv6Brackets(hostname: string): string { + return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname; +} + +export async function resolvePublicAddresses(url: string): Promise { try { const parsed = new URL(url); - if (!['http:', 'https:'].includes(parsed.protocol)) { - return false; + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) { + return null; } - const hostname = parsed.hostname.toLowerCase(); + const hostname = withoutIpv6Brackets(parsed.hostname.toLowerCase()); if (hostname === 'localhost' || hostname.endsWith('.localhost')) { - return false; + return null; } - if (isBlockedAddress(hostname)) { - return false; + const literalFamily = isIP(hostname); + if (literalFamily !== 0) { + if (isBlockedAddress(hostname)) return null; + return [{ address: hostname, family: literalFamily === 4 ? 4 : 6 }]; } - if (isIP(hostname) !== 0) { - return true; + const results = await lookup(hostname, { all: true }); + if (results.length === 0 || results.some(({ address }) => isBlockedAddress(address))) { + return null; } - return !(await resolvesToBlockedAddress(hostname)); + const publicAddresses: PublicAddress[] = []; + for (const { address, family } of results) { + if (family === 4 || family === 6) { + publicAddresses.push({ address, family }); + } + } + + return publicAddresses.length > 0 ? publicAddresses : null; } catch { - return false; + return null; } } + +export async function isValidUrl(url: string): Promise { + return (await resolvePublicAddresses(url)) !== null; +} diff --git a/apps/server/src/queues/feed-poll-scheduler.queue.test.ts b/apps/server/src/queues/feed-poll-scheduler.queue.test.ts new file mode 100644 index 0000000..83db051 --- /dev/null +++ b/apps/server/src/queues/feed-poll-scheduler.queue.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const queueMock = vi.hoisted(() => ({ + close: vi.fn(), + on: vi.fn(), + upsertJobScheduler: vi.fn(async () => ({ id: 'scheduled-job' })) +})); + +vi.mock('@/lib/job-queue.js', () => ({ + createQueue: vi.fn(() => queueMock) +})); + +const { registerFeedPollScheduler } = await import('./feed-poll-scheduler.queue.js'); + +describe('feed poll scheduler queue', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('upserts one stable recurring scan across server instances', async () => { + await registerFeedPollScheduler({ batchSize: 75, intervalMs: 30_000 }); + await registerFeedPollScheduler({ batchSize: 75, intervalMs: 30_000 }); + + expect(queueMock.upsertJobScheduler).toHaveBeenCalledTimes(2); + for (const callNumber of [1, 2]) { + expect(queueMock.upsertJobScheduler).toHaveBeenNthCalledWith( + callNumber, + 'feed-poll-due-scan', + { every: 30_000 }, + { + data: { batchSize: 75 }, + name: 'scan-due-feeds' + } + ); + } + }); +}); diff --git a/apps/server/src/queues/feed-poll-scheduler.queue.ts b/apps/server/src/queues/feed-poll-scheduler.queue.ts new file mode 100644 index 0000000..d2a4290 --- /dev/null +++ b/apps/server/src/queues/feed-poll-scheduler.queue.ts @@ -0,0 +1,44 @@ +import type { Job } from 'bullmq'; + +import { env } from '@/lib/env-config.js'; +import { createQueue } from '@/lib/job-queue.js'; +import { logger } from '@/lib/logger.js'; + +export interface FeedPollScanJobData { + batchSize: number; +} + +const SCHEDULER_ID = 'feed-poll-due-scan'; + +const feedPollSchedulerQueue = createQueue('feed-poll-scheduler'); + +feedPollSchedulerQueue.on('error', (error: Error) => { + logger.error(`[Queue] Feed poll scheduler queue error: ${JSON.stringify(error)}`); +}); + +export async function registerFeedPollScheduler( + options: { + batchSize?: number; + intervalMs?: number; + } = {} +): Promise { + const batchSize = options.batchSize ?? env.FEED_POLL_SCAN_BATCH_SIZE; + const intervalMs = options.intervalMs ?? env.FEED_POLL_SCAN_INTERVAL_MS; + + const job = await feedPollSchedulerQueue.upsertJobScheduler( + SCHEDULER_ID, + { every: intervalMs }, + { + data: { batchSize } satisfies FeedPollScanJobData, + name: 'scan-due-feeds' + } + ); + + logger.info( + `[Queue] Feed poll scheduler registered every ${intervalMs}ms with batch size ${batchSize}` + ); + + return job; +} + +export { feedPollSchedulerQueue }; diff --git a/apps/server/src/queues/feed-poll.queue.test.ts b/apps/server/src/queues/feed-poll.queue.test.ts index 918718b..2a0ec8e 100644 --- a/apps/server/src/queues/feed-poll.queue.test.ts +++ b/apps/server/src/queues/feed-poll.queue.test.ts @@ -16,7 +16,7 @@ describe('feed poll queue', () => { vi.clearAllMocks(); }); - it('enqueues a single feed poll job with stable identity', async () => { + it('deduplicates a subscription only while its poll job is pending or active', async () => { await enqueueFeedPollJob({ reason: 'manual', subscriptionId: 'feed-1', @@ -26,7 +26,12 @@ describe('feed poll queue', () => { expect(queueMock.add).toHaveBeenCalledWith( 'poll-feed', { reason: 'manual', subscriptionId: 'feed-1', userId: 'user-1' }, - { jobId: 'feed-poll:feed-1:manual' } + { + deduplication: { + id: 'feed-poll:feed-1', + keepLastIfActive: true + } + } ); }); @@ -49,7 +54,12 @@ describe('feed poll queue', () => { 2, 'poll-feed', { reason: 'scheduled', subscriptionId: 'feed-2', userId: 'user-2' }, - { jobId: 'feed-poll:feed-2:scheduled' } + { + deduplication: { + id: 'feed-poll:feed-2', + keepLastIfActive: true + } + } ); }); }); diff --git a/apps/server/src/queues/feed-poll.queue.ts b/apps/server/src/queues/feed-poll.queue.ts index 8546a27..c53b17a 100644 --- a/apps/server/src/queues/feed-poll.queue.ts +++ b/apps/server/src/queues/feed-poll.queue.ts @@ -26,7 +26,10 @@ enqueueFeedPoll.on('error', (error: Error) => { export async function enqueueFeedPollJob(data: FeedPollJobData): Promise> { return enqueueFeedPoll.add('poll-feed', data, { - jobId: `feed-poll:${data.subscriptionId}:${data.reason}` + deduplication: { + id: `feed-poll:${data.subscriptionId}`, + keepLastIfActive: true + } }); } diff --git a/apps/server/src/repositories/feed-items.repository.test.ts b/apps/server/src/repositories/feed-items.repository.test.ts index a6d194e..1ca025f 100644 --- a/apps/server/src/repositories/feed-items.repository.test.ts +++ b/apps/server/src/repositories/feed-items.repository.test.ts @@ -1,3 +1,4 @@ +import { sql } from 'drizzle-orm'; import { describe, expect, it } from 'vitest'; import { db } from '@/db/index.js'; @@ -153,6 +154,57 @@ describe('feed items repository', () => { expect(oldestFirst.items.map((item) => item.id)).toEqual([oldest.id, middle.id]); }); + it('traverses sub-millisecond timestamp ties without gaps in both directions', async () => { + const items = createDrizzleFeedItemsAdapter(db); + await seedUser(USER_A_ID, 'feed-items-a@example.com'); + const subscription = await seedSubscription(USER_A_ID); + const ids = [ + '21000000-0000-0000-0000-000000000001', + '21000000-0000-0000-0000-000000000002', + '21000000-0000-0000-0000-000000000003' + ]; + + await db.execute(sql` + insert into feed_items ( + id, subscription_id, user_id, url, normalized_url, title, + published_at, discovered_at, state + ) values + (${ids[0]}::uuid, ${subscription.id}::uuid, ${USER_A_ID}::uuid, + 'https://example.com/micro-1', 'https://example.com/micro-1', 'Micro 1', + '2026-06-28T12:00:00.123100Z'::timestamptz, + '2026-06-28T12:00:00.123100Z'::timestamptz, 'new'), + (${ids[1]}::uuid, ${subscription.id}::uuid, ${USER_A_ID}::uuid, + 'https://example.com/micro-2', 'https://example.com/micro-2', 'Micro 2', + '2026-06-28T12:00:00.123500Z'::timestamptz, + '2026-06-28T12:00:00.123500Z'::timestamptz, 'new'), + (${ids[2]}::uuid, ${subscription.id}::uuid, ${USER_A_ID}::uuid, + 'https://example.com/micro-3', 'https://example.com/micro-3', 'Micro 3', + '2026-06-28T12:00:00.123900Z'::timestamptz, + '2026-06-28T12:00:00.123900Z'::timestamptz, 'new') + `); + + async function traverse(sort: 'newest' | 'oldest') { + const visited: string[] = []; + let cursor: string | undefined; + + do { + const page = await items.findManyForReview({ + cursor, + limit: 1, + sort, + userId: USER_A_ID + }); + visited.push(...page.items.map((item) => item.id)); + cursor = page.nextCursor; + } while (cursor); + + return visited; + } + + await expect(traverse('newest')).resolves.toEqual([...ids].reverse()); + await expect(traverse('oldest')).resolves.toEqual(ids); + }); + it('summarizes item states for one user-owned subscription', async () => { const items = createDrizzleFeedItemsAdapter(db); await seedUser(USER_A_ID, 'feed-items-a@example.com'); diff --git a/apps/server/src/repositories/feed-items.repository.ts b/apps/server/src/repositories/feed-items.repository.ts index 5b5f0a8..e591029 100644 --- a/apps/server/src/repositories/feed-items.repository.ts +++ b/apps/server/src/repositories/feed-items.repository.ts @@ -215,6 +215,7 @@ export function createDrizzleFeedItemsAdapter(db: DrizzleClient): FeedItemsRepos const limit = Math.max(1, Math.min(requestedLimit ?? 24, 60)); const ascending = sort === 'oldest'; const effectiveDate = sql`coalesce(${feedItemsTable.publishedAt}, ${feedItemsTable.discoveredAt})`; + const effectiveCursor = sql`to_char(${effectiveDate} at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`; const baseConditions = [eq(feedItemsTable.userId, userId)]; if (state) baseConditions.push(eq(feedItemsTable.state, state)); @@ -230,7 +231,7 @@ export function createDrizzleFeedItemsAdapter(db: DrizzleClient): FeedItemsRepos const pageConditions = [...baseConditions]; if (cursor) { const cursorData = decodeCursor(cursor); - const cursorDate = new Date(cursorData.createdAt); + const cursorDate = sql`${cursorData.createdAt}::timestamptz`; const cursorCondition = ascending ? or( gt(effectiveDate, cursorDate), @@ -245,25 +246,26 @@ export function createDrizzleFeedItemsAdapter(db: DrizzleClient): FeedItemsRepos } const rows = (await db - .select(itemColumns) + .select({ ...itemColumns, effectiveCursor }) .from(feedItemsTable) .where(and(...pageConditions)) .orderBy( ascending ? asc(effectiveDate) : desc(effectiveDate), ascending ? asc(feedItemsTable.id) : desc(feedItemsTable.id) ) - .limit(limit + 1)) as FeedItemData[]; + .limit(limit + 1)) as (FeedItemData & { effectiveCursor: string })[]; const hasMore = rows.length > limit; - const items = hasMore ? rows.slice(0, limit) : rows; - const lastItem = items.at(-1); + const pageRows = hasMore ? rows.slice(0, limit) : rows; + const lastRow = pageRows.at(-1); const nextCursor = - hasMore && lastItem + hasMore && lastRow ? encodeCursor({ - createdAt: (lastItem.publishedAt ?? lastItem.discoveredAt).toISOString(), - id: lastItem.id + createdAt: lastRow.effectiveCursor, + id: lastRow.id }) : undefined; + const items = pageRows.map(({ effectiveCursor: _effectiveCursor, ...item }) => item); return { hasMore, items, nextCursor, total }; }, @@ -281,7 +283,7 @@ export function createDrizzleFeedItemsAdapter(db: DrizzleClient): FeedItemsRepos ) .returning({ id: feedItemsTable.id }); - const excessRows = await db + const excessIds = db .select({ id: feedItemsTable.id }) .from(feedItemsTable) .where( @@ -294,14 +296,14 @@ export function createDrizzleFeedItemsAdapter(db: DrizzleClient): FeedItemsRepos .orderBy(desc(feedItemsTable.discoveredAt), desc(feedItemsTable.createdAt)) .offset(keepLatest); - if (excessRows.length === 0) return oldRows.length; - const removedExcess = await db .delete(feedItemsTable) .where( - inArray( - feedItemsTable.id, - excessRows.map((row) => row.id) + and( + eq(feedItemsTable.subscriptionId, subscriptionId), + eq(feedItemsTable.userId, userId), + ne(feedItemsTable.state, 'saved'), + inArray(feedItemsTable.id, excessIds) ) ) .returning({ id: feedItemsTable.id }); diff --git a/apps/server/src/services/storage.service.ts b/apps/server/src/services/storage.service.ts index 2119fbd..b512f2c 100644 --- a/apps/server/src/services/storage.service.ts +++ b/apps/server/src/services/storage.service.ts @@ -224,7 +224,7 @@ class LocalStorageAdapter implements IStorageService { logger.info(`Downloading image from URL: ${imageUrl} (attempt ${attempt})`); const response = await fetchWithValidatedRedirects(imageUrl, { - headers: { 'User-Agent': rotatedUserAgent }, + headers: { 'User-Agent': rotatedUserAgent ?? 'Mozilla/5.0' }, timeoutMs: 5000 }); @@ -410,7 +410,7 @@ class S3StorageAdapter implements IStorageService { } const response = await fetchWithValidatedRedirects(imageUrl, { - headers: { 'User-Agent': rotatedUserAgent }, + headers: { 'User-Agent': rotatedUserAgent ?? 'Mozilla/5.0' }, timeoutMs: 5000 }); diff --git a/apps/server/src/workers/content-extraction.worker.test.ts b/apps/server/src/workers/content-extraction.worker.test.ts index 92e41c1..6180044 100644 --- a/apps/server/src/workers/content-extraction.worker.test.ts +++ b/apps/server/src/workers/content-extraction.worker.test.ts @@ -43,11 +43,15 @@ const importSessionsAdapterMock = vi.hoisted(() => ({ vi.mock('@/db/index.js', () => ({ db: {} })); +vi.mock('@/index.js', () => ({ getIsShuttingDown: vi.fn(() => false) })); + vi.mock('@/lib/job-queue.js', () => ({ createWorker: createWorkerMock, createQueue: vi.fn(() => ({ add: vi.fn(), - on: vi.fn() + close: vi.fn(), + on: vi.fn(), + upsertJobScheduler: vi.fn() })) })); diff --git a/apps/server/src/workers/csv-import.worker.test.ts b/apps/server/src/workers/csv-import.worker.test.ts index e515a85..abf36b2 100644 --- a/apps/server/src/workers/csv-import.worker.test.ts +++ b/apps/server/src/workers/csv-import.worker.test.ts @@ -73,7 +73,9 @@ vi.mock('@/lib/job-queue.js', () => ({ createWorker: createWorkerMock, createQueue: vi.fn(() => ({ add: vi.fn(), - on: vi.fn() + close: vi.fn(), + on: vi.fn(), + upsertJobScheduler: vi.fn() })) })); diff --git a/apps/server/src/workers/feed-poll-scheduler.worker.test.ts b/apps/server/src/workers/feed-poll-scheduler.worker.test.ts new file mode 100644 index 0000000..9412aee --- /dev/null +++ b/apps/server/src/workers/feed-poll-scheduler.worker.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const createWorkerMock = vi.hoisted(() => vi.fn(() => ({ close: vi.fn(), on: vi.fn() }))); +const feedSubscriptionsAdapterMock = vi.hoisted(() => ({ findDue: vi.fn() })); +const enqueueDueFeedPollsMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/db/index.js', () => ({ db: {} })); +vi.mock('@/lib/job-queue.js', () => ({ createWorker: createWorkerMock })); +vi.mock('@/queues/feed-poll.queue.js', () => ({ + enqueueDueFeedPolls: enqueueDueFeedPollsMock +})); +vi.mock('@/repositories/feed-subscriptions.repository.js', () => ({ + createDrizzleFeedSubscriptionsAdapter: vi.fn(() => feedSubscriptionsAdapterMock) +})); + +const { scanDueFeedsJob } = await import('./feed-poll-scheduler.worker.js'); + +describe('feed poll scheduler worker', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('scans one bounded batch and enqueues each due subscription', async () => { + enqueueDueFeedPollsMock.mockResolvedValue(3); + + await expect( + scanDueFeedsJob({ data: { batchSize: 75 }, id: 'scan-1' } as never) + ).resolves.toEqual({ enqueued: 3, status: 'completed' }); + + expect(enqueueDueFeedPollsMock).toHaveBeenCalledWith({ + feedSubscriptions: feedSubscriptionsAdapterMock, + limit: 75 + }); + }); +}); diff --git a/apps/server/src/workers/feed-poll-scheduler.worker.ts b/apps/server/src/workers/feed-poll-scheduler.worker.ts new file mode 100644 index 0000000..cae0131 --- /dev/null +++ b/apps/server/src/workers/feed-poll-scheduler.worker.ts @@ -0,0 +1,46 @@ +import type { Job } from 'bullmq'; + +import type { FeedPollScanJobData } from '@/queues/feed-poll-scheduler.queue.js'; +import { enqueueDueFeedPolls } from '@/queues/feed-poll.queue.js'; + +import { db } from '@/db/index.js'; + +import { createWorker } from '@/lib/job-queue.js'; +import { logger } from '@/lib/logger.js'; + +import { createDrizzleFeedSubscriptionsAdapter } from '@/repositories/feed-subscriptions.repository.js'; + +const workerName = 'feed-poll-scheduler-worker'; +const feedSubscriptions = createDrizzleFeedSubscriptionsAdapter(db); + +export async function scanDueFeedsJob( + job: Job +): Promise<{ enqueued: number; status: 'completed' }> { + const enqueued = await enqueueDueFeedPolls({ + feedSubscriptions, + limit: job.data.batchSize + }); + + logger.info(`[${workerName}] Due-feed scan ${job.id} enqueued ${enqueued} subscription(s)`); + return { enqueued, status: 'completed' }; +} + +const feedPollSchedulerWorker = createWorker('feed-poll-scheduler', scanDueFeedsJob, { + concurrency: 1 +}); + +feedPollSchedulerWorker.on('completed', (job: Job) => { + logger.info(`[${workerName}] Job ${job.id} completed`); +}); + +feedPollSchedulerWorker.on('failed', (job: Job | undefined, error: Error) => { + logger.error(`[${workerName}] Job ${job?.id ?? 'unknown'} failed: ${error.message}`); +}); + +feedPollSchedulerWorker.on('error', (error: Error) => { + logger.error(`[${workerName}] Error: ${JSON.stringify(error)}`); +}); + +logger.info(`[${workerName}] Feed poll scheduler worker started and listening for scan jobs.`); + +export default feedPollSchedulerWorker; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b8b0ad8..3267549 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -54,6 +54,8 @@ services: CORS_ORIGINS: ${CORS_ORIGINS} REDIS_HOST: loreo-redis REDIS_PORT: 6379 + FEED_POLL_SCAN_INTERVAL_MS: ${FEED_POLL_SCAN_INTERVAL_MS:-60000} + FEED_POLL_SCAN_BATCH_SIZE: ${FEED_POLL_SCAN_BATCH_SIZE:-100} STORAGE_PROVIDER: local-docker STORAGE_PATH: /app/data/storage PUBLIC_URL: ${PUBLIC_URL:-http://localhost:3001} diff --git a/docker-compose.yml b/docker-compose.yml index 2aedb98..e4ec108 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,8 @@ services: CORS_ORIGINS: '*' REDIS_HOST: redis REDIS_PORT: 6379 + FEED_POLL_SCAN_INTERVAL_MS: ${FEED_POLL_SCAN_INTERVAL_MS:-60000} + FEED_POLL_SCAN_BATCH_SIZE: ${FEED_POLL_SCAN_BATCH_SIZE:-100} STORAGE_PROVIDER: local-docker STORAGE_PATH: /app/data/storage PUBLIC_URL: http://${HOST_IP:-localhost}:${APP_PORT:-3000} diff --git a/docs/SELF_HOSTING.md b/docs/SELF_HOSTING.md index 1d006e3..bfd8d2f 100644 --- a/docs/SELF_HOSTING.md +++ b/docs/SELF_HOSTING.md @@ -69,6 +69,15 @@ cp .env.prod.example .env.prod | `SERVER_PUBLIC_PORT` | `3000` | Host port for the API container. Change if port 3000 is in use. The server always listens on port 3000 internally; this only controls the host mapping. | | `WEB_PUBLIC_PORT` | `3001` | Host port for the web container. Change if port 3001 is in use. | +### Optional RSS Polling Variables + +| Variable | Default | Description | +| ---------------------------- | ------- | ------------------------------------------------------------- | +| `FEED_POLL_SCAN_INTERVAL_MS` | `60000` | How often BullMQ scans for subscriptions that are due to poll | +| `FEED_POLL_SCAN_BATCH_SIZE` | `100` | Maximum due subscriptions enqueued by each scan | + +BullMQ stores one stable recurring scan scheduler in Redis, so registering it from multiple server replicas does not multiply scheduled scans. + ### Generating Secrets ```bash @@ -242,6 +251,8 @@ docker compose --env-file .env.prod -f docker-compose.prod.yml up -d Database migrations run automatically when the server container starts. The server's `docker-entrypoint.sh` applies any pending migrations before starting the API. +Migration `0004_feed_item_pagination_query_shapes` builds two `feed_items` indexes with standard `CREATE INDEX`, which briefly blocks writes to that table while each index is built. Installations with a large existing RSS collection should schedule this update during a low-traffic maintenance window. + ### Pinning Versions For production stability, consider pinning to specific version tags instead of `latest`: From 837b28baccf624310f9a19af26ef036ba48ba43b Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sat, 18 Jul 2026 18:41:16 +0700 Subject: [PATCH 14/18] feat(feeds): refine review and source management Add confirmed user-scoped feed deletion while preserving saved articles. Simplify management hierarchy, recovery states, image fallbacks, and focus-safe collection virtualization across desktop and mobile. --- .../feed-subscriptions.repository.test.ts | 49 +- .../server/src/routes/feeds/feeds.handlers.ts | 26 + apps/server/src/routes/feeds/feeds.index.ts | 1 + apps/server/src/routes/feeds/feeds.routes.ts | 17 + apps/server/src/routes/feeds/feeds.test.ts | 36 +- apps/server/src/routes/feeds/feeds.types.ts | 2 + .../feeds/api/delete-feed-subscription.ts | 33 ++ .../src/features/feeds/api/get-feed-items.ts | 1 + .../feeds/components/add-feed-form.tsx | 8 +- .../feeds/components/delete-feed-dialog.tsx | 83 ++++ .../feeds/components/feed-collection.tsx | 17 +- .../components/feed-manager-dialog.test.tsx | 237 +++++++++ .../feeds/components/feed-manager-dialog.tsx | 461 ++++++++++++------ .../components/virtualized-feed-grid.test.tsx | 71 +++ .../components/virtualized-feed-grid.tsx | 41 +- apps/web/src/pages/feeds.test.tsx | 35 +- apps/web/src/pages/feeds.tsx | 396 +++++++++------ apps/web/src/translations/en.json | 46 +- apps/web/src/translations/id.json | 46 +- 19 files changed, 1270 insertions(+), 336 deletions(-) create mode 100644 apps/web/src/features/feeds/api/delete-feed-subscription.ts create mode 100644 apps/web/src/features/feeds/components/delete-feed-dialog.tsx create mode 100644 apps/web/src/features/feeds/components/feed-manager-dialog.test.tsx create mode 100644 apps/web/src/features/feeds/components/virtualized-feed-grid.test.tsx diff --git a/apps/server/src/repositories/feed-subscriptions.repository.test.ts b/apps/server/src/repositories/feed-subscriptions.repository.test.ts index abf9bd9..0bcdd9b 100644 --- a/apps/server/src/repositories/feed-subscriptions.repository.test.ts +++ b/apps/server/src/repositories/feed-subscriptions.repository.test.ts @@ -1,7 +1,8 @@ +import { eq } from 'drizzle-orm'; import { describe, expect, it } from 'vitest'; import { db } from '@/db/index.js'; -import { usersTable } from '@/db/schemas/index.js'; +import { feedItemsTable, linksTable, usersTable } from '@/db/schemas/index.js'; import { createDrizzleFeedSubscriptionsAdapter } from './feed-subscriptions.repository.js'; @@ -42,6 +43,52 @@ describe('feed subscriptions repository', () => { await expect(repo.findById(subscription.id, USER_B_ID)).resolves.toBeNull(); }); + it('deletes feed item history while preserving saved library articles', async () => { + const repo = createDrizzleFeedSubscriptionsAdapter(db); + await seedUser(USER_A_ID, 'feeds-a@example.com'); + + const subscription = await repo.create({ + feedUrl: 'https://example.com/feed.xml', + normalizedFeedUrl: 'https://example.com/feed.xml', + title: 'Example Feed', + userId: USER_A_ID + }); + const [link] = await db + .insert(linksTable) + .values({ + readingTime: 0, + title: 'Saved Article', + url: 'https://example.com/article', + userId: USER_A_ID + }) + .returning({ id: linksTable.id }); + if (!link) throw new Error('Expected saved article fixture'); + + await db.insert(feedItemsTable).values({ + linkId: link.id, + normalizedUrl: 'https://example.com/article', + state: 'saved', + subscriptionId: subscription.id, + title: 'Saved Article', + url: 'https://example.com/article', + userId: USER_A_ID + }); + + await expect(repo.delete(subscription.id, USER_A_ID)).resolves.toBe(true); + + const feedItems = await db + .select({ id: feedItemsTable.id }) + .from(feedItemsTable) + .where(eq(feedItemsTable.subscriptionId, subscription.id)); + const savedLinks = await db + .select({ id: linksTable.id }) + .from(linksTable) + .where(eq(linksTable.id, link.id)); + + expect(feedItems).toHaveLength(0); + expect(savedLinks).toEqual([{ id: link.id }]); + }); + it('finds duplicate normalized feed URLs per user while allowing another user', async () => { const repo = createDrizzleFeedSubscriptionsAdapter(db); await seedUser(USER_A_ID, 'feeds-a@example.com'); diff --git a/apps/server/src/routes/feeds/feeds.handlers.ts b/apps/server/src/routes/feeds/feeds.handlers.ts index 7064a2d..58a9e59 100644 --- a/apps/server/src/routes/feeds/feeds.handlers.ts +++ b/apps/server/src/routes/feeds/feeds.handlers.ts @@ -9,6 +9,7 @@ import { saveLink } from '@/services/link-save.service.js'; import type { CreateFeedSubscriptionRoute, + DeleteFeedSubscriptionRoute, DismissFeedItemRoute, GetFeedSubscriptionSummaryRoute, ListFeedItemsRoute, @@ -114,6 +115,31 @@ export const updateFeedSubscription: AppRouteHandler = async (c) => { + if (isDemoMode()) return c.json(demoModeForbiddenResponse(), HttpStatus.FORBIDDEN); + + const user = c.get('user'); + const repos = requireFeedRepos(c); + const { id } = c.req.valid('param'); + + try { + const deleted = await repos.feedSubscriptions.delete(id, user.id); + if (!deleted) { + const response = errorResponse('Feed subscription not found', HttpStatus.NOT_FOUND); + return c.json(response, response.status); + } + + const response = successResponse({ id }, 'Feed subscription deleted successfully'); + return c.json(response, response.status); + } catch { + const response = errorResponse( + 'An error occurred when deleting feed subscription', + HttpStatus.BAD_REQUEST + ); + return c.json(response, response.status); + } +}; + export const refreshFeedSubscription: AppRouteHandler = async (c) => { if (isDemoMode()) return c.json(demoModeForbiddenResponse(), HttpStatus.FORBIDDEN); diff --git a/apps/server/src/routes/feeds/feeds.index.ts b/apps/server/src/routes/feeds/feeds.index.ts index d18a3a2..e3443fa 100644 --- a/apps/server/src/routes/feeds/feeds.index.ts +++ b/apps/server/src/routes/feeds/feeds.index.ts @@ -8,6 +8,7 @@ const router = createRouter() .openapi(routes.createFeedSubscription, handlers.createFeedSubscription) .openapi(routes.getFeedSubscriptionSummary, handlers.getFeedSubscriptionSummary) .openapi(routes.updateFeedSubscription, handlers.updateFeedSubscription) + .openapi(routes.deleteFeedSubscription, handlers.deleteFeedSubscription) .openapi(routes.refreshFeedSubscription, handlers.refreshFeedSubscription) .openapi(routes.listFeedItems, handlers.listFeedItems) .openapi(routes.saveFeedItem, handlers.saveFeedItem) diff --git a/apps/server/src/routes/feeds/feeds.routes.ts b/apps/server/src/routes/feeds/feeds.routes.ts index 0b137be..f4945af 100644 --- a/apps/server/src/routes/feeds/feeds.routes.ts +++ b/apps/server/src/routes/feeds/feeds.routes.ts @@ -145,6 +145,23 @@ export const updateFeedSubscription = createRoute({ } }); +export const deleteFeedSubscription = createRoute({ + tags, + method: 'delete', + path: '/feeds/subscriptions/:id', + middleware: [currentUser], + request: { params: z.object({ id: z.string() }) }, + responses: { + [HttpStatus.OK]: jsonContent( + successResponseSchema(z.object({ id: z.string() })), + 'Feed subscription deleted successfully' + ), + [HttpStatus.FORBIDDEN]: jsonContent(errorResponseSchema(HttpStatus.FORBIDDEN), ''), + [HttpStatus.NOT_FOUND]: jsonContent(errorResponseSchema(HttpStatus.NOT_FOUND), ''), + [HttpStatus.BAD_REQUEST]: jsonContent(errorResponseSchema(HttpStatus.BAD_REQUEST), '') + } +}); + export const refreshFeedSubscription = createRoute({ tags, method: 'post', diff --git a/apps/server/src/routes/feeds/feeds.test.ts b/apps/server/src/routes/feeds/feeds.test.ts index fe56f56..8d2a33a 100644 --- a/apps/server/src/routes/feeds/feeds.test.ts +++ b/apps/server/src/routes/feeds/feeds.test.ts @@ -493,6 +493,29 @@ describe('feed routes', () => { expect(deniedResponse.status).toBe(HttpStatus.NOT_FOUND); }); + it('deletes only user-owned feed subscriptions', async () => { + const userFeed = subscriptionFixture({ id: 'feed-user', userId: TEST_USER.id }); + const otherFeed = subscriptionFixture({ id: 'feed-other', userId: OTHER_USER_ID }); + built.subscriptions.set(userFeed.id, userFeed); + built.subscriptions.set(otherFeed.id, otherFeed); + + const response = await built.client.feeds.subscriptions[':id'].$delete( + { param: { id: userFeed.id } }, + { headers: { Cookie: authCookie } } + ); + const deniedResponse = await built.client.feeds.subscriptions[':id'].$delete( + { param: { id: otherFeed.id } }, + { headers: { Cookie: authCookie } } + ); + const json = (await response.json()) as { result: { id: string } }; + + expect(response.status).toBe(HttpStatus.OK); + expect(json.result.id).toBe(userFeed.id); + expect(built.subscriptions.has(userFeed.id)).toBe(false); + expect(deniedResponse.status).toBe(HttpStatus.NOT_FOUND); + expect(built.subscriptions.has(otherFeed.id)).toBe(true); + }); + it('saves and dismisses feed items', async () => { const item = itemFixture({ id: 'item-user', url: 'https://example.com/feed-item' }); built.items.set(item.id, item); @@ -517,11 +540,20 @@ describe('feed routes', () => { it('blocks feed mutations in demo mode', async () => { demoMode = true; - const response = await built.client.feeds.subscriptions.$post( + const feed = subscriptionFixture({ id: 'feed-user', userId: TEST_USER.id }); + built.subscriptions.set(feed.id, feed); + + const createResponse = await built.client.feeds.subscriptions.$post( { json: { feedUrl: 'https://example.com/feed.xml' } }, { headers: { Cookie: authCookie } } ); + const deleteResponse = await built.client.feeds.subscriptions[':id'].$delete( + { param: { id: feed.id } }, + { headers: { Cookie: authCookie } } + ); - expect(response.status).toBe(HttpStatus.FORBIDDEN); + expect(createResponse.status).toBe(HttpStatus.FORBIDDEN); + expect(deleteResponse.status).toBe(HttpStatus.FORBIDDEN); + expect(built.subscriptions.has(feed.id)).toBe(true); }); }); diff --git a/apps/server/src/routes/feeds/feeds.types.ts b/apps/server/src/routes/feeds/feeds.types.ts index 43a5523..5852888 100644 --- a/apps/server/src/routes/feeds/feeds.types.ts +++ b/apps/server/src/routes/feeds/feeds.types.ts @@ -1,5 +1,6 @@ import type { createFeedSubscription, + deleteFeedSubscription, dismissFeedItem, getFeedSubscriptionSummary, listFeedItems, @@ -11,6 +12,7 @@ import type { export type ListFeedSubscriptionsRoute = typeof listFeedSubscriptions; export type CreateFeedSubscriptionRoute = typeof createFeedSubscription; +export type DeleteFeedSubscriptionRoute = typeof deleteFeedSubscription; export type GetFeedSubscriptionSummaryRoute = typeof getFeedSubscriptionSummary; export type UpdateFeedSubscriptionRoute = typeof updateFeedSubscription; export type RefreshFeedSubscriptionRoute = typeof refreshFeedSubscription; diff --git a/apps/web/src/features/feeds/api/delete-feed-subscription.ts b/apps/web/src/features/feeds/api/delete-feed-subscription.ts new file mode 100644 index 0000000..7f2a3a7 --- /dev/null +++ b/apps/web/src/features/feeds/api/delete-feed-subscription.ts @@ -0,0 +1,33 @@ +import { useMutation } from '@tanstack/react-query'; + +import { apiClient } from '@/lib/api-client'; +import type { MutationConfig } from '@/lib/react-query'; + +import type { ApiResult } from '@/types/api'; + +import { feedKeys } from './query-keys'; + +type DeleteFeedSubscriptionVariables = { + subscriptionId: string; +}; + +export const deleteFeedSubscription = async ({ + subscriptionId +}: DeleteFeedSubscriptionVariables): Promise> => { + const response = await apiClient.delete(`feeds/subscriptions/${subscriptionId}`); + + return response.json(); +}; + +type UseDeleteFeedSubscriptionOptions = { + mutationConfig?: MutationConfig; +}; + +export const useDeleteFeedSubscription = ({ + mutationConfig +}: UseDeleteFeedSubscriptionOptions = {}) => + useMutation({ + ...mutationConfig, + mutationFn: deleteFeedSubscription, + meta: { invalidates: [feedKeys.all] } + }); diff --git a/apps/web/src/features/feeds/api/get-feed-items.ts b/apps/web/src/features/feeds/api/get-feed-items.ts index a36cec1..4f2c476 100644 --- a/apps/web/src/features/feeds/api/get-feed-items.ts +++ b/apps/web/src/features/feeds/api/get-feed-items.ts @@ -52,6 +52,7 @@ export const useFeedItems = ({ filters = {}, queryConfig }: UseFeedItemsOptions isError: infiniteQuery.isError, isFetchingNextPage: infiniteQuery.isFetchingNextPage, isLoading: infiniteQuery.isLoading, + refetch: infiniteQuery.refetch, total: infiniteQuery.data?.pages[0]?.pagination.total ?? 0 }; }; diff --git a/apps/web/src/features/feeds/components/add-feed-form.tsx b/apps/web/src/features/feeds/components/add-feed-form.tsx index 4bce050..c8c2bbe 100644 --- a/apps/web/src/features/feeds/components/add-feed-form.tsx +++ b/apps/web/src/features/feeds/components/add-feed-form.tsx @@ -141,7 +141,7 @@ export function AddFeedForm({
-