Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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?
Expand Down
4 changes: 4 additions & 0 deletions apps/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions apps/server/scripts/migrate-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');

Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ 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';
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';
Expand All @@ -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),
Expand All @@ -43,6 +48,7 @@ const router = app
.route('/', auth)
.route('/', home)
.route('/', links)
.route('/', feeds)
.route('/', highlights)
.route('/', tags)
.route('/', files)
Expand Down
57 changes: 57 additions & 0 deletions apps/server/src/db/migrations/0001_create_rss_feed_tables.sql
Original file line number Diff line number Diff line change
@@ -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");
86 changes: 86 additions & 0 deletions apps/server/src/db/migrations/0002_smooth_rss_feed_tables.sql
Original file line number Diff line number Diff line change
@@ -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");
Original file line number Diff line number Diff line change
@@ -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"
);
Original file line number Diff line number Diff line change
@@ -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");
6 changes: 6 additions & 0 deletions apps/server/src/db/migrations/0005_feed_owner_constraints.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading