diff --git a/CHANGELOG.md b/CHANGELOG.md index 205b00d..3598b49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Add user-curated RSS and Atom subscriptions with staged Review, Saved, and Dismissed collections. +- Add source filtering, newest/oldest sorting, cursor-based infinite loading, and responsive feed management. +- Add per-feed pause, future-only Auto-save, health warnings, and confirmed deletion that preserves saved articles. +- Add autonomous conditional polling with failure backoff, bounded retention, SSRF-safe fetching, and parser workload limits. - Add admin dashboard with dedicated layout for account management and service health visibility. - Add admin account management: list, detail, update name/role, reset password, soft delete, and restore users. - Expose user role in current-user response; client role gates admin navigation affordance only. 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/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/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..d9020ea 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'; @@ -12,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'; @@ -24,6 +27,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), @@ -43,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/db/migrations/0001_create_rss_feed_tables.sql b/apps/server/src/db/migrations/0001_create_rss_feed_tables.sql new file mode 100644 index 0000000..636b670 --- /dev/null +++ b/apps/server/src/db/migrations/0001_create_rss_feed_tables.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"); 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/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/0005_feed_owner_constraints.sql b/apps/server/src/db/migrations/0005_feed_owner_constraints.sql new file mode 100644 index 0000000..c7f7da9 --- /dev/null +++ b/apps/server/src/db/migrations/0005_feed_owner_constraints.sql @@ -0,0 +1,6 @@ +CREATE UNIQUE INDEX "uq_feed_subscriptions_id_user" ON "feed_subscriptions" USING btree ("id","user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "uq_links_id_user" ON "links" USING btree ("id","user_id");--> statement-breakpoint +ALTER TABLE "feed_items" ADD CONSTRAINT "fk_feed_items_subscription_owner" FOREIGN KEY ("subscription_id","user_id") REFERENCES "public"."feed_subscriptions"("id","user_id") ON DELETE cascade ON UPDATE no action NOT VALID;--> statement-breakpoint +ALTER TABLE "feed_items" ADD CONSTRAINT "fk_feed_items_link_owner" FOREIGN KEY ("link_id","user_id") REFERENCES "public"."links"("id","user_id") ON DELETE no action ON UPDATE no action NOT VALID;--> statement-breakpoint +ALTER TABLE "feed_items" ADD CONSTRAINT "chk_feed_items_state" CHECK ("feed_items"."state" in ('new', 'dismissed', 'saved')) NOT VALID;--> statement-breakpoint +ALTER TABLE "feed_subscriptions" ADD CONSTRAINT "chk_feed_subscriptions_status" CHECK ("feed_subscriptions"."status" in ('active', 'paused')) NOT VALID; 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/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/0005_snapshot.json b/apps/server/src/db/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..e7a2cc3 --- /dev/null +++ b/apps/server/src/db/migrations/meta/0005_snapshot.json @@ -0,0 +1,2072 @@ +{ + "id": "5ac123a5-d46d-4537-a3c5-98d178865d36", + "prevId": "db71409b-99e9-449b-ae09-9e34f87b19e6", + "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" + }, + "fk_feed_items_subscription_owner": { + "name": "fk_feed_items_subscription_owner", + "tableFrom": "feed_items", + "tableTo": "feed_subscriptions", + "columnsFrom": ["subscription_id", "user_id"], + "columnsTo": ["id", "user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fk_feed_items_link_owner": { + "name": "fk_feed_items_link_owner", + "tableFrom": "feed_items", + "tableTo": "links", + "columnsFrom": ["link_id", "user_id"], + "columnsTo": ["id", "user_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chk_feed_items_state": { + "name": "chk_feed_items_state", + "value": "\"feed_items\".\"state\" in ('new', 'dismissed', 'saved')" + } + }, + "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_id_user": { + "name": "uq_feed_subscriptions_id_user", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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": { + "chk_feed_subscriptions_status": { + "name": "chk_feed_subscriptions_status", + "value": "\"feed_subscriptions\".\"status\" in ('active', 'paused')" + } + }, + "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": { + "uq_links_id_user": { + "name": "uq_links_id_user", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "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..f00fc66 100644 --- a/apps/server/src/db/migrations/meta/_journal.json +++ b/apps/server/src/db/migrations/meta/_journal.json @@ -8,6 +8,41 @@ "when": 1777355318209, "tag": "0000_melted_mandroid", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1782600940254, + "tag": "0001_create_rss_feed_tables", + "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 + }, + { + "idx": 4, + "version": "7", + "when": 1784366874258, + "tag": "0004_feed_item_pagination_query_shapes", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1784379225121, + "tag": "0005_feed_owner_constraints", + "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..bdc70d4 --- /dev/null +++ b/apps/server/src/db/migrations/rss-feed-upgrade.test.ts @@ -0,0 +1,198 @@ +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', '') + .replaceAll('"public"."feed_subscriptions"', '"rss_feed_upgrade_test"."feed_subscriptions"') + .replaceAll('"public"."links"', '"rss_feed_upgrade_test"."links"') + ) + ); +} + +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 db.execute(sql` + create table rss_feed_upgrade_test.links ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references public.users(id) on delete cascade + ) + `); + + 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 userA = '30000000-0000-0000-0000-000000000001'; + const userB = '30000000-0000-0000-0000-000000000002'; + await db.execute(sql` + insert into public.users (id, email, password_hash, name, settings) + values + (${userA}::uuid, 'rss-upgrade-a@example.com', 'hash', 'Upgrade A', '{}'::jsonb), + (${userB}::uuid, 'rss-upgrade-b@example.com', 'hash', 'Upgrade B', '{}'::jsonb) + on conflict (id) do nothing + `); + await db.execute(sql` + insert into rss_feed_upgrade_test.feed_subscriptions ( + id, user_id, feed_url, normalized_feed_url, title, status + ) values + ('30000000-0000-0000-0000-000000000010', ${userA}::uuid, + 'https://example.com/feed', 'https://example.com/feed', 'Valid legacy feed', 'active'), + ('30000000-0000-0000-0000-000000000011', ${userA}::uuid, + 'https://example.com/invalid', 'https://example.com/invalid', 'Invalid legacy feed', 'legacy') + `); + await db.execute(sql` + insert into rss_feed_upgrade_test.links (id, user_id) + values ('30000000-0000-0000-0000-000000000020', ${userB}::uuid) + `); + await db.execute(sql` + insert into rss_feed_upgrade_test.feed_items ( + id, subscription_id, user_id, link_id, url, normalized_url, title, state + ) values + ('30000000-0000-0000-0000-000000000030', + '30000000-0000-0000-0000-000000000010', ${userB}::uuid, null, + 'https://example.com/cross-sub', 'https://example.com/cross-sub', + 'Legacy cross-owner subscription', 'new'), + ('30000000-0000-0000-0000-000000000031', + '30000000-0000-0000-0000-000000000010', ${userA}::uuid, + '30000000-0000-0000-0000-000000000020', + 'https://example.com/cross-link', 'https://example.com/cross-link', + 'Legacy cross-owner link', 'saved'), + ('30000000-0000-0000-0000-000000000032', + '30000000-0000-0000-0000-000000000010', ${userA}::uuid, null, + 'https://example.com/invalid-state', 'https://example.com/invalid-state', + 'Legacy invalid state', 'legacy') + `); + + await applyMigration('./0005_feed_owner_constraints.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' + ]) + ); + + const constraintResult = await db.execute<{ conname: string }>(sql` + select constraint_name as conname + from information_schema.table_constraints + where table_schema = 'rss_feed_upgrade_test' + and table_name in ('feed_items', 'feed_subscriptions') + `); + const constraints = constraintResult.rows.map(({ conname }) => conname); + expect(constraints).toEqual( + expect.arrayContaining([ + 'chk_feed_items_state', + 'chk_feed_subscriptions_status', + 'fk_feed_items_link_owner', + 'fk_feed_items_subscription_owner' + ]) + ); + + const validationResult = await db.execute<{ conname: string; validated: boolean }>(sql` + select conname, convalidated as validated + from pg_constraint + where conname in ( + 'chk_feed_items_state', + 'chk_feed_subscriptions_status', + 'fk_feed_items_link_owner', + 'fk_feed_items_subscription_owner' + ) + and conrelid in ( + 'rss_feed_upgrade_test.feed_items'::regclass, + 'rss_feed_upgrade_test.feed_subscriptions'::regclass + ) + order by conname + `); + expect(validationResult.rows).toEqual([ + { conname: 'chk_feed_items_state', validated: false }, + { conname: 'chk_feed_subscriptions_status', validated: false }, + { conname: 'fk_feed_items_link_owner', validated: false }, + { conname: 'fk_feed_items_subscription_owner', validated: false } + ]); + + const legacyViolations = await db.execute<{ violations: number }>(sql` + select count(*)::int as violations + from rss_feed_upgrade_test.feed_items item + left join rss_feed_upgrade_test.feed_subscriptions subscription + on subscription.id = item.subscription_id and subscription.user_id = item.user_id + left join rss_feed_upgrade_test.links link + on link.id = item.link_id and link.user_id = item.user_id + where subscription.id is null + or (item.link_id is not null and link.id is null) + or item.state not in ('new', 'dismissed', 'saved') + `); + expect(legacyViolations.rows[0]?.violations).toBe(3); + + await db.execute(sql` + delete from rss_feed_upgrade_test.feed_items + where id in ( + '30000000-0000-0000-0000-000000000030', + '30000000-0000-0000-0000-000000000031', + '30000000-0000-0000-0000-000000000032' + ) + `); + await db.execute(sql` + delete from rss_feed_upgrade_test.feed_subscriptions + where id = '30000000-0000-0000-0000-000000000011' + `); + await db.execute(sql` + alter table rss_feed_upgrade_test.feed_items + validate constraint fk_feed_items_subscription_owner; + alter table rss_feed_upgrade_test.feed_items + validate constraint fk_feed_items_link_owner; + alter table rss_feed_upgrade_test.feed_items + validate constraint chk_feed_items_state; + alter table rss_feed_upgrade_test.feed_subscriptions + validate constraint chk_feed_subscriptions_status; + `); + + const validatedResult = await db.execute<{ validated: boolean }>(sql` + select bool_and(convalidated) as validated + from pg_constraint + where conname in ( + 'chk_feed_items_state', + 'chk_feed_subscriptions_status', + 'fk_feed_items_link_owner', + 'fk_feed_items_subscription_owner' + ) + and conrelid in ( + 'rss_feed_upgrade_test.feed_items'::regclass, + 'rss_feed_upgrade_test.feed_subscriptions'::regclass + ) + `); + expect(validatedResult.rows[0]?.validated).toBe(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..7fdeed4 --- /dev/null +++ b/apps/server/src/db/schemas/feed-items.ts @@ -0,0 +1,108 @@ +import { sql } from 'drizzle-orm'; +import { + check, + foreignKey, + 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) => [ + check('chk_feed_items_state', sql`${table.state} in ('new', 'dismissed', 'saved')`), + foreignKey({ + columns: [table.subscriptionId, table.userId], + foreignColumns: [feedSubscriptionsTable.id, feedSubscriptionsTable.userId], + name: 'fk_feed_items_subscription_owner' + }).onDelete('cascade'), + foreignKey({ + columns: [table.linkId, table.userId], + foreignColumns: [linksTable.id, linksTable.userId], + name: 'fk_feed_items_link_owner' + }), + 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_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, + 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, + 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..fc45654 --- /dev/null +++ b/apps/server/src/db/schemas/feed-subscriptions.ts @@ -0,0 +1,64 @@ +import { sql } from 'drizzle-orm'; +import { + boolean, + check, + 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) => [ + check('chk_feed_subscriptions_status', sql`${table.status} in ('active', 'paused')`), + uniqueIndex('uq_feed_subscriptions_id_user').on(table.id, table.userId), + 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/db/schemas/links.ts b/apps/server/src/db/schemas/links.ts index 8504565..86c0a12 100644 --- a/apps/server/src/db/schemas/links.ts +++ b/apps/server/src/db/schemas/links.ts @@ -68,6 +68,7 @@ export const linksTable = pgTable( updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow() }, (table) => [ + uniqueIndex('uq_links_id_user').on(table.id, table.userId), index('idx_links_user_id').on(table.userId), index('idx_links_url').on(table.url), index('idx_links_title').on(table.title), diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 65ef136..4693881 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -10,8 +10,15 @@ 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) { logger.info('Available routes:'); @@ -28,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() { @@ -41,7 +53,11 @@ 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/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/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/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/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/lib/url-validator.test.ts b/apps/server/src/lib/url-validator.test.ts index a20e592..00fd5d1 100644 --- a/apps/server/src/lib/url-validator.test.ts +++ b/apps/server/src/lib/url-validator.test.ts @@ -35,6 +35,20 @@ describe('isValidUrl', () => { expect(lookupMock).toHaveBeenCalledWith('example.com', { all: true }); }); + it('rejects a hostname when DNS resolution exceeds the timeout', async () => { + vi.useFakeTimers(); + try { + lookupMock.mockReturnValue(new Promise(() => undefined)); + const validation = isValidUrl('https://slow.example/article'); + + await vi.advanceTimersByTimeAsync(2000); + + await expect(validation).resolves.toBe(false); + } finally { + vi.useRealTimers(); + } + }); + it('rejects a public hostname that resolves to a private IP', async () => { lookupMock.mockResolvedValue([ { @@ -47,8 +61,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..d543f9e 100644 --- a/apps/server/src/lib/url-validator.ts +++ b/apps/server/src/lib/url-validator.ts @@ -3,6 +3,8 @@ import { isIP } from 'node:net'; import isPrivateIP from 'private-ip'; +const DNS_LOOKUP_TIMEOUT_MS = 2000; + function isBlockedIpv4Address(address: string): boolean { const [firstOctet = Number.NaN, secondOctet = Number.NaN] = address .split('.') @@ -42,9 +44,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 +81,68 @@ 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; +} + +async function lookupAllWithTimeout(hostname: string) { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + lookup(hostname, { all: true }), + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error('DNS lookup timed out')), + DNS_LOOKUP_TIMEOUT_MS + ); + }) + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +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; + } + + const literalFamily = isIP(hostname); + if (literalFamily !== 0) { + if (isBlockedAddress(hostname)) return null; + return [{ address: hostname, family: literalFamily === 4 ? 4 : 6 }]; } - if (isBlockedAddress(hostname)) { - return false; + const results = await lookupAllWithTimeout(hostname); + if (results.length === 0 || results.some(({ address }) => isBlockedAddress(address))) { + return null; } - if (isIP(hostname) !== 0) { - return true; + const publicAddresses: PublicAddress[] = []; + for (const { address, family } of results) { + if (family === 4 || family === 6) { + publicAddresses.push({ address, family }); + } } - return !(await resolvesToBlockedAddress(hostname)); + 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/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/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 new file mode 100644 index 0000000..2a0ec8e --- /dev/null +++ b/apps/server/src/queues/feed-poll.queue.test.ts @@ -0,0 +1,65 @@ +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('deduplicates a subscription only while its poll job is pending or active', 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' }, + { + deduplication: { + id: 'feed-poll:feed-1', + keepLastIfActive: true + } + } + ); + }); + + 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' }, + { + 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 new file mode 100644 index 0000000..c53b17a --- /dev/null +++ b/apps/server/src/queues/feed-poll.queue.ts @@ -0,0 +1,55 @@ +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, { + deduplication: { + id: `feed-poll:${data.subscriptionId}`, + keepLastIfActive: true + } + }); +} + +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/repositories/feed-items.repository.test.ts b/apps/server/src/repositories/feed-items.repository.test.ts new file mode 100644 index 0000000..aa5c57d --- /dev/null +++ b/apps/server/src/repositories/feed-items.repository.test.ts @@ -0,0 +1,618 @@ +import { sql } from 'drizzle-orm'; +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({ items: [{ id: itemA.id, title: 'Item A' }], total: 1 }); + await expect( + items.findManyForReview({ state: 'new', userId: USER_B_ID }) + ).resolves.toMatchObject({ items: [expect.any(Object)], total: 1 }); + }); + + it('rejects feed items whose user does not own the 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); + + await expect( + items.create({ + normalizedUrl: 'https://example.com/cross-user-subscription', + subscriptionId: subscriptionA.id, + title: 'Cross-user subscription', + url: 'https://example.com/cross-user-subscription', + userId: USER_B_ID + }) + ).rejects.toThrow(); + }); + + it("rejects feed items linked to another user's saved article", 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 linkB = await seedLink( + USER_B_ID, + '20000000-0000-0000-0000-000000000011', + 'https://example.com/user-b-post' + ); + + await expect( + items.create({ + linkId: linkB, + normalizedUrl: 'https://example.com/cross-user-link', + state: 'saved', + subscriptionId: subscriptionA.id, + title: 'Cross-user link', + url: 'https://example.com/cross-user-link', + userId: USER_A_ID + }) + ).rejects.toThrow(); + }); + + it('rejects invalid feed item states at the database boundary', async () => { + await seedUser(USER_A_ID, 'feed-items-a@example.com'); + const subscription = await seedSubscription(USER_A_ID); + + await expect( + db.execute(sql` + insert into feed_items ( + subscription_id, user_id, url, normalized_url, title, state + ) values ( + ${subscription.id}::uuid, + ${USER_A_ID}::uuid, + 'https://example.com/invalid-state', + 'https://example.com/invalid-state', + 'Invalid state', + 'unexpected' + ) + `) + ).rejects.toThrow(); + }); + + 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('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'); + 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 () => { + 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(created.created).toBe(true); + expect(updatedByGuid.created).toBe(false); + expect(updatedByUrl.created).toBe(false); + expect(updatedByGuid.item.id).toBe(created.item.id); + expect(updatedByGuid.item.title).toBe('Updated title'); + expect(updatedByUrl.item.id).toBe(created.item.id); + expect(updatedByUrl.item.title).toBe('Updated by URL'); + await expect(items.findManyForReview({ userId: USER_A_ID })).resolves.toMatchObject({ + items: [expect.any(Object)], + total: 1 + }); + }); + + it('resolves concurrent identity inserts to one feed item', async () => { + const items = createDrizzleFeedItemsAdapter(db); + await seedUser(USER_A_ID, 'feed-items-a@example.com'); + const subscription = await seedSubscription(USER_A_ID); + const input = { + guid: 'concurrent-guid', + normalizedUrl: 'https://example.com/concurrent', + subscriptionId: subscription.id, + title: 'Concurrent item', + url: 'https://example.com/concurrent', + userId: USER_A_ID + }; + + const results = await Promise.all([ + items.upsertByIdentity(input), + items.upsertByIdentity(input) + ]); + + expect(results.filter(({ created }) => created)).toHaveLength(1); + expect(new Set(results.map(({ item }) => item.id)).size).toBe(1); + await expect(items.findManyForReview({ userId: USER_A_ID })).resolves.toMatchObject({ + total: 1 + }); + }); + + it('resolves a GUID and URL bridge without overwriting either stored identity', async () => { + const items = createDrizzleFeedItemsAdapter(db); + await seedUser(USER_A_ID, 'feed-items-a@example.com'); + const subscription = await seedSubscription(USER_A_ID); + const guidItem = await items.create({ + guid: 'stable-guid', + normalizedUrl: 'https://example.com/guid-item', + subscriptionId: subscription.id, + title: 'GUID item', + url: 'https://example.com/guid-item', + userId: USER_A_ID + }); + const urlItem = await items.create({ + guid: 'other-guid', + normalizedUrl: 'https://example.com/url-item', + subscriptionId: subscription.id, + title: 'URL item', + url: 'https://example.com/url-item', + userId: USER_A_ID + }); + + const result = await items.upsertByIdentity({ + guid: 'stable-guid', + normalizedUrl: 'https://example.com/url-item', + subscriptionId: subscription.id, + title: 'Updated GUID item', + url: 'https://example.com/url-item', + userId: USER_A_ID + }); + + expect(result).toMatchObject({ + created: false, + item: { + id: guidItem.id, + normalizedUrl: 'https://example.com/guid-item', + title: 'Updated GUID item', + url: 'https://example.com/guid-item' + } + }); + await expect(items.findById(urlItem.id, USER_A_ID)).resolves.toMatchObject({ + id: urlItem.id, + normalizedUrl: 'https://example.com/url-item', + title: 'URL item' + }); + }); + + it('rolls back every item in a failed identity batch', 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 existing = await items.create({ + guid: 'existing-guid', + normalizedUrl: 'https://example.com/existing', + subscriptionId: subscription.id, + title: 'Original title', + url: 'https://example.com/existing', + userId: USER_A_ID + }); + + await expect( + items.upsertManyByIdentity([ + { + guid: 'existing-guid', + normalizedUrl: 'https://example.com/existing', + subscriptionId: subscription.id, + title: 'Partially updated title', + url: 'https://example.com/existing', + userId: USER_A_ID + }, + { + normalizedUrl: 'https://example.com/cross-owner', + subscriptionId: subscription.id, + title: 'Cross-owner item', + url: 'https://example.com/cross-owner', + userId: USER_B_ID + } + ]) + ).rejects.toThrow(); + + await expect(items.findById(existing.id, USER_A_ID)).resolves.toMatchObject({ + id: existing.id, + title: 'Original title' + }); + await expect(items.findManyForReview({ userId: USER_B_ID })).resolves.toMatchObject({ + total: 0 + }); + }); + + 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('clears the feed item link reference when its saved article is deleted', 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 item = await items.create({ + linkId, + normalizedUrl: 'https://example.com/post', + state: 'saved', + subscriptionId: subscription.id, + title: 'Saved item', + url: 'https://example.com/post', + userId: USER_A_ID + }); + + await db.delete(linksTable).where(sql`${linksTable.id} = ${linkId}::uuid`); + + await expect(items.findById(item.id, USER_A_ID)).resolves.toMatchObject({ + id: item.id, + linkId: null + }); + }); + + 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.toMatchObject({ items: [expect.any(Object)], total: 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..55fc1b4 --- /dev/null +++ b/apps/server/src/repositories/feed-items.repository.ts @@ -0,0 +1,445 @@ +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; + 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' + > + >; + +export type UpsertFeedItemResult = { + created: boolean; + item: FeedItemData; +}; + +type UpdateFeedItemData = Partial< + Pick< + FeedItemData, + | 'author' + | 'dismissedAt' + | 'discoveredAt' + | 'excerpt' + | 'guid' + | 'imageUrl' + | 'linkId' + | 'normalizedUrl' + | 'publishedAt' + | 'savedAt' + | 'state' + | 'title' + | 'url' + > +>; + +function createFeedItemValues(data: CreateFeedItemData): typeof feedItemsTable.$inferInsert { + return { + 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 + }; +} + +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; + delete(id: string, userId: string): 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: { + cursor?: string; + limit?: number; + sort?: 'newest' | 'oldest'; + state?: FeedItemState; + subscriptionId?: string; + userId: string; + }): Promise & { total: number }>; + 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; + summarizeBySubscription(subscriptionId: string, userId: string): Promise; + upsertByIdentity(data: CreateFeedItemData): Promise; + upsertManyByIdentity(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 findIdentityMatches( + client: DrizzleClient, + { + guid, + normalizedUrl, + subscriptionId, + userId + }: { + guid?: string | null; + normalizedUrl: string; + subscriptionId: string; + userId: string; + } + ): Promise { + const identityCondition = guid + ? or(eq(feedItemsTable.guid, guid), eq(feedItemsTable.normalizedUrl, normalizedUrl)) + : eq(feedItemsTable.normalizedUrl, normalizedUrl); + + const rows = await client + .select(itemColumns) + .from(feedItemsTable) + .where( + and( + eq(feedItemsTable.subscriptionId, subscriptionId), + eq(feedItemsTable.userId, userId), + identityCondition + ) + ); + + return (rows as FeedItemData[]).sort((left, right) => { + const leftPriority = + guid && left.guid === guid ? 0 : left.normalizedUrl === normalizedUrl ? 1 : 2; + const rightPriority = + guid && right.guid === guid ? 0 : right.normalizedUrl === normalizedUrl ? 1 : 2; + return leftPriority - rightPriority || left.createdAt.getTime() - right.createdAt.getTime(); + }); + } + + async function findBySubscriptionAndIdentity(input: { + guid?: string | null; + normalizedUrl: string; + subscriptionId: string; + userId: string; + }): Promise { + const [row] = await findIdentityMatches(db, input); + return row ?? null; + } + + async function updateWithClient( + client: DrizzleClient, + id: string, + userId: string, + updates: UpdateFeedItemData + ): Promise { + const [row] = await client + .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; + } + + async function update( + id: string, + userId: string, + updates: UpdateFeedItemData + ): Promise { + return updateWithClient(db, id, userId, updates); + } + + async function upsertByIdentityWithClient( + client: DrizzleClient, + data: CreateFeedItemData + ): Promise { + const [inserted] = await client + .insert(feedItemsTable) + .values(createFeedItemValues(data)) + .onConflictDoNothing() + .returning(itemColumns); + + if (inserted) { + return { created: true, item: inserted as FeedItemData }; + } + + const matches = await findIdentityMatches(client, { + guid: data.guid, + normalizedUrl: data.normalizedUrl, + subscriptionId: data.subscriptionId, + userId: data.userId + }); + const existing = matches[0]; + if (!existing) throw new Error('Failed to resolve feed item identity conflict'); + + const urlBelongsToAnotherIdentity = matches.some( + (match) => match.id !== existing.id && match.normalizedUrl === data.normalizedUrl + ); + const updated = await updateWithClient(client, 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: urlBelongsToAnotherIdentity ? existing.normalizedUrl : data.normalizedUrl, + publishedAt: data.publishedAt ?? existing.publishedAt, + title: data.title, + url: urlBelongsToAnotherIdentity ? existing.url : data.url + }); + + if (!updated) throw new Error('Failed to update feed item'); + return { created: false, item: updated }; + } + + return { + findById, + findBySubscriptionAndIdentity, + + async create(data) { + const [row] = await db + .insert(feedItemsTable) + .values(createFeedItemValues(data)) + .returning(itemColumns); + + if (!row) throw new Error('Failed to create feed item'); + return row as FeedItemData; + }, + + async delete(id, userId) { + const rows = await db + .delete(feedItemsTable) + .where(and(eq(feedItemsTable.id, id), eq(feedItemsTable.userId, userId))) + .returning({ id: feedItemsTable.id }); + + return rows.length > 0; + }, + + async dismiss(id, userId, dismissedAt = new Date()) { + return update(id, userId, { dismissedAt, state: 'dismissed' }); + }, + + 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 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)); + 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 = sql`${cursorData.createdAt}::timestamptz`; + 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, effectiveCursor }) + .from(feedItemsTable) + .where(and(...pageConditions)) + .orderBy( + ascending ? asc(effectiveDate) : desc(effectiveDate), + ascending ? asc(feedItemsTable.id) : desc(feedItemsTable.id) + ) + .limit(limit + 1)) as (FeedItemData & { effectiveCursor: string })[]; + + const hasMore = rows.length > limit; + const pageRows = hasMore ? rows.slice(0, limit) : rows; + const lastRow = pageRows.at(-1); + const nextCursor = + hasMore && lastRow + ? encodeCursor({ + createdAt: lastRow.effectiveCursor, + id: lastRow.id + }) + : undefined; + const items = pageRows.map(({ effectiveCursor: _effectiveCursor, ...item }) => item); + + return { hasMore, items, nextCursor, total }; + }, + + async pruneForSubscription({ before, keepLatest, subscriptionId, userId }) { + const excessIds = 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); + + const removed = await db + .delete(feedItemsTable) + .where( + and( + eq(feedItemsTable.subscriptionId, subscriptionId), + eq(feedItemsTable.userId, userId), + ne(feedItemsTable.state, 'saved'), + or(lt(feedItemsTable.discoveredAt, before), inArray(feedItemsTable.id, excessIds)) + ) + ) + .returning({ id: feedItemsTable.id }); + + return removed.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 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) { + return upsertByIdentityWithClient(db, data); + }, + + async upsertManyByIdentity(data) { + return db.transaction(async (transaction) => { + const client = transaction as unknown as DrizzleClient; + const results: UpsertFeedItemResult[] = []; + for (const item of data) results.push(await upsertByIdentityWithClient(client, item)); + return results; + }); + } + }; +} 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..5d940bc --- /dev/null +++ b/apps/server/src/repositories/feed-subscriptions.repository.test.ts @@ -0,0 +1,222 @@ +import { eq, sql } from 'drizzle-orm'; +import { describe, expect, it } from 'vitest'; + +import { db } from '@/db/index.js'; +import { feedItemsTable, linksTable, 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('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'); + 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('rejects invalid subscription statuses at the database boundary', 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 + }); + + await expect( + db.execute(sql` + update feed_subscriptions + set status = 'unexpected' + where id = ${subscription.id}::uuid + `) + ).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 + }; +} 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/feeds/feeds.handlers.ts b/apps/server/src/routes/feeds/feeds.handlers.ts new file mode 100644 index 0000000..58a9e59 --- /dev/null +++ b/apps/server/src/routes/feeds/feeds.handlers.ts @@ -0,0 +1,275 @@ +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, + DeleteFeedSubscriptionRoute, + DismissFeedItemRoute, + GetFeedSubscriptionSummaryRoute, + 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 getFeedSubscriptionSummary: 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); + + 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 deleteFeedSubscription: 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); + + 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 { cursor, limit, sort, state, subscriptionId } = c.req.valid('query'); + const pageLimit = Math.max(1, Math.min(limit ? Number(limit) : 24, 60)); + + try { + const page = await repos.feedItems.findManyForReview({ + cursor, + limit: pageLimit, + sort, + state, + subscriptionId, + userId: user.id + }); + 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', + 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..e3443fa --- /dev/null +++ b/apps/server/src/routes/feeds/feeds.index.ts @@ -0,0 +1,17 @@ +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.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) + .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..f4945af --- /dev/null +++ b/apps/server/src/routes/feeds/feeds.routes.ts @@ -0,0 +1,241 @@ +import { createRoute, z } from '@hono/zod-openapi'; + +import { jsonContent, jsonContentRequired } from '@/lib/openapi.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'; + +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 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', + 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 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', + 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({ + 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( + paginatedSuccessResponseSchema(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..0b9d1f6 --- /dev/null +++ b/apps/server/src/routes/feeds/feeds.test.ts @@ -0,0 +1,565 @@ +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 { 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'; + +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; + }, + delete: async (id, userId) => { + const existing = await feedItems.findById(id, userId); + return existing ? items.delete(id) : false; + }, + 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 ({ + 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( + (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; + }, + 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) => ({ created: true, item: await feedItems.create(data) }), + upsertManyByIdentity: async (data) => + Promise.all(data.map((item) => feedItems.upsertByIdentity(item))) + }; + + 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('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); + + 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('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); + + 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 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(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 new file mode 100644 index 0000000..5852888 --- /dev/null +++ b/apps/server/src/routes/feeds/feeds.types.ts @@ -0,0 +1,21 @@ +import type { + createFeedSubscription, + deleteFeedSubscription, + dismissFeedItem, + getFeedSubscriptionSummary, + listFeedItems, + listFeedSubscriptions, + refreshFeedSubscription, + saveFeedItem, + updateFeedSubscription +} from './feeds.routes.js'; + +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; +export type ListFeedItemsRoute = typeof listFeedItems; +export type SaveFeedItemRoute = typeof saveFeedItem; +export type DismissFeedItemRoute = typeof dismissFeedItem; 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/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-ingestion.service.test.ts b/apps/server/src/services/feed-ingestion.service.test.ts new file mode 100644 index 0000000..cdc1f34 --- /dev/null +++ b/apps/server/src/services/feed-ingestion.service.test.ts @@ -0,0 +1,564 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { + CreateFeedItemData, + FeedItemData, + 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: FeedItemData[] = []; + + const feedSubscriptions = { + create: vi.fn(async () => subscription), + delete: vi.fn(async () => true), + findByNormalizedUrl: vi.fn(async () => options.existingSubscription ?? null), + update: vi.fn(async (_id, _userId, updates) => ({ + ...(options.existingSubscription ?? subscription), + ...updates + })), + updateFetchMetadata: vi.fn(async (_id, _userId, updates) => ({ + ...(options.existingSubscription ?? subscription), + ...updates + })) + } as unknown as FeedSubscriptionsRepository; + + const feedItems = { + delete: vi.fn(async () => true), + pruneForSubscription: vi.fn(async () => 0), + save: vi.fn(async (id, userId, linkId, savedAt = new Date()) => ({ + ...createdItems.find((item) => (item as { id: string }).id === id), + id, + linkId, + savedAt, + state: 'saved', + userId + })), + upsertByIdentity: vi.fn(async (data) => { + const createdAt = new Date(); + const item: FeedItemData = { + author: data.author ?? null, + createdAt, + discoveredAt: data.discoveredAt ?? createdAt, + dismissedAt: null, + excerpt: data.excerpt ?? null, + guid: data.guid ?? null, + id: crypto.randomUUID(), + imageUrl: data.imageUrl ?? null, + linkId: data.linkId ?? null, + normalizedUrl: data.normalizedUrl, + publishedAt: data.publishedAt ?? null, + savedAt: null, + state: data.state ?? 'new', + subscriptionId: data.subscriptionId, + title: data.title, + updatedAt: createdAt, + url: data.url, + userId: data.userId + }; + createdItems.push(item); + return { created: true, item }; + }) + } as unknown as FeedItemsRepository; + feedItems.upsertManyByIdentity = vi.fn(async (items: CreateFeedItemData[]) => + Promise.all(items.map((item) => feedItems.upsertByIdentity(item))) + ); + + const links = { + create: vi.fn(), + findByUrl: vi.fn(async () => null) + } 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('selects the latest 50 initial items even when the feed is oldest-first', async () => { + const { repos } = createRepos(); + const feed = feedFixture(55); + feed.items.reverse(); + const service = createFeedIngestionService({ + fetchFeed: vi.fn(async () => ({ + body: '', + headers: { etag: null, lastModified: null }, + status: 'ok' as const + })), + now: () => NOW, + parseFeedXml: vi.fn(async () => feed), + repos + }); + + await service.addSubscription({ + feedUrl: 'https://example.com/feed.xml', + user: USER + }); + + const stagedGuids = vi + .mocked(repos.feedItems.upsertByIdentity) + .mock.calls.map(([item]) => item.guid); + expect(stagedGuids).toHaveLength(50); + expect(stagedGuids).toContain('guid-0'); + expect(stagedGuids).toContain('guid-49'); + expect(stagedGuids).not.toContain('guid-50'); + }); + + it('returns an existing subscription without fetching on duplicate add', async () => { + const existingSubscription = subscriptionFixture({ + id: 'existing-feed', + lastSuccessfulFetchAt: NOW + }); + 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('resumes ingestion when duplicate add finds an incomplete subscription', async () => { + const existingSubscription = subscriptionFixture({ + id: 'existing-incomplete-feed', + lastSuccessfulFetchAt: null + }); + const { repos } = createRepos({ existingSubscription }); + const fetchFeed = vi.fn(async () => ({ + body: '', + headers: { etag: null, lastModified: null }, + status: 'ok' as const + })); + const service = createFeedIngestionService({ + fetchFeed, + now: () => NOW, + parseFeedXml: vi.fn(async () => feedFixture(1)), + repos + }); + + const result = await service.addSubscription({ + feedUrl: existingSubscription.feedUrl, + user: USER + }); + + expect(result).toMatchObject({ createdSubscription: false, fetched: true, staged: 1 }); + expect(fetchFeed).toHaveBeenCalledOnce(); + }); + + it('returns the concurrent winner when subscription creation hits a duplicate race', async () => { + const concurrent = subscriptionFixture({ + id: 'concurrent-feed', + lastSuccessfulFetchAt: NOW + }); + const { repos } = createRepos(); + vi.mocked(repos.feedSubscriptions.findByNormalizedUrl) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(concurrent); + vi.mocked(repos.feedSubscriptions.create).mockRejectedValueOnce( + new Error('duplicate key value violates unique constraint') + ); + const service = createFeedIngestionService({ + fetchFeed: vi.fn(async () => ({ + body: '', + headers: { etag: null, lastModified: null }, + status: 'ok' as const + })), + now: () => NOW, + parseFeedXml: vi.fn(async () => feedFixture(1)), + repos + }); + + await expect( + service.addSubscription({ feedUrl: concurrent.feedUrl, user: USER }) + ).resolves.toMatchObject({ + createdSubscription: false, + fetched: false, + subscription: { id: concurrent.id } + }); + }); + + it('removes a new subscription when initial item persistence fails', async () => { + const { repos } = createRepos(); + vi.mocked(repos.feedItems.upsertByIdentity).mockRejectedValueOnce( + new Error('database write failed') + ); + const service = createFeedIngestionService({ + fetchFeed: vi.fn(async () => ({ + body: '', + headers: { etag: null, lastModified: null }, + status: 'ok' as const + })), + now: () => NOW, + parseFeedXml: vi.fn(async () => feedFixture(1)), + repos + }); + + await expect( + service.addSubscription({ + feedUrl: 'https://example.com/feed.xml', + user: USER + }) + ).rejects.toThrow(/persist feed items/i); + + expect(repos.feedSubscriptions.delete).toHaveBeenCalledWith(expect.any(String), USER.id); + }); + + 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 first discovered while auto-save is enabled', 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: 'new' }) + ); + expect(repos.feedItems.save).toHaveBeenCalledTimes(2); + }); + + it('does not retroactively auto-save existing review or dismissed items', async () => { + const { repos } = createRepos(); + vi.mocked(repos.feedItems.upsertByIdentity) + .mockResolvedValueOnce({ + created: false, + item: { + ...feedFixture(1).items[0]!, + createdAt: NOW, + discoveredAt: NOW, + dismissedAt: null, + id: 'existing-review-item', + linkId: null, + savedAt: null, + state: 'new', + subscriptionId: 'subscription-id', + updatedAt: NOW, + userId: USER.id + } + }) + .mockResolvedValueOnce({ + created: false, + item: { + ...feedFixture(2).items[1]!, + createdAt: NOW, + discoveredAt: NOW, + dismissedAt: NOW, + id: 'existing-dismissed-item', + linkId: null, + savedAt: null, + state: 'dismissed', + subscriptionId: 'subscription-id', + updatedAt: NOW, + userId: USER.id + } + }); + const saveLink = vi.fn(); + 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: 0, staged: 0 }); + expect(saveLink).not.toHaveBeenCalled(); + expect(repos.feedItems.save).not.toHaveBeenCalled(); + }); + + it('finishes a pending auto-save when the saved link already exists', async () => { + const { repos } = createRepos(); + const existingItem = { + ...feedFixture(1).items[0]!, + createdAt: NOW, + discoveredAt: NOW, + dismissedAt: null, + id: 'pending-auto-save-item', + linkId: null, + savedAt: null, + state: 'new' as const, + subscriptionId: 'feed-subscription-1', + updatedAt: NOW, + userId: USER.id + }; + vi.mocked(repos.feedItems.upsertByIdentity).mockResolvedValueOnce({ + created: false, + item: existingItem + }); + vi.mocked(repos.links.findByUrl).mockResolvedValueOnce({ id: 'existing-link' } as never); + const saveLinkMock = vi.fn(async () => ({ + created: false, + link: { id: 'existing-link' }, + 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(1)), + repos, + saveLink: saveLinkMock as never + }); + + await expect( + service.pollSubscription({ + subscription: subscriptionFixture({ autoSave: true }), + user: USER + }) + ).resolves.toMatchObject({ autoSaved: 1, staged: 0 }); + expect(saveLinkMock).toHaveBeenCalledOnce(); + expect(repos.feedItems.save).toHaveBeenCalledWith(existingItem.id, USER.id, 'existing-link'); + }); + + it('removes a newly discovered item when auto-save fails before a link exists', async () => { + const { repos } = createRepos(); + const service = createFeedIngestionService({ + fetchFeed: vi.fn(async () => ({ + body: '', + headers: { etag: null, lastModified: null }, + status: 'ok' as const + })), + now: () => NOW, + parseFeedXml: vi.fn(async () => feedFixture(1)), + repos, + saveLink: vi.fn(async () => { + throw new Error('queue unavailable'); + }) + }); + + await expect( + service.pollSubscription({ + subscription: subscriptionFixture({ autoSave: true }), + user: USER + }) + ).rejects.toThrow(/auto-save.*feed items/i); + expect(repos.feedItems.delete).toHaveBeenCalledOnce(); + }); + + 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..4cbe11a --- /dev/null +++ b/apps/server/src/services/feed-ingestion.service.ts @@ -0,0 +1,413 @@ +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; + +class FeedItemPersistenceError extends Error { + constructor(cause: unknown) { + super('Failed to persist feed items', { cause }); + this.name = 'FeedItemPersistenceError'; + } +} + +class FeedAutoSaveError extends Error { + constructor(cause: unknown) { + super('Failed to auto-save one or more feed items', { cause }); + this.name = 'FeedAutoSaveError'; + } +} + +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 + .map((item, sourceIndex) => ({ item, sourceIndex })) + .filter(({ item }) => !item.publishedAt || item.publishedAt >= cutoff) + .sort((left, right) => { + const leftTimestamp = left.item.publishedAt?.getTime() ?? Number.NEGATIVE_INFINITY; + const rightTimestamp = right.item.publishedAt?.getTime() ?? Number.NEGATIVE_INFINITY; + return rightTimestamp - leftTimestamp || left.sourceIndex - right.sourceIndex; + }) + .slice(0, INITIAL_ITEM_LIMIT) + .map(({ item }) => item); +} + +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 }> { + const itemData = input.items.map((item) => ({ + author: item.author, + excerpt: item.excerpt, + guid: item.guid, + imageUrl: item.imageUrl, + linkId: null, + normalizedUrl: item.normalizedUrl, + publishedAt: item.publishedAt, + state: 'new' as const, + subscriptionId: input.subscription.id, + title: item.title, + url: item.url, + userId: input.user.id + })); + + let results: Awaited>; + try { + results = await repos.feedItems.upsertManyByIdentity(itemData); + } catch (error) { + throw new FeedItemPersistenceError(error); + } + + const persisted = results.map((result, index) => ({ + created: result.created, + feedItem: result.item, + parsedItem: input.items[index]! + })); + + let autoSaved = 0; + let staged = persisted.filter(({ created }) => created).length; + let firstAutoSaveError: unknown; + + if (input.subscription.autoSave) { + for (const { created, feedItem, parsedItem } of persisted) { + if (feedItem.state !== 'new') continue; + if (!created) { + const existingLink = await repos.links.findByUrl(parsedItem.url, input.user.id); + if (!existingLink) continue; + } + + try { + const saved = await saveLinkDependency({ + reconcileFeedItems: false, + repos, + user: input.user, + url: parsedItem.url + }); + const updated = await repos.feedItems.save(feedItem.id, input.user.id, saved.link.id); + if (!updated) throw new Error('Failed to mark auto-saved feed item as saved'); + autoSaved += 1; + if (created) staged -= 1; + } catch (error) { + firstAutoSaveError ??= error; + + let persistedLink: Awaited> | undefined; + try { + persistedLink = await repos.links.findByUrl(parsedItem.url, input.user.id); + } catch { + persistedLink = undefined; + } + if (persistedLink === null) { + await repos.feedItems.delete(feedItem.id, input.user.id); + } + } + } + } + + if (firstAutoSaveError) throw new FeedAutoSaveError(firstAutoSaveError); + 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 resultForExistingSubscription( + subscription: FeedSubscriptionData, + user: UserWithoutPassword + ): Promise { + if (!subscription.lastSuccessfulFetchAt) { + return pollSubscription({ subscription, user }); + } + return { + autoSaved: 0, + createdSubscription: false, + fetched: false, + pruned: 0, + staged: 0, + subscription + }; + } + + async function addSubscription(input: AddFeedInput): Promise { + const normalizedFeedUrl = normalizeUrl(input.feedUrl); + const existing = await repos.feedSubscriptions.findByNormalizedUrl( + normalizedFeedUrl, + input.user.id + ); + if (existing) { + return resultForExistingSubscription(existing, input.user); + } + + 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: FeedSubscriptionData; + try { + 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 + }); + } catch (error) { + const concurrent = await repos.feedSubscriptions.findByNormalizedUrl( + normalizedFeedUrl, + input.user.id + ); + if (!concurrent) throw error; + return resultForExistingSubscription(concurrent, input.user); + } + + try { + 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 + }); + subscription = await markSuccess({ + feed, + fetchedAt, + headers: fetched.headers, + subscription + }); + + return { + ...stagedResult, + createdSubscription: true, + fetched: true, + pruned, + subscription + }; + } catch (error) { + if (error instanceof FeedItemPersistenceError) { + await repos.feedSubscriptions.delete(subscription.id, input.user.id); + } + throw error; + } + } + + return { + addSubscription, + pollSubscription + }; +} 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..c114459 --- /dev/null +++ b/apps/server/src/services/feed-parser.service.test.ts @@ -0,0 +1,193 @@ +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 RSS 1.0 items that are siblings of the channel', async () => { + const feed = await parseFeedXml( + ` + + + Example RDF + https://example.com/ + RDF summary + + + RDF article + https://example.com/rdf-item + RDF excerpt + + `, + 'https://example.com/feed.rdf' + ); + + expect(feed).toMatchObject({ title: 'Example RDF' }); + expect(feed.items).toHaveLength(1); + expect(feed.items[0]).toMatchObject({ + excerpt: 'RDF excerpt', + title: 'RDF article', + url: 'https://example.com/rdf-item' + }); + }); + + 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('caps item validation work before processing oversized entry lists', async () => { + const items = Array.from( + { length: 600 }, + (_, index) => + `Item ${index}https://example.com/items/${index}${new Date(2026, 0, 1, 0, index).toUTCString()}` + ).join(''); + + const feed = await parseFeedXml( + `Large feed${items}`, + 'https://example.com/feed.xml' + ); + + expect(feed.items).toHaveLength(500); + expect(isSafeFeedEntryUrlMock).toHaveBeenCalledTimes(1); + }); + + it('rejects feeds that exceed the unique-host validation budget', async () => { + const items = Array.from( + { length: 51 }, + (_, index) => + `Item ${index}https://host-${index}.example/items/${index}` + ).join(''); + + await expect( + parseFeedXml( + `Too many hosts${items}`, + 'https://example.com/feed.xml' + ) + ).rejects.toThrow(/too many unique hosts/i); + expect(isSafeFeedEntryUrlMock).toHaveBeenCalledTimes(50); + }); + + 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..9a3bfec --- /dev/null +++ b/apps/server/src/services/feed-parser.service.ts @@ -0,0 +1,322 @@ +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; +const MAX_FEED_ENTRIES = 500; +const MAX_UNIQUE_ENTRY_HOSTS = 50; +const URL_VALIDATION_BUDGET_MS = 5000; + +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 firstDirectText(parent: Element, tagNames: string[]): string | null { + const normalizedNames = new Set(tagNames.map((name) => name.toLowerCase())); + for (const child of Array.from(parent.children)) { + if (!normalizedNames.has(child.tagName.toLowerCase())) continue; + const text = sanitizeText(child.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); +} + +type SafeUrlResolver = (value: string | null, baseUrl: string) => Promise; + +function createSafeUrlResolver(): SafeUrlResolver { + const deadline = Date.now() + URL_VALIDATION_BUDGET_MS; + const safetyByHostname = new Map>(); + + return async (value, baseUrl) => { + const url = resolveUrl(value, baseUrl); + if (!url) return null; + + const parsed = new URL(url); + if ( + !['http:', 'https:'].includes(parsed.protocol) || + parsed.username.length > 0 || + parsed.password.length > 0 + ) { + return null; + } + + if (Date.now() >= deadline) { + throw new FeedParseError('Feed URL validation exceeded its time budget'); + } + + const hostname = parsed.hostname.toLowerCase(); + let safety = safetyByHostname.get(hostname); + if (!safety) { + if (safetyByHostname.size >= MAX_UNIQUE_ENTRY_HOSTS) { + throw new FeedParseError('Feed contains too many unique hosts'); + } + safety = isSafeFeedEntryUrl(url); + safetyByHostname.set(hostname, safety); + } + + const isSafe = await safety; + if (Date.now() >= deadline) { + throw new FeedParseError('Feed URL validation exceeded its time budget'); + } + return isSafe ? url : null; + }; +} + +function boundedNewestElements(elements: Element[], dateTags: string[]): Element[] { + return elements + .map((element, sourceIndex) => ({ + element, + publishedAt: parseDate(firstText(element, dateTags)), + sourceIndex + })) + .sort((left, right) => { + const leftTimestamp = left.publishedAt?.getTime() ?? Number.NEGATIVE_INFINITY; + const rightTimestamp = right.publishedAt?.getTime() ?? Number.NEGATIVE_INFINITY; + return rightTimestamp - leftTimestamp || left.sourceIndex - right.sourceIndex; + }) + .slice(0, MAX_FEED_ENTRIES) + .map(({ element }) => element); +} + +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): string | null { + const links = Array.from(parent.children).filter( + (child) => child.tagName.toLowerCase() === 'link' + ); + 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, + safeUrl: SafeUrlResolver, + isRdf: boolean +): Promise { + const channel = rssChannel(document); + const rawSiteUrl = firstDirectText(channel, ['link']); + const siteUrl = await safeUrl(rawSiteUrl, feedUrl); + const imageUrl = await safeUrl(rssChannelImage(channel), feedUrl); + const title = + sanitizeText(firstDirectText(channel, ['title']), MAX_TITLE_LENGTH) ?? 'Untitled feed'; + const items: NormalizedFeedItem[] = []; + + const itemElements = isRdf + ? (Array.from(document.querySelectorAll('item')) as Element[]) + : (Array.from(channel.querySelectorAll('item')) as Element[]); + for (const item of boundedNewestElements(itemElements, ['pubDate', 'published', 'updated'])) { + 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( + firstDirectText(channel, ['description', 'subtitle']), + MAX_DESCRIPTION_LENGTH + ), + imageUrl, + items, + siteUrl, + title + }; +} + +async function parseAtom( + document: ParsedFeedDocument, + feedUrl: string, + safeUrl: SafeUrlResolver +): Promise { + const feed = atomFeed(document); + const siteUrl = await safeUrl(atomLink(feed), feedUrl); + const imageUrl = await safeUrl(firstDirectText(feed, ['icon', 'logo']), feedUrl); + const title = sanitizeText(firstDirectText(feed, ['title']), MAX_TITLE_LENGTH) ?? 'Untitled feed'; + const items: NormalizedFeedItem[] = []; + + const entryElements = Array.from(feed.querySelectorAll('entry')) as Element[]; + for (const entry of boundedNewestElements(entryElements, ['published', 'updated'])) { + 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( + firstDirectText(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(); + const safeUrl = createSafeUrlResolver(); + + if (rootName === 'rss' || rootName === 'rdf:rdf') { + return parseRss(document, feedUrl, safeUrl, rootName === 'rdf:rdf'); + } + + if (rootName === 'feed') { + return parseAtom(document, feedUrl, safeUrl); + } + + throw new FeedParseError('Unsupported feed XML'); +} 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..b893922 --- /dev/null +++ b/apps/server/src/services/link-save.service.test.ts @@ -0,0 +1,181 @@ +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 + }, + { jobId: `content-extraction-${createdLink.id}` } + ); + 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 completed user link without enqueueing extraction', async () => { + const existingLink = linkFixture({ + id: '00000000-0000-0000-0000-000000000200', + processingStatus: 'completed' + }); + 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('idempotently re-enqueues extraction for an existing pending link', async () => { + const existingLink = linkFixture({ id: '00000000-0000-0000-0000-000000000201' }); + const { repos } = buildRepos(existingLink); + const enqueueExtraction = vi.fn(async () => ({ id: 'job-1' })); + + await saveLink({ + enqueueExtraction, + reconcileFeedItems: false, + repos, + user: TEST_USER, + url: existingLink.url + }); + + expect(enqueueExtraction).toHaveBeenCalledWith( + 'process-new-article', + { linkId: existingLink.id, url: existingLink.url, user: TEST_USER }, + { jobId: `content-extraction-${existingLink.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..f8b35a4 --- /dev/null +++ b/apps/server/src/services/link-save.service.ts @@ -0,0 +1,120 @@ +import type { JobsOptions } from 'bullmq'; + +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, + options?: JobsOptions +) => 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; + const enqueueExtraction = + input.enqueueExtraction ?? enqueueContentExtraction.add.bind(enqueueContentExtraction); + const enqueuePendingExtraction = (link: LinkData, targetUrl = link.url) => + enqueueExtraction( + 'process-new-article', + { + linkId: link.id, + url: targetUrl, + user + }, + { jobId: `content-extraction-${link.id}` } + ); + + if (existing) { + if (existing.processingStatus === 'pending') { + await enqueuePendingExtraction(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'); + + await enqueuePendingExtraction(link, url); + + 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/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/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) }; 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; } 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/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; 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/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/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/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/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.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 new file mode 100644 index 0000000..4f2c476 --- /dev/null +++ b/apps/web/src/features/feeds/api/get-feed-items.ts @@ -0,0 +1,58 @@ +import { infiniteQueryOptions, useInfiniteQuery } from '@tanstack/react-query'; + +import { apiClient } from '@/lib/api-client'; +import type { QueryConfig } from '@/lib/react-query'; + +import type { PaginatedResponseOptions } from '@/types/api'; +import type { FeedItem, FeedItemFilters } from '@/types/feeds'; + +import { feedKeys } from './query-keys'; + +const DEFAULT_PAGE_SIZE = 24; + +const getFeedItems = async ( + filters: FeedItemFilters = {}, + cursor?: string +): Promise> => { + const params = Object.fromEntries( + 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); + + return response.json(); +}; + +export const getFeedItemsQueryOptions = (filters: FeedItemFilters = {}) => + infiniteQueryOptions({ + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage: PaginatedResponseOptions) => + lastPage.pagination.nextCursor, + queryKey: feedKeys.itemList(filters), + queryFn: ({ pageParam }) => getFeedItems(filters, pageParam) + }); + +type UseFeedItemsOptions = { + filters?: FeedItemFilters; + queryConfig?: QueryConfig; +}; + +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, + refetch: infiniteQuery.refetch, + 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/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..5dc2002 --- /dev/null +++ b/apps/web/src/features/feeds/api/query-keys.ts @@ -0,0 +1,10 @@ +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/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/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..95eccd3 --- /dev/null +++ b/apps/web/src/features/feeds/components/add-feed-form.tsx @@ -0,0 +1,219 @@ +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/delete-feed-dialog.tsx b/apps/web/src/features/feeds/components/delete-feed-dialog.tsx new file mode 100644 index 0000000..713d4e3 --- /dev/null +++ b/apps/web/src/features/feeds/components/delete-feed-dialog.tsx @@ -0,0 +1,83 @@ +import { TrashIcon } from '@phosphor-icons/react'; +import { useTranslation } from 'react-i18next'; + +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog'; +import { Spinner } from '@/components/ui/spinner'; + +import type { FeedSubscription } from '@/types/feeds'; + +type DeleteFeedDialogProps = { + errorMessage?: string | null; + isDeleting: boolean; + onConfirm: () => void; + onOpenChange: (open: boolean) => void; + open: boolean; + subscription: FeedSubscription; +}; + +export function DeleteFeedDialog({ + errorMessage, + isDeleting, + onConfirm, + onOpenChange, + open, + subscription +}: DeleteFeedDialogProps) { + const { t } = useTranslation(); + + return ( + !isDeleting && onOpenChange(nextOpen)} open={open}> + + + + {t('feeds.manager.delete.title', { title: subscription.title })} + + + + {t('feeds.manager.delete.description', { title: subscription.title })} + + + {t('feeds.manager.delete.savedArticlesRemain')} + + + {t('feeds.manager.delete.cannotUndo')} + + + + + {errorMessage ? ( +

+ {t('feeds.manager.delete.error', { message: errorMessage })} +

+ ) : 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..b0f6c32 --- /dev/null +++ b/apps/web/src/features/feeds/components/feed-collection.tsx @@ -0,0 +1,138 @@ +import { BookmarkSimpleIcon } from '@phosphor-icons/react'; +import { useEffect, useState } from '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, locale: string) => { + if (!value) return null; + + return new Intl.DateTimeFormat(locale, { 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 }) { + const [imageFailed, setImageFailed] = useState(false); + + useEffect(() => setImageFailed(false), [item.imageUrl]); + + return ( + + {item.imageUrl && !imageFailed ? ( + setImageFailed(true)} + src={item.imageUrl} + width={356} + /> + ) : ( + + + )} + + ); +} + +function GridFeedItemCard({ item, showActions, sourceTitle }: FeedItemCardProps) { + const { i18n } = useTranslation(); + const publishedAt = formatPublishedDate(item.publishedAt, i18n.language); + + 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 ( + <> +