From d2b5fd71eeedd9dae8a95862aa04208c4d79e0b5 Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Sun, 4 Jan 2026 14:39:27 +0000 Subject: [PATCH 01/38] feat(db): add content aggregator schema and Reddit-style voting - Add feed_source, aggregated_article, and aggregated_article_vote tables - Add ContentReport table for moderation with reason tags - Add post_vote table for Reddit-style upvote/downvote on posts - Add upvotes/downvotes columns to Post table - Migrate existing post likes to upvotes with data migration SQL - Update seed data for new content types --- drizzle/0011_content_aggregator.sql | 88 +++ drizzle/0012_alter_excerpt_to_text.sql | 2 + drizzle/0013_add_og_image_url.sql | 2 + drizzle/0014_reddit_style_migration.sql | 77 +++ drizzle/0015_unified_content.sql | 143 +++++ drizzle/0016_post_voting.sql | 41 ++ drizzle/meta/_journal.json | 42 ++ drizzle/seed.ts | 587 +++++++++++++++++- server/db/schema.ts | 778 +++++++++++++++++++++++- 9 files changed, 1755 insertions(+), 5 deletions(-) create mode 100644 drizzle/0011_content_aggregator.sql create mode 100644 drizzle/0012_alter_excerpt_to_text.sql create mode 100644 drizzle/0013_add_og_image_url.sql create mode 100644 drizzle/0014_reddit_style_migration.sql create mode 100644 drizzle/0015_unified_content.sql create mode 100644 drizzle/0016_post_voting.sql diff --git a/drizzle/0011_content_aggregator.sql b/drizzle/0011_content_aggregator.sql new file mode 100644 index 00000000..dbaa5250 --- /dev/null +++ b/drizzle/0011_content_aggregator.sql @@ -0,0 +1,88 @@ +-- Content Aggregator Migration +-- Adds tables for RSS feed sources, aggregated articles, votes, and bookmarks + +-- Create enums +DO $$ BEGIN + CREATE TYPE "FeedSourceStatus" AS ENUM ('ACTIVE', 'PAUSED', 'ERROR'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +DO $$ BEGIN + CREATE TYPE "VoteType" AS ENUM ('UP', 'DOWN'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Create FeedSource table +CREATE TABLE IF NOT EXISTS "FeedSource" ( + "id" SERIAL PRIMARY KEY NOT NULL UNIQUE, + "name" TEXT NOT NULL, + "url" TEXT NOT NULL, + "websiteUrl" TEXT, + "logoUrl" TEXT, + "category" VARCHAR(50), + "status" "FeedSourceStatus" DEFAULT 'ACTIVE' NOT NULL, + "lastFetchedAt" TIMESTAMP(3) WITH TIME ZONE, + "lastSuccessAt" TIMESTAMP(3) WITH TIME ZONE, + "errorCount" INTEGER DEFAULT 0 NOT NULL, + "lastError" TEXT, + "createdAt" TIMESTAMP(3) WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" TIMESTAMP(3) WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS "FeedSource_url_key" ON "FeedSource"("url"); +CREATE INDEX IF NOT EXISTS "FeedSource_status_index" ON "FeedSource"("status"); + +-- Create AggregatedArticle table +CREATE TABLE IF NOT EXISTS "AggregatedArticle" ( + "id" SERIAL PRIMARY KEY NOT NULL UNIQUE, + "sourceId" INTEGER NOT NULL REFERENCES "FeedSource"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "title" TEXT NOT NULL, + "excerpt" VARCHAR(300), + "url" TEXT NOT NULL, + "imageUrl" TEXT, + "author" TEXT, + "publishedAt" TIMESTAMP(3) WITH TIME ZONE, + "fetchedAt" TIMESTAMP(3) WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + "upvotes" INTEGER DEFAULT 0 NOT NULL, + "downvotes" INTEGER DEFAULT 0 NOT NULL, + "clickCount" INTEGER DEFAULT 0 NOT NULL, + "createdAt" TIMESTAMP(3) WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS "AggregatedArticle_url_key" ON "AggregatedArticle"("url"); +CREATE INDEX IF NOT EXISTS "AggregatedArticle_sourceId_index" ON "AggregatedArticle"("sourceId"); +CREATE INDEX IF NOT EXISTS "AggregatedArticle_publishedAt_index" ON "AggregatedArticle"("publishedAt"); +CREATE INDEX IF NOT EXISTS "AggregatedArticle_upvotes_index" ON "AggregatedArticle"("upvotes"); + +-- Create AggregatedArticleTag junction table +CREATE TABLE IF NOT EXISTS "AggregatedArticleTag" ( + "id" SERIAL PRIMARY KEY NOT NULL, + "articleId" INTEGER NOT NULL REFERENCES "AggregatedArticle"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "tagId" INTEGER NOT NULL REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE UNIQUE INDEX IF NOT EXISTS "AggregatedArticleTag_articleId_tagId_key" ON "AggregatedArticleTag"("articleId", "tagId"); + +-- Create AggregatedArticleVote table +CREATE TABLE IF NOT EXISTS "AggregatedArticleVote" ( + "id" SERIAL PRIMARY KEY NOT NULL UNIQUE, + "articleId" INTEGER NOT NULL REFERENCES "AggregatedArticle"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "userId" TEXT NOT NULL REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "voteType" "VoteType" NOT NULL, + "createdAt" TIMESTAMP(3) WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS "AggregatedArticleVote_userId_articleId_key" ON "AggregatedArticleVote"("userId", "articleId"); +CREATE INDEX IF NOT EXISTS "AggregatedArticleVote_articleId_index" ON "AggregatedArticleVote"("articleId"); + +-- Create AggregatedArticleBookmark table +CREATE TABLE IF NOT EXISTS "AggregatedArticleBookmark" ( + "id" SERIAL PRIMARY KEY NOT NULL UNIQUE, + "articleId" INTEGER NOT NULL REFERENCES "AggregatedArticle"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "userId" TEXT NOT NULL REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "createdAt" TIMESTAMP(3) WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS "AggregatedArticleBookmark_userId_articleId_key" ON "AggregatedArticleBookmark"("userId", "articleId"); diff --git a/drizzle/0012_alter_excerpt_to_text.sql b/drizzle/0012_alter_excerpt_to_text.sql new file mode 100644 index 00000000..66ad5348 --- /dev/null +++ b/drizzle/0012_alter_excerpt_to_text.sql @@ -0,0 +1,2 @@ +-- Alter excerpt column from varchar(300) to text to allow longer excerpts +ALTER TABLE "AggregatedArticle" ALTER COLUMN "excerpt" TYPE text; diff --git a/drizzle/0013_add_og_image_url.sql b/drizzle/0013_add_og_image_url.sql new file mode 100644 index 00000000..19e8f91a --- /dev/null +++ b/drizzle/0013_add_og_image_url.sql @@ -0,0 +1,2 @@ +-- Add ogImageUrl column to AggregatedArticle for storing Open Graph images fetched from article URLs +ALTER TABLE "AggregatedArticle" ADD COLUMN IF NOT EXISTS "ogImageUrl" text; diff --git a/drizzle/0014_reddit_style_migration.sql b/drizzle/0014_reddit_style_migration.sql new file mode 100644 index 00000000..a589f9df --- /dev/null +++ b/drizzle/0014_reddit_style_migration.sql @@ -0,0 +1,77 @@ +-- Reddit-style feed migration +-- Adds slug/description to FeedSource, shortId to AggregatedArticle, and Discussion system + +-- ============================================================================ +-- Phase 1: Add new columns to FeedSource +-- ============================================================================ + +ALTER TABLE "FeedSource" ADD COLUMN IF NOT EXISTS "slug" VARCHAR(100); +ALTER TABLE "FeedSource" ADD COLUMN IF NOT EXISTS "description" TEXT; + +-- ============================================================================ +-- Phase 2: Add shortId to AggregatedArticle +-- ============================================================================ + +ALTER TABLE "AggregatedArticle" ADD COLUMN IF NOT EXISTS "shortId" VARCHAR(7); + +-- ============================================================================ +-- Phase 3: Create Discussion system +-- ============================================================================ + +-- Create the enum for discussion target type +DO $$ BEGIN + CREATE TYPE "DiscussionTargetType" AS ENUM ('POST', 'ARTICLE'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Create Discussion table +CREATE TABLE IF NOT EXISTS "Discussion" ( + "id" SERIAL PRIMARY KEY NOT NULL UNIQUE, + "body" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" TIMESTAMP(3) WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + "targetType" "DiscussionTargetType" NOT NULL, + "postId" TEXT REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "articleId" INTEGER REFERENCES "AggregatedArticle"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "userId" TEXT NOT NULL REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "parentId" INTEGER +); + +-- Add self-referential foreign key for nested replies (if not exists) +DO $$ BEGIN + ALTER TABLE "Discussion" + ADD CONSTRAINT "Discussion_parentId_fkey" + FOREIGN KEY ("parentId") REFERENCES "Discussion"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Create DiscussionLike table +CREATE TABLE IF NOT EXISTS "DiscussionLike" ( + "id" SERIAL PRIMARY KEY NOT NULL UNIQUE, + "discussionId" INTEGER NOT NULL REFERENCES "Discussion"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "userId" TEXT NOT NULL REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "createdAt" TIMESTAMP(3) WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "DiscussionLike_userId_discussionId_key" UNIQUE ("userId", "discussionId") +); + +-- ============================================================================ +-- Phase 4: Create indexes +-- ============================================================================ + +-- FeedSource indexes +CREATE UNIQUE INDEX IF NOT EXISTS "FeedSource_slug_key" ON "FeedSource"("slug"); + +-- AggregatedArticle indexes +CREATE UNIQUE INDEX IF NOT EXISTS "AggregatedArticle_shortId_key" ON "AggregatedArticle"("shortId"); +CREATE INDEX IF NOT EXISTS "AggregatedArticle_sourceId_shortId_index" ON "AggregatedArticle"("sourceId", "shortId"); + +-- Discussion indexes +CREATE INDEX IF NOT EXISTS "Discussion_postId_index" ON "Discussion"("postId"); +CREATE INDEX IF NOT EXISTS "Discussion_articleId_index" ON "Discussion"("articleId"); +CREATE INDEX IF NOT EXISTS "Discussion_userId_index" ON "Discussion"("userId"); + +-- DiscussionLike indexes +CREATE INDEX IF NOT EXISTS "DiscussionLike_discussionId_index" ON "DiscussionLike"("discussionId"); diff --git a/drizzle/0015_unified_content.sql b/drizzle/0015_unified_content.sql new file mode 100644 index 00000000..88bba77c --- /dev/null +++ b/drizzle/0015_unified_content.sql @@ -0,0 +1,143 @@ +-- Unified Content System Migration +-- This migration creates the unified content tables and updates existing tables + +-- Create ContentType enum +DO $$ BEGIN + CREATE TYPE "ContentType" AS ENUM ('ARTICLE', 'LINK', 'QUESTION', 'VIDEO', 'DISCUSSION'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Create ReportReason enum +DO $$ BEGIN + CREATE TYPE "ReportReason" AS ENUM ('SPAM', 'HARASSMENT', 'HATE_SPEECH', 'MISINFORMATION', 'COPYRIGHT', 'NSFW', 'OFF_TOPIC', 'OTHER'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Create ReportStatus enum +DO $$ BEGIN + CREATE TYPE "ReportStatus" AS ENUM ('PENDING', 'REVIEWED', 'DISMISSED', 'ACTIONED'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Add CONTENT to DiscussionTargetType enum +ALTER TYPE "DiscussionTargetType" ADD VALUE IF NOT EXISTS 'CONTENT'; + +-- Create Content table +CREATE TABLE IF NOT EXISTS "Content" ( + "id" text PRIMARY KEY NOT NULL, + "type" "ContentType" NOT NULL, + "title" varchar(500) NOT NULL, + "body" text, + "excerpt" text, + "userId" text REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "externalUrl" varchar(2000), + "imageUrl" text, + "ogImageUrl" text, + "sourceId" integer REFERENCES "FeedSource"("id") ON DELETE SET NULL ON UPDATE CASCADE, + "sourceAuthor" varchar(200), + "published" boolean DEFAULT false NOT NULL, + "publishedAt" timestamp(3) with time zone, + "upvotes" integer DEFAULT 0 NOT NULL, + "downvotes" integer DEFAULT 0 NOT NULL, + "readTimeMins" integer, + "clickCount" integer DEFAULT 0 NOT NULL, + "slug" varchar(300), + "canonicalUrl" text, + "coverImage" text, + "showComments" boolean DEFAULT true NOT NULL, + "createdAt" timestamp(3) with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + "updatedAt" timestamp(3) with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +-- Create indexes for Content table +CREATE UNIQUE INDEX IF NOT EXISTS "Content_slug_key" ON "Content"("slug"); +CREATE INDEX IF NOT EXISTS "Content_type_index" ON "Content"("type"); +CREATE INDEX IF NOT EXISTS "Content_userId_index" ON "Content"("userId"); +CREATE INDEX IF NOT EXISTS "Content_sourceId_index" ON "Content"("sourceId"); +CREATE INDEX IF NOT EXISTS "Content_publishedAt_index" ON "Content"("publishedAt"); +CREATE INDEX IF NOT EXISTS "Content_published_index" ON "Content"("published"); + +-- Create ContentVote table +CREATE TABLE IF NOT EXISTS "ContentVote" ( + "id" serial PRIMARY KEY NOT NULL, + "contentId" text NOT NULL REFERENCES "Content"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "userId" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "voteType" "VoteType" NOT NULL, + "createdAt" timestamp(3) with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "ContentVote_contentId_userId_key" UNIQUE("contentId", "userId") +); + +CREATE INDEX IF NOT EXISTS "ContentVote_contentId_index" ON "ContentVote"("contentId"); + +-- Create ContentBookmark table +CREATE TABLE IF NOT EXISTS "ContentBookmark" ( + "id" serial PRIMARY KEY NOT NULL, + "contentId" text NOT NULL REFERENCES "Content"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "userId" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "createdAt" timestamp(3) with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "ContentBookmark_contentId_userId_key" UNIQUE("contentId", "userId") +); + +-- Create ContentTag table +CREATE TABLE IF NOT EXISTS "ContentTag" ( + "id" serial PRIMARY KEY NOT NULL, + "contentId" text NOT NULL REFERENCES "Content"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "tagId" integer NOT NULL REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "ContentTag_contentId_tagId_key" UNIQUE("contentId", "tagId") +); + +-- Add columns to Discussion table +ALTER TABLE "Discussion" ADD COLUMN IF NOT EXISTS "contentId" text; +ALTER TABLE "Discussion" ADD COLUMN IF NOT EXISTS "upvotes" integer DEFAULT 0 NOT NULL; +ALTER TABLE "Discussion" ADD COLUMN IF NOT EXISTS "downvotes" integer DEFAULT 0 NOT NULL; + +CREATE INDEX IF NOT EXISTS "Discussion_contentId_index" ON "Discussion"("contentId"); + +-- Create DiscussionVote table +CREATE TABLE IF NOT EXISTS "DiscussionVote" ( + "id" serial PRIMARY KEY NOT NULL, + "discussionId" integer NOT NULL REFERENCES "Discussion"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "userId" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "voteType" "VoteType" NOT NULL, + "createdAt" timestamp(3) with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "DiscussionVote_discussionId_userId_key" UNIQUE("discussionId", "userId") +); + +CREATE INDEX IF NOT EXISTS "DiscussionVote_discussionId_index" ON "DiscussionVote"("discussionId"); + +-- Create ContentReport table +CREATE TABLE IF NOT EXISTS "ContentReport" ( + "id" serial PRIMARY KEY NOT NULL, + "contentId" text REFERENCES "Content"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "discussionId" integer REFERENCES "Discussion"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "legacyPostId" text, + "legacyArticleId" integer, + "reporterId" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE, + "reason" "ReportReason" NOT NULL, + "details" text, + "status" "ReportStatus" DEFAULT 'PENDING' NOT NULL, + "reviewedById" text REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE, + "reviewedAt" timestamp(3) with time zone, + "actionTaken" text, + "createdAt" timestamp(3) with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +CREATE INDEX IF NOT EXISTS "ContentReport_status_index" ON "ContentReport"("status"); +CREATE INDEX IF NOT EXISTS "ContentReport_reporterId_index" ON "ContentReport"("reporterId"); +CREATE INDEX IF NOT EXISTS "ContentReport_contentId_index" ON "ContentReport"("contentId"); +CREATE INDEX IF NOT EXISTS "ContentReport_discussionId_index" ON "ContentReport"("discussionId"); + +-- Migrate existing discussion likes to votes (as upvotes) +INSERT INTO "DiscussionVote" ("discussionId", "userId", "voteType", "createdAt") +SELECT "discussionId", "userId", 'UP'::"VoteType", "createdAt" +FROM "DiscussionLike" +ON CONFLICT DO NOTHING; + +-- Update Discussion upvotes count from likes +UPDATE "Discussion" d +SET "upvotes" = ( + SELECT COUNT(*) FROM "DiscussionLike" dl WHERE dl."discussionId" = d."id" +); diff --git a/drizzle/0016_post_voting.sql b/drizzle/0016_post_voting.sql new file mode 100644 index 00000000..543e3e1e --- /dev/null +++ b/drizzle/0016_post_voting.sql @@ -0,0 +1,41 @@ +-- Add upvotes and downvotes columns to Post table +ALTER TABLE "Post" ADD COLUMN IF NOT EXISTS "upvotes" integer NOT NULL DEFAULT 0; +ALTER TABLE "Post" ADD COLUMN IF NOT EXISTS "downvotes" integer NOT NULL DEFAULT 0; + +-- Migrate existing likes to upvotes (since likes are positive votes) +UPDATE "Post" SET "upvotes" = "likes" WHERE "likes" > 0; + +-- Create PostVote table for tracking individual votes +CREATE TABLE IF NOT EXISTS "PostVote" ( + "id" serial PRIMARY KEY NOT NULL, + "postId" text NOT NULL, + "userId" text NOT NULL, + "voteType" "VoteType" NOT NULL, + "createdAt" timestamp(3) with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + CONSTRAINT "PostVote_postId_userId_key" UNIQUE("postId", "userId") +); + +-- Add foreign key constraints +DO $$ BEGIN + ALTER TABLE "PostVote" ADD CONSTRAINT "PostVote_postId_Post_id_fk" + FOREIGN KEY ("postId") REFERENCES "Post"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE "PostVote" ADD CONSTRAINT "PostVote_userId_user_id_fk" + FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +-- Create index for faster lookups +CREATE INDEX IF NOT EXISTS "PostVote_postId_index" ON "PostVote"("postId"); + +-- Migrate existing likes from Like table to PostVote table as upvotes +INSERT INTO "PostVote" ("postId", "userId", "voteType", "createdAt") +SELECT "postId", "userId", 'UP'::"VoteType", CURRENT_TIMESTAMP +FROM "Like" +WHERE "postId" IS NOT NULL +ON CONFLICT ("postId", "userId") DO NOTHING; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index bee95933..f810e61d 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -78,6 +78,48 @@ "when": 1728680482423, "tag": "0010_email-tokens-and-indexes", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1735938000000, + "tag": "0011_content_aggregator", + "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1735942800000, + "tag": "0012_alter_excerpt_to_text", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1735984800000, + "tag": "0013_add_og_image_url", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1735986000000, + "tag": "0014_reddit_style_migration", + "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1736000000000, + "tag": "0015_unified_content", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1736002800000, + "tag": "0016_post_voting", + "breakpoints": true } ] } diff --git a/drizzle/seed.ts b/drizzle/seed.ts index 256941d7..42eb4ac5 100644 --- a/drizzle/seed.ts +++ b/drizzle/seed.ts @@ -1,8 +1,34 @@ -import { nanoid } from "nanoid"; +import { nanoid, customAlphabet } from "nanoid"; import { Chance } from "chance"; -import { post, user, tag, like, post_tag, session } from "../server/db/schema"; +import { + post, + user, + tag, + like, + post_tag, + session, + feed_source, + aggregated_article, + discussion, +} from "../server/db/schema"; import { sql, eq } from "drizzle-orm"; +// Generate Reddit-style short IDs: lowercase + numbers, 7 characters +const generateShortId = customAlphabet( + "0123456789abcdefghijklmnopqrstuvwxyz", + 7, +); + +// Generate slug from name +const generateSlug = (name: string): string => { + return name + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .trim(); +}; + import "dotenv/config"; import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js"; @@ -178,13 +204,568 @@ ${chance.paragraph()} } console.log(`Added ${usersResponse.length} users with posts and likes`); + + // Return posts for discussion seeding + const allPosts = await db.select().from(post); + return { users: usersResponse, posts: allPosts }; + }; + + // Initial RSS feed sources for content aggregator + // Each source gets a slug auto-generated from the name + const feedSourcesRaw = [ + // ============================================ + // Individual Developer Blogs + // ============================================ + { + name: "Josh W Comeau", + url: "https://www.joshwcomeau.com/rss.xml", + websiteUrl: "https://www.joshwcomeau.com", + category: "frontend", + description: "Frontend developer sharing CSS tricks, React patterns, and web development insights through interactive tutorials.", + }, + { + name: "Kent C. Dodds", + url: "https://kentcdodds.com/blog/rss.xml", + websiteUrl: "https://kentcdodds.com", + category: "react", + description: "Full stack JavaScript engineer teaching React and testing best practices through EpicReact and Testing JavaScript.", + }, + { + name: "Dan Abramov (Overreacted)", + url: "https://overreacted.io/rss.xml", + websiteUrl: "https://overreacted.io", + category: "react", + description: "React core team member exploring JavaScript fundamentals and React internals with deep technical insights.", + }, + { + name: "Robin Wieruch", + url: "https://www.robinwieruch.de/index.xml", + websiteUrl: "https://www.robinwieruch.de", + category: "react", + }, + { + name: "Lee Robinson", + url: "https://leerob.io/feed.xml", + websiteUrl: "https://leerob.io", + category: "nextjs", + }, + { + name: "Tania Rascia", + url: "https://www.taniarascia.com/rss.xml", + websiteUrl: "https://www.taniarascia.com", + category: "webdev", + }, + { + name: "Flavio Copes", + url: "https://flaviocopes.com/index.xml", + websiteUrl: "https://flaviocopes.com", + category: "javascript", + }, + { + name: "Wes Bos", + url: "https://wesbos.com/rss.xml", + websiteUrl: "https://wesbos.com", + category: "javascript", + }, + { + name: "Sara Soueidan", + url: "https://sarasoueidan.com/blog/index.xml", + websiteUrl: "https://sarasoueidan.com", + category: "css", + }, + { + name: "Addy Osmani", + url: "https://addyosmani.com/rss.xml", + websiteUrl: "https://addyosmani.com", + category: "webperf", + }, + { + name: "Una Kravets", + url: "https://una.im/feed.xml", + websiteUrl: "https://una.im", + category: "css", + }, + { + name: "Cassidy Williams", + url: "https://cassidoo.co/rss.xml", + websiteUrl: "https://cassidoo.co", + category: "webdev", + }, + { + name: "Swyx", + url: "https://www.swyx.io/rss.xml", + websiteUrl: "https://www.swyx.io", + category: "career", + }, + { + name: "Julia Evans", + url: "https://jvns.ca/atom.xml", + websiteUrl: "https://jvns.ca", + category: "backend", + }, + { + name: "Monica Dinculescu", + url: "https://meowni.ca/atom.xml", + websiteUrl: "https://meowni.ca", + category: "webdev", + }, + { + name: "Lea Verou", + url: "https://lea.verou.me/feed.xml", + websiteUrl: "https://lea.verou.me", + category: "css", + }, + { + name: "Ahmad Shadeed", + url: "https://ishadeed.com/feed.xml", + websiteUrl: "https://ishadeed.com", + category: "css", + }, + { + name: "Stefan Judis", + url: "https://www.stefanjudis.com/rss.xml", + websiteUrl: "https://www.stefanjudis.com", + category: "webdev", + }, + { + name: "Zach Leatherman", + url: "https://www.zachleat.com/web/feed/", + websiteUrl: "https://www.zachleat.com", + category: "jamstack", + }, + { + name: "Jake Archibald", + url: "https://jakearchibald.com/posts.rss", + websiteUrl: "https://jakearchibald.com", + category: "webdev", + }, + // ============================================ + // Publications & Aggregators + // ============================================ + { + name: "CSS-Tricks", + url: "https://css-tricks.com/feed/", + websiteUrl: "https://css-tricks.com", + category: "css", + }, + { + name: "Smashing Magazine", + url: "https://www.smashingmagazine.com/feed/", + websiteUrl: "https://www.smashingmagazine.com", + category: "webdev", + }, + { + name: "freeCodeCamp", + url: "https://www.freecodecamp.org/news/rss/", + websiteUrl: "https://www.freecodecamp.org/news", + category: "tutorial", + }, + // A List Apart removed - poor quality RSS feed data + { + name: "LogRocket Blog", + url: "https://blog.logrocket.com/feed/", + websiteUrl: "https://blog.logrocket.com", + category: "frontend", + }, + { + name: "The New Stack", + url: "https://thenewstack.io/feed/", + websiteUrl: "https://thenewstack.io", + category: "devops", + }, + { + name: "InfoQ", + url: "https://feed.infoq.com/", + websiteUrl: "https://www.infoq.com", + category: "enterprise", + }, + { + name: "Hacker Noon", + url: "https://hackernoon.com/feed", + websiteUrl: "https://hackernoon.com", + category: "tech", + }, + { + name: "SitePoint", + url: "https://www.sitepoint.com/feed/", + websiteUrl: "https://www.sitepoint.com", + category: "webdev", + }, + { + name: "Codrops", + url: "https://tympanus.net/codrops/feed/", + websiteUrl: "https://tympanus.net/codrops", + category: "frontend", + }, + { + name: "web.dev", + url: "https://web.dev/feed.xml", + websiteUrl: "https://web.dev", + category: "webdev", + }, + // ============================================ + // Company Engineering Blogs + // ============================================ + { + name: "Vercel Blog", + url: "https://vercel.com/blog/rss.xml", + websiteUrl: "https://vercel.com/blog", + category: "nextjs", + }, + { + name: "Netlify Blog", + url: "https://www.netlify.com/blog/feed.xml", + websiteUrl: "https://www.netlify.com/blog", + category: "jamstack", + }, + { + name: "Prisma Blog", + url: "https://www.prisma.io/blog/rss.xml", + websiteUrl: "https://www.prisma.io/blog", + category: "database", + }, + { + name: "Supabase Blog", + url: "https://supabase.com/blog/rss.xml", + websiteUrl: "https://supabase.com/blog", + category: "backend", + }, + { + name: "Cloudflare Blog", + url: "https://blog.cloudflare.com/rss/", + websiteUrl: "https://blog.cloudflare.com", + category: "devops", + }, + { + name: "GitHub Blog", + url: "https://github.blog/feed/", + websiteUrl: "https://github.blog", + category: "devtools", + }, + { + name: "Stripe Blog", + url: "https://stripe.com/blog/feed.rss", + websiteUrl: "https://stripe.com/blog", + category: "backend", + }, + { + name: "Netflix Tech Blog", + url: "https://netflixtechblog.com/feed", + websiteUrl: "https://netflixtechblog.com", + category: "engineering", + }, + { + name: "Spotify Engineering", + url: "https://engineering.atspotify.com/feed/", + websiteUrl: "https://engineering.atspotify.com", + category: "engineering", + }, + { + name: "Airbnb Engineering", + url: "https://medium.com/feed/airbnb-engineering", + websiteUrl: "https://medium.com/airbnb-engineering", + category: "engineering", + }, + { + name: "Uber Engineering", + url: "https://www.uber.com/en-IE/blog/engineering/rss/", + websiteUrl: "https://www.uber.com/blog/engineering", + category: "engineering", + }, + { + name: "Shopify Engineering", + url: "https://shopify.engineering/blog.atom", + websiteUrl: "https://shopify.engineering", + category: "engineering", + }, + { + name: "Discord Blog", + url: "https://discord.com/blog/rss.xml", + websiteUrl: "https://discord.com/blog", + category: "engineering", + }, + { + name: "Linear Blog", + url: "https://linear.app/blog/rss.xml", + websiteUrl: "https://linear.app/blog", + category: "product", + }, + { + name: "Render Blog", + url: "https://render.com/blog/rss.xml", + websiteUrl: "https://render.com/blog", + category: "devops", + }, + { + name: "Railway Blog", + url: "https://blog.railway.app/feed.xml", + websiteUrl: "https://blog.railway.app", + category: "devops", + }, + { + name: "PlanetScale Blog", + url: "https://planetscale.com/blog/rss.xml", + websiteUrl: "https://planetscale.com/blog", + category: "database", + }, + { + name: "Turso Blog", + url: "https://blog.turso.tech/rss.xml", + websiteUrl: "https://blog.turso.tech", + category: "database", + }, + { + name: "Deno Blog", + url: "https://deno.com/blog/rss.xml", + websiteUrl: "https://deno.com/blog", + category: "javascript", + }, + { + name: "Bun Blog", + url: "https://bun.sh/blog/rss.xml", + websiteUrl: "https://bun.sh/blog", + category: "javascript", + }, + { + name: "Figma Blog", + url: "https://www.figma.com/blog/feed/", + websiteUrl: "https://www.figma.com/blog", + category: "design", + }, + { + name: "Notion Blog", + url: "https://www.notion.so/blog/rss.xml", + websiteUrl: "https://www.notion.so/blog", + category: "product", + }, + // ============================================ + // Career & Industry + // ============================================ + { + name: "The Pragmatic Engineer", + url: "https://newsletter.pragmaticengineer.com/feed", + websiteUrl: "https://newsletter.pragmaticengineer.com", + category: "career", + }, + { + name: "StaffEng", + url: "https://staffeng.com/feed.xml", + websiteUrl: "https://staffeng.com", + category: "career", + }, + { + name: "Bytes.dev", + url: "https://bytes.dev/rss", + websiteUrl: "https://bytes.dev", + category: "javascript", + }, + { + name: "This Week in React", + url: "https://thisweekinreact.com/rss.xml", + websiteUrl: "https://thisweekinreact.com", + category: "react", + }, + // ============================================ + // AI/ML + // ============================================ + { + name: "Hugging Face Blog", + url: "https://huggingface.co/blog/feed.xml", + websiteUrl: "https://huggingface.co/blog", + category: "ai", + }, + { + name: "OpenAI Blog", + url: "https://openai.com/blog/rss/", + websiteUrl: "https://openai.com/blog", + category: "ai", + }, + { + name: "Anthropic News", + url: "https://www.anthropic.com/news/rss.xml", + websiteUrl: "https://www.anthropic.com/news", + category: "ai", + }, + { + name: "Google AI Blog", + url: "https://blog.research.google/feeds/posts/default", + websiteUrl: "https://blog.research.google", + category: "ai", + }, + { + name: "Towards Data Science", + url: "https://towardsdatascience.com/feed", + websiteUrl: "https://towardsdatascience.com", + category: "ai", + }, + { + name: "The Batch (DeepLearning.AI)", + url: "https://www.deeplearning.ai/the-batch/feed/", + websiteUrl: "https://www.deeplearning.ai/the-batch", + category: "ai", + }, + ]; + + // Transform raw sources to include generated slugs + const feedSources = feedSourcesRaw.map((source) => ({ + ...source, + slug: generateSlug(source.name), + })); + + const addFeedSources = async () => { + const sourcesResponse = await db + .insert(feed_source) + .values(feedSources) + .onConflictDoNothing() + .returning(); + + console.log(`Added ${sourcesResponse.length} feed sources`); + return sourcesResponse; + }; + + // Add sample aggregated articles for testing + const addSampleArticles = async (sourceIds: { id: number; websiteUrl: string | null }[]) => { + if (sourceIds.length === 0) { + console.log("No sources to add articles for, fetching from DB..."); + const existingSources = await db.select({ id: feed_source.id, websiteUrl: feed_source.websiteUrl }).from(feed_source); + if (existingSources.length === 0) { + console.log("No feed sources found, skipping sample articles"); + return []; + } + sourceIds = existingSources; + } + + // Filter to only sources with valid websiteUrls + const validSources = sourceIds.filter(s => s.websiteUrl && s.websiteUrl.length > 0); + if (validSources.length === 0) { + console.log("No sources with valid websiteUrls, skipping sample articles"); + return []; + } + + const sampleArticles = []; + const now = new Date(); + + // Add 3 sample articles per source (first 5 sources only to keep it manageable) + for (let i = 0; i < Math.min(5, validSources.length); i++) { + const source = validSources[i]; + for (let j = 0; j < 3; j++) { + const daysAgo = chance.integer({ min: 1, max: 14 }); + const publishedDate = new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000); + + sampleArticles.push({ + sourceId: source.id, + shortId: generateShortId(), + title: chance.sentence({ words: chance.integer({ min: 5, max: 12 }) }), + excerpt: chance.paragraph(), + url: `${source.websiteUrl}/posts/${chance.word()}-${chance.word()}-${chance.integer({ min: 1000, max: 9999 })}`, + author: chance.name(), + publishedAt: publishedDate.toISOString(), + upvotes: chance.integer({ min: 0, max: 100 }), + downvotes: chance.integer({ min: 0, max: 10 }), + clickCount: chance.integer({ min: 0, max: 500 }), + }); + } + } + + const articlesResponse = await db + .insert(aggregated_article) + .values(sampleArticles) + .onConflictDoNothing() + .returning(); + + console.log(`Added ${articlesResponse.length} sample articles`); + return articlesResponse; + }; + + // Add sample discussions on posts and articles + const addSampleDiscussions = async (users: { id: string }[], articles: { id: number }[], posts: { id: string }[]) => { + if (users.length === 0) { + console.log("No users found, skipping discussions"); + return; + } + + const discussions = []; + + // Add discussions on articles + for (const article of articles.slice(0, 10)) { + const numComments = chance.integer({ min: 1, max: 5 }); + for (let i = 0; i < numComments; i++) { + discussions.push({ + body: chance.paragraph(), + targetType: "ARTICLE" as const, + articleId: article.id, + userId: users[chance.integer({ min: 0, max: users.length - 1 })].id, + }); + } + } + + // Add discussions on posts + for (const p of posts.slice(0, 10)) { + const numComments = chance.integer({ min: 1, max: 3 }); + for (let i = 0; i < numComments; i++) { + discussions.push({ + body: chance.paragraph(), + targetType: "POST" as const, + postId: p.id, + userId: users[chance.integer({ min: 0, max: users.length - 1 })].id, + }); + } + } + + if (discussions.length === 0) { + console.log("No discussions to add"); + return; + } + + const discussionsResponse = await db + .insert(discussion) + .values(discussions) + .onConflictDoNothing() + .returning(); + + console.log(`Added ${discussionsResponse.length} sample discussions`); + + // Add some nested replies + if (discussionsResponse.length > 0) { + const replies = []; + for (const disc of discussionsResponse.slice(0, 5)) { + replies.push({ + body: chance.sentence({ words: chance.integer({ min: 10, max: 30 }) }), + targetType: disc.targetType, + articleId: disc.articleId, + postId: disc.postId, + userId: users[chance.integer({ min: 0, max: users.length - 1 })].id, + parentId: disc.id, + }); + } + + const repliesResponse = await db + .insert(discussion) + .values(replies) + .onConflictDoNothing() + .returning(); + + console.log(`Added ${repliesResponse.length} nested replies`); + } }; async function addSeedDataToDb() { console.log(`Start seeding, please wait... `); try { - await addUserData(); + // Add users and posts + const userData = await addUserData(); + + // Add feed sources with slugs + const sources = await addFeedSources(); + + // Add sample articles for testing (sources already has id and websiteUrl from returning()) + const articles = await addSampleArticles(sources); + + // Add sample discussions on posts and articles + if (userData && articles.length > 0) { + await addSampleDiscussions(userData.users, articles, userData.posts); + } } catch (error) { console.log("Error:", error); } diff --git a/server/db/schema.ts b/server/db/schema.ts index ce7a53e6..96c8dc38 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -12,12 +12,14 @@ import { boolean, primaryKey, varchar, + unique, } from "drizzle-orm/pg-core"; import { relations, sql } from "drizzle-orm"; import { type AdapterAccount } from "next-auth/adapters"; export const role = pgEnum("Role", ["MODERATOR", "ADMIN", "USER"]); +export const voteType = pgEnum("VoteType", ["UP", "DOWN"]); export const session = pgTable("session", { sessionToken: text("sessionToken").notNull().primaryKey(), @@ -110,6 +112,7 @@ export const tag = pgTable( export const tagRelations = relations(tag, ({ one, many }) => ({ PostTag: many(post_tag), + AggregatedArticleTag: many(aggregated_article_tag), })); export const post = pgTable( @@ -149,13 +152,15 @@ export const post = pgTable( .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), showComments: boolean("showComments").default(true).notNull(), likes: integer("likes").default(0).notNull(), + upvotes: integer("upvotes").default(0).notNull(), + downvotes: integer("downvotes").default(0).notNull(), }, (table) => { return { idKey: uniqueIndex("Post_id_key").on(table.id), slugKey: uniqueIndex("Post_slug_key").on(table.slug), slugIndex: index("Post_slug_index").on(table.slug), - userIdIndex: index("Post_userId_index").on(table.userId), // Add this line + userIdIndex: index("Post_userId_index").on(table.userId), }; }, ); @@ -165,11 +170,43 @@ export const postRelations = relations(post, ({ one, many }) => ({ comments: many(comment), Flagged: many(flagged), likes: many(like), + votes: many(post_vote), notifications: many(notification), user: one(user, { fields: [post.userId], references: [user.id] }), tags: many(post_tag), })); +// POST VOTING (Reddit-style upvote/downvote) +export const post_vote = pgTable( + "PostVote", + { + id: serial("id").primaryKey().notNull(), + postId: text("postId") + .notNull() + .references(() => post.id, { onDelete: "cascade", onUpdate: "cascade" }), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), + voteType: voteType("voteType").notNull(), + createdAt: timestamp("createdAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (table) => ({ + uniqueVote: unique("PostVote_postId_userId_key").on(table.postId, table.userId), + postIdIndex: index("PostVote_postId_index").on(table.postId), + }), +); + +export const postVoteRelations = relations(post_vote, ({ one }) => ({ + post: one(post, { fields: [post_vote.postId], references: [post.id] }), + user: one(user, { fields: [post_vote.userId], references: [user.id] }), +})); + export const user = pgTable( "user", { @@ -240,6 +277,7 @@ export const userRelations = relations(user, ({ one, many }) => ({ flaggedByUser: many(flagged, { relationName: "flaggedByUser" }), flaggedContent: many(flagged, { relationName: "flaggedContent" }), likes: many(like), + postVotes: many(post_vote), notificationsCreated: many(notification, { relationName: "notificationsCreated", }), @@ -247,9 +285,11 @@ export const userRelations = relations(user, ({ one, many }) => ({ relationName: "notificationsReceived", }), posts: many(post), - sessions: many(session), // This should now be correctly inferred + sessions: many(session), emailChangeRequests: many(emailChangeRequest), emailChangeHistory: many(emailChangeHistory), + aggregatedArticleVotes: many(aggregated_article_vote), + aggregatedArticleBookmarks: many(aggregated_article_bookmark), })); export const bookmark = pgTable( @@ -578,3 +618,737 @@ export const emailChangeHistoryRelations = relations( }), }), ); + +// ============================================ +// Content Aggregator Tables +// ============================================ + +export const feedSourceStatus = pgEnum("FeedSourceStatus", [ + "ACTIVE", + "PAUSED", + "ERROR", +]); + +export const feed_source = pgTable( + "FeedSource", + { + id: serial("id").primaryKey().notNull().unique(), + name: text("name").notNull(), + url: text("url").notNull(), + websiteUrl: text("websiteUrl"), + logoUrl: text("logoUrl"), + category: varchar("category", { length: 50 }), + slug: varchar("slug", { length: 100 }), + description: text("description"), + status: feedSourceStatus("status").default("ACTIVE").notNull(), + lastFetchedAt: timestamp("lastFetchedAt", { + precision: 3, + mode: "string", + withTimezone: true, + }), + lastSuccessAt: timestamp("lastSuccessAt", { + precision: 3, + mode: "string", + withTimezone: true, + }), + errorCount: integer("errorCount").default(0).notNull(), + lastError: text("lastError"), + createdAt: timestamp("createdAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedAt: timestamp("updatedAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .notNull() + .$onUpdate(() => new Date().toISOString()) + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => { + return { + urlKey: uniqueIndex("FeedSource_url_key").on(table.url), + slugKey: uniqueIndex("FeedSource_slug_key").on(table.slug), + statusIndex: index("FeedSource_status_index").on(table.status), + }; + }, +); + +export const feedSourceRelations = relations(feed_source, ({ many }) => ({ + articles: many(aggregated_article), +})); + +export const aggregated_article = pgTable( + "AggregatedArticle", + { + id: serial("id").primaryKey().notNull().unique(), + sourceId: integer("sourceId") + .notNull() + .references(() => feed_source.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + title: text("title").notNull(), + excerpt: text("excerpt"), + url: text("url").notNull(), + imageUrl: text("imageUrl"), + ogImageUrl: text("ogImageUrl"), + shortId: varchar("shortId", { length: 7 }), + author: text("author"), + publishedAt: timestamp("publishedAt", { + precision: 3, + mode: "string", + withTimezone: true, + }), + fetchedAt: timestamp("fetchedAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + upvotes: integer("upvotes").default(0).notNull(), + downvotes: integer("downvotes").default(0).notNull(), + clickCount: integer("clickCount").default(0).notNull(), + createdAt: timestamp("createdAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (table) => { + return { + urlKey: uniqueIndex("AggregatedArticle_url_key").on(table.url), + shortIdKey: uniqueIndex("AggregatedArticle_shortId_key").on(table.shortId), + sourceIdIndex: index("AggregatedArticle_sourceId_index").on( + table.sourceId, + ), + sourceIdShortIdIndex: index("AggregatedArticle_sourceId_shortId_index").on( + table.sourceId, + table.shortId, + ), + publishedAtIndex: index("AggregatedArticle_publishedAt_index").on( + table.publishedAt, + ), + upvotesIndex: index("AggregatedArticle_upvotes_index").on(table.upvotes), + }; + }, +); + +export const aggregatedArticleRelations = relations( + aggregated_article, + ({ one, many }) => ({ + source: one(feed_source, { + fields: [aggregated_article.sourceId], + references: [feed_source.id], + }), + tags: many(aggregated_article_tag), + votes: many(aggregated_article_vote), + bookmarks: many(aggregated_article_bookmark), + }), +); + +export const aggregated_article_tag = pgTable( + "AggregatedArticleTag", + { + id: serial("id").primaryKey().notNull(), + articleId: integer("articleId") + .notNull() + .references(() => aggregated_article.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + tagId: integer("tagId") + .notNull() + .references(() => tag.id, { onDelete: "cascade", onUpdate: "cascade" }), + }, + (table) => { + return { + articleIdTagIdKey: uniqueIndex( + "AggregatedArticleTag_articleId_tagId_key", + ).on(table.articleId, table.tagId), + }; + }, +); + +export const aggregatedArticleTagRelations = relations( + aggregated_article_tag, + ({ one }) => ({ + article: one(aggregated_article, { + fields: [aggregated_article_tag.articleId], + references: [aggregated_article.id], + }), + tag: one(tag, { + fields: [aggregated_article_tag.tagId], + references: [tag.id], + }), + }), +); + +export const aggregated_article_vote = pgTable( + "AggregatedArticleVote", + { + id: serial("id").primaryKey().notNull().unique(), + articleId: integer("articleId") + .notNull() + .references(() => aggregated_article.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), + voteType: voteType("voteType").notNull(), + createdAt: timestamp("createdAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (table) => { + return { + userIdArticleIdKey: uniqueIndex( + "AggregatedArticleVote_userId_articleId_key", + ).on(table.userId, table.articleId), + articleIdIndex: index("AggregatedArticleVote_articleId_index").on( + table.articleId, + ), + }; + }, +); + +export const aggregatedArticleVoteRelations = relations( + aggregated_article_vote, + ({ one }) => ({ + article: one(aggregated_article, { + fields: [aggregated_article_vote.articleId], + references: [aggregated_article.id], + }), + user: one(user, { + fields: [aggregated_article_vote.userId], + references: [user.id], + }), + }), +); + +export const aggregated_article_bookmark = pgTable( + "AggregatedArticleBookmark", + { + id: serial("id").primaryKey().notNull().unique(), + articleId: integer("articleId") + .notNull() + .references(() => aggregated_article.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), + createdAt: timestamp("createdAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (table) => { + return { + userIdArticleIdKey: uniqueIndex( + "AggregatedArticleBookmark_userId_articleId_key", + ).on(table.userId, table.articleId), + }; + }, +); + +export const aggregatedArticleBookmarkRelations = relations( + aggregated_article_bookmark, + ({ one }) => ({ + article: one(aggregated_article, { + fields: [aggregated_article_bookmark.articleId], + references: [aggregated_article.id], + }), + user: one(user, { + fields: [aggregated_article_bookmark.userId], + references: [user.id], + }), + }), +); + +// ============================================================================ +// DISCUSSION SYSTEM - Generic discussions for posts and articles +// ============================================================================ + +export const discussionTargetType = pgEnum("DiscussionTargetType", [ + "POST", + "ARTICLE", + "CONTENT", +]); + +export const discussion = pgTable( + "Discussion", + { + id: serial("id").primaryKey().notNull().unique(), + body: text("body").notNull(), + createdAt: timestamp("createdAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedAt: timestamp("updatedAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .notNull() + .$onUpdate(() => new Date().toISOString()) + .default(sql`CURRENT_TIMESTAMP`), + // Polymorphic target - either a post OR an article OR content + targetType: discussionTargetType("targetType").notNull(), + postId: text("postId").references(() => post.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + articleId: integer("articleId").references(() => aggregated_article.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + contentId: text("contentId"), // Will be FK to content table - added after content table exists + // User who wrote the discussion + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), + // Self-referential for nested replies + parentId: integer("parentId"), + // Voting (denormalized for performance) + upvotes: integer("upvotes").default(0).notNull(), + downvotes: integer("downvotes").default(0).notNull(), + }, + (table) => ({ + discussionParentIdFkey: foreignKey({ + columns: [table.parentId], + foreignColumns: [table.id], + name: "Discussion_parentId_fkey", + }) + .onUpdate("cascade") + .onDelete("cascade"), + postIdIndex: index("Discussion_postId_index").on(table.postId), + articleIdIndex: index("Discussion_articleId_index").on(table.articleId), + contentIdIndex: index("Discussion_contentId_index").on(table.contentId), + userIdIndex: index("Discussion_userId_index").on(table.userId), + }), +); + +export const discussionRelations = relations(discussion, ({ one, many }) => ({ + parent: one(discussion, { + fields: [discussion.parentId], + references: [discussion.id], + relationName: "discussions", + }), + children: many(discussion, { relationName: "discussions" }), + post: one(post, { fields: [discussion.postId], references: [post.id] }), + article: one(aggregated_article, { + fields: [discussion.articleId], + references: [aggregated_article.id], + }), + user: one(user, { fields: [discussion.userId], references: [user.id] }), + likes: many(discussion_like), + votes: many(discussion_vote), + reports: many(content_report), +})); + +export const discussion_like = pgTable( + "DiscussionLike", + { + id: serial("id").primaryKey().notNull().unique(), + discussionId: integer("discussionId") + .notNull() + .references(() => discussion.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), + createdAt: timestamp("createdAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (table) => ({ + userIdDiscussionIdKey: uniqueIndex( + "DiscussionLike_userId_discussionId_key", + ).on(table.userId, table.discussionId), + discussionIdIndex: index("DiscussionLike_discussionId_index").on( + table.discussionId, + ), + }), +); + +export const discussionLikeRelations = relations(discussion_like, ({ one }) => ({ + discussion: one(discussion, { + fields: [discussion_like.discussionId], + references: [discussion.id], + }), + user: one(user, { + fields: [discussion_like.userId], + references: [user.id], + }), +})); + +// ============================================================================ +// UNIFIED CONTENT SYSTEM +// ============================================================================ + +export const contentType = pgEnum("ContentType", [ + "ARTICLE", + "LINK", + "QUESTION", + "VIDEO", + "DISCUSSION", +]); + +export const content = pgTable( + "Content", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + type: contentType("type").notNull(), + + // Common fields + title: varchar("title", { length: 500 }).notNull(), + body: text("body"), // For articles/questions (Tiptap JSON or markdown) + excerpt: text("excerpt"), + + // Author info + userId: text("userId").references(() => user.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), // null for RSS imports + + // External content (LINK, VIDEO, RSS articles) + externalUrl: varchar("externalUrl", { length: 2000 }), + imageUrl: text("imageUrl"), + ogImageUrl: text("ogImageUrl"), + + // Source info (for RSS content) + sourceId: integer("sourceId").references(() => feed_source.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + sourceAuthor: varchar("sourceAuthor", { length: 200 }), + + // Publishing + published: boolean("published").default(false).notNull(), + publishedAt: timestamp("publishedAt", { + precision: 3, + mode: "string", + withTimezone: true, + }), + + // Voting (denormalized for performance) + upvotes: integer("upvotes").default(0).notNull(), + downvotes: integer("downvotes").default(0).notNull(), + + // Metadata + readTimeMins: integer("readTimeMins"), + clickCount: integer("clickCount").default(0).notNull(), + + // SEO & URL + slug: varchar("slug", { length: 300 }), + canonicalUrl: text("canonicalUrl"), + coverImage: text("coverImage"), + + // Settings + showComments: boolean("showComments").default(true).notNull(), + + // Timestamps + createdAt: timestamp("createdAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedAt: timestamp("updatedAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .notNull() + .$onUpdate(() => new Date().toISOString()) + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => ({ + slugKey: uniqueIndex("Content_slug_key").on(table.slug), + typeIndex: index("Content_type_index").on(table.type), + userIdIndex: index("Content_userId_index").on(table.userId), + sourceIdIndex: index("Content_sourceId_index").on(table.sourceId), + publishedAtIndex: index("Content_publishedAt_index").on(table.publishedAt), + publishedIndex: index("Content_published_index").on(table.published), + }), +); + +export const contentRelations = relations(content, ({ one, many }) => ({ + user: one(user, { fields: [content.userId], references: [user.id] }), + source: one(feed_source, { + fields: [content.sourceId], + references: [feed_source.id], + }), + votes: many(content_vote), + bookmarks: many(content_bookmark), + tags: many(content_tag), + reports: many(content_report), +})); + +export const content_vote = pgTable( + "ContentVote", + { + id: serial("id").primaryKey().notNull(), + contentId: text("contentId") + .notNull() + .references(() => content.id, { onDelete: "cascade", onUpdate: "cascade" }), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), + voteType: voteType("voteType").notNull(), + createdAt: timestamp("createdAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (table) => ({ + uniqueVote: unique("ContentVote_contentId_userId_key").on( + table.contentId, + table.userId, + ), + contentIdIndex: index("ContentVote_contentId_index").on(table.contentId), + }), +); + +export const contentVoteRelations = relations(content_vote, ({ one }) => ({ + content: one(content, { + fields: [content_vote.contentId], + references: [content.id], + }), + user: one(user, { fields: [content_vote.userId], references: [user.id] }), +})); + +export const content_bookmark = pgTable( + "ContentBookmark", + { + id: serial("id").primaryKey().notNull(), + contentId: text("contentId") + .notNull() + .references(() => content.id, { onDelete: "cascade", onUpdate: "cascade" }), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), + createdAt: timestamp("createdAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (table) => ({ + uniqueBookmark: unique("ContentBookmark_contentId_userId_key").on( + table.contentId, + table.userId, + ), + }), +); + +export const contentBookmarkRelations = relations(content_bookmark, ({ one }) => ({ + content: one(content, { + fields: [content_bookmark.contentId], + references: [content.id], + }), + user: one(user, { fields: [content_bookmark.userId], references: [user.id] }), +})); + +export const content_tag = pgTable( + "ContentTag", + { + id: serial("id").primaryKey().notNull(), + contentId: text("contentId") + .notNull() + .references(() => content.id, { onDelete: "cascade", onUpdate: "cascade" }), + tagId: integer("tagId") + .notNull() + .references(() => tag.id, { onDelete: "cascade", onUpdate: "cascade" }), + }, + (table) => ({ + uniqueContentTag: unique("ContentTag_contentId_tagId_key").on( + table.contentId, + table.tagId, + ), + }), +); + +export const contentTagRelations = relations(content_tag, ({ one }) => ({ + content: one(content, { + fields: [content_tag.contentId], + references: [content.id], + }), + tag: one(tag, { fields: [content_tag.tagId], references: [tag.id] }), +})); + +// ============================================================================ +// DISCUSSION VOTING (Reddit-style upvote/downvote) +// ============================================================================ + +export const discussion_vote = pgTable( + "DiscussionVote", + { + id: serial("id").primaryKey().notNull(), + discussionId: integer("discussionId") + .notNull() + .references(() => discussion.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), + voteType: voteType("voteType").notNull(), + createdAt: timestamp("createdAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (table) => ({ + uniqueVote: unique("DiscussionVote_discussionId_userId_key").on( + table.discussionId, + table.userId, + ), + discussionIdIndex: index("DiscussionVote_discussionId_index").on( + table.discussionId, + ), + }), +); + +export const discussionVoteRelations = relations(discussion_vote, ({ one }) => ({ + discussion: one(discussion, { + fields: [discussion_vote.discussionId], + references: [discussion.id], + }), + user: one(user, { fields: [discussion_vote.userId], references: [user.id] }), +})); + +// ============================================================================ +// CONTENT REPORTING SYSTEM +// ============================================================================ + +export const reportReason = pgEnum("ReportReason", [ + "SPAM", + "HARASSMENT", + "HATE_SPEECH", + "MISINFORMATION", + "COPYRIGHT", + "NSFW", + "OFF_TOPIC", + "OTHER", +]); + +export const reportStatus = pgEnum("ReportStatus", [ + "PENDING", + "REVIEWED", + "DISMISSED", + "ACTIONED", +]); + +export const content_report = pgTable( + "ContentReport", + { + id: serial("id").primaryKey().notNull(), + // Can report content OR discussion (one must be set) + contentId: text("contentId").references(() => content.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + discussionId: integer("discussionId").references(() => discussion.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + // Legacy support for old Post/Article tables during migration + legacyPostId: text("legacyPostId"), + legacyArticleId: integer("legacyArticleId"), + // Reporter info + reporterId: text("reporterId") + .notNull() + .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), + reason: reportReason("reason").notNull(), + details: text("details"), // Optional additional info + // Review info + status: reportStatus("status").default("PENDING").notNull(), + reviewedById: text("reviewedById").references(() => user.id, { + onDelete: "set null", + onUpdate: "cascade", + }), + reviewedAt: timestamp("reviewedAt", { + precision: 3, + mode: "string", + withTimezone: true, + }), + actionTaken: text("actionTaken"), + // Timestamps + createdAt: timestamp("createdAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (table) => ({ + statusIndex: index("ContentReport_status_index").on(table.status), + reporterIdIndex: index("ContentReport_reporterId_index").on(table.reporterId), + contentIdIndex: index("ContentReport_contentId_index").on(table.contentId), + discussionIdIndex: index("ContentReport_discussionId_index").on( + table.discussionId, + ), + }), +); + +export const contentReportRelations = relations(content_report, ({ one }) => ({ + content: one(content, { + fields: [content_report.contentId], + references: [content.id], + }), + discussion: one(discussion, { + fields: [content_report.discussionId], + references: [discussion.id], + }), + reporter: one(user, { + fields: [content_report.reporterId], + references: [user.id], + relationName: "reportsMade", + }), + reviewedBy: one(user, { + fields: [content_report.reviewedById], + references: [user.id], + relationName: "reportsReviewed", + }), +})); From 34a5c12f917426053f9df3c24e041a03df5ff25b Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Sun, 4 Jan 2026 14:40:12 +0000 Subject: [PATCH 02/38] feat(api): add feed, content, and voting API routers - Add feed router with getFeed, vote, bookmark, getCategories procedures - Add content router for unified content management - Add discussion router with Reddit-style upvote/downvote - Add report router for content moderation queue - Update admin router with getStats, getUsers, getBannedUsers - Update post router with vote mutation and trending algorithm - Add hot score calculation for trending sort (recency + votes) - Update sidebarData to return upvotes/downvotes instead of likes --- schema/content.ts | 137 +++++ schema/discussion.ts | 46 ++ schema/feed.ts | 117 +++++ schema/post.ts | 14 +- schema/report.ts | 49 ++ server/api/router/admin.ts | 149 +++++- server/api/router/content.ts | 869 ++++++++++++++++++++++++++++++++ server/api/router/discussion.ts | 480 ++++++++++++++++++ server/api/router/feed.ts | 857 +++++++++++++++++++++++++++++++ server/api/router/index.ts | 6 + server/api/router/post.ts | 176 ++++++- server/api/router/report.ts | 335 +++++++++++- server/lib/posts.ts | 2 + 13 files changed, 3216 insertions(+), 21 deletions(-) create mode 100644 schema/content.ts create mode 100644 schema/discussion.ts create mode 100644 schema/feed.ts create mode 100644 server/api/router/content.ts create mode 100644 server/api/router/discussion.ts create mode 100644 server/api/router/feed.ts diff --git a/schema/content.ts b/schema/content.ts new file mode 100644 index 00000000..128c3b64 --- /dev/null +++ b/schema/content.ts @@ -0,0 +1,137 @@ +import z from "zod"; + +// Content Type enum matching the database +export const ContentTypeSchema = z.enum([ + "ARTICLE", + "LINK", + "QUESTION", + "VIDEO", + "DISCUSSION", +]); +export type ContentType = z.TypeOf; + +// Get Feed Schema - unified feed with type filtering +export const GetUnifiedFeedSchema = z.object({ + limit: z.number().min(1).max(100).nullish(), + cursor: z + .object({ + id: z.string(), + publishedAt: z.string().optional(), + score: z.number().optional(), + }) + .nullish(), + sort: z.enum(["recent", "trending", "popular"]).default("recent"), + type: ContentTypeSchema.nullish(), // Filter by content type + category: z.string().nullish(), + tag: z.string().nullish(), + sourceId: z.number().nullish(), + userId: z.string().nullish(), // Filter by author +}); + +export type GetUnifiedFeedInput = z.TypeOf; + +// Get Content by ID +export const GetContentByIdSchema = z.object({ + id: z.string(), +}); + +export type GetContentByIdInput = z.TypeOf; + +// Get Content by Slug +export const GetContentBySlugSchema = z.object({ + slug: z.string().min(1).max(300), +}); + +export type GetContentBySlugInput = z.TypeOf; + +// Create Content Schema +export const CreateContentSchema = z.object({ + type: ContentTypeSchema, + title: z.string().min(1).max(500), + body: z.string().nullish(), // Required for ARTICLE, optional for others + excerpt: z.string().max(300).nullish(), + externalUrl: z.string().url().max(2000).nullish(), // Required for LINK, VIDEO + imageUrl: z.string().url().nullish(), + tags: z.array(z.string()).max(5).optional(), + published: z.boolean().default(false), + showComments: z.boolean().default(true), + canonicalUrl: z.string().url().nullish(), + coverImage: z.string().url().nullish(), +}); + +export type CreateContentInput = z.TypeOf; + +// Update Content Schema +export const UpdateContentSchema = z.object({ + id: z.string(), + title: z.string().min(1).max(500).optional(), + body: z.string().nullish(), + excerpt: z.string().max(300).nullish(), + externalUrl: z.string().url().max(2000).nullish(), + imageUrl: z.string().url().nullish(), + tags: z.array(z.string()).max(5).optional(), + published: z.boolean().optional(), + showComments: z.boolean().optional(), + canonicalUrl: z.string().url().nullish(), + coverImage: z.string().url().nullish(), +}); + +export type UpdateContentInput = z.TypeOf; + +// Delete Content Schema +export const DeleteContentSchema = z.object({ + id: z.string(), +}); + +export type DeleteContentInput = z.TypeOf; + +// Vote Schema +export const VoteContentSchema = z.object({ + contentId: z.string(), + voteType: z.enum(["UP", "DOWN"]).nullable(), // null removes the vote +}); + +export type VoteContentInput = z.TypeOf; + +// Bookmark Schema +export const BookmarkContentSchema = z.object({ + contentId: z.string(), + setBookmarked: z.boolean(), +}); + +export type BookmarkContentInput = z.TypeOf; + +// Track Click Schema +export const TrackClickContentSchema = z.object({ + contentId: z.string(), +}); + +export type TrackClickContentInput = z.TypeOf; + +// Get User's Content Schema +export const GetUserContentSchema = z.object({ + userId: z.string(), + type: ContentTypeSchema.nullish(), + limit: z.number().min(1).max(100).default(20), + cursor: z + .object({ + id: z.string(), + publishedAt: z.string().optional(), + }) + .nullish(), +}); + +export type GetUserContentInput = z.TypeOf; + +// Get Saved Content Schema +export const GetSavedContentSchema = z.object({ + limit: z.number().min(1).max(100).default(20), + cursor: z + .object({ + id: z.string(), + createdAt: z.string().optional(), + }) + .nullish(), +}); + +export type GetSavedContentInput = z.TypeOf; diff --git a/schema/discussion.ts b/schema/discussion.ts new file mode 100644 index 00000000..26237f19 --- /dev/null +++ b/schema/discussion.ts @@ -0,0 +1,46 @@ +import z from "zod"; + +export const CreateDiscussionSchema = z.object({ + body: z.string().min(1).max(5000).trim(), + targetType: z.enum(["POST", "ARTICLE"]), + postId: z.string().optional(), + articleId: z.number().optional(), + parentId: z.number().optional(), +}); + +export type CreateDiscussionInput = z.TypeOf; + +export const EditDiscussionSchema = z.object({ + id: z.number(), + body: z.string().min(1).max(5000).trim(), +}); + +export type EditDiscussionInput = z.TypeOf; + +export const DeleteDiscussionSchema = z.object({ + id: z.number(), +}); + +export type DeleteDiscussionInput = z.TypeOf; + +export const GetDiscussionsSchema = z.object({ + targetType: z.enum(["POST", "ARTICLE"]), + postId: z.string().optional(), + articleId: z.number().optional(), +}); + +export type GetDiscussionsInput = z.TypeOf; + +export const LikeDiscussionSchema = z.object({ + discussionId: z.number(), +}); + +export type LikeDiscussionInput = z.TypeOf; + +// Reddit-style voting schema +export const VoteDiscussionSchema = z.object({ + discussionId: z.number(), + voteType: z.enum(["UP", "DOWN"]).nullable(), // null removes the vote +}); + +export type VoteDiscussionInput = z.TypeOf; diff --git a/schema/feed.ts b/schema/feed.ts new file mode 100644 index 00000000..cec501a1 --- /dev/null +++ b/schema/feed.ts @@ -0,0 +1,117 @@ +import z from "zod"; + +// Feed Query Schema +export const GetFeedSchema = z.object({ + limit: z.number().min(1).max(100).nullish(), + cursor: z + .object({ + id: z.number(), + publishedAt: z.string().optional(), + score: z.number().optional(), + }) + .nullish(), + sort: z.enum(["recent", "trending", "popular"]), + category: z.string().nullish(), + tag: z.string().nullish(), + sourceId: z.number().nullish(), + includeCommunity: z.boolean().default(true), +}); + +export type GetFeedInput = z.TypeOf; + +// Vote Schema +export const VoteArticleSchema = z.object({ + articleId: z.number(), + voteType: z.enum(["UP", "DOWN"]).nullable(), +}); + +export type VoteArticleInput = z.TypeOf; + +// Bookmark Schema +export const BookmarkArticleSchema = z.object({ + articleId: z.number(), + setBookmarked: z.boolean(), +}); + +export type BookmarkArticleInput = z.TypeOf; + +// Track Click Schema +export const TrackClickSchema = z.object({ + articleId: z.number(), +}); + +export type TrackClickInput = z.TypeOf; + +// Get Article by ID Schema +export const GetArticleByIdSchema = z.object({ + id: z.number(), +}); + +// Feed Source Schemas +export const CreateFeedSourceSchema = z.object({ + name: z.string().min(1).max(100), + url: z.string().url(), + websiteUrl: z.string().url().optional(), + logoUrl: z.string().url().optional(), + category: z.string().max(50).optional(), +}); + +export type CreateFeedSourceInput = z.TypeOf; + +export const UpdateFeedSourceSchema = z.object({ + id: z.number(), + name: z.string().min(1).max(100).optional(), + status: z.enum(["ACTIVE", "PAUSED", "ERROR"]).optional(), + category: z.string().max(50).optional(), + logoUrl: z.string().url().optional(), + websiteUrl: z.string().url().optional(), +}); + +export type UpdateFeedSourceInput = z.TypeOf; + +// Get Sources Schema +export const GetSourcesSchema = z.object({ + status: z.enum(["ACTIVE", "PAUSED", "ERROR"]).optional(), + category: z.string().optional(), +}); + +export type GetSourcesInput = z.TypeOf; + +// Delete Source Schema +export const DeleteFeedSourceSchema = z.object({ + id: z.number(), +}); + +export type DeleteFeedSourceInput = z.TypeOf; + +// Get Article by Slug and ShortId (Reddit-style URL) +export const GetArticleBySlugSchema = z.object({ + sourceSlug: z.string().min(1).max(100), + shortId: z.string().min(1).max(7), +}); + +export type GetArticleBySlugInput = z.TypeOf; + +// Get Source Profile by Slug +export const GetSourceBySlugSchema = z.object({ + slug: z.string().min(1).max(100), +}); + +export type GetSourceBySlugInput = z.TypeOf; + +// Get Articles by Source (paginated) +export const GetArticlesBySourceSchema = z.object({ + sourceSlug: z.string().min(1).max(100), + limit: z.number().min(1).max(100).default(20), + cursor: z + .object({ + id: z.number(), + publishedAt: z.string().optional(), + }) + .nullish(), + sort: z.enum(["recent", "trending", "popular"]).default("recent"), +}); + +export type GetArticlesBySourceInput = z.TypeOf< + typeof GetArticlesBySourceSchema +>; diff --git a/schema/post.ts b/schema/post.ts index 224e8940..c14d3d5c 100644 --- a/schema/post.ts +++ b/schema/post.ts @@ -10,6 +10,11 @@ export const LikePostSchema = z.object({ setLiked: z.boolean(), }); +export const VotePostSchema = z.object({ + postId: z.string(), + voteType: z.enum(["UP", "DOWN"]).nullable(), +}); + export const BookmarkPostSchema = z.object({ postId: z.string(), setBookmarked: z.boolean(), @@ -60,9 +65,14 @@ export const GetPostsSchema = z.object({ userId: z.string().optional(), limit: z.number().min(1).max(100).nullish(), cursor: z - .object({ id: z.string(), published: z.string(), likes: z.number() }) + .object({ + id: z.string(), + published: z.string(), + likes: z.number(), + hotScore: z.number().optional(), + }) .nullish(), - sort: z.enum(["newest", "oldest", "top"]), + sort: z.enum(["newest", "oldest", "top", "trending"]), tag: z.string().nullish(), }); diff --git a/schema/report.ts b/schema/report.ts index d9812681..6f31afb4 100644 --- a/schema/report.ts +++ b/schema/report.ts @@ -1,5 +1,20 @@ import z from "zod"; +// Report reasons (matching database enum) +export const ReportReasonSchema = z.enum([ + "SPAM", + "HARASSMENT", + "HATE_SPEECH", + "MISINFORMATION", + "COPYRIGHT", + "NSFW", + "OFF_TOPIC", + "OTHER", +]); + +export type ReportReason = z.TypeOf; + +// Legacy report schema (for backwards compatibility) export const ReportSchema = z.discriminatedUnion("type", [ z .strictObject({ @@ -16,3 +31,37 @@ export const ReportSchema = z.discriminatedUnion("type", [ ]); export type ReportInput = z.TypeOf; + +// New unified report schema +export const CreateReportSchema = z.object({ + contentId: z.string().optional(), // For content reports + discussionId: z.number().optional(), // For discussion reports + reason: ReportReasonSchema, + details: z.string().max(1000).optional(), // Optional additional details +}); + +export type CreateReportInput = z.TypeOf; + +// Admin: Get reports schema +export const GetReportsSchema = z.object({ + status: z.enum(["PENDING", "REVIEWED", "DISMISSED", "ACTIONED"]).optional(), + reason: ReportReasonSchema.optional(), + limit: z.number().min(1).max(100).default(20), + cursor: z + .object({ + id: z.number(), + createdAt: z.string().optional(), + }) + .nullish(), +}); + +export type GetReportsInput = z.TypeOf; + +// Admin: Review report schema +export const ReviewReportSchema = z.object({ + reportId: z.number(), + status: z.enum(["REVIEWED", "DISMISSED", "ACTIONED"]), + actionTaken: z.string().max(500).optional(), +}); + +export type ReviewReportInput = z.TypeOf; diff --git a/server/api/router/admin.ts b/server/api/router/admin.ts index 630ee8f0..327d9e49 100644 --- a/server/api/router/admin.ts +++ b/server/api/router/admin.ts @@ -1,11 +1,156 @@ import { TRPCError } from "@trpc/server"; import { BanUserSchema, UnbanUserSchema } from "../../../schema/admin"; +import z from "zod"; import { createTRPCRouter, adminOnlyProcedure } from "../trpc"; -import { banned_users, session } from "@/server/db/schema"; -import { eq } from "drizzle-orm"; +import { + banned_users, + session, + user, + post, + content, + content_report, + aggregated_article, + feed_source, +} from "@/server/db/schema"; +import { and, count, desc, eq, isNotNull, like, lte, sql } from "drizzle-orm"; export const adminRouter = createTRPCRouter({ + // Get dashboard stats + getStats: adminOnlyProcedure.query(async ({ ctx }) => { + const [usersCount] = await ctx.db.select({ count: count() }).from(user); + + const [postsCount] = await ctx.db + .select({ count: count() }) + .from(post) + .where(isNotNull(post.published)); + + const [contentCount] = await ctx.db + .select({ count: count() }) + .from(content) + .where(eq(content.published, true)); + + const [articlesCount] = await ctx.db + .select({ count: count() }) + .from(aggregated_article); + + const [pendingReports] = await ctx.db + .select({ count: count() }) + .from(content_report) + .where(eq(content_report.status, "PENDING")); + + const [bannedUsersCount] = await ctx.db + .select({ count: count() }) + .from(banned_users); + + const [activeSourcesCount] = await ctx.db + .select({ count: count() }) + .from(feed_source) + .where(eq(feed_source.status, "ACTIVE")); + + return { + totalUsers: usersCount.count, + publishedPosts: postsCount.count, + unifiedContent: contentCount.count, + aggregatedArticles: articlesCount.count, + pendingReports: pendingReports.count, + bannedUsers: bannedUsersCount.count, + activeFeedSources: activeSourcesCount.count, + }; + }), + + // Get users with search/filter + getUsers: adminOnlyProcedure + .input( + z.object({ + search: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + cursor: z.number().optional(), + }), + ) + .query(async ({ ctx, input }) => { + const { search, limit, cursor } = input; + + const conditions = []; + + if (search) { + conditions.push( + sql`(${user.username} ILIKE ${`%${search}%`} OR ${user.name} ILIKE ${`%${search}%`} OR ${user.email} ILIKE ${`%${search}%`})`, + ); + } + + if (cursor) { + conditions.push(sql`${user.id} > ${cursor.toString()}`); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const users = await ctx.db.query.user.findMany({ + where: whereClause, + columns: { + id: true, + username: true, + name: true, + email: true, + image: true, + role: true, + createdAt: true, + }, + with: { + bannedUsers: { + columns: { + id: true, + createdAt: true, + note: true, + }, + }, + }, + orderBy: [desc(user.createdAt)], + limit: limit + 1, + }); + + let nextCursor: number | undefined; + if (users.length > limit) { + users.pop(); + nextCursor = users.length; + } + + return { + users: users.map((u) => ({ + ...u, + isBanned: !!u.bannedUsers, + })), + nextCursor, + }; + }), + + // Get banned users list + getBannedUsers: adminOnlyProcedure.query(async ({ ctx }) => { + const banned = await ctx.db.query.banned_users.findMany({ + with: { + user: { + columns: { + id: true, + username: true, + name: true, + email: true, + image: true, + }, + }, + bannedBy: { + columns: { + id: true, + username: true, + name: true, + }, + }, + }, + orderBy: [desc(banned_users.createdAt)], + }); + + return banned; + }), + ban: adminOnlyProcedure .input(BanUserSchema) .mutation(async ({ input, ctx }) => { diff --git a/server/api/router/content.ts b/server/api/router/content.ts new file mode 100644 index 00000000..c09d595a --- /dev/null +++ b/server/api/router/content.ts @@ -0,0 +1,869 @@ +import { TRPCError } from "@trpc/server"; +import { + createTRPCRouter, + publicProcedure, + protectedProcedure, +} from "../trpc"; +import { + GetUnifiedFeedSchema, + GetContentByIdSchema, + GetContentBySlugSchema, + CreateContentSchema, + UpdateContentSchema, + DeleteContentSchema, + VoteContentSchema, + BookmarkContentSchema, + TrackClickContentSchema, + GetUserContentSchema, + GetSavedContentSchema, +} from "../../../schema/content"; +import { + content, + content_vote, + content_bookmark, + content_tag, + feed_source, + tag, + user, + discussion, +} from "@/server/db/schema"; +import { + and, + eq, + desc, + lt, + lte, + sql, + isNotNull, + count, + inArray, +} from "drizzle-orm"; +import { increment, decrement } from "./utils"; +import { db } from "@/server/db"; +import crypto from "crypto"; + +// Helper to generate slug from title +function generateSlug(title: string): string { + const baseSlug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .substring(0, 80); + const uniqueId = crypto.randomBytes(3).toString("hex"); + return `${baseSlug}-${uniqueId}`; +} + +// Helper to calculate read time +function calculateReadTime(body: string | null | undefined): number { + if (!body) return 1; + const wordsPerMinute = 200; + const words = body.trim().split(/\s+/).length; + return Math.max(1, Math.ceil(words / wordsPerMinute)); +} + +export const contentRouter = createTRPCRouter({ + // Get unified feed with optional type filtering + getFeed: publicProcedure + .input(GetUnifiedFeedSchema) + .query(async ({ ctx, input }) => { + const userId = ctx.session?.user?.id; + const limit = input?.limit ?? 20; + const { cursor, sort, type, category, sourceId } = input; + + // Build the vote subquery for current user + const userVotes = userId + ? ctx.db + .select({ + contentId: content_vote.contentId, + voteType: content_vote.voteType, + }) + .from(content_vote) + .where(eq(content_vote.userId, userId)) + .as("userVotes") + : null; + + // Build the bookmark subquery for current user + const userBookmarks = userId + ? ctx.db + .select({ + contentId: content_bookmark.contentId, + }) + .from(content_bookmark) + .where(eq(content_bookmark.userId, userId)) + .as("userBookmarks") + : null; + + // Calculate score for trending + const scoreExpr = sql`(${content.upvotes} - ${content.downvotes})`; + + // Build conditions + const conditions = [eq(content.published, true)]; + + if (type) { + conditions.push(eq(content.type, type)); + } + + if (sourceId) { + conditions.push(eq(content.sourceId, sourceId)); + } + + // Build order by and cursor conditions based on sort type + const getOrderAndCursor = () => { + switch (sort) { + case "recent": + return { + orderBy: desc(content.publishedAt), + cursorCondition: cursor?.publishedAt + ? lte(content.publishedAt, cursor.publishedAt) + : undefined, + }; + case "trending": + return { + orderBy: desc(scoreExpr), + cursorCondition: cursor?.score !== undefined + ? lt(scoreExpr, cursor.score) + : undefined, + }; + case "popular": + return { + orderBy: desc(content.upvotes), + cursorCondition: cursor?.score !== undefined + ? lt(content.upvotes, cursor.score) + : undefined, + }; + default: + return { + orderBy: desc(content.publishedAt), + cursorCondition: undefined, + }; + } + }; + + const { orderBy, cursorCondition } = getOrderAndCursor(); + + if (cursorCondition) { + conditions.push(cursorCondition); + } + + // Build query + let query; + if (userVotes && userBookmarks) { + query = ctx.db + .select({ + id: content.id, + type: content.type, + title: content.title, + excerpt: content.excerpt, + body: content.body, + externalUrl: content.externalUrl, + imageUrl: content.imageUrl, + ogImageUrl: content.ogImageUrl, + slug: content.slug, + publishedAt: content.publishedAt, + upvotes: content.upvotes, + downvotes: content.downvotes, + clickCount: content.clickCount, + readTimeMins: content.readTimeMins, + userId: content.userId, + sourceId: content.sourceId, + sourceAuthor: content.sourceAuthor, + createdAt: content.createdAt, + // Source info + sourceName: feed_source.name, + sourceSlug: feed_source.slug, + sourceLogo: feed_source.logoUrl, + sourceWebsite: feed_source.websiteUrl, + sourceCategory: feed_source.category, + // Author info + authorName: user.name, + authorUsername: user.username, + authorImage: user.image, + // User-specific + userVote: userVotes.voteType, + isBookmarked: sql`${userBookmarks.contentId} IS NOT NULL`, + }) + .from(content) + .leftJoin(feed_source, eq(content.sourceId, feed_source.id)) + .leftJoin(user, eq(content.userId, user.id)) + .leftJoin(userVotes, eq(content.id, userVotes.contentId)) + .leftJoin(userBookmarks, eq(content.id, userBookmarks.contentId)) + .where(and(...conditions)) + .orderBy(orderBy) + .limit(limit + 1); + } else { + query = ctx.db + .select({ + id: content.id, + type: content.type, + title: content.title, + excerpt: content.excerpt, + body: content.body, + externalUrl: content.externalUrl, + imageUrl: content.imageUrl, + ogImageUrl: content.ogImageUrl, + slug: content.slug, + publishedAt: content.publishedAt, + upvotes: content.upvotes, + downvotes: content.downvotes, + clickCount: content.clickCount, + readTimeMins: content.readTimeMins, + userId: content.userId, + sourceId: content.sourceId, + sourceAuthor: content.sourceAuthor, + createdAt: content.createdAt, + // Source info + sourceName: feed_source.name, + sourceSlug: feed_source.slug, + sourceLogo: feed_source.logoUrl, + sourceWebsite: feed_source.websiteUrl, + sourceCategory: feed_source.category, + // Author info + authorName: user.name, + authorUsername: user.username, + authorImage: user.image, + // User-specific (null when not logged in) + userVote: sql<"UP" | "DOWN" | null>`NULL`, + isBookmarked: sql`FALSE`, + }) + .from(content) + .leftJoin(feed_source, eq(content.sourceId, feed_source.id)) + .leftJoin(user, eq(content.userId, user.id)) + .where(and(...conditions)) + .orderBy(orderBy) + .limit(limit + 1); + } + + const results = await query; + + // Check if there's a next page + let nextCursor: { id: string; publishedAt?: string; score?: number } | undefined; + if (results.length > limit) { + const lastItem = results.pop()!; + const score = lastItem.upvotes - lastItem.downvotes; + nextCursor = { + id: lastItem.id, + publishedAt: lastItem.publishedAt || undefined, + score, + }; + } + + return { + items: results, + nextCursor, + }; + }), + + // Get content by ID + getById: publicProcedure + .input(GetContentByIdSchema) + .query(async ({ ctx, input }) => { + const userId = ctx.session?.user?.id; + + const results = await ctx.db + .select({ + id: content.id, + type: content.type, + title: content.title, + body: content.body, + excerpt: content.excerpt, + externalUrl: content.externalUrl, + imageUrl: content.imageUrl, + ogImageUrl: content.ogImageUrl, + slug: content.slug, + canonicalUrl: content.canonicalUrl, + coverImage: content.coverImage, + publishedAt: content.publishedAt, + upvotes: content.upvotes, + downvotes: content.downvotes, + clickCount: content.clickCount, + readTimeMins: content.readTimeMins, + showComments: content.showComments, + userId: content.userId, + sourceId: content.sourceId, + sourceAuthor: content.sourceAuthor, + createdAt: content.createdAt, + updatedAt: content.updatedAt, + // Source info + sourceName: feed_source.name, + sourceSlug: feed_source.slug, + sourceLogo: feed_source.logoUrl, + sourceWebsite: feed_source.websiteUrl, + sourceCategory: feed_source.category, + // Author info + authorName: user.name, + authorUsername: user.username, + authorImage: user.image, + }) + .from(content) + .leftJoin(feed_source, eq(content.sourceId, feed_source.id)) + .leftJoin(user, eq(content.userId, user.id)) + .where(eq(content.id, input.id)) + .limit(1); + + if (results.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Content not found", + }); + } + + const item = results[0]; + + // Get user vote if logged in + let userVote: "UP" | "DOWN" | null = null; + let isBookmarked = false; + + if (userId) { + const [voteResult, bookmarkResult] = await Promise.all([ + ctx.db + .select({ voteType: content_vote.voteType }) + .from(content_vote) + .where( + and( + eq(content_vote.contentId, input.id), + eq(content_vote.userId, userId) + ) + ) + .limit(1), + ctx.db + .select({ id: content_bookmark.id }) + .from(content_bookmark) + .where( + and( + eq(content_bookmark.contentId, input.id), + eq(content_bookmark.userId, userId) + ) + ) + .limit(1), + ]); + + userVote = voteResult[0]?.voteType ?? null; + isBookmarked = bookmarkResult.length > 0; + } + + // Get discussion count + const discussionCountResult = await ctx.db + .select({ count: count() }) + .from(discussion) + .where(eq(discussion.contentId, input.id)); + + return { + ...item, + userVote, + isBookmarked, + discussionCount: discussionCountResult[0]?.count ?? 0, + }; + }), + + // Get content by slug + getBySlug: publicProcedure + .input(GetContentBySlugSchema) + .query(async ({ ctx, input }) => { + const results = await ctx.db + .select({ id: content.id }) + .from(content) + .where(eq(content.slug, input.slug)) + .limit(1); + + if (results.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Content not found", + }); + } + + // Reuse getById logic + return ctx.db.query.content.findFirst({ + where: eq(content.slug, input.slug), + }); + }), + + // Create new content + create: protectedProcedure + .input(CreateContentSchema) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + + // Validate based on content type + if (input.type === "ARTICLE" && !input.body) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Body is required for articles", + }); + } + + if ((input.type === "LINK" || input.type === "VIDEO") && !input.externalUrl) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "External URL is required for links and videos", + }); + } + + const slug = generateSlug(input.title); + const readTimeMins = calculateReadTime(input.body); + + const [newContent] = await ctx.db + .insert(content) + .values({ + type: input.type, + title: input.title, + body: input.body, + excerpt: input.excerpt, + externalUrl: input.externalUrl, + imageUrl: input.imageUrl, + coverImage: input.coverImage, + canonicalUrl: input.canonicalUrl, + userId, + slug, + readTimeMins, + published: input.published, + publishedAt: input.published ? new Date().toISOString() : null, + showComments: input.showComments, + }) + .returning(); + + // Add tags if provided + if (input.tags && input.tags.length > 0) { + // Get or create tags + for (const tagName of input.tags) { + // Try to find existing tag + const existingTags = await ctx.db + .select({ id: tag.id }) + .from(tag) + .where(eq(tag.title, tagName.toLowerCase())) + .limit(1); + + let tagId: number; + if (existingTags.length > 0) { + tagId = existingTags[0].id; + } else { + // Create new tag + const [newTag] = await ctx.db + .insert(tag) + .values({ title: tagName.toLowerCase() }) + .returning(); + tagId = newTag.id; + } + + // Link tag to content + await ctx.db + .insert(content_tag) + .values({ contentId: newContent.id, tagId }) + .onConflictDoNothing(); + } + } + + return newContent; + }), + + // Update content + update: protectedProcedure + .input(UpdateContentSchema) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + + // Check ownership + const existing = await ctx.db + .select({ userId: content.userId, type: content.type }) + .from(content) + .where(eq(content.id, input.id)) + .limit(1); + + if (existing.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Content not found", + }); + } + + if (existing[0].userId !== userId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can only edit your own content", + }); + } + + const updateData: Record = {}; + if (input.title !== undefined) updateData.title = input.title; + if (input.body !== undefined) { + updateData.body = input.body; + updateData.readTimeMins = calculateReadTime(input.body); + } + if (input.excerpt !== undefined) updateData.excerpt = input.excerpt; + if (input.externalUrl !== undefined) updateData.externalUrl = input.externalUrl; + if (input.imageUrl !== undefined) updateData.imageUrl = input.imageUrl; + if (input.coverImage !== undefined) updateData.coverImage = input.coverImage; + if (input.canonicalUrl !== undefined) updateData.canonicalUrl = input.canonicalUrl; + if (input.showComments !== undefined) updateData.showComments = input.showComments; + if (input.published !== undefined) { + updateData.published = input.published; + if (input.published) { + updateData.publishedAt = new Date().toISOString(); + } + } + + const [updated] = await ctx.db + .update(content) + .set(updateData) + .where(eq(content.id, input.id)) + .returning(); + + // Update tags if provided + if (input.tags !== undefined) { + // Remove existing tags + await ctx.db + .delete(content_tag) + .where(eq(content_tag.contentId, input.id)); + + // Add new tags + for (const tagName of input.tags) { + const existingTags = await ctx.db + .select({ id: tag.id }) + .from(tag) + .where(eq(tag.title, tagName.toLowerCase())) + .limit(1); + + let tagId: number; + if (existingTags.length > 0) { + tagId = existingTags[0].id; + } else { + const [newTag] = await ctx.db + .insert(tag) + .values({ title: tagName.toLowerCase() }) + .returning(); + tagId = newTag.id; + } + + await ctx.db + .insert(content_tag) + .values({ contentId: input.id, tagId }) + .onConflictDoNothing(); + } + } + + return updated; + }), + + // Delete content + delete: protectedProcedure + .input(DeleteContentSchema) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + + // Check ownership + const existing = await ctx.db + .select({ userId: content.userId }) + .from(content) + .where(eq(content.id, input.id)) + .limit(1); + + if (existing.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Content not found", + }); + } + + if (existing[0].userId !== userId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can only delete your own content", + }); + } + + await ctx.db.delete(content).where(eq(content.id, input.id)); + + return { success: true }; + }), + + // Vote on content + vote: protectedProcedure + .input(VoteContentSchema) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + const { contentId, voteType } = input; + + // Check if content exists + const contentItem = await ctx.db + .select({ id: content.id, upvotes: content.upvotes, downvotes: content.downvotes }) + .from(content) + .where(eq(content.id, contentId)) + .limit(1); + + if (contentItem.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Content not found", + }); + } + + // Get existing vote + const existingVote = await ctx.db + .select({ id: content_vote.id, voteType: content_vote.voteType }) + .from(content_vote) + .where( + and( + eq(content_vote.contentId, contentId), + eq(content_vote.userId, userId) + ) + ) + .limit(1); + + if (voteType === null) { + // Remove vote + if (existingVote.length > 0) { + const oldVoteType = existingVote[0].voteType; + await ctx.db + .delete(content_vote) + .where(eq(content_vote.id, existingVote[0].id)); + + // Update vote counts + if (oldVoteType === "UP") { + await ctx.db + .update(content) + .set({ upvotes: decrement(content.upvotes) }) + .where(eq(content.id, contentId)); + } else { + await ctx.db + .update(content) + .set({ downvotes: decrement(content.downvotes) }) + .where(eq(content.id, contentId)); + } + } + } else if (existingVote.length === 0) { + // New vote + await ctx.db.insert(content_vote).values({ + contentId, + userId, + voteType, + }); + + // Update vote counts + if (voteType === "UP") { + await ctx.db + .update(content) + .set({ upvotes: increment(content.upvotes) }) + .where(eq(content.id, contentId)); + } else { + await ctx.db + .update(content) + .set({ downvotes: increment(content.downvotes) }) + .where(eq(content.id, contentId)); + } + } else if (existingVote[0].voteType !== voteType) { + // Change vote + await ctx.db + .update(content_vote) + .set({ voteType }) + .where(eq(content_vote.id, existingVote[0].id)); + + // Update vote counts (flip both) + if (voteType === "UP") { + await ctx.db + .update(content) + .set({ + upvotes: increment(content.upvotes), + downvotes: decrement(content.downvotes), + }) + .where(eq(content.id, contentId)); + } else { + await ctx.db + .update(content) + .set({ + upvotes: decrement(content.upvotes), + downvotes: increment(content.downvotes), + }) + .where(eq(content.id, contentId)); + } + } + + return { success: true }; + }), + + // Bookmark content + bookmark: protectedProcedure + .input(BookmarkContentSchema) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + const { contentId, setBookmarked } = input; + + // Check if content exists + const contentItem = await ctx.db + .select({ id: content.id }) + .from(content) + .where(eq(content.id, contentId)) + .limit(1); + + if (contentItem.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Content not found", + }); + } + + if (setBookmarked) { + await ctx.db + .insert(content_bookmark) + .values({ contentId, userId }) + .onConflictDoNothing(); + } else { + await ctx.db + .delete(content_bookmark) + .where( + and( + eq(content_bookmark.contentId, contentId), + eq(content_bookmark.userId, userId) + ) + ); + } + + return { success: true }; + }), + + // Track click on external content + trackClick: publicProcedure + .input(TrackClickContentSchema) + .mutation(async ({ ctx, input }) => { + await ctx.db + .update(content) + .set({ clickCount: increment(content.clickCount) }) + .where(eq(content.id, input.contentId)); + + return { success: true }; + }), + + // Get user's content + getUserContent: publicProcedure + .input(GetUserContentSchema) + .query(async ({ ctx, input }) => { + const { userId: targetUserId, type, limit, cursor } = input; + const currentUserId = ctx.session?.user?.id; + + const conditions = [eq(content.userId, targetUserId)]; + + // Only show published content unless viewing own profile + if (currentUserId !== targetUserId) { + conditions.push(eq(content.published, true)); + } + + if (type) { + conditions.push(eq(content.type, type)); + } + + if (cursor?.publishedAt) { + conditions.push(lte(content.publishedAt, cursor.publishedAt)); + } + + const results = await ctx.db + .select({ + id: content.id, + type: content.type, + title: content.title, + excerpt: content.excerpt, + slug: content.slug, + publishedAt: content.publishedAt, + upvotes: content.upvotes, + downvotes: content.downvotes, + published: content.published, + createdAt: content.createdAt, + }) + .from(content) + .where(and(...conditions)) + .orderBy(desc(content.publishedAt)) + .limit(limit + 1); + + let nextCursor: { id: string; publishedAt?: string } | undefined; + if (results.length > limit) { + const lastItem = results.pop()!; + nextCursor = { + id: lastItem.id, + publishedAt: lastItem.publishedAt || undefined, + }; + } + + return { + items: results, + nextCursor, + }; + }), + + // Get saved content for current user + mySavedContent: protectedProcedure + .input(GetSavedContentSchema) + .query(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + const { limit, cursor } = input; + + const conditions = [eq(content_bookmark.userId, userId)]; + + if (cursor?.createdAt) { + conditions.push(lte(content_bookmark.createdAt, cursor.createdAt)); + } + + const results = await ctx.db + .select({ + id: content.id, + type: content.type, + title: content.title, + excerpt: content.excerpt, + externalUrl: content.externalUrl, + slug: content.slug, + publishedAt: content.publishedAt, + upvotes: content.upvotes, + downvotes: content.downvotes, + sourceName: feed_source.name, + sourceSlug: feed_source.slug, + authorName: user.name, + authorUsername: user.username, + bookmarkedAt: content_bookmark.createdAt, + }) + .from(content_bookmark) + .innerJoin(content, eq(content_bookmark.contentId, content.id)) + .leftJoin(feed_source, eq(content.sourceId, feed_source.id)) + .leftJoin(user, eq(content.userId, user.id)) + .where(and(...conditions)) + .orderBy(desc(content_bookmark.createdAt)) + .limit(limit + 1); + + let nextCursor: { id: string; createdAt?: string } | undefined; + if (results.length > limit) { + const lastItem = results.pop()!; + nextCursor = { + id: lastItem.id, + createdAt: lastItem.bookmarkedAt || undefined, + }; + } + + return { + items: results, + nextCursor, + }; + }), + + // Get categories (from sources) + getCategories: publicProcedure.query(async ({ ctx }) => { + const results = await ctx.db + .selectDistinct({ category: feed_source.category }) + .from(feed_source) + .where(isNotNull(feed_source.category)); + + return results + .map((r) => r.category) + .filter((c): c is string => c !== null) + .sort(); + }), + + // Get content types count + getTypeCounts: publicProcedure.query(async ({ ctx }) => { + const results = await ctx.db + .select({ + type: content.type, + count: count(), + }) + .from(content) + .where(eq(content.published, true)) + .groupBy(content.type); + + return results; + }), +}); diff --git a/server/api/router/discussion.ts b/server/api/router/discussion.ts new file mode 100644 index 00000000..2bb43edd --- /dev/null +++ b/server/api/router/discussion.ts @@ -0,0 +1,480 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; +import { + CreateDiscussionSchema, + EditDiscussionSchema, + DeleteDiscussionSchema, + GetDiscussionsSchema, + LikeDiscussionSchema, + VoteDiscussionSchema, +} from "@/schema/discussion"; +import { + NEW_COMMENT_ON_YOUR_POST, + NEW_REPLY_TO_YOUR_COMMENT, +} from "@/utils/notifications"; +import { + discussion, + discussion_like, + discussion_vote, + notification, + post, + aggregated_article, +} from "@/server/db/schema"; +import { and, count, desc, eq, isNull } from "drizzle-orm"; +import { db } from "@/server/db"; +import { increment, decrement } from "./utils"; + +export const discussionRouter = createTRPCRouter({ + create: protectedProcedure + .input(CreateDiscussionSchema) + .mutation(async ({ input, ctx }) => { + const { body, targetType, postId, articleId, parentId } = input; + const userId = ctx.session.user.id; + + // Validate target exists + if (targetType === "POST" && postId) { + const postData = await ctx.db.query.post.findFirst({ + where: (posts, { eq }) => eq(posts.id, postId), + }); + if (!postData) { + throw new TRPCError({ code: "NOT_FOUND", message: "Post not found" }); + } + } else if (targetType === "ARTICLE" && articleId) { + const articleData = await ctx.db.query.aggregated_article.findFirst({ + where: (articles, { eq }) => eq(articles.id, articleId), + }); + if (!articleData) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Article not found", + }); + } + } + + const now = new Date().toISOString(); + + const [createdDiscussion] = await ctx.db + .insert(discussion) + .values({ + userId, + body, + targetType, + postId: targetType === "POST" ? postId : null, + articleId: targetType === "ARTICLE" ? articleId : null, + parentId, + createdAt: now, + updatedAt: now, + }) + .returning(); + + // Send notifications for replies + if (parentId) { + const parentDiscussion = await ctx.db.query.discussion.findFirst({ + where: (discussions, { eq }) => eq(discussions.id, parentId), + columns: { userId: true }, + }); + + if (parentDiscussion?.userId && parentDiscussion.userId !== userId) { + await ctx.db.insert(notification).values({ + notifierId: userId, + type: NEW_REPLY_TO_YOUR_COMMENT, + postId: targetType === "POST" ? postId : null, + userId: parentDiscussion.userId, + }); + } + } + + // Send notification for new top-level discussion on posts + if (!parentId && targetType === "POST" && postId) { + const postData = await ctx.db.query.post.findFirst({ + where: (posts, { eq }) => eq(posts.id, postId), + columns: { userId: true }, + }); + + if (postData?.userId && postData.userId !== userId) { + await ctx.db.insert(notification).values({ + notifierId: userId, + type: NEW_COMMENT_ON_YOUR_POST, + postId, + userId: postData.userId, + }); + } + } + + return createdDiscussion.id; + }), + + edit: protectedProcedure + .input(EditDiscussionSchema) + .mutation(async ({ input, ctx }) => { + const { body, id } = input; + + const currentDiscussion = await ctx.db.query.discussion.findFirst({ + where: (discussions, { eq }) => eq(discussions.id, id), + }); + + if (currentDiscussion?.userId !== ctx.session.user.id) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + + if (currentDiscussion.body === body) { + return currentDiscussion; + } + + const [updatedDiscussion] = await ctx.db + .update(discussion) + .set({ body }) + .where(eq(discussion.id, id)) + .returning(); + + return updatedDiscussion; + }), + + delete: protectedProcedure + .input(DeleteDiscussionSchema) + .mutation(async ({ input, ctx }) => { + const { id } = input; + + const currentDiscussion = await ctx.db.query.discussion.findFirst({ + where: (discussions, { eq }) => eq(discussions.id, id), + }); + + if (currentDiscussion?.userId !== ctx.session.user.id) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + + const [deletedDiscussion] = await ctx.db + .delete(discussion) + .where(eq(discussion.id, id)) + .returning(); + + return deletedDiscussion.id; + }), + + like: protectedProcedure + .input(LikeDiscussionSchema) + .mutation(async ({ input, ctx }) => { + const { discussionId } = input; + const userId = ctx.session.user.id; + + const existingLike = await ctx.db.query.discussion_like.findFirst({ + where: (likes, { eq }) => + and(eq(likes.userId, userId), eq(likes.discussionId, discussionId)), + }); + + if (existingLike) { + await ctx.db + .delete(discussion_like) + .where( + and( + eq(discussion_like.userId, userId), + eq(discussion_like.discussionId, discussionId), + ), + ); + return { liked: false }; + } else { + await ctx.db.insert(discussion_like).values({ + discussionId, + userId, + }); + return { liked: true }; + } + }), + + // Reddit-style voting (upvote/downvote) + vote: protectedProcedure + .input(VoteDiscussionSchema) + .mutation(async ({ input, ctx }) => { + const { discussionId, voteType } = input; + const userId = ctx.session.user.id; + + // Check if discussion exists + const discussionItem = await ctx.db + .select({ id: discussion.id, upvotes: discussion.upvotes, downvotes: discussion.downvotes }) + .from(discussion) + .where(eq(discussion.id, discussionId)) + .limit(1); + + if (discussionItem.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Discussion not found", + }); + } + + // Get existing vote + const existingVote = await ctx.db + .select({ id: discussion_vote.id, voteType: discussion_vote.voteType }) + .from(discussion_vote) + .where( + and( + eq(discussion_vote.discussionId, discussionId), + eq(discussion_vote.userId, userId) + ) + ) + .limit(1); + + if (voteType === null) { + // Remove vote + if (existingVote.length > 0) { + const oldVoteType = existingVote[0].voteType; + await ctx.db + .delete(discussion_vote) + .where(eq(discussion_vote.id, existingVote[0].id)); + + // Update vote counts + if (oldVoteType === "UP") { + await ctx.db + .update(discussion) + .set({ upvotes: decrement(discussion.upvotes) }) + .where(eq(discussion.id, discussionId)); + } else { + await ctx.db + .update(discussion) + .set({ downvotes: decrement(discussion.downvotes) }) + .where(eq(discussion.id, discussionId)); + } + } + return { voteType: null }; + } else if (existingVote.length === 0) { + // New vote + await ctx.db.insert(discussion_vote).values({ + discussionId, + userId, + voteType, + }); + + // Update vote counts + if (voteType === "UP") { + await ctx.db + .update(discussion) + .set({ upvotes: increment(discussion.upvotes) }) + .where(eq(discussion.id, discussionId)); + } else { + await ctx.db + .update(discussion) + .set({ downvotes: increment(discussion.downvotes) }) + .where(eq(discussion.id, discussionId)); + } + return { voteType }; + } else if (existingVote[0].voteType !== voteType) { + // Change vote + await ctx.db + .update(discussion_vote) + .set({ voteType }) + .where(eq(discussion_vote.id, existingVote[0].id)); + + // Update vote counts (flip both) + if (voteType === "UP") { + await ctx.db + .update(discussion) + .set({ + upvotes: increment(discussion.upvotes), + downvotes: decrement(discussion.downvotes), + }) + .where(eq(discussion.id, discussionId)); + } else { + await ctx.db + .update(discussion) + .set({ + upvotes: decrement(discussion.upvotes), + downvotes: increment(discussion.downvotes), + }) + .where(eq(discussion.id, discussionId)); + } + return { voteType }; + } + + // Same vote, no change needed + return { voteType }; + }), + + get: publicProcedure + .input(GetDiscussionsSchema) + .query(async ({ ctx, input }) => { + const { targetType, postId, articleId } = input; + const userId = ctx?.session?.user?.id; + + // Build where clause based on target type + const whereClause = + targetType === "POST" && postId + ? and( + eq(discussion.targetType, "POST"), + eq(discussion.postId, postId), + isNull(discussion.parentId), + ) + : targetType === "ARTICLE" && articleId + ? and( + eq(discussion.targetType, "ARTICLE"), + eq(discussion.articleId, articleId), + isNull(discussion.parentId), + ) + : undefined; + + if (!whereClause) { + return { data: [], count: 0 }; + } + + // Get total count + const [discussionCount] = await db + .select({ count: count() }) + .from(discussion) + .where( + targetType === "POST" && postId + ? and( + eq(discussion.targetType, "POST"), + eq(discussion.postId, postId), + ) + : and( + eq(discussion.targetType, "ARTICLE"), + eq(discussion.articleId, articleId!), + ), + ); + + const columns = { + id: true, + body: true, + createdAt: true, + updatedAt: true, + upvotes: true, + downvotes: true, + }; + + const userColumns = { + name: true, + image: true, + username: true, + id: true, + email: true, + }; + + // Fetch discussions with nested children (6 levels deep like comments) + const response = await db.query.discussion.findMany({ + columns, + with: { + children: { + columns, + with: { + children: { + columns, + with: { + children: { + columns, + with: { + children: { + columns, + with: { + children: { + columns, + with: { + user: { columns: userColumns }, + likes: { columns: { userId: true } }, + votes: { columns: { userId: true, voteType: true } }, + }, + }, + user: { columns: userColumns }, + likes: { columns: { userId: true } }, + votes: { columns: { userId: true, voteType: true } }, + }, + }, + user: { columns: userColumns }, + likes: { columns: { userId: true } }, + votes: { columns: { userId: true, voteType: true } }, + }, + }, + user: { columns: userColumns }, + likes: { columns: { userId: true } }, + votes: { columns: { userId: true, voteType: true } }, + }, + }, + user: { columns: userColumns }, + likes: { columns: { userId: true } }, + votes: { columns: { userId: true, voteType: true } }, + }, + }, + user: { columns: userColumns }, + likes: { columns: { userId: true } }, + votes: { columns: { userId: true, voteType: true } }, + }, + where: whereClause, + orderBy: [desc(discussion.createdAt)], + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function shapeDiscussions(discussionsArr: any[]): any[] { + return discussionsArr.map((disc) => { + const { children, likes, votes, upvotes, downvotes, ...rest } = disc; + + // Find the current user's vote + const userVoteRecord = votes?.find( + (v: { userId: string; voteType: string }) => v.userId === userId, + ); + + const shaped = { + // Legacy like support (for backwards compatibility) + youLikedThis: likes?.some( + (obj: { userId: string }) => obj.userId === userId, + ), + likeCount: likes?.length ?? 0, + // Reddit-style voting + userVote: userVoteRecord?.voteType ?? null, + score: (upvotes ?? 0) - (downvotes ?? 0), + upvotes: upvotes ?? 0, + downvotes: downvotes ?? 0, + ...rest, + }; + + if (children && children.length > 0) { + return { + ...shaped, + children: shapeDiscussions(children), + }; + } + return shaped; + }); + } + + const discussions = shapeDiscussions(response); + + return { data: discussions, count: discussionCount.count }; + }), + + // Get discussion count for an article (useful for feed display) + getArticleDiscussionCount: publicProcedure + .input( + GetDiscussionsSchema.pick({ articleId: true }).extend({ + articleId: GetDiscussionsSchema.shape.articleId.unwrap(), + }), + ) + .query(async ({ input }) => { + const [result] = await db + .select({ count: count() }) + .from(discussion) + .where( + and( + eq(discussion.targetType, "ARTICLE"), + eq(discussion.articleId, input.articleId), + ), + ); + + return result.count; + }), + + // Get discussion count for unified content (useful for feed display) + getContentDiscussionCount: publicProcedure + .input(z.object({ contentId: z.string() })) + .query(async ({ input }) => { + const [result] = await db + .select({ count: count() }) + .from(discussion) + .where( + and( + eq(discussion.targetType, "CONTENT"), + eq(discussion.contentId, input.contentId), + ), + ); + + return result.count; + }), +}); diff --git a/server/api/router/feed.ts b/server/api/router/feed.ts new file mode 100644 index 00000000..caae1963 --- /dev/null +++ b/server/api/router/feed.ts @@ -0,0 +1,857 @@ +import { TRPCError } from "@trpc/server"; +import { + createTRPCRouter, + publicProcedure, + protectedProcedure, + adminOnlyProcedure, +} from "../trpc"; +import { + GetFeedSchema, + VoteArticleSchema, + BookmarkArticleSchema, + TrackClickSchema, + CreateFeedSourceSchema, + UpdateFeedSourceSchema, + GetSourcesSchema, + DeleteFeedSourceSchema, + GetArticleByIdSchema, + GetArticleBySlugSchema, + GetSourceBySlugSchema, + GetArticlesBySourceSchema, +} from "../../../schema/feed"; +import { + aggregated_article, + aggregated_article_vote, + aggregated_article_bookmark, + feed_source, + aggregated_article_tag, + tag, + post, + user, + bookmark, +} from "@/server/db/schema"; +import { + and, + eq, + desc, + asc, + lte, + lt, + sql, + isNotNull, + count, +} from "drizzle-orm"; +import { increment, decrement } from "./utils"; +import { db } from "@/server/db"; + +export const feedRouter = createTRPCRouter({ + // Get aggregated feed with optional community posts + getFeed: publicProcedure.input(GetFeedSchema).query(async ({ ctx, input }) => { + const userId = ctx.session?.user?.id; + const limit = input?.limit ?? 20; + const { cursor, sort, category, tag: tagFilter, includeCommunity } = input; + + // Build the vote subquery for current user + const userVotes = userId + ? ctx.db + .select({ + articleId: aggregated_article_vote.articleId, + voteType: aggregated_article_vote.voteType, + }) + .from(aggregated_article_vote) + .where(eq(aggregated_article_vote.userId, userId)) + .as("userVotes") + : null; + + // Build the bookmark subquery for current user + const userBookmarks = userId + ? ctx.db + .select({ + articleId: aggregated_article_bookmark.articleId, + }) + .from(aggregated_article_bookmark) + .where(eq(aggregated_article_bookmark.userId, userId)) + .as("userBookmarks") + : null; + + // Calculate score for trending + const scoreExpr = sql`(${aggregated_article.upvotes} - ${aggregated_article.downvotes})`; + + // Build order by and cursor conditions based on sort type + const getOrderAndCursor = () => { + switch (sort) { + case "recent": + return { + orderBy: desc(aggregated_article.publishedAt), + cursorCondition: cursor + ? lte(aggregated_article.publishedAt, cursor.publishedAt as string) + : undefined, + }; + case "trending": + // Trending: recent articles with high engagement + return { + orderBy: desc(scoreExpr), + cursorCondition: cursor + ? lt(scoreExpr, cursor.score as number) + : undefined, + }; + case "popular": + return { + orderBy: desc(aggregated_article.upvotes), + cursorCondition: cursor + ? lt(aggregated_article.upvotes, cursor.score as number) + : undefined, + }; + default: + return { + orderBy: desc(aggregated_article.publishedAt), + cursorCondition: undefined, + }; + } + }; + + const { orderBy, cursorCondition } = getOrderAndCursor(); + + // Base query for aggregated articles + let query = ctx.db + .select({ + id: aggregated_article.id, + shortId: aggregated_article.shortId, + title: aggregated_article.title, + excerpt: aggregated_article.excerpt, + url: aggregated_article.url, + imageUrl: aggregated_article.imageUrl, + ogImageUrl: aggregated_article.ogImageUrl, + author: aggregated_article.author, + publishedAt: aggregated_article.publishedAt, + upvotes: aggregated_article.upvotes, + downvotes: aggregated_article.downvotes, + clickCount: aggregated_article.clickCount, + sourceName: feed_source.name, + sourceSlug: feed_source.slug, + sourceLogo: feed_source.logoUrl, + sourceWebsite: feed_source.websiteUrl, + sourceCategory: feed_source.category, + userVote: userVotes ? userVotes.voteType : sql`NULL`, + isBookmarked: userBookmarks + ? sql`CASE WHEN ${userBookmarks.articleId} IS NOT NULL THEN TRUE ELSE FALSE END` + : sql`FALSE`, + }) + .from(aggregated_article) + .leftJoin(feed_source, eq(aggregated_article.sourceId, feed_source.id)); + + // Add user joins if logged in + if (userVotes) { + query = query.leftJoin( + userVotes, + eq(aggregated_article.id, userVotes.articleId), + ) as typeof query; + } + if (userBookmarks) { + query = query.leftJoin( + userBookmarks, + eq(aggregated_article.id, userBookmarks.articleId), + ) as typeof query; + } + + // Build where conditions + const whereConditions = [ + eq(feed_source.status, "ACTIVE"), + category ? eq(feed_source.category, category) : undefined, + cursorCondition, + ].filter(Boolean); + + const response = await query + .where(and(...whereConditions)) + .limit(limit + 1) + .orderBy(orderBy); + + // Transform results + const articles = response.map((item) => ({ + type: "aggregated" as const, + id: item.id, + shortId: item.shortId, + title: item.title, + excerpt: item.excerpt, + url: item.url, + imageUrl: item.ogImageUrl || item.imageUrl, + author: item.author, + publishedAt: item.publishedAt, + upvotes: item.upvotes, + downvotes: item.downvotes, + score: item.upvotes - item.downvotes, + clickCount: item.clickCount, + sourceName: item.sourceName, + sourceSlug: item.sourceSlug, + sourceLogo: item.sourceLogo, + sourceWebsite: item.sourceWebsite, + sourceCategory: item.sourceCategory, + userVote: item.userVote as "UP" | "DOWN" | null, + isBookmarked: Boolean(item.isBookmarked), + })); + + // Calculate next cursor + let nextCursor: typeof cursor | undefined = undefined; + if (articles.length > limit) { + const nextItem = articles.pop(); + if (nextItem) { + nextCursor = { + id: nextItem.id, + publishedAt: nextItem.publishedAt || undefined, + score: nextItem.score, + }; + } + } + + return { articles, nextCursor }; + }), + + // Vote on an aggregated article + vote: protectedProcedure + .input(VoteArticleSchema) + .mutation(async ({ input, ctx }) => { + const { articleId, voteType } = input; + const userId = ctx.session.user.id; + + // Verify article exists + const article = await ctx.db.query.aggregated_article.findFirst({ + where: eq(aggregated_article.id, articleId), + }); + + if (!article) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Article not found", + }); + } + + // Check existing vote + const existingVote = + await ctx.db.query.aggregated_article_vote.findFirst({ + where: and( + eq(aggregated_article_vote.articleId, articleId), + eq(aggregated_article_vote.userId, userId), + ), + }); + + return await ctx.db.transaction(async (tx) => { + if (voteType === null) { + // Remove vote + if (existingVote) { + await tx + .delete(aggregated_article_vote) + .where(eq(aggregated_article_vote.id, existingVote.id)); + + // Update counts + if (existingVote.voteType === "UP") { + await tx + .update(aggregated_article) + .set({ upvotes: decrement(aggregated_article.upvotes) }) + .where(eq(aggregated_article.id, articleId)); + } else { + await tx + .update(aggregated_article) + .set({ downvotes: decrement(aggregated_article.downvotes) }) + .where(eq(aggregated_article.id, articleId)); + } + } + return { success: true, voteType: null }; + } + + if (existingVote) { + // Update existing vote if different + if (existingVote.voteType !== voteType) { + await tx + .update(aggregated_article_vote) + .set({ voteType }) + .where(eq(aggregated_article_vote.id, existingVote.id)); + + // Adjust counts (swap vote) + if (voteType === "UP") { + await tx + .update(aggregated_article) + .set({ + upvotes: increment(aggregated_article.upvotes), + downvotes: decrement(aggregated_article.downvotes), + }) + .where(eq(aggregated_article.id, articleId)); + } else { + await tx + .update(aggregated_article) + .set({ + upvotes: decrement(aggregated_article.upvotes), + downvotes: increment(aggregated_article.downvotes), + }) + .where(eq(aggregated_article.id, articleId)); + } + } + } else { + // Insert new vote + await tx + .insert(aggregated_article_vote) + .values({ articleId, userId, voteType }); + + if (voteType === "UP") { + await tx + .update(aggregated_article) + .set({ upvotes: increment(aggregated_article.upvotes) }) + .where(eq(aggregated_article.id, articleId)); + } else { + await tx + .update(aggregated_article) + .set({ downvotes: increment(aggregated_article.downvotes) }) + .where(eq(aggregated_article.id, articleId)); + } + } + + return { success: true, voteType }; + }); + }), + + // Bookmark an aggregated article + bookmark: protectedProcedure + .input(BookmarkArticleSchema) + .mutation(async ({ input, ctx }) => { + const { articleId, setBookmarked } = input; + const userId = ctx.session.user.id; + + // Verify article exists + const article = await ctx.db.query.aggregated_article.findFirst({ + where: eq(aggregated_article.id, articleId), + }); + + if (!article) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Article not found", + }); + } + + if (setBookmarked) { + await ctx.db + .insert(aggregated_article_bookmark) + .values({ articleId, userId }) + .onConflictDoNothing(); + } else { + await ctx.db + .delete(aggregated_article_bookmark) + .where( + and( + eq(aggregated_article_bookmark.articleId, articleId), + eq(aggregated_article_bookmark.userId, userId), + ), + ); + } + + return { success: true, bookmarked: setBookmarked }; + }), + + // Track article click + trackClick: publicProcedure + .input(TrackClickSchema) + .mutation(async ({ input, ctx }) => { + await ctx.db + .update(aggregated_article) + .set({ clickCount: increment(aggregated_article.clickCount) }) + .where(eq(aggregated_article.id, input.articleId)); + + return { success: true }; + }), + + // Get user's saved/bookmarked aggregated articles + mySavedArticles: protectedProcedure.query(async ({ ctx }) => { + const userId = ctx.session.user.id; + + const saved = await ctx.db + .select({ + id: aggregated_article.id, + shortId: aggregated_article.shortId, + title: aggregated_article.title, + excerpt: aggregated_article.excerpt, + url: aggregated_article.url, + imageUrl: aggregated_article.imageUrl, + ogImageUrl: aggregated_article.ogImageUrl, + author: aggregated_article.author, + publishedAt: aggregated_article.publishedAt, + upvotes: aggregated_article.upvotes, + downvotes: aggregated_article.downvotes, + sourceName: feed_source.name, + sourceSlug: feed_source.slug, + sourceLogo: feed_source.logoUrl, + sourceWebsite: feed_source.websiteUrl, + bookmarkedAt: aggregated_article_bookmark.createdAt, + }) + .from(aggregated_article_bookmark) + .innerJoin( + aggregated_article, + eq(aggregated_article_bookmark.articleId, aggregated_article.id), + ) + .leftJoin(feed_source, eq(aggregated_article.sourceId, feed_source.id)) + .where(eq(aggregated_article_bookmark.userId, userId)) + .orderBy(desc(aggregated_article_bookmark.createdAt)); + + return saved; + }), + + // Get article by ID + getById: publicProcedure + .input(GetArticleByIdSchema) + .query(async ({ input, ctx }) => { + const userId = ctx.session?.user?.id; + + const article = await ctx.db.query.aggregated_article.findFirst({ + where: eq(aggregated_article.id, input.id), + with: { + source: true, + tags: { + with: { + tag: true, + }, + }, + }, + }); + + if (!article) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Article not found", + }); + } + + // Get user's vote and bookmark status + let userVote: "UP" | "DOWN" | null = null; + let isBookmarked = false; + + if (userId) { + const vote = await ctx.db.query.aggregated_article_vote.findFirst({ + where: and( + eq(aggregated_article_vote.articleId, input.id), + eq(aggregated_article_vote.userId, userId), + ), + }); + userVote = vote?.voteType || null; + + const bookmarkRecord = + await ctx.db.query.aggregated_article_bookmark.findFirst({ + where: and( + eq(aggregated_article_bookmark.articleId, input.id), + eq(aggregated_article_bookmark.userId, userId), + ), + }); + isBookmarked = !!bookmarkRecord; + } + + return { + ...article, + userVote, + isBookmarked, + }; + }), + + // Get article by source slug and shortId (Reddit-style URL) + getBySlugAndShortId: publicProcedure + .input(GetArticleBySlugSchema) + .query(async ({ input, ctx }) => { + const userId = ctx.session?.user?.id; + const { sourceSlug, shortId } = input; + + // Find the source by slug + const source = await ctx.db.query.feed_source.findFirst({ + where: eq(feed_source.slug, sourceSlug), + }); + + if (!source) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Source not found", + }); + } + + // Find the article by shortId and sourceId + const article = await ctx.db.query.aggregated_article.findFirst({ + where: and( + eq(aggregated_article.shortId, shortId), + eq(aggregated_article.sourceId, source.id), + ), + with: { + source: true, + tags: { + with: { + tag: true, + }, + }, + }, + }); + + if (!article) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Article not found", + }); + } + + // Get user's vote and bookmark status + let userVote: "UP" | "DOWN" | null = null; + let isBookmarked = false; + + if (userId) { + const vote = await ctx.db.query.aggregated_article_vote.findFirst({ + where: and( + eq(aggregated_article_vote.articleId, article.id), + eq(aggregated_article_vote.userId, userId), + ), + }); + userVote = vote?.voteType || null; + + const bookmarkRecord = + await ctx.db.query.aggregated_article_bookmark.findFirst({ + where: and( + eq(aggregated_article_bookmark.articleId, article.id), + eq(aggregated_article_bookmark.userId, userId), + ), + }); + isBookmarked = !!bookmarkRecord; + } + + return { + ...article, + userVote, + isBookmarked, + }; + }), + + // Get source profile by slug + getSourceBySlug: publicProcedure + .input(GetSourceBySlugSchema) + .query(async ({ input }) => { + const { slug } = input; + + const source = await db.query.feed_source.findFirst({ + where: eq(feed_source.slug, slug), + }); + + if (!source) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Source not found", + }); + } + + // Get article count for this source + const [articleCountResult] = await db + .select({ count: count() }) + .from(aggregated_article) + .where(eq(aggregated_article.sourceId, source.id)); + + // Get total upvotes across all articles from this source + const [totalVotesResult] = await db + .select({ + totalUpvotes: sql`COALESCE(SUM(${aggregated_article.upvotes}), 0)`, + totalDownvotes: sql`COALESCE(SUM(${aggregated_article.downvotes}), 0)`, + }) + .from(aggregated_article) + .where(eq(aggregated_article.sourceId, source.id)); + + return { + ...source, + articleCount: articleCountResult.count, + totalUpvotes: Number(totalVotesResult.totalUpvotes), + totalDownvotes: Number(totalVotesResult.totalDownvotes), + }; + }), + + // Get paginated articles by source + getArticlesBySource: publicProcedure + .input(GetArticlesBySourceSchema) + .query(async ({ input, ctx }) => { + const userId = ctx.session?.user?.id; + const { sourceSlug, limit, cursor, sort } = input; + + // Find the source by slug + const source = await ctx.db.query.feed_source.findFirst({ + where: eq(feed_source.slug, sourceSlug), + }); + + if (!source) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Source not found", + }); + } + + // Build the vote subquery for current user + const userVotes = userId + ? ctx.db + .select({ + articleId: aggregated_article_vote.articleId, + voteType: aggregated_article_vote.voteType, + }) + .from(aggregated_article_vote) + .where(eq(aggregated_article_vote.userId, userId)) + .as("userVotes") + : null; + + // Build the bookmark subquery for current user + const userBookmarks = userId + ? ctx.db + .select({ + articleId: aggregated_article_bookmark.articleId, + }) + .from(aggregated_article_bookmark) + .where(eq(aggregated_article_bookmark.userId, userId)) + .as("userBookmarks") + : null; + + // Calculate score for trending + const scoreExpr = sql`(${aggregated_article.upvotes} - ${aggregated_article.downvotes})`; + + // Build order by and cursor conditions based on sort type + const getOrderAndCursor = () => { + switch (sort) { + case "recent": + return { + orderBy: desc(aggregated_article.publishedAt), + cursorCondition: cursor + ? lte( + aggregated_article.publishedAt, + cursor.publishedAt as string, + ) + : undefined, + }; + case "trending": + return { + orderBy: desc(scoreExpr), + cursorCondition: undefined, + }; + case "popular": + return { + orderBy: desc(aggregated_article.upvotes), + cursorCondition: undefined, + }; + default: + return { + orderBy: desc(aggregated_article.publishedAt), + cursorCondition: undefined, + }; + } + }; + + const { orderBy, cursorCondition } = getOrderAndCursor(); + + // Base query for aggregated articles + let query = ctx.db + .select({ + id: aggregated_article.id, + shortId: aggregated_article.shortId, + title: aggregated_article.title, + excerpt: aggregated_article.excerpt, + url: aggregated_article.url, + imageUrl: aggregated_article.imageUrl, + ogImageUrl: aggregated_article.ogImageUrl, + author: aggregated_article.author, + publishedAt: aggregated_article.publishedAt, + upvotes: aggregated_article.upvotes, + downvotes: aggregated_article.downvotes, + clickCount: aggregated_article.clickCount, + sourceName: feed_source.name, + sourceSlug: feed_source.slug, + sourceLogo: feed_source.logoUrl, + sourceWebsite: feed_source.websiteUrl, + sourceCategory: feed_source.category, + userVote: userVotes ? userVotes.voteType : sql`NULL`, + isBookmarked: userBookmarks + ? sql`CASE WHEN ${userBookmarks.articleId} IS NOT NULL THEN TRUE ELSE FALSE END` + : sql`FALSE`, + }) + .from(aggregated_article) + .leftJoin(feed_source, eq(aggregated_article.sourceId, feed_source.id)); + + // Add user joins if logged in + if (userVotes) { + query = query.leftJoin( + userVotes, + eq(aggregated_article.id, userVotes.articleId), + ) as typeof query; + } + if (userBookmarks) { + query = query.leftJoin( + userBookmarks, + eq(aggregated_article.id, userBookmarks.articleId), + ) as typeof query; + } + + // Build where conditions + const whereConditions = [ + eq(aggregated_article.sourceId, source.id), + cursorCondition, + ].filter(Boolean); + + const response = await query + .where(and(...whereConditions)) + .limit(limit + 1) + .orderBy(orderBy); + + // Transform results + const articles = response.map((item) => ({ + type: "aggregated" as const, + id: item.id, + shortId: item.shortId, + title: item.title, + excerpt: item.excerpt, + url: item.url, + imageUrl: item.ogImageUrl || item.imageUrl, + author: item.author, + publishedAt: item.publishedAt, + upvotes: item.upvotes, + downvotes: item.downvotes, + score: item.upvotes - item.downvotes, + clickCount: item.clickCount, + sourceName: item.sourceName, + sourceSlug: item.sourceSlug, + sourceLogo: item.sourceLogo, + sourceWebsite: item.sourceWebsite, + sourceCategory: item.sourceCategory, + userVote: item.userVote as "UP" | "DOWN" | null, + isBookmarked: Boolean(item.isBookmarked), + })); + + // Calculate next cursor + let nextCursor: typeof cursor | undefined = undefined; + if (articles.length > limit) { + const nextItem = articles.pop(); + if (nextItem) { + nextCursor = { + id: nextItem.id, + publishedAt: nextItem.publishedAt || undefined, + }; + } + } + + return { articles, nextCursor, source }; + }), + + // Get all feed sources (public) + getSources: publicProcedure + .input(GetSourcesSchema.optional()) + .query(async ({ ctx, input }) => { + const whereConditions = []; + + if (input?.status) { + whereConditions.push(eq(feed_source.status, input.status)); + } + if (input?.category) { + whereConditions.push(eq(feed_source.category, input.category)); + } + + return await ctx.db.query.feed_source.findMany({ + where: whereConditions.length > 0 ? and(...whereConditions) : undefined, + orderBy: asc(feed_source.name), + }); + }), + + // Get distinct categories + getCategories: publicProcedure.query(async ({ ctx }) => { + const categories = await ctx.db + .selectDistinct({ category: feed_source.category }) + .from(feed_source) + .where( + and(eq(feed_source.status, "ACTIVE"), isNotNull(feed_source.category)), + ); + + return categories + .map((c) => c.category) + .filter((c): c is string => c !== null); + }), + + // Get source stats for admin + getSourceStats: adminOnlyProcedure.query(async ({ ctx }) => { + const stats = await ctx.db + .select({ + sourceId: feed_source.id, + sourceName: feed_source.name, + status: feed_source.status, + articleCount: count(aggregated_article.id), + lastFetchedAt: feed_source.lastFetchedAt, + errorCount: feed_source.errorCount, + }) + .from(feed_source) + .leftJoin( + aggregated_article, + eq(feed_source.id, aggregated_article.sourceId), + ) + .groupBy(feed_source.id) + .orderBy(desc(feed_source.createdAt)); + + return stats; + }), + + // Admin: Create feed source + createSource: adminOnlyProcedure + .input(CreateFeedSourceSchema) + .mutation(async ({ input, ctx }) => { + // Check if URL already exists + const existing = await ctx.db.query.feed_source.findFirst({ + where: eq(feed_source.url, input.url), + }); + + if (existing) { + throw new TRPCError({ + code: "CONFLICT", + message: "A feed source with this URL already exists", + }); + } + + const [newSource] = await ctx.db + .insert(feed_source) + .values(input) + .returning(); + + return newSource; + }), + + // Admin: Update feed source + updateSource: adminOnlyProcedure + .input(UpdateFeedSourceSchema) + .mutation(async ({ input, ctx }) => { + const { id, ...data } = input; + + const existing = await ctx.db.query.feed_source.findFirst({ + where: eq(feed_source.id, id), + }); + + if (!existing) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Feed source not found", + }); + } + + const [updated] = await ctx.db + .update(feed_source) + .set(data) + .where(eq(feed_source.id, id)) + .returning(); + + return updated; + }), + + // Admin: Delete feed source + deleteSource: adminOnlyProcedure + .input(DeleteFeedSourceSchema) + .mutation(async ({ input, ctx }) => { + const existing = await ctx.db.query.feed_source.findFirst({ + where: eq(feed_source.id, input.id), + }); + + if (!existing) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Feed source not found", + }); + } + + await ctx.db.delete(feed_source).where(eq(feed_source.id, input.id)); + + return { success: true }; + }), +}); diff --git a/server/api/router/index.ts b/server/api/router/index.ts index d7274a63..ef59de57 100644 --- a/server/api/router/index.ts +++ b/server/api/router/index.ts @@ -2,20 +2,26 @@ import { createTRPCRouter } from "../trpc"; import { postRouter } from "./post"; import { profileRouter } from "./profile"; import { commentRouter } from "./comment"; +import { discussionRouter } from "./discussion"; import { notificationRouter } from "./notification"; import { adminRouter } from "./admin"; import { reportRouter } from "./report"; import { tagRouter } from "./tag"; +import { feedRouter } from "./feed"; +import { contentRouter } from "./content"; export const appRouter = createTRPCRouter({ post: postRouter, profile: profileRouter, comment: commentRouter, + discussion: discussionRouter, notification: notificationRouter, admin: adminRouter, report: reportRouter, tag: tagRouter, + feed: feedRouter, + content: contentRouter, }); // export type definition of API diff --git a/server/api/router/post.ts b/server/api/router/post.ts index 8a41482b..d1e78c2d 100644 --- a/server/api/router/post.ts +++ b/server/api/router/post.ts @@ -9,12 +9,13 @@ import { DeletePostSchema, GetPostsSchema, LikePostSchema, + VotePostSchema, BookmarkPostSchema, GetByIdSchema, GetLimitSidePosts, } from "../../../schema/post"; import { removeMarkdown } from "../../../utils/removeMarkdown"; -import { bookmark, like, post, post_tag, tag, user } from "@/server/db/schema"; +import { bookmark, like, post, post_tag, post_vote, tag, user } from "@/server/db/schema"; import { and, eq, @@ -27,6 +28,7 @@ import { lt, asc, gte, + sql, } from "drizzle-orm"; import { decrement, increment } from "./utils"; @@ -233,6 +235,97 @@ export const postRouter = createTRPCRouter({ return res; }), + vote: protectedProcedure + .input(VotePostSchema) + .mutation(async ({ input, ctx }) => { + const { postId, voteType } = input; + const userId = ctx.session.user.id; + + return await ctx.db.transaction(async (tx) => { + // Get existing vote + const [existingVote] = await tx + .select() + .from(post_vote) + .where( + and(eq(post_vote.postId, postId), eq(post_vote.userId, userId)), + ); + + // If removing vote (voteType is null) + if (voteType === null) { + if (existingVote) { + await tx + .delete(post_vote) + .where( + and(eq(post_vote.postId, postId), eq(post_vote.userId, userId)), + ); + + // Update counts + if (existingVote.voteType === "UP") { + await tx + .update(post) + .set({ upvotes: decrement(post.upvotes) }) + .where(eq(post.id, postId)); + } else { + await tx + .update(post) + .set({ downvotes: decrement(post.downvotes) }) + .where(eq(post.id, postId)); + } + } + return { voteType: null }; + } + + // If changing vote + if (existingVote) { + if (existingVote.voteType !== voteType) { + // Update vote type + await tx + .update(post_vote) + .set({ voteType }) + .where( + and(eq(post_vote.postId, postId), eq(post_vote.userId, userId)), + ); + + // Update counts (swap) + if (voteType === "UP") { + await tx + .update(post) + .set({ + upvotes: increment(post.upvotes), + downvotes: decrement(post.downvotes), + }) + .where(eq(post.id, postId)); + } else { + await tx + .update(post) + .set({ + upvotes: decrement(post.upvotes), + downvotes: increment(post.downvotes), + }) + .where(eq(post.id, postId)); + } + } + } else { + // New vote + await tx.insert(post_vote).values({ postId, userId, voteType }); + + // Update counts + if (voteType === "UP") { + await tx + .update(post) + .set({ upvotes: increment(post.upvotes) }) + .where(eq(post.id, postId)); + } else { + await tx + .update(post) + .set({ downvotes: increment(post.downvotes) }) + .where(eq(post.id, postId)); + } + } + + return { voteType }; + }); + }), bookmark: protectedProcedure .input(BookmarkPostSchema) .mutation(async ({ input, ctx }) => { @@ -258,24 +351,28 @@ export const postRouter = createTRPCRouter({ .query(async ({ input, ctx }) => { const { id } = input; - const [[likes], [userLikesPost], [userBookedmarkedPost]] = + const [[postData], [userVoteData], [userBookedmarkedPost]] = await Promise.all([ ctx.db - .selectDistinct({ count: post.likes }) + .select({ + upvotes: post.upvotes, + downvotes: post.downvotes, + likes: post.likes, + }) .from(post) .where(eq(post.id, id)), - // if user not logged in and they wont have any liked posts so default to a count of 0 + // Get user's vote on this post ctx.session?.user?.id ? ctx.db - .selectDistinct() - .from(like) + .select({ voteType: post_vote.voteType }) + .from(post_vote) .where( and( - eq(like.postId, id), - eq(like.userId, ctx.session.user.id), + eq(post_vote.postId, id), + eq(post_vote.userId, ctx.session.user.id), ), ) - : [false], + : [null], // if user not logged in and they wont have any bookmarked posts so default to a count of 0 ctx.session?.user?.id ? ctx.db @@ -290,8 +387,11 @@ export const postRouter = createTRPCRouter({ : [false], ]); return { - likes: likes.count, - currentUserLiked: !!userLikesPost, + upvotes: postData?.upvotes ?? 0, + downvotes: postData?.downvotes ?? 0, + likes: postData?.likes ?? 0, + userVote: (userVoteData as { voteType: "UP" | "DOWN" } | null)?.voteType ?? null, + currentUserLiked: false, // Deprecated, kept for backwards compatibility currentUserBookmarked: !!userBookedmarkedPost, }; }), @@ -302,6 +402,15 @@ export const postRouter = createTRPCRouter({ const limit = input?.limit ?? 50; const { cursor, sort, tag: tagFilter } = input; + // Reddit-style hot score calculation + // Formula: log10(max(|score|, 1)) + sign(score) * seconds / 45000 + // This makes recent content with votes rank higher than old content with many votes + const hotScoreExpr = sql` + LOG(GREATEST(ABS(${post.upvotes} - ${post.downvotes}), 1)) + + SIGN(${post.upvotes} - ${post.downvotes}) * + EXTRACT(EPOCH FROM (${post.published}::timestamp - '2024-01-01'::timestamp)) / 45000 + `; + const paginationMapping = { newest: { orderBy: desc(post.published), @@ -312,8 +421,14 @@ export const postRouter = createTRPCRouter({ cursor: gte(post.published, cursor?.published as string), }, top: { - orderBy: desc(post.likes), - cursor: lt(post.likes, cursor?.likes as number), + orderBy: desc(sql`${post.upvotes} - ${post.downvotes}`), + cursor: lt(sql`${post.upvotes} - ${post.downvotes}`, cursor?.likes as number), + }, + trending: { + orderBy: desc(hotScoreExpr), + cursor: cursor?.hotScore + ? lt(hotScoreExpr, cursor.hotScore) + : undefined, }, }; @@ -325,6 +440,12 @@ export const postRouter = createTRPCRouter({ .where(eq(bookmark.userId, userId || "")) .as("bookmarked"); + const userVoteSubquery = ctx.db + .select() + .from(post_vote) + .where(eq(post_vote.userId, userId || "")) + .as("userVote"); + const response = await ctx.db .select({ post: { @@ -335,13 +456,17 @@ export const postRouter = createTRPCRouter({ published: post.published, readTimeMins: post.readTimeMins, likes: post.likes, + upvotes: post.upvotes, + downvotes: post.downvotes, }, bookmarked: { id: bookmarked.id }, + userVote: { voteType: userVoteSubquery.voteType }, user: { name: user.name, username: user.username, image: user.image }, }) .from(post) .leftJoin(user, eq(post.userId, user.id)) .leftJoin(bookmarked, eq(bookmarked.postId, post.id)) + .leftJoin(userVoteSubquery, eq(userVoteSubquery.postId, post.id)) .leftJoin(post_tag, eq(post.id, post_tag.postId)) .leftJoin(tag, eq(post_tag.tagId, tag.id)) .where( @@ -360,18 +485,42 @@ export const postRouter = createTRPCRouter({ post.published, post.readTimeMins, post.likes, + post.upvotes, + post.downvotes, bookmarked.id, + userVoteSubquery.voteType, user.id, ) .limit(limit + 1) .orderBy(paginationMapping[sort].orderBy); + // Calculate hotScore for each post (for pagination) + const calculateHotScore = ( + upvotes: number, + downvotes: number, + publishedAt: string, + ): number => { + const score = upvotes - downvotes; + const sign = score > 0 ? 1 : score < 0 ? -1 : 0; + const epoch2024 = new Date("2024-01-01").getTime() / 1000; + const publishedEpoch = new Date(publishedAt).getTime() / 1000; + const seconds = publishedEpoch - epoch2024; + return Math.log10(Math.max(Math.abs(score), 1)) + (sign * seconds) / 45000; + }; + const cleaned = response.map((elem) => { const currentUserBookmarkedPost = userId ? !!elem.bookmarked : false; + const hotScore = calculateHotScore( + elem.post.upvotes, + elem.post.downvotes, + elem.post.published as string, + ); return { ...elem.post, user: elem.user, currentUserBookmarkedPost, + userVote: elem.userVote?.voteType ?? null, + hotScore, }; }); @@ -383,6 +532,7 @@ export const postRouter = createTRPCRouter({ id: nextItem?.id, published: nextItem.published as string, likes: nextItem.likes, + hotScore: sort === "trending" ? nextItem.hotScore : undefined, }; } diff --git a/server/api/router/report.ts b/server/api/router/report.ts index 80a8ef3c..73a0471a 100644 --- a/server/api/router/report.ts +++ b/server/api/router/report.ts @@ -1,14 +1,32 @@ -import { ReportSchema } from "@/schema/report"; +import { + ReportSchema, + CreateReportSchema, + GetReportsSchema, + ReviewReportSchema, +} from "@/schema/report"; import * as Sentry from "@sentry/nextjs"; import sendEmail from "@/utils/sendEmail"; -import { createTRPCRouter, protectedProcedure } from "../trpc"; +import { + createTRPCRouter, + protectedProcedure, + adminOnlyProcedure, +} from "../trpc"; import { TRPCError } from "@trpc/server"; import { createCommentReportEmailTemplate } from "@/utils/createCommentReportEmailTemplate"; import { createArticleReportEmailTemplate } from "@/utils/createArticleReportEmailTemplate"; -import { comment, post, user } from "@/server/db/schema"; -import { eq } from "drizzle-orm"; +import { + comment, + post, + user, + content_report, + content, + discussion, +} from "@/server/db/schema"; +import { and, count, desc, eq, lt } from "drizzle-orm"; +import { db } from "@/server/db"; export const reportRouter = createTRPCRouter({ + // Legacy: Send report via email (backwards compatibility) send: protectedProcedure .input(ReportSchema) .mutation(async ({ input, ctx }) => { @@ -121,4 +139,313 @@ export const reportRouter = createTRPCRouter({ }); } }), + + // New: Create a report (stored in database) + create: protectedProcedure + .input(CreateReportSchema) + .mutation(async ({ input, ctx }) => { + const { contentId, discussionId, reason, details } = input; + const reporterId = ctx.session.user.id; + + // Validate that at least one target is provided + if (!contentId && !discussionId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Must provide either contentId or discussionId", + }); + } + + // Validate content exists if provided + if (contentId) { + const contentItem = await db.query.content.findFirst({ + where: (c, { eq }) => eq(c.id, contentId), + columns: { id: true }, + }); + if (!contentItem) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Content not found", + }); + } + } + + // Validate discussion exists if provided + if (discussionId) { + const discussionItem = await db.query.discussion.findFirst({ + where: (d, { eq }) => eq(d.id, discussionId), + columns: { id: true }, + }); + if (!discussionItem) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Discussion not found", + }); + } + } + + // Check for duplicate reports from same user + const existingReport = await db.query.content_report.findFirst({ + where: (r, { eq, and }) => + and( + eq(r.reporterId, reporterId), + contentId ? eq(r.contentId, contentId) : eq(r.discussionId, discussionId!), + ), + }); + + if (existingReport) { + throw new TRPCError({ + code: "CONFLICT", + message: "You have already reported this item", + }); + } + + const now = new Date().toISOString(); + + const [report] = await db + .insert(content_report) + .values({ + contentId: contentId ?? null, + discussionId: discussionId ?? null, + reporterId, + reason, + details: details ?? null, + status: "PENDING", + createdAt: now, + }) + .returning(); + + return { id: report.id, message: "Report submitted successfully" }; + }), + + // Admin: Get all reports with filtering + getAll: adminOnlyProcedure + .input(GetReportsSchema) + .query(async ({ input }) => { + const { status, reason, limit, cursor } = input; + + const conditions = []; + + if (status) { + conditions.push(eq(content_report.status, status)); + } + + if (reason) { + conditions.push(eq(content_report.reason, reason)); + } + + if (cursor) { + conditions.push(lt(content_report.id, cursor.id)); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const reports = await db.query.content_report.findMany({ + where: whereClause, + with: { + reporter: { + columns: { + id: true, + username: true, + name: true, + image: true, + }, + }, + reviewedBy: { + columns: { + id: true, + username: true, + name: true, + }, + }, + content: { + columns: { + id: true, + title: true, + type: true, + userId: true, + }, + with: { + user: { + columns: { + id: true, + username: true, + name: true, + }, + }, + }, + }, + discussion: { + columns: { + id: true, + body: true, + userId: true, + }, + with: { + user: { + columns: { + id: true, + username: true, + name: true, + }, + }, + }, + }, + }, + orderBy: [desc(content_report.createdAt)], + limit: limit + 1, + }); + + let nextCursor: { id: number; createdAt?: string } | undefined; + if (reports.length > limit) { + const nextItem = reports.pop(); + nextCursor = { + id: nextItem!.id, + createdAt: nextItem!.createdAt ?? undefined, + }; + } + + return { + reports, + nextCursor, + }; + }), + + // Admin: Get report counts by status + getCounts: adminOnlyProcedure.query(async () => { + const [pending] = await db + .select({ count: count() }) + .from(content_report) + .where(eq(content_report.status, "PENDING")); + + const [reviewed] = await db + .select({ count: count() }) + .from(content_report) + .where(eq(content_report.status, "REVIEWED")); + + const [dismissed] = await db + .select({ count: count() }) + .from(content_report) + .where(eq(content_report.status, "DISMISSED")); + + const [actioned] = await db + .select({ count: count() }) + .from(content_report) + .where(eq(content_report.status, "ACTIONED")); + + return { + pending: pending.count, + reviewed: reviewed.count, + dismissed: dismissed.count, + actioned: actioned.count, + total: pending.count + reviewed.count + dismissed.count + actioned.count, + }; + }), + + // Admin: Review a report + review: adminOnlyProcedure + .input(ReviewReportSchema) + .mutation(async ({ input, ctx }) => { + const { reportId, status, actionTaken } = input; + const reviewerId = ctx.session.user.id; + + const existingReport = await db.query.content_report.findFirst({ + where: (r, { eq }) => eq(r.id, reportId), + }); + + if (!existingReport) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Report not found", + }); + } + + const [updatedReport] = await db + .update(content_report) + .set({ + status, + actionTaken: actionTaken ?? null, + reviewedById: reviewerId, + reviewedAt: new Date().toISOString(), + }) + .where(eq(content_report.id, reportId)) + .returning(); + + return updatedReport; + }), + + // Admin: Get a single report by ID + getById: adminOnlyProcedure + .input(ReviewReportSchema.pick({ reportId: true })) + .query(async ({ input }) => { + const report = await db.query.content_report.findFirst({ + where: (r, { eq }) => eq(r.id, input.reportId), + with: { + reporter: { + columns: { + id: true, + username: true, + name: true, + image: true, + email: true, + }, + }, + reviewedBy: { + columns: { + id: true, + username: true, + name: true, + }, + }, + content: { + columns: { + id: true, + title: true, + type: true, + body: true, + excerpt: true, + externalUrl: true, + userId: true, + createdAt: true, + }, + with: { + user: { + columns: { + id: true, + username: true, + name: true, + email: true, + }, + }, + }, + }, + discussion: { + columns: { + id: true, + body: true, + userId: true, + createdAt: true, + }, + with: { + user: { + columns: { + id: true, + username: true, + name: true, + email: true, + }, + }, + }, + }, + }, + }); + + if (!report) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Report not found", + }); + } + + return report; + }), }); diff --git a/server/lib/posts.ts b/server/lib/posts.ts index 3a1811a0..3fc0b888 100644 --- a/server/lib/posts.ts +++ b/server/lib/posts.ts @@ -38,6 +38,8 @@ export async function getPost({ slug }: GetPost) { excerpt: true, canonicalUrl: true, showComments: true, + upvotes: true, + downvotes: true, }, where: (posts, { eq }) => eq(posts.slug, slug), with: { From f223195d2cbac41f8762d7418a5759c99b78b0f6 Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Sun, 4 Jan 2026 14:41:09 +0000 Subject: [PATCH 03/38] feat(feed): add curated developer content feed with RSS aggregation - Add FeedFilters, FeedItemAggregated, FeedItemLoading components - Add feed pages with source profiles and article detail views - Add RSS fetcher Lambda for content aggregation - Update Algolia indexer for feed content - Add og-image utility for Open Graph metadata - Support upvote/downvote, bookmarking, and external link tracking --- .../feed/[sourceSlug]/[shortId]/_client.tsx | 377 ++++ .../feed/[sourceSlug]/[shortId]/page.tsx | 75 + app/(app)/feed/[sourceSlug]/_client.tsx | 293 ++++ app/(app)/feed/[sourceSlug]/page.tsx | 49 + app/(app)/feed/_client.tsx | 260 +++ app/(app)/feed/page.tsx | 24 + cdk/lambdas/algoliaIndex/index.ts | 13 +- cdk/lambdas/algoliaIndex/package-lock.json | 266 +-- cdk/lambdas/algoliaIndex/package.json | 2 +- cdk/lambdas/rssFetcher/index.ts | 310 ++++ cdk/lambdas/rssFetcher/package-lock.json | 1531 +++++++++++++++++ cdk/lambdas/rssFetcher/package.json | 14 + cdk/lib/cron-stack.ts | 26 +- components/Feed/FeedFilters.tsx | 219 +++ components/Feed/FeedItemAggregated.tsx | 388 +++++ components/Feed/FeedItemLoading.tsx | 48 + components/Feed/VoteButtons.tsx | 70 + components/Feed/index.ts | 4 + lib/og-image.ts | 128 ++ 19 files changed, 3989 insertions(+), 108 deletions(-) create mode 100644 app/(app)/feed/[sourceSlug]/[shortId]/_client.tsx create mode 100644 app/(app)/feed/[sourceSlug]/[shortId]/page.tsx create mode 100644 app/(app)/feed/[sourceSlug]/_client.tsx create mode 100644 app/(app)/feed/[sourceSlug]/page.tsx create mode 100644 app/(app)/feed/_client.tsx create mode 100644 app/(app)/feed/page.tsx create mode 100644 cdk/lambdas/rssFetcher/index.ts create mode 100644 cdk/lambdas/rssFetcher/package-lock.json create mode 100644 cdk/lambdas/rssFetcher/package.json create mode 100644 components/Feed/FeedFilters.tsx create mode 100644 components/Feed/FeedItemAggregated.tsx create mode 100644 components/Feed/FeedItemLoading.tsx create mode 100644 components/Feed/VoteButtons.tsx create mode 100644 components/Feed/index.ts create mode 100644 lib/og-image.ts diff --git a/app/(app)/feed/[sourceSlug]/[shortId]/_client.tsx b/app/(app)/feed/[sourceSlug]/[shortId]/_client.tsx new file mode 100644 index 00000000..2cdaa671 --- /dev/null +++ b/app/(app)/feed/[sourceSlug]/[shortId]/_client.tsx @@ -0,0 +1,377 @@ +"use client"; + +import Link from "next/link"; +import * as Sentry from "@sentry/nextjs"; +import { + ArrowTopRightOnSquareIcon, + BookmarkIcon, + ChatBubbleLeftIcon, + ChevronUpIcon, + ChevronDownIcon, + ShareIcon, +} from "@heroicons/react/20/solid"; +import { BookmarkIcon as BookmarkOutlineIcon } from "@heroicons/react/24/outline"; +import { api } from "@/server/trpc/react"; +import { signIn, useSession } from "next-auth/react"; +import { toast } from "sonner"; +import { Temporal } from "@js-temporal/polyfill"; +import DiscussionArea from "@/components/Discussion/DiscussionArea"; + +type Props = { + sourceSlug: string; + shortId: string; +}; + +// Get favicon URL from a website +const getFaviconUrl = ( + websiteUrl: string | null | undefined, +): string | null => { + if (!websiteUrl) return null; + try { + const url = new URL(websiteUrl); + return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32`; + } catch { + return null; + } +}; + +// Get hostname from URL +const getHostname = (urlString: string): string => { + try { + const url = new URL(urlString); + return url.hostname; + } catch { + return urlString; + } +}; + +// Ensure image URL uses https (many RSS feeds provide http which won't load due to mixed content) +const ensureHttps = (url: string | null | undefined): string | null => { + if (!url) return null; + if (url.startsWith("http://")) { + return url.replace("http://", "https://"); + } + return url; +}; + +const FeedArticlePage = ({ sourceSlug, shortId }: Props) => { + const { data: session } = useSession(); + const utils = api.useUtils(); + + const { data: article, status } = api.feed.getBySlugAndShortId.useQuery({ + sourceSlug, + shortId, + }); + + const { data: discussionCount } = + api.discussion.getArticleDiscussionCount.useQuery( + { articleId: article?.id ?? 0 }, + { enabled: !!article?.id }, + ); + + const { mutate: vote, status: voteStatus } = api.feed.vote.useMutation({ + onSuccess: () => { + utils.feed.getBySlugAndShortId.invalidate({ sourceSlug, shortId }); + utils.feed.getFeed.invalidate(); + }, + onError: (error) => { + toast.error("Failed to update vote"); + Sentry.captureException(error); + }, + }); + + const { mutate: bookmark, status: bookmarkStatus } = + api.feed.bookmark.useMutation({ + onSuccess: () => { + utils.feed.getBySlugAndShortId.invalidate({ sourceSlug, shortId }); + utils.feed.getFeed.invalidate(); + utils.feed.mySavedArticles.invalidate(); + }, + onError: (error) => { + toast.error("Failed to update bookmark"); + Sentry.captureException(error); + }, + }); + + const { mutate: trackClick } = api.feed.trackClick.useMutation(); + + const handleVote = (voteType: "UP" | "DOWN" | null) => { + if (!session) { + signIn(); + return; + } + if (article) { + vote({ articleId: article.id, voteType }); + } + }; + + const handleBookmark = () => { + if (!session) { + signIn(); + return; + } + if (article) { + bookmark({ articleId: article.id, setBookmarked: !article.isBookmarked }); + } + }; + + const handleShare = async () => { + const shareUrl = `${window.location.origin}/feed/${sourceSlug}/${shortId}`; + try { + await navigator.clipboard.writeText(shareUrl); + toast.success("Link copied to clipboard"); + } catch { + toast.error("Failed to copy link"); + } + }; + + const handleExternalClick = () => { + if (article) { + trackClick({ articleId: article.id }); + } + }; + + if (status === "pending") { + return ( +
+
+
+
+
+
+
+
+
+
+ ); + } + + if (status === "error" || !article) { + return ( +
+ + Back to Feed + +
+

+ Article Not Found +

+

+ This article may have been removed or the link is invalid. +

+
+
+ ); + } + + const dateTime = article.publishedAt + ? Temporal.Instant.from(new Date(article.publishedAt).toISOString()) + : null; + const readableDate = dateTime + ? dateTime.toLocaleString(["en-IE"], { + year: "numeric", + month: "long", + day: "numeric", + }) + : null; + + const faviconUrl = getFaviconUrl(article.source?.websiteUrl || article.url); + const hostname = getHostname(article.url); + const score = article.upvotes - article.downvotes; + + return ( +
+ {/* Breadcrumb */} + + + {/* Article card */} +
+ {/* Source info */} +
+ + {article.source?.logoUrl ? ( + + ) : faviconUrl ? ( + + ) : ( +
+ {article.source?.name?.charAt(0).toUpperCase() || "?"} +
+ )} + + {article.source?.name || "Unknown Source"} + + + {article.author && article.author.trim() && !["by", "by,", "by ,"].includes(article.author.trim().toLowerCase()) && ( + <> + + {article.author.replace(/^by\s+/i, "").trim()} + + )} + {readableDate && ( + <> + + + + )} +
+ + {/* Title */} +

+ {article.title} +

+ + {/* Excerpt */} + {article.excerpt && ( +

+ {article.excerpt} +

+ )} + + {/* Thumbnail image */} + {(ensureHttps(article.ogImageUrl) || ensureHttps(article.imageUrl)) && ( + + +
+ + {hostname} +
+
+ )} + + {/* Read article CTA */} + + + Read Full Article at {hostname} + + + {/* Action bar */} +
+ {/* Vote buttons */} +
+ + 0 + ? "text-green-500" + : score < 0 + ? "text-red-500" + : "text-neutral-500 dark:text-neutral-400" + }`} + > + {score} + + +
+ + {/* Comments count */} + + + {discussionCount ?? 0} comments + + + {/* Save button */} + + + {/* Share button */} + +
+
+ + {/* Discussion section */} +
+ +
+
+ ); +}; + +export default FeedArticlePage; diff --git a/app/(app)/feed/[sourceSlug]/[shortId]/page.tsx b/app/(app)/feed/[sourceSlug]/[shortId]/page.tsx new file mode 100644 index 00000000..a50ca338 --- /dev/null +++ b/app/(app)/feed/[sourceSlug]/[shortId]/page.tsx @@ -0,0 +1,75 @@ +import { notFound } from "next/navigation"; +import { db } from "@/server/db"; +import { aggregated_article, feed_source } from "@/server/db/schema"; +import { eq, and } from "drizzle-orm"; +import type { Metadata } from "next"; +import FeedArticlePage from "./_client"; + +type Props = { + params: Promise<{ sourceSlug: string; shortId: string }>; +}; + +export async function generateMetadata({ params }: Props): Promise { + const { sourceSlug, shortId } = await params; + + // Find the source by slug + const source = await db.query.feed_source.findFirst({ + where: eq(feed_source.slug, sourceSlug), + }); + + if (!source) { + return { title: "Article Not Found" }; + } + + // Find the article by shortId and sourceId + const article = await db.query.aggregated_article.findFirst({ + where: and( + eq(aggregated_article.shortId, shortId), + eq(aggregated_article.sourceId, source.id), + ), + with: { + source: true, + }, + }); + + if (!article) { + return { title: "Article Not Found" }; + } + + return { + title: `${article.title} | Codú Feed`, + description: article.excerpt || `Discussion about ${article.title}`, + openGraph: { + title: article.title, + description: article.excerpt || `Discussion about ${article.title}`, + images: article.ogImageUrl || article.imageUrl ? [article.ogImageUrl || article.imageUrl!] : undefined, + }, + }; +} + +export default async function Page({ params }: Props) { + const { sourceSlug, shortId } = await params; + + // Verify source exists + const source = await db.query.feed_source.findFirst({ + where: eq(feed_source.slug, sourceSlug), + }); + + if (!source) { + notFound(); + } + + // Verify article exists + const article = await db.query.aggregated_article.findFirst({ + where: and( + eq(aggregated_article.shortId, shortId), + eq(aggregated_article.sourceId, source.id), + ), + }); + + if (!article) { + notFound(); + } + + return ; +} diff --git a/app/(app)/feed/[sourceSlug]/_client.tsx b/app/(app)/feed/[sourceSlug]/_client.tsx new file mode 100644 index 00000000..5f5e74f7 --- /dev/null +++ b/app/(app)/feed/[sourceSlug]/_client.tsx @@ -0,0 +1,293 @@ +"use client"; + +import Link from "next/link"; +import { LinkIcon } from "@heroicons/react/20/solid"; +import { api } from "@/server/trpc/react"; +import { Temporal } from "@js-temporal/polyfill"; +import { useInView } from "react-intersection-observer"; +import { useEffect, useState } from "react"; +import { Heading } from "@/components/ui-components/heading"; + +type Props = { + sourceSlug: string; +}; + +// Get favicon URL from a website +const getFaviconUrl = ( + websiteUrl: string | null | undefined, +): string | null => { + if (!websiteUrl) return null; + try { + const url = new URL(websiteUrl); + return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=128`; + } catch { + return null; + } +}; + +function getDomainFromUrl(url: string) { + const domain = url.replace(/(https?:\/\/)?(www.)?/i, ""); + if (domain[domain.length - 1] === "/") { + return domain.slice(0, domain.length - 1); + } + return domain; +} + +const SourceProfilePage = ({ sourceSlug }: Props) => { + const [sort, setSort] = useState<"recent" | "trending" | "popular">("recent"); + const { ref: loadMoreRef, inView } = useInView({ threshold: 0 }); + + const { data: source, status: sourceStatus } = + api.feed.getSourceBySlug.useQuery({ slug: sourceSlug }); + + const { + data: articlesData, + status: articlesStatus, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = api.feed.getArticlesBySource.useInfiniteQuery( + { sourceSlug, sort, limit: 20 }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ); + + useEffect(() => { + if (inView && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); + + if (sourceStatus === "pending") { + return ( +
+
+
+
+
+
+
+
+
+
+
+ ); + } + + if (sourceStatus === "error" || !source) { + return ( +
+
+

+ Source Not Found +

+

+ This source may have been removed or the link is invalid. +

+ + Back to Feed + +
+
+ ); + } + + const faviconUrl = getFaviconUrl(source.websiteUrl); + const articles = articlesData?.pages.flatMap((page) => page.articles) ?? []; + const totalScore = source.totalUpvotes - source.totalDownvotes; + + return ( + <> +
+ {/* Profile header - matching user profile pattern */} +
+
+ {source.logoUrl ? ( + {`Logo + ) : faviconUrl ? ( + {`Favicon + ) : ( +
+ {source.name?.charAt(0).toUpperCase() || "?"} +
+ )} +
+
+

{source.name}

+ {source.category && ( +

+ {source.category} +

+ )} +

{source.description || "No description available."}

+ {source.websiteUrl && ( + + +

+ {getDomainFromUrl(source.websiteUrl)} +

+ + )} + {/* Stats inline with header */} +
+ + + {source.articleCount} + {" "} + articles + + + 0 + ? "text-green-500" + : totalScore < 0 + ? "text-red-500" + : "text-neutral-900 dark:text-neutral-100" + } + > + {totalScore >= 0 ? "+" : ""} + {totalScore} + {" "} + karma + +
+
+
+ + {/* Sort tabs + Articles header */} +
+
+ {`Articles (${source.articleCount})`} +
+ {(["recent", "trending", "popular"] as const).map((sortOption) => ( + + ))} +
+
+
+ + {/* Articles list */} +
+ {articlesStatus === "pending" ? ( +
+ {[...Array(5)].map((_, i) => ( +
+
+
+
+ ))} +
+ ) : articles.length === 0 ? ( +

Nothing published yet... 🥲

+ ) : ( + <> + {articles.map((article) => { + const dateTime = article.publishedAt + ? Temporal.Instant.from( + new Date(article.publishedAt).toISOString(), + ) + : null; + const readableDate = dateTime + ? dateTime.toLocaleString(["en-IE"], { + year: "numeric", + month: "short", + day: "numeric", + }) + : null; + + return ( +
+ +

+ {article.title} +

+ + {article.excerpt && ( +

+ {article.excerpt} +

+ )} +
+ {readableDate && {readableDate}} + {article.author && article.author.trim() && !["by", "by,", "by ,"].includes(article.author.trim().toLowerCase()) && ( + <> + + {article.author.replace(/^by\s+/i, "").trim()} + + )} + + 0 + ? "text-green-500" + : article.score < 0 + ? "text-red-500" + : "" + } + > + {article.score} points + +
+
+ ); + })} + + {/* Load more trigger */} +
+ {isFetchingNextPage && ( +
+ Loading more articles... +
+ )} + {!hasNextPage && articles.length > 0 && ( +
+ No more articles +
+ )} +
+ + )} +
+
+ + ); +}; + +export default SourceProfilePage; diff --git a/app/(app)/feed/[sourceSlug]/page.tsx b/app/(app)/feed/[sourceSlug]/page.tsx new file mode 100644 index 00000000..194b6053 --- /dev/null +++ b/app/(app)/feed/[sourceSlug]/page.tsx @@ -0,0 +1,49 @@ +import { notFound } from "next/navigation"; +import { db } from "@/server/db"; +import { feed_source } from "@/server/db/schema"; +import { eq } from "drizzle-orm"; +import type { Metadata } from "next"; +import SourceProfilePage from "./_client"; + +type Props = { + params: Promise<{ sourceSlug: string }>; +}; + +export async function generateMetadata({ params }: Props): Promise { + const { sourceSlug } = await params; + + const source = await db.query.feed_source.findFirst({ + where: eq(feed_source.slug, sourceSlug), + }); + + if (!source) { + return { title: "Source Not Found" }; + } + + return { + title: `${source.name} | Codú Feed`, + description: + source.description || `Articles from ${source.name} on Codú Feed`, + openGraph: { + title: source.name, + description: + source.description || `Articles from ${source.name} on Codú Feed`, + images: source.logoUrl ? [source.logoUrl] : undefined, + }, + }; +} + +export default async function Page({ params }: Props) { + const { sourceSlug } = await params; + + // Verify source exists + const source = await db.query.feed_source.findFirst({ + where: eq(feed_source.slug, sourceSlug), + }); + + if (!source) { + notFound(); + } + + return ; +} diff --git a/app/(app)/feed/_client.tsx b/app/(app)/feed/_client.tsx new file mode 100644 index 00000000..d485e1d9 --- /dev/null +++ b/app/(app)/feed/_client.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { Fragment, useEffect } from "react"; +import { useInView } from "react-intersection-observer"; +import { useSearchParams, useRouter } from "next/navigation"; +import { api } from "@/server/trpc/react"; +import { useSession } from "next-auth/react"; +import { + FeedItemAggregated, + FeedItemLoading, + FeedFilters, +} from "@/components/Feed"; + +type SortOption = "recent" | "trending" | "popular"; +const validSorts: SortOption[] = ["recent", "trending", "popular"]; + +const FeedPage = () => { + const searchParams = useSearchParams(); + const router = useRouter(); + const { data: session } = useSession(); + + // Get filter params from URL + const sortParam = searchParams?.get("sort"); + const categoryParam = searchParams?.get("category"); + + // Validate sort param + const sort: SortOption = validSorts.includes(sortParam as SortOption) + ? (sortParam as SortOption) + : "recent"; + + const category = typeof categoryParam === "string" ? categoryParam : null; + + // Fetch feed data with infinite scroll + const { status, data, isFetchingNextPage, fetchNextPage, hasNextPage } = + api.feed.getFeed.useInfiniteQuery( + { + limit: 20, + sort, + category, + includeCommunity: false, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ); + + // Fetch categories for filter dropdown + const { data: categoriesData } = api.feed.getCategories.useQuery(); + + // Intersection observer for infinite scroll + const { ref, inView } = useInView(); + + useEffect(() => { + if (inView && hasNextPage) { + fetchNextPage(); + } + }, [inView, hasNextPage, fetchNextPage]); + + // Handle filter changes + const handleSortChange = (newSort: SortOption) => { + const params = new URLSearchParams(); + if (newSort !== "recent") params.set("sort", newSort); + if (category) params.set("category", category); + const queryString = params.toString(); + router.push(`/feed${queryString ? `?${queryString}` : ""}`); + }; + + const handleCategoryChange = (newCategory: string | null) => { + const params = new URLSearchParams(); + if (sort !== "recent") params.set("sort", sort); + if (newCategory) params.set("category", newCategory); + const queryString = params.toString(); + router.push(`/feed${queryString ? `?${queryString}` : ""}`); + }; + + return ( +
+ {/* Header */} +
+

+ Feed +

+ +
+ + {/* Main content grid */} +
+ {/* Feed items */} +
+
+ {status === "error" && ( +
+ Something went wrong loading the feed. Please refresh the page. +
+ )} + + {status === "pending" && + Array.from({ length: 7 }, (_, i) => )} + + {status === "success" && + data.pages.map((page, pageIndex) => ( + + {page.articles.map((article) => ( + + ))} + + ))} + + {status === "success" && !data.pages[0].articles.length && ( +
+

+ No articles yet +

+

+ Check back soon for curated developer content. +

+
+ )} + + {isFetchingNextPage && } + + + intersection observer marker + +
+
+ + {/* Sidebar */} +
+ {/* About section - moved above topics */} +
+

+ About the Feed +

+

+ Curated developer content from across the web. Upvote articles you + find helpful, save them for later, and discover trending topics in + the developer community. +

+
+ + {/* Categories section */} +
+

+ Topics +

+
+ {categoriesData?.map((cat) => ( + + ))} +
+
+ + {/* Saved articles for logged in users */} + {session && ( +
+

+ Your Saved Articles +

+ +
+ )} +
+
+
+ ); +}; + +// Component to show saved articles preview in sidebar +const SavedArticlesPreview = () => { + const { data, status } = api.feed.mySavedArticles.useQuery(); + + if (status === "pending") { + return ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ); + } + + if (status === "error" || !data?.length) { + return ( +

+ No saved articles yet. Save articles to read them later! +

+ ); + } + + return ( +
+ {data.slice(0, 3).map((article) => ( + +

+ {article.title} +

+

+ {article.sourceName} +

+
+ ))} + {data.length > 3 && ( + + View all saved ({data.length}) + + )} +
+ ); +}; + +export default FeedPage; diff --git a/app/(app)/feed/page.tsx b/app/(app)/feed/page.tsx new file mode 100644 index 00000000..4d956207 --- /dev/null +++ b/app/(app)/feed/page.tsx @@ -0,0 +1,24 @@ +import Content from "./_client"; + +export const metadata = { + title: "Codú Feed - Curated Developer Content", + description: + "Discover the best developer articles from across the web, curated by the Codú community. Upvote, save, and find content that matters to you.", + keywords: [ + "developer feed", + "programming articles", + "tech news", + "web development", + "JavaScript", + "React", + "TypeScript", + "Python", + "DevOps", + "career", + "curated content", + ], +}; + +export default async function Page() { + return ; +} diff --git a/cdk/lambdas/algoliaIndex/index.ts b/cdk/lambdas/algoliaIndex/index.ts index d8b62f35..d05be467 100644 --- a/cdk/lambdas/algoliaIndex/index.ts +++ b/cdk/lambdas/algoliaIndex/index.ts @@ -1,6 +1,6 @@ import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm"; import { Client } from "pg"; -import algoliasearch from "algoliasearch"; +import { algoliasearch } from "algoliasearch"; const ssmClient = new SSMClient({ region: "eu-west-1" }); const [ARTICLE, PAGE, USER] = ["Article", "Page", "User"]; @@ -54,7 +54,7 @@ exports.handler = async function () { ); const { rows: users } = await client.query( - `SELECT username, name, image, bio FROM "User";`, + `SELECT username, name, image, bio FROM "user";`, ); const postIdx = posts.map(({ title, excerpt, slug }) => ({ @@ -74,7 +74,6 @@ exports.handler = async function () { })); const algoliaClient = algoliasearch(ALGOLIA_APP_ID, ALGOLIA_ADMIN_KEY); - const index = algoliaClient.initIndex(ALGOLIA_SOURCE_IDX); const PAGES = [ { @@ -100,9 +99,11 @@ exports.handler = async function () { }, ]; - await index.clearObjects(); - await index.saveObjects([...postIdx, ...userIdx, ...PAGES], { - autoGenerateObjectIDIfNotExist: true, + // v5 API: methods called directly on client with indexName parameter + await algoliaClient.clearObjects({ indexName: ALGOLIA_SOURCE_IDX }); + await algoliaClient.saveObjects({ + indexName: ALGOLIA_SOURCE_IDX, + objects: [...postIdx, ...userIdx, ...PAGES], }); console.log("Algolia index updated"); diff --git a/cdk/lambdas/algoliaIndex/package-lock.json b/cdk/lambdas/algoliaIndex/package-lock.json index 423a4eaf..97177124 100644 --- a/cdk/lambdas/algoliaIndex/package-lock.json +++ b/cdk/lambdas/algoliaIndex/package-lock.json @@ -10,7 +10,7 @@ "license": "ISC", "dependencies": { "@aws-sdk/client-ssm": "^3.509.0", - "algoliasearch": "^4.22.1", + "algoliasearch": "5.12.0", "pg": "^8.11.3" }, "devDependencies": { @@ -18,119 +18,184 @@ "pg-types": "^4.0.2" } }, - "node_modules/@algolia/cache-browser-local-storage": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.22.1.tgz", - "integrity": "sha512-Sw6IAmOCvvP6QNgY9j+Hv09mvkvEIDKjYW8ow0UDDAxSXy664RBNQk3i/0nt7gvceOJ6jGmOTimaZoY1THmU7g==", + "node_modules/@algolia/client-abtesting": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.12.0.tgz", + "integrity": "sha512-hx4eVydkm3yrFCFxmcBtSzI/ykt0cZ6sDWch+v3JTgKpD2WtosMJU3Upv1AjQ4B6COSHCOWEX3vfFxW6OoH6aA==", + "license": "MIT", "dependencies": { - "@algolia/cache-common": "4.22.1" + "@algolia/client-common": "5.12.0", + "@algolia/requester-browser-xhr": "5.12.0", + "@algolia/requester-fetch": "5.12.0", + "@algolia/requester-node-http": "5.12.0" + }, + "engines": { + "node": ">= 14.0.0" } }, - "node_modules/@algolia/cache-common": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.22.1.tgz", - "integrity": "sha512-TJMBKqZNKYB9TptRRjSUtevJeQVXRmg6rk9qgFKWvOy8jhCPdyNZV1nB3SKGufzvTVbomAukFR8guu/8NRKBTA==" - }, - "node_modules/@algolia/cache-in-memory": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.22.1.tgz", - "integrity": "sha512-ve+6Ac2LhwpufuWavM/aHjLoNz/Z/sYSgNIXsinGofWOysPilQZPUetqLj8vbvi+DHZZaYSEP9H5SRVXnpsNNw==", + "node_modules/@algolia/client-analytics": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.12.0.tgz", + "integrity": "sha512-EpTsSv6IW8maCfXCDIptgT7+mQJj7pImEkcNUnxR8yUKAHzTogTXv9yGm2WXOZFVuwstd2i0sImhQ1Vz8RH/hA==", + "license": "MIT", "dependencies": { - "@algolia/cache-common": "4.22.1" + "@algolia/client-common": "5.12.0", + "@algolia/requester-browser-xhr": "5.12.0", + "@algolia/requester-fetch": "5.12.0", + "@algolia/requester-node-http": "5.12.0" + }, + "engines": { + "node": ">= 14.0.0" } }, - "node_modules/@algolia/client-account": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.22.1.tgz", - "integrity": "sha512-k8m+oegM2zlns/TwZyi4YgCtyToackkOpE+xCaKCYfBfDtdGOaVZCM5YvGPtK+HGaJMIN/DoTL8asbM3NzHonw==", - "dependencies": { - "@algolia/client-common": "4.22.1", - "@algolia/client-search": "4.22.1", - "@algolia/transporter": "4.22.1" + "node_modules/@algolia/client-common": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.12.0.tgz", + "integrity": "sha512-od3WmO8qxyfNhKc+K3D17tvun3IMs/xMNmxCG9MiElAkYVbPPTRUYMkRneCpmJyQI0hNx2/EA4kZgzVfQjO86Q==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" } }, - "node_modules/@algolia/client-analytics": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.22.1.tgz", - "integrity": "sha512-1ssi9pyxyQNN4a7Ji9R50nSdISIumMFDwKNuwZipB6TkauJ8J7ha/uO60sPJFqQyqvvI+px7RSNRQT3Zrvzieg==", + "node_modules/@algolia/client-insights": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.12.0.tgz", + "integrity": "sha512-8alajmsYUd+7vfX5lpRNdxqv3Xx9clIHLUItyQK0Z6gwGMbVEFe6YYhgDtwslMAP0y6b0WeJEIZJMLgT7VYpRw==", + "license": "MIT", "dependencies": { - "@algolia/client-common": "4.22.1", - "@algolia/client-search": "4.22.1", - "@algolia/requester-common": "4.22.1", - "@algolia/transporter": "4.22.1" + "@algolia/client-common": "5.12.0", + "@algolia/requester-browser-xhr": "5.12.0", + "@algolia/requester-fetch": "5.12.0", + "@algolia/requester-node-http": "5.12.0" + }, + "engines": { + "node": ">= 14.0.0" } }, - "node_modules/@algolia/client-common": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.22.1.tgz", - "integrity": "sha512-IvaL5v9mZtm4k4QHbBGDmU3wa/mKokmqNBqPj0K7lcR8ZDKzUorhcGp/u8PkPC/e0zoHSTvRh7TRkGX3Lm7iOQ==", + "node_modules/@algolia/client-personalization": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.12.0.tgz", + "integrity": "sha512-bUV9HtfkTBgpoVhxFrMkmVPG03ZN1Rtn51kiaEtukucdk3ggjR9Qu1YUfRSU2lFgxr9qJc8lTxwfvhjCeJRcqw==", + "license": "MIT", "dependencies": { - "@algolia/requester-common": "4.22.1", - "@algolia/transporter": "4.22.1" + "@algolia/client-common": "5.12.0", + "@algolia/requester-browser-xhr": "5.12.0", + "@algolia/requester-fetch": "5.12.0", + "@algolia/requester-node-http": "5.12.0" + }, + "engines": { + "node": ">= 14.0.0" } }, - "node_modules/@algolia/client-personalization": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-4.22.1.tgz", - "integrity": "sha512-sl+/klQJ93+4yaqZ7ezOttMQ/nczly/3GmgZXJ1xmoewP5jmdP/X/nV5U7EHHH3hCUEHeN7X1nsIhGPVt9E1cQ==", + "node_modules/@algolia/client-query-suggestions": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.12.0.tgz", + "integrity": "sha512-Q5CszzGWfxbIDs9DJ/QJsL7bP6h+lJMg27KxieEnI9KGCu0Jt5iFA3GkREkgRZxRdzlHbZKkrIzhtHVbSHw/rg==", + "license": "MIT", "dependencies": { - "@algolia/client-common": "4.22.1", - "@algolia/requester-common": "4.22.1", - "@algolia/transporter": "4.22.1" + "@algolia/client-common": "5.12.0", + "@algolia/requester-browser-xhr": "5.12.0", + "@algolia/requester-fetch": "5.12.0", + "@algolia/requester-node-http": "5.12.0" + }, + "engines": { + "node": ">= 14.0.0" } }, "node_modules/@algolia/client-search": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.22.1.tgz", - "integrity": "sha512-yb05NA4tNaOgx3+rOxAmFztgMTtGBi97X7PC3jyNeGiwkAjOZc2QrdZBYyIdcDLoI09N0gjtpClcackoTN0gPA==", + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.12.0.tgz", + "integrity": "sha512-R3qzEytgVLHOGNri+bpta6NtTt7YtkvUe/QBcAmMDjW4Jk1P0eBYIPfvnzIPbINRsLxIq9fZs9uAYBgsrts4Zg==", + "license": "MIT", "dependencies": { - "@algolia/client-common": "4.22.1", - "@algolia/requester-common": "4.22.1", - "@algolia/transporter": "4.22.1" + "@algolia/client-common": "5.12.0", + "@algolia/requester-browser-xhr": "5.12.0", + "@algolia/requester-fetch": "5.12.0", + "@algolia/requester-node-http": "5.12.0" + }, + "engines": { + "node": ">= 14.0.0" } }, - "node_modules/@algolia/logger-common": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.22.1.tgz", - "integrity": "sha512-OnTFymd2odHSO39r4DSWRFETkBufnY2iGUZNrMXpIhF5cmFE8pGoINNPzwg02QLBlGSaLqdKy0bM8S0GyqPLBg==" + "node_modules/@algolia/ingestion": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.12.0.tgz", + "integrity": "sha512-zpHo6qhR22tL8FsdSI4DvEraPDi/019HmMrCFB/TUX98yzh5ooAU7sNW0qPL1I7+S++VbBmNzJOEU9VI8tEC8A==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.12.0", + "@algolia/requester-browser-xhr": "5.12.0", + "@algolia/requester-fetch": "5.12.0", + "@algolia/requester-node-http": "5.12.0" + }, + "engines": { + "node": ">= 14.0.0" + } }, - "node_modules/@algolia/logger-console": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.22.1.tgz", - "integrity": "sha512-O99rcqpVPKN1RlpgD6H3khUWylU24OXlzkavUAMy6QZd1776QAcauE3oP8CmD43nbaTjBexZj2nGsBH9Tc0FVA==", + "node_modules/@algolia/monitoring": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.12.0.tgz", + "integrity": "sha512-i2AJZED/zf4uhxezAJUhMKoL5QoepCBp2ynOYol0N76+TSoohaMADdPnWCqOULF4RzOwrG8wWynAwBlXsAI1RQ==", + "license": "MIT", "dependencies": { - "@algolia/logger-common": "4.22.1" + "@algolia/client-common": "5.12.0", + "@algolia/requester-browser-xhr": "5.12.0", + "@algolia/requester-fetch": "5.12.0", + "@algolia/requester-node-http": "5.12.0" + }, + "engines": { + "node": ">= 14.0.0" } }, - "node_modules/@algolia/requester-browser-xhr": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.22.1.tgz", - "integrity": "sha512-dtQGYIg6MteqT1Uay3J/0NDqD+UciHy3QgRbk7bNddOJu+p3hzjTRYESqEnoX/DpEkaNYdRHUKNylsqMpgwaEw==", + "node_modules/@algolia/recommend": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.12.0.tgz", + "integrity": "sha512-0jmZyKvYnB/Bj5c7WKsKedOUjnr0UtXm0LVFUdQrxXfqOqvWv9n6Vpr65UjdYG4Q49kRQxhlwtal9WJYrYymXg==", + "license": "MIT", "dependencies": { - "@algolia/requester-common": "4.22.1" + "@algolia/client-common": "5.12.0", + "@algolia/requester-browser-xhr": "5.12.0", + "@algolia/requester-fetch": "5.12.0", + "@algolia/requester-node-http": "5.12.0" + }, + "engines": { + "node": ">= 14.0.0" } }, - "node_modules/@algolia/requester-common": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.22.1.tgz", - "integrity": "sha512-dgvhSAtg2MJnR+BxrIFqlLtkLlVVhas9HgYKMk2Uxiy5m6/8HZBL40JVAMb2LovoPFs9I/EWIoFVjOrFwzn5Qg==" + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.12.0.tgz", + "integrity": "sha512-KxwleraFuVoEGCoeW6Y1RAEbgBMS7SavqeyzWdtkJc6mXeCOJXn1iZitb8Tyn2FcpMNUKlSm0adrUTt7G47+Ow==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.12.0" + }, + "engines": { + "node": ">= 14.0.0" + } }, - "node_modules/@algolia/requester-node-http": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.22.1.tgz", - "integrity": "sha512-JfmZ3MVFQkAU+zug8H3s8rZ6h0ahHZL/SpMaSasTCGYR5EEJsCc8SI5UZ6raPN2tjxa5bxS13BRpGSBUens7EA==", + "node_modules/@algolia/requester-fetch": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.12.0.tgz", + "integrity": "sha512-FuDZXUGU1pAg2HCnrt8+q1VGHKChV/LhvjvZlLOT7e56GJie6p+EuLu4/hMKPOVuQQ8XXtrTHKIU3Lw+7O5/bQ==", + "license": "MIT", "dependencies": { - "@algolia/requester-common": "4.22.1" + "@algolia/client-common": "5.12.0" + }, + "engines": { + "node": ">= 14.0.0" } }, - "node_modules/@algolia/transporter": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.22.1.tgz", - "integrity": "sha512-kzWgc2c9IdxMa3YqA6TN0NW5VrKYYW/BELIn7vnLyn+U/RFdZ4lxxt9/8yq3DKV5snvoDzzO4ClyejZRdV3lMQ==", + "node_modules/@algolia/requester-node-http": { + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.12.0.tgz", + "integrity": "sha512-ncDDY7CxZhMs6LIoPl+vHFQceIBhYPY5EfuGF1V7beO0U38xfsCYEyutEFB2kRzf4D9Gqppn3iWX71sNtrKcuw==", + "license": "MIT", "dependencies": { - "@algolia/cache-common": "4.22.1", - "@algolia/logger-common": "4.22.1", - "@algolia/requester-common": "4.22.1" + "@algolia/client-common": "5.12.0" + }, + "engines": { + "node": ">= 14.0.0" } }, "node_modules/@aws-crypto/crc32": { @@ -1305,24 +1370,27 @@ } }, "node_modules/algoliasearch": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.22.1.tgz", - "integrity": "sha512-jwydKFQJKIx9kIZ8Jm44SdpigFwRGPESaxZBaHSV0XWN2yBJAOT4mT7ppvlrpA4UGzz92pqFnVKr/kaZXrcreg==", - "dependencies": { - "@algolia/cache-browser-local-storage": "4.22.1", - "@algolia/cache-common": "4.22.1", - "@algolia/cache-in-memory": "4.22.1", - "@algolia/client-account": "4.22.1", - "@algolia/client-analytics": "4.22.1", - "@algolia/client-common": "4.22.1", - "@algolia/client-personalization": "4.22.1", - "@algolia/client-search": "4.22.1", - "@algolia/logger-common": "4.22.1", - "@algolia/logger-console": "4.22.1", - "@algolia/requester-browser-xhr": "4.22.1", - "@algolia/requester-common": "4.22.1", - "@algolia/requester-node-http": "4.22.1", - "@algolia/transporter": "4.22.1" + "version": "5.12.0", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.12.0.tgz", + "integrity": "sha512-psGBRYdGgik8I6m28iAB8xpubvjEt7UQU+w5MAJUA2324WHiGoHap5BPkkjB14rMaXeRts6pmOsrVIglGyOVwg==", + "license": "MIT", + "dependencies": { + "@algolia/client-abtesting": "5.12.0", + "@algolia/client-analytics": "5.12.0", + "@algolia/client-common": "5.12.0", + "@algolia/client-insights": "5.12.0", + "@algolia/client-personalization": "5.12.0", + "@algolia/client-query-suggestions": "5.12.0", + "@algolia/client-search": "5.12.0", + "@algolia/ingestion": "1.12.0", + "@algolia/monitoring": "1.12.0", + "@algolia/recommend": "5.12.0", + "@algolia/requester-browser-xhr": "5.12.0", + "@algolia/requester-fetch": "5.12.0", + "@algolia/requester-node-http": "5.12.0" + }, + "engines": { + "node": ">= 14.0.0" } }, "node_modules/bowser": { diff --git a/cdk/lambdas/algoliaIndex/package.json b/cdk/lambdas/algoliaIndex/package.json index 3757cf41..61e172d0 100644 --- a/cdk/lambdas/algoliaIndex/package.json +++ b/cdk/lambdas/algoliaIndex/package.json @@ -11,7 +11,7 @@ "license": "ISC", "dependencies": { "@aws-sdk/client-ssm": "^3.509.0", - "algoliasearch": "^4.22.1", + "algoliasearch": "5.12.0", "pg": "^8.11.3" }, "devDependencies": { diff --git a/cdk/lambdas/rssFetcher/index.ts b/cdk/lambdas/rssFetcher/index.ts new file mode 100644 index 00000000..0d327e8b --- /dev/null +++ b/cdk/lambdas/rssFetcher/index.ts @@ -0,0 +1,310 @@ +import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm"; +import { Client } from "pg"; +import Parser from "rss-parser"; + +const ssmClient = new SSMClient({ region: "eu-west-1" }); + +// Custom fields to extract from RSS items +const parser = new Parser({ + customFields: { + item: [ + ["media:content", "mediaContent", { keepArray: false }], + ["media:thumbnail", "mediaThumbnail", { keepArray: false }], + ["enclosure", "enclosure", { keepArray: false }], + ], + }, +}); + +// Keyword to tag mapping for auto-tagging +const TAG_KEYWORDS: Record = { + JAVASCRIPT: [ + "javascript", + "js", + "node", + "nodejs", + "deno", + "bun", + "npm", + "yarn", + ], + REACT: ["react", "nextjs", "next.js", "remix", "gatsby"], + VUE: ["vue", "nuxt", "vuejs"], + TYPESCRIPT: ["typescript", "ts"], + PYTHON: ["python", "django", "flask", "fastapi"], + CSS: ["css", "tailwind", "sass", "scss", "styling", "styled-components"], + "WEB DEV": ["web", "frontend", "backend", "fullstack", "api", "rest", "graphql"], + DEVOPS: ["docker", "kubernetes", "k8s", "ci/cd", "aws", "azure", "gcp", "cloud"], + CAREER: ["career", "job", "interview", "resume", "hiring", "salary"], + TUTORIAL: ["tutorial", "guide", "how to", "learn", "beginner", "getting started"], + AI: ["ai", "machine learning", "ml", "gpt", "llm", "openai", "claude", "chatgpt"], + DATABASE: ["database", "sql", "postgres", "mongodb", "redis", "prisma", "drizzle"], +}; + +// Helper to get values from AWS SSM +async function getSsmValue(secretName: string): Promise { + const params = { + Name: secretName, + WithDecryption: true, + }; + + try { + const command = new GetParameterCommand(params); + const response = await ssmClient.send(command); + if (!response.Parameter || !response.Parameter.Value) { + throw new Error(`Parameter not found: ${secretName}`); + } + return response.Parameter.Value; + } catch (error) { + console.error(`Error retrieving secret: ${error}`); + throw error; + } +} + +// Extract tags from title and content based on keywords +function extractTags(title: string, content: string): string[] { + const text = `${title} ${content}`.toLowerCase(); + const tags: string[] = []; + + for (const [tag, keywords] of Object.entries(TAG_KEYWORDS)) { + if (keywords.some((keyword) => text.includes(keyword))) { + tags.push(tag); + } + } + + return tags.slice(0, 5); // Max 5 tags per article +} + +// Extract and clean excerpt from content +function extractExcerpt( + content: string | undefined, + maxLength = 300, +): string { + if (!content) return ""; + + // Strip HTML tags + const text = content + .replace(/<[^>]*>/g, "") + .replace(/\s+/g, " ") + .trim(); + + if (text.length <= maxLength) return text; + return text.substring(0, maxLength).trim() + "..."; +} + +// Extract image URL from various RSS item fields +function extractImageUrl(item: Parser.Item): string | null { + // Try different common image locations in RSS feeds + const mediaContent = (item as Record).mediaContent as + | { $?: { url?: string } } + | undefined; + const mediaThumbnail = (item as Record).mediaThumbnail as + | { $?: { url?: string } } + | undefined; + const enclosure = item.enclosure as + | { url?: string; type?: string } + | undefined; + + if (mediaContent?.$?.url) { + return mediaContent.$.url; + } + if (mediaThumbnail?.$?.url) { + return mediaThumbnail.$.url; + } + if (enclosure?.url && enclosure.type?.startsWith("image/")) { + return enclosure.url; + } + + return null; +} + +// Main Lambda handler +exports.handler = async function () { + console.log("RSS Fetcher Lambda running"); + + const stats = { + sourcesProcessed: 0, + articlesAdded: 0, + sourcesWithErrors: 0, + errors: [] as string[], + }; + + try { + const DATABASE_URL = await getSsmValue("/env/db/dbUrl"); + + const client = new Client({ + connectionString: DATABASE_URL, + }); + + await client.connect(); + console.log("Connected to database"); + + // Get active feed sources + const { rows: sources } = await client.query(` + SELECT id, url, name + FROM "FeedSource" + WHERE status = 'ACTIVE' + `); + + console.log(`Found ${sources.length} active feed sources`); + + for (const source of sources) { + try { + console.log(`Fetching: ${source.name} (${source.url})`); + + const feed = await parser.parseURL(source.url); + let newArticles = 0; + + for (const item of feed.items) { + // Skip items without required fields + if (!item.link || !item.title) { + continue; + } + + // Skip articles without a publish date (poor quality RSS feeds) + // Articles without dates pile up as "new" and aren't useful + if (!item.pubDate) { + continue; + } + + // Skip articles older than 30 days + const publishedDate = new Date(item.pubDate); + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + if (publishedDate < thirtyDaysAgo) { + continue; + } + + // Check if article already exists (by URL) + const { rows: existing } = await client.query( + `SELECT id FROM "AggregatedArticle" WHERE url = $1`, + [item.link], + ); + + if (existing.length > 0) { + continue; + } + + // Extract content for excerpt and tagging + const contentSnippet = + item.contentSnippet || item.content || item.summary || ""; + const excerpt = extractExcerpt(contentSnippet); + const imageUrl = extractImageUrl(item); + + // Insert new article + const { + rows: [newArticle], + } = await client.query( + `INSERT INTO "AggregatedArticle" + ("sourceId", "title", "excerpt", "url", "imageUrl", "author", "publishedAt", "fetchedAt", "createdAt") + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) + RETURNING id`, + [ + source.id, + item.title.substring(0, 500), // Limit title length + excerpt, + item.link, + imageUrl, + item.creator || item.author || null, + item.pubDate ? new Date(item.pubDate).toISOString() : null, + ], + ); + + // Auto-tag the article + const tags = extractTags(item.title, contentSnippet); + for (const tagTitle of tags) { + try { + // Get or create tag + let tagId: number; + const { rows: existingTags } = await client.query( + `SELECT id FROM "Tag" WHERE title = $1`, + [tagTitle], + ); + + if (existingTags.length > 0) { + tagId = existingTags[0].id; + } else { + const { rows: newTags } = await client.query( + `INSERT INTO "Tag" (title, "createdAt") VALUES ($1, NOW()) RETURNING id`, + [tagTitle], + ); + tagId = newTags[0].id; + } + + // Link tag to article + await client.query( + `INSERT INTO "AggregatedArticleTag" ("articleId", "tagId") + VALUES ($1, $2) ON CONFLICT DO NOTHING`, + [newArticle.id, tagId], + ); + } catch (tagError) { + // Log but don't fail if tagging fails + console.warn(`Failed to add tag "${tagTitle}":`, tagError); + } + } + + newArticles++; + } + + // Update source status - success + await client.query( + `UPDATE "FeedSource" + SET "lastFetchedAt" = NOW(), + "lastSuccessAt" = NOW(), + "errorCount" = 0, + "lastError" = NULL, + "updatedAt" = NOW() + WHERE id = $1`, + [source.id], + ); + + console.log(`Added ${newArticles} new articles from ${source.name}`); + stats.articlesAdded += newArticles; + stats.sourcesProcessed++; + } catch (error) { + console.error(`Error fetching ${source.name}:`, error); + stats.sourcesWithErrors++; + stats.errors.push(`${source.name}: ${(error as Error).message}`); + + // Update source with error status + await client.query( + `UPDATE "FeedSource" + SET "lastFetchedAt" = NOW(), + "errorCount" = "errorCount" + 1, + "lastError" = $1, + "status" = CASE WHEN "errorCount" >= 5 THEN 'ERROR' ELSE status END, + "updatedAt" = NOW() + WHERE id = $2`, + [(error as Error).message.substring(0, 500), source.id], + ); + } + + // Small delay between sources to be polite + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + await client.end(); + + console.log("RSS Fetcher completed:", stats); + + return { + statusCode: 200, + headers: { "Content-Type": "application/json" }, + body: { + message: "success", + stats, + }, + }; + } catch (error) { + console.error("Fatal error:", error); + + return { + statusCode: 500, + headers: { "Content-Type": "application/json" }, + body: { + message: "error", + error: (error as Error).message, + stats, + }, + }; + } +}; diff --git a/cdk/lambdas/rssFetcher/package-lock.json b/cdk/lambdas/rssFetcher/package-lock.json new file mode 100644 index 00000000..87879437 --- /dev/null +++ b/cdk/lambdas/rssFetcher/package-lock.json @@ -0,0 +1,1531 @@ +{ + "name": "rss-fetcher", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rss-fetcher", + "version": "1.0.0", + "dependencies": { + "@aws-sdk/client-ssm": "^3.509.0", + "pg": "^8.11.3", + "rss-parser": "^3.13.0" + }, + "devDependencies": { + "@types/pg": "^8.11.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-ssm": { + "version": "3.962.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ssm/-/client-ssm-3.962.0.tgz", + "integrity": "sha512-UONnQGDp+02fh4M4ym44Zzato39O36JoJ+vvLKMsAfPJ70dhSbtO0xnMBhUtmNZ3d68JtCa6gBzXileSIKCNhw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.957.0", + "@aws-sdk/credential-provider-node": "3.962.0", + "@aws-sdk/middleware-host-header": "3.957.0", + "@aws-sdk/middleware-logger": "3.957.0", + "@aws-sdk/middleware-recursion-detection": "3.957.0", + "@aws-sdk/middleware-user-agent": "3.957.0", + "@aws-sdk/region-config-resolver": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@aws-sdk/util-endpoints": "3.957.0", + "@aws-sdk/util-user-agent-browser": "3.957.0", + "@aws-sdk/util-user-agent-node": "3.957.0", + "@smithy/config-resolver": "^4.4.5", + "@smithy/core": "^3.20.0", + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/hash-node": "^4.2.7", + "@smithy/invalid-dependency": "^4.2.7", + "@smithy/middleware-content-length": "^4.2.7", + "@smithy/middleware-endpoint": "^4.4.1", + "@smithy/middleware-retry": "^4.4.17", + "@smithy/middleware-serde": "^4.2.8", + "@smithy/middleware-stack": "^4.2.7", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.16", + "@smithy/util-defaults-mode-node": "^4.2.19", + "@smithy/util-endpoints": "^3.2.7", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-retry": "^4.2.7", + "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.958.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.958.0.tgz", + "integrity": "sha512-6qNCIeaMzKzfqasy2nNRuYnMuaMebCcCPP4J2CVGkA8QYMbIVKPlkn9bpB20Vxe6H/r3jtCCLQaOJjVTx/6dXg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.957.0", + "@aws-sdk/middleware-host-header": "3.957.0", + "@aws-sdk/middleware-logger": "3.957.0", + "@aws-sdk/middleware-recursion-detection": "3.957.0", + "@aws-sdk/middleware-user-agent": "3.957.0", + "@aws-sdk/region-config-resolver": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@aws-sdk/util-endpoints": "3.957.0", + "@aws-sdk/util-user-agent-browser": "3.957.0", + "@aws-sdk/util-user-agent-node": "3.957.0", + "@smithy/config-resolver": "^4.4.5", + "@smithy/core": "^3.20.0", + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/hash-node": "^4.2.7", + "@smithy/invalid-dependency": "^4.2.7", + "@smithy/middleware-content-length": "^4.2.7", + "@smithy/middleware-endpoint": "^4.4.1", + "@smithy/middleware-retry": "^4.4.17", + "@smithy/middleware-serde": "^4.2.8", + "@smithy/middleware-stack": "^4.2.7", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.16", + "@smithy/util-defaults-mode-node": "^4.2.19", + "@smithy/util-endpoints": "^3.2.7", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-retry": "^4.2.7", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.957.0.tgz", + "integrity": "sha512-DrZgDnF1lQZv75a52nFWs6MExihJF2GZB6ETZRqr6jMwhrk2kbJPUtvgbifwcL7AYmVqHQDJBrR/MqkwwFCpiw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@aws-sdk/xml-builder": "3.957.0", + "@smithy/core": "^3.20.0", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/signature-v4": "^5.3.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.957.0.tgz", + "integrity": "sha512-475mkhGaWCr+Z52fOOVb/q2VHuNvqEDixlYIkeaO6xJ6t9qR0wpLt4hOQaR6zR1wfZV0SlE7d8RErdYq/PByog==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.957.0.tgz", + "integrity": "sha512-8dS55QHRxXgJlHkEYaCGZIhieCs9NU1HU1BcqQ4RfUdSsfRdxxktqUKgCnBnOOn0oD3PPA8cQOCAVgIyRb3Rfw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/util-stream": "^4.5.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.962.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.962.0.tgz", + "integrity": "sha512-h0kVnXLW2d3nxbcrR/Pfg3W/+YoCguasWz7/3nYzVqmdKarGrpJzaFdoZtLgvDSZ8VgWUC4lWOTcsDMV0UNqUQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.957.0", + "@aws-sdk/credential-provider-env": "3.957.0", + "@aws-sdk/credential-provider-http": "3.957.0", + "@aws-sdk/credential-provider-login": "3.962.0", + "@aws-sdk/credential-provider-process": "3.957.0", + "@aws-sdk/credential-provider-sso": "3.958.0", + "@aws-sdk/credential-provider-web-identity": "3.958.0", + "@aws-sdk/nested-clients": "3.958.0", + "@aws-sdk/types": "3.957.0", + "@smithy/credential-provider-imds": "^4.2.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.962.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.962.0.tgz", + "integrity": "sha512-kHYH6Av2UifG3mPkpPUNRh/PuX6adaAcpmsclJdHdxlixMCRdh8GNeEihq480DC0GmfqdpoSf1w2CLmLLPIS6w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.957.0", + "@aws-sdk/nested-clients": "3.958.0", + "@aws-sdk/types": "3.957.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.962.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.962.0.tgz", + "integrity": "sha512-CS78NsWRxLa+nWqeWBEYMZTLacMFIXs1C5WJuM9kD05LLiWL32ksljoPsvNN24Bc7rCSQIIMx/U3KGvkDVZMVg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.957.0", + "@aws-sdk/credential-provider-http": "3.957.0", + "@aws-sdk/credential-provider-ini": "3.962.0", + "@aws-sdk/credential-provider-process": "3.957.0", + "@aws-sdk/credential-provider-sso": "3.958.0", + "@aws-sdk/credential-provider-web-identity": "3.958.0", + "@aws-sdk/types": "3.957.0", + "@smithy/credential-provider-imds": "^4.2.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.957.0.tgz", + "integrity": "sha512-/KIz9kadwbeLy6SKvT79W81Y+hb/8LMDyeloA2zhouE28hmne+hLn0wNCQXAAupFFlYOAtZR2NTBs7HBAReJlg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.958.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.958.0.tgz", + "integrity": "sha512-CBYHJ5ufp8HC4q+o7IJejCUctJXWaksgpmoFpXerbjAso7/Fg7LLUu9inXVOxlHKLlvYekDXjIUBXDJS2WYdgg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.958.0", + "@aws-sdk/core": "3.957.0", + "@aws-sdk/token-providers": "3.958.0", + "@aws-sdk/types": "3.957.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.958.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.958.0.tgz", + "integrity": "sha512-dgnvwjMq5Y66WozzUzxNkCFap+umHUtqMMKlr8z/vl9NYMLem/WUbWNpFFOVFWquXikc+ewtpBMR4KEDXfZ+KA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.957.0", + "@aws-sdk/nested-clients": "3.958.0", + "@aws-sdk/types": "3.957.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.957.0.tgz", + "integrity": "sha512-BBgKawVyfQZglEkNTuBBdC3azlyqNXsvvN4jPkWAiNYcY0x1BasaJFl+7u/HisfULstryweJq/dAvIZIxzlZaA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.957.0.tgz", + "integrity": "sha512-w1qfKrSKHf9b5a8O76yQ1t69u6NWuBjr5kBX+jRWFx/5mu6RLpqERXRpVJxfosbep7k3B+DSB5tZMZ82GKcJtQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.957.0.tgz", + "integrity": "sha512-D2H/WoxhAZNYX+IjkKTdOhOkWQaK0jjJrDBj56hKjU5c9ltQiaX/1PqJ4dfjHntEshJfu0w+E6XJ+/6A6ILBBA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.957.0.tgz", + "integrity": "sha512-50vcHu96XakQnIvlKJ1UoltrFODjsq2KvtTgHiPFteUS884lQnK5VC/8xd1Msz/1ONpLMzdCVproCQqhDTtMPQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@aws-sdk/util-endpoints": "3.957.0", + "@smithy/core": "^3.20.0", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.958.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.958.0.tgz", + "integrity": "sha512-/KuCcS8b5TpQXkYOrPLYytrgxBhv81+5pChkOlhegbeHttjM69pyUpQVJqyfDM/A7wPLnDrzCAnk4zaAOkY0Nw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.957.0", + "@aws-sdk/middleware-host-header": "3.957.0", + "@aws-sdk/middleware-logger": "3.957.0", + "@aws-sdk/middleware-recursion-detection": "3.957.0", + "@aws-sdk/middleware-user-agent": "3.957.0", + "@aws-sdk/region-config-resolver": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@aws-sdk/util-endpoints": "3.957.0", + "@aws-sdk/util-user-agent-browser": "3.957.0", + "@aws-sdk/util-user-agent-node": "3.957.0", + "@smithy/config-resolver": "^4.4.5", + "@smithy/core": "^3.20.0", + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/hash-node": "^4.2.7", + "@smithy/invalid-dependency": "^4.2.7", + "@smithy/middleware-content-length": "^4.2.7", + "@smithy/middleware-endpoint": "^4.4.1", + "@smithy/middleware-retry": "^4.4.17", + "@smithy/middleware-serde": "^4.2.8", + "@smithy/middleware-stack": "^4.2.7", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-body-length-node": "^4.2.1", + "@smithy/util-defaults-mode-browser": "^4.3.16", + "@smithy/util-defaults-mode-node": "^4.2.19", + "@smithy/util-endpoints": "^3.2.7", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-retry": "^4.2.7", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.957.0.tgz", + "integrity": "sha512-V8iY3blh8l2iaOqXWW88HbkY5jDoWjH56jonprG/cpyqqCnprvpMUZWPWYJoI8rHRf2bqzZeql1slxG6EnKI7A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@smithy/config-resolver": "^4.4.5", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.958.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.958.0.tgz", + "integrity": "sha512-UCj7lQXODduD1myNJQkV+LYcGYJ9iiMggR8ow8Hva1g3A/Na5imNXzz6O67k7DAee0TYpy+gkNw+SizC6min8Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.957.0", + "@aws-sdk/nested-clients": "3.958.0", + "@aws-sdk/types": "3.957.0", + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.957.0.tgz", + "integrity": "sha512-wzWC2Nrt859ABk6UCAVY/WYEbAd7FjkdrQL6m24+tfmWYDNRByTJ9uOgU/kw9zqLCAwb//CPvrJdhqjTznWXAg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.957.0.tgz", + "integrity": "sha512-xwF9K24mZSxcxKS3UKQFeX/dPYkEps9wF1b+MGON7EvnbcucrJGyQyK1v1xFPn1aqXkBTFi+SZaMRx5E5YCVFw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-endpoints": "^3.2.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.957.0.tgz", + "integrity": "sha512-nhmgKHnNV9K+i9daumaIz8JTLsIIML9PE/HUks5liyrjUzenjW/aHoc7WJ9/Td/gPZtayxFnXQSJRb/fDlBuJw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.957.0.tgz", + "integrity": "sha512-exueuwxef0lUJRnGaVkNSC674eAiWU07ORhxBnevFFZEKisln+09Qrtw823iyv5I1N8T+wKfh95xvtWQrNKNQw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.957.0", + "@smithy/types": "^4.11.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.957.0.tgz", + "integrity": "sha512-ycbYCwqXk4gJGp0Oxkzf2KBeeGBdTxz559D41NJP8FlzSej1Gh7Rk40Zo6AyTfsNWkrl/kVi1t937OIzC5t+9Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.957.0", + "@aws-sdk/types": "3.957.0", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.957.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.957.0.tgz", + "integrity": "sha512-Ai5iiQqS8kJ5PjzMhWcLKN0G2yasAkvpnPlq2EnqlIMdB48HsizElt62qcktdxp4neRMyGkFq4NzgmDbXnhRiA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz", + "integrity": "sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/abort-controller": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.7.tgz", + "integrity": "sha512-rzMY6CaKx2qxrbYbqjXWS0plqEy7LOdKHS0bg4ixJ6aoGDPNUcLWk/FRNuCILh7GKLG9TFUXYYeQQldMBBwuyw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.5.tgz", + "integrity": "sha512-HAGoUAFYsUkoSckuKbCPayECeMim8pOu+yLy1zOxt1sifzEbrsRpYa+mKcMdiHKMeiqOibyPG0sFJnmaV/OGEg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.7", + "@smithy/types": "^4.11.0", + "@smithy/util-config-provider": "^4.2.0", + "@smithy/util-endpoints": "^3.2.7", + "@smithy/util-middleware": "^4.2.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.20.0.tgz", + "integrity": "sha512-WsSHCPq/neD5G/MkK4csLI5Y5Pkd9c1NMfpYEKeghSGaD4Ja1qLIohRQf2D5c1Uy5aXp76DeKHkzWZ9KAlHroQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.2.8", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-body-length-browser": "^4.2.0", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-stream": "^4.5.8", + "@smithy/util-utf8": "^4.2.0", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.7.tgz", + "integrity": "sha512-CmduWdCiILCRNbQWFR0OcZlUPVtyE49Sr8yYL0rZQ4D/wKxiNzBNS/YHemvnbkIWj623fplgkexUd/c9CAKdoA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.8.tgz", + "integrity": "sha512-h/Fi+o7mti4n8wx1SR6UHWLaakwHRx29sizvp8OOm7iqwKGFneT06GCSFhml6Bha5BT6ot5pj3CYZnCHhGC2Rg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.7", + "@smithy/querystring-builder": "^4.2.7", + "@smithy/types": "^4.11.0", + "@smithy/util-base64": "^4.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.7.tgz", + "integrity": "sha512-PU/JWLTBCV1c8FtB8tEFnY4eV1tSfBc7bDBADHfn1K+uRbPgSJ9jnJp0hyjiFN2PMdPzxsf1Fdu0eo9fJ760Xw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.7.tgz", + "integrity": "sha512-ncvgCr9a15nPlkhIUx3CU4d7E7WEuVJOV7fS7nnK2hLtPK9tYRBkMHQbhXU1VvvKeBm/O0x26OEoBq+ngFpOEQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", + "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.7.tgz", + "integrity": "sha512-GszfBfCcvt7kIbJ41LuNa5f0wvQCHhnGx/aDaZJCCT05Ld6x6U2s0xsc/0mBFONBZjQJp2U/0uSJ178OXOwbhg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.1.tgz", + "integrity": "sha512-gpLspUAoe6f1M6H0u4cVuFzxZBrsGZmjx2O9SigurTx4PbntYa4AJ+o0G0oGm1L2oSX6oBhcGHwrfJHup2JnJg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.20.0", + "@smithy/middleware-serde": "^4.2.8", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "@smithy/url-parser": "^4.2.7", + "@smithy/util-middleware": "^4.2.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.4.17", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.17.tgz", + "integrity": "sha512-MqbXK6Y9uq17h+4r0ogu/sBT6V/rdV+5NvYL7ZV444BKfQygYe8wAhDrVXagVebN6w2RE0Fm245l69mOsPGZzg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/service-error-classification": "^4.2.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-retry": "^4.2.7", + "@smithy/uuid": "^1.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.8.tgz", + "integrity": "sha512-8rDGYen5m5+NV9eHv9ry0sqm2gI6W7mc1VSFMtn6Igo25S507/HaOX9LTHAS2/J32VXD0xSzrY0H5FJtOMS4/w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.7.tgz", + "integrity": "sha512-bsOT0rJ+HHlZd9crHoS37mt8qRRN/h9jRve1SXUhVbkRzu0QaNYZp1i1jha4n098tsvROjcwfLlfvcFuJSXEsw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.7.tgz", + "integrity": "sha512-7r58wq8sdOcrwWe+klL9y3bc4GW1gnlfnFOuL7CXa7UzfhzhxKuzNdtqgzmTV+53lEp9NXh5hY/S4UgjLOzPfw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.7", + "@smithy/shared-ini-file-loader": "^4.4.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.7.tgz", + "integrity": "sha512-NELpdmBOO6EpZtWgQiHjoShs1kmweaiNuETUpuup+cmm/xJYjT4eUjfhrXRP4jCOaAsS3c3yPsP3B+K+/fyPCQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/querystring-builder": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.7.tgz", + "integrity": "sha512-jmNYKe9MGGPoSl/D7JDDs1C8b3dC8f/w78LbaVfoTtWy4xAd5dfjaFG9c9PWPihY4ggMQNQSMtzU77CNgAJwmA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.7.tgz", + "integrity": "sha512-1r07pb994I20dD/c2seaZhoCuNYm0rWrvBxhCQ70brNh11M5Ml2ew6qJVo0lclB3jMIXirD4s2XRXRe7QEi0xA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.7.tgz", + "integrity": "sha512-eKONSywHZxK4tBxe2lXEysh8wbBdvDWiA+RIuaxZSgCMmA0zMgoDpGLJhnyj+c0leOQprVnXOmcB4m+W9Rw7sg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "@smithy/util-uri-escape": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.7.tgz", + "integrity": "sha512-3X5ZvzUHmlSTHAXFlswrS6EGt8fMSIxX/c3Rm1Pni3+wYWB6cjGocmRIoqcQF9nU5OgGmL0u7l9m44tSUpfj9w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.7.tgz", + "integrity": "sha512-YB7oCbukqEb2Dlh3340/8g8vNGbs/QsNNRms+gv3N2AtZz9/1vSBx6/6tpwQpZMEJFs7Uq8h4mmOn48ZZ72MkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.2.tgz", + "integrity": "sha512-M7iUUff/KwfNunmrgtqBfvZSzh3bmFgv/j/t1Y1dQ+8dNo34br1cqVEqy6v0mYEgi0DkGO7Xig0AnuOaEGVlcg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.7.tgz", + "integrity": "sha512-9oNUlqBlFZFOSdxgImA6X5GFuzE7V2H7VG/7E70cdLhidFbdtvxxt81EHgykGK5vq5D3FafH//X+Oy31j3CKOg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-middleware": "^4.2.7", + "@smithy/util-uri-escape": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.10.2", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.10.2.tgz", + "integrity": "sha512-D5z79xQWpgrGpAHb054Fn2CCTQZpog7JELbVQ6XAvXs5MNKWf28U9gzSBlJkOyMl9LA1TZEjRtwvGXfP0Sl90g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.20.0", + "@smithy/middleware-endpoint": "^4.4.1", + "@smithy/middleware-stack": "^4.2.7", + "@smithy/protocol-http": "^5.3.7", + "@smithy/types": "^4.11.0", + "@smithy/util-stream": "^4.5.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.11.0.tgz", + "integrity": "sha512-mlrmL0DRDVe3mNrjTcVcZEgkFmufITfUAPBEA+AHYiIeYyJebso/He1qLbP3PssRe22KUzLRpQSdBPbXdgZ2VA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.7.tgz", + "integrity": "sha512-/RLtVsRV4uY3qPWhBDsjwahAtt3x2IsMGnP5W1b2VZIe+qgCqkLxI1UOHDZp1Q1QSOrdOR32MF3Ph2JfWT1VHg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", + "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", + "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", + "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", + "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", + "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.3.16", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.16.tgz", + "integrity": "sha512-/eiSP3mzY3TsvUOYMeL4EqUX6fgUOj2eUOU4rMMgVbq67TiRLyxT7Xsjxq0bW3OwuzK009qOwF0L2OgJqperAQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.2.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.2.19", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.19.tgz", + "integrity": "sha512-3a4+4mhf6VycEJyHIQLypRbiwG6aJvbQAeRAVXydMmfweEPnLLabRbdyo/Pjw8Rew9vjsh5WCdhmDaHkQnhhhA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.4.5", + "@smithy/credential-provider-imds": "^4.2.7", + "@smithy/node-config-provider": "^4.3.7", + "@smithy/property-provider": "^4.2.7", + "@smithy/smithy-client": "^4.10.2", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.7.tgz", + "integrity": "sha512-s4ILhyAvVqhMDYREeTS68R43B1V5aenV5q/V1QpRQJkCXib5BPRo4s7uNdzGtIKxaPHCfU/8YkvPAEvTpxgspg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.3.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", + "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.7.tgz", + "integrity": "sha512-i1IkpbOae6NvIKsEeLLM9/2q4X+M90KV3oCFgWQI4q0Qz+yUZvsr+gZPdAEAtFhWQhAHpTsJO8DRJPuwVyln+w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.7.tgz", + "integrity": "sha512-SvDdsQyF5CIASa4EYVT02LukPHVzAgUA4kMAuZ97QJc2BpAqZfA4PINB8/KOoCXEw9tsuv/jQjMeaHFvxdLNGg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.5.8", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.8.tgz", + "integrity": "sha512-ZnnBhTapjM0YPGUSmOs0Mcg/Gg87k503qG4zU2v/+Js2Gu+daKOJMeqcQns8ajepY8tgzzfYxl6kQyZKml6O2w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.3.8", + "@smithy/node-http-handler": "^4.4.7", + "@smithy/types": "^4.11.0", + "@smithy/util-base64": "^4.3.0", + "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/util-utf8": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", + "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", + "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.7.tgz", + "integrity": "sha512-vHJFXi9b7kUEpHWUCY3Twl+9NPOZvQ0SAi+Ewtn48mbiJk4JY9MZmKQjGB4SCvVb9WPiSphZJYY6RIbs+grrzw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.2.7", + "@smithy/types": "^4.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/uuid": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", + "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/pg": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/bowser": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", + "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rss-parser": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.13.0.tgz", + "integrity": "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==", + "license": "MIT", + "dependencies": { + "entities": "^2.0.3", + "xml2js": "^0.5.0" + } + }, + "node_modules/sax": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz", + "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", + "license": "BlueOak-1.0.0" + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/cdk/lambdas/rssFetcher/package.json b/cdk/lambdas/rssFetcher/package.json new file mode 100644 index 00000000..f33a99c0 --- /dev/null +++ b/cdk/lambdas/rssFetcher/package.json @@ -0,0 +1,14 @@ +{ + "name": "rss-fetcher", + "version": "1.0.0", + "description": "RSS Feed Fetcher Lambda for Codu Content Aggregator", + "main": "index.js", + "dependencies": { + "@aws-sdk/client-ssm": "^3.509.0", + "pg": "^8.11.3", + "rss-parser": "^3.13.0" + }, + "devDependencies": { + "@types/pg": "^8.11.0" + } +} diff --git a/cdk/lib/cron-stack.ts b/cdk/lib/cron-stack.ts index c07620bf..8d849e65 100644 --- a/cdk/lib/cron-stack.ts +++ b/cdk/lib/cron-stack.ts @@ -46,10 +46,32 @@ export class CronStack extends cdk.Stack { // 6:00 (am) every day // See https://docs.aws.amazon.com/lambda/latest/dg/tutorial-scheduled-events-schedule-expressions.html - const rule = new events.Rule(this, "Rule", { + const algoliaRule = new events.Rule(this, "AlgoliaRule", { schedule: events.Schedule.expression("cron(0 6 * * ? *)"), }); - rule.addTarget(new targets.LambdaFunction(lambdaFn)); + algoliaRule.addTarget(new targets.LambdaFunction(lambdaFn)); + + // RSS Feed Fetcher Lambda + const rssFetcherFn = new NodejsFunction(this, "RSSFetcherLambda", { + timeout: cdk.Duration.seconds(300), // 5 minutes for processing multiple feeds + runtime: lambda.Runtime.NODEJS_20_X, + entry: path.join(__dirname, "/../lambdas/rssFetcher/index.ts"), + depsLockFilePath: path.join( + __dirname, + "/../lambdas/rssFetcher/package-lock.json", + ), + role: lambdaRole, + bundling: { + nodeModules: ["@aws-sdk/client-ssm", "pg", "rss-parser"], + }, + }); + + // Run every 3 hours to fetch new articles + const rssFetcherRule = new events.Rule(this, "RSSFetcherRule", { + schedule: events.Schedule.expression("rate(3 hours)"), + }); + + rssFetcherRule.addTarget(new targets.LambdaFunction(rssFetcherFn)); } } diff --git a/components/Feed/FeedFilters.tsx b/components/Feed/FeedFilters.tsx new file mode 100644 index 00000000..42a8b1fa --- /dev/null +++ b/components/Feed/FeedFilters.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { Fragment } from "react"; +import { + Menu, + MenuButton, + MenuItem, + MenuItems, + Transition, +} from "@headlessui/react"; +import { + ChevronDownIcon, + ClockIcon, + FireIcon, + ArrowTrendingUpIcon, + DocumentTextIcon, + LinkIcon, + QuestionMarkCircleIcon, + VideoCameraIcon, + ChatBubbleLeftRightIcon, + Squares2X2Icon, +} from "@heroicons/react/20/solid"; + +type SortOption = "recent" | "trending" | "popular"; +type ContentType = "ARTICLE" | "LINK" | "QUESTION" | "VIDEO" | "DISCUSSION" | null; + +type Props = { + sort: SortOption; + type?: ContentType; + category?: string | null; + categories: string[]; + onSortChange: (sort: SortOption) => void; + onTypeChange?: (type: ContentType) => void; + onCategoryChange: (category: string | null) => void; + showTypeFilter?: boolean; +}; + +const sortOptions: { value: SortOption; label: string; icon: typeof ClockIcon }[] = [ + { value: "recent", label: "Recent", icon: ClockIcon }, + { value: "trending", label: "Trending", icon: FireIcon }, + { value: "popular", label: "Popular", icon: ArrowTrendingUpIcon }, +]; + +const typeOptions: { value: ContentType; label: string; icon: typeof DocumentTextIcon }[] = [ + { value: null, label: "All Types", icon: Squares2X2Icon }, + { value: "ARTICLE", label: "Articles", icon: DocumentTextIcon }, + { value: "LINK", label: "Links", icon: LinkIcon }, + { value: "QUESTION", label: "Questions", icon: QuestionMarkCircleIcon }, + { value: "VIDEO", label: "Videos", icon: VideoCameraIcon }, + { value: "DISCUSSION", label: "Discussions", icon: ChatBubbleLeftRightIcon }, +]; + +const FeedFilters = ({ + sort, + type, + category, + categories, + onSortChange, + onTypeChange, + onCategoryChange, + showTypeFilter = true, +}: Props) => { + const currentSort = sortOptions.find((opt) => opt.value === sort) || sortOptions[0]; + const currentType = typeOptions.find((opt) => opt.value === type) || typeOptions[0]; + + return ( +
+ {/* Content Type Dropdown */} + {showTypeFilter && onTypeChange && ( + + + + {currentType.label} + + + + +
+ {typeOptions.map((option) => ( + + {({ focus }) => ( + + )} + + ))} +
+
+
+
+ )} + + {/* Sort Dropdown */} + + + + {currentSort.label} + + + + +
+ {sortOptions.map((option) => ( + + {({ focus }) => ( + + )} + + ))} +
+
+
+
+ + {/* Category Dropdown */} + {categories.length > 0 && ( + + + {category || "All Topics"} + + + + +
+ + {({ focus }) => ( + + )} + + {categories.map((cat) => ( + + {({ focus }) => ( + + )} + + ))} +
+
+
+
+ )} +
+ ); +}; + +export default FeedFilters; diff --git a/components/Feed/FeedItemAggregated.tsx b/components/Feed/FeedItemAggregated.tsx new file mode 100644 index 00000000..2321fdde --- /dev/null +++ b/components/Feed/FeedItemAggregated.tsx @@ -0,0 +1,388 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import * as Sentry from "@sentry/nextjs"; +import { + BookmarkIcon, + ArrowTopRightOnSquareIcon, + ChatBubbleLeftIcon, + ShareIcon, + ChevronUpIcon, + ChevronDownIcon, +} from "@heroicons/react/20/solid"; +import { BookmarkIcon as BookmarkOutlineIcon } from "@heroicons/react/24/outline"; +import { api } from "@/server/trpc/react"; +import { signIn, useSession } from "next-auth/react"; +import { toast } from "sonner"; +import { Temporal } from "@js-temporal/polyfill"; + +type Props = { + id: number; + shortId: string | null; + title: string; + excerpt: string | null; + url: string; + imageUrl?: string | null; + publishedAt: string | null; + upvotes: number; + downvotes: number; + sourceName: string | null; + sourceSlug: string | null; + sourceLogo: string | null; + sourceWebsite?: string | null; + author: string | null; + userVote: "UP" | "DOWN" | null; + isBookmarked: boolean; +}; + +// Get favicon URL from a website +const getFaviconUrl = (websiteUrl: string | null | undefined): string | null => { + if (!websiteUrl) return null; + try { + const url = new URL(websiteUrl); + return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32`; + } catch { + return null; + } +}; + +// Get relative time string +const getRelativeTime = (dateStr: string): string => { + const now = new Date(); + const date = new Date(dateStr); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +}; + +// Get display URL (truncated, without protocol) +const getDisplayUrl = (urlString: string): string => { + try { + const url = new URL(urlString); + const path = url.pathname === "/" ? "" : url.pathname; + const display = url.hostname + path; + return display.length > 50 ? display.substring(0, 50) + "..." : display; + } catch { + return urlString; + } +}; + +// Ensure image URL uses https (many RSS feeds provide http which won't load due to mixed content) +const ensureHttps = (url: string | null | undefined): string | null => { + if (!url) return null; + if (url.startsWith("http://")) { + return url.replace("http://", "https://"); + } + return url; +}; + +const FeedItemAggregated = ({ + id, + shortId, + title, + excerpt, + url, + imageUrl: rawImageUrl, + publishedAt, + upvotes, + downvotes, + sourceName, + sourceSlug, + sourceLogo, + sourceWebsite, + author, + userVote, + isBookmarked: initialBookmarked, +}: Props) => { + // Build the article URL - use new format if available, fallback to old + const articleUrl = sourceSlug && shortId ? `/feed/${sourceSlug}/${shortId}` : `/feed/${id}`; + const sourceProfileUrl = sourceSlug ? `/feed/${sourceSlug}` : null; + const [imageError, setImageError] = useState(false); + const { data: session } = useSession(); + const utils = api.useUtils(); + + // Convert http to https for images + const imageUrl = ensureHttps(rawImageUrl); + + const { mutate: vote, status: voteStatus } = api.feed.vote.useMutation({ + onSuccess: () => { + utils.feed.getFeed.invalidate(); + }, + onError: (error) => { + toast.error("Failed to update vote"); + Sentry.captureException(error); + }, + }); + + const { mutate: bookmark, status: bookmarkStatus } = + api.feed.bookmark.useMutation({ + onSuccess: () => { + utils.feed.getFeed.invalidate(); + utils.feed.mySavedArticles.invalidate(); + }, + onError: (error) => { + toast.error("Failed to update bookmark"); + Sentry.captureException(error); + }, + }); + + const { mutate: trackClick } = api.feed.trackClick.useMutation(); + + const handleClick = () => { + trackClick({ articleId: id }); + }; + + const handleVote = (voteType: "UP" | "DOWN" | null) => { + if (!session) { + signIn(); + return; + } + vote({ articleId: id, voteType }); + }; + + const handleBookmark = () => { + if (!session) { + signIn(); + return; + } + bookmark({ articleId: id, setBookmarked: !initialBookmarked }); + }; + + const handleShare = async () => { + const shareUrl = `${window.location.origin}${articleUrl}`; + try { + await navigator.clipboard.writeText(shareUrl); + toast.success("Link copied to clipboard"); + } catch { + toast.error("Failed to copy link"); + } + }; + + const dateTime = publishedAt + ? Temporal.Instant.from(new Date(publishedAt).toISOString()) + : null; + const readableDate = dateTime + ? dateTime.toLocaleString(["en-IE"], { + year: "numeric", + month: "short", + day: "numeric", + }) + : null; + + const faviconUrl = getFaviconUrl(sourceWebsite || url); + const relativeTime = publishedAt ? getRelativeTime(publishedAt) : null; + const displayUrl = getDisplayUrl(url); + const showThumbnail = imageUrl && !imageError; + const score = upvotes - downvotes; + + return ( +
+ {/* Source info row - full width above content */} +
+ {sourceProfileUrl ? ( + + {sourceLogo ? ( + + ) : faviconUrl ? ( + + ) : ( +
+ {sourceName?.charAt(0).toUpperCase() || "?"} +
+ )} + {sourceName || "Unknown"} + + ) : ( + <> + {sourceLogo ? ( + + ) : faviconUrl ? ( + + ) : ( +
+ {sourceName?.charAt(0).toUpperCase() || "?"} +
+ )} + {sourceName || "Unknown"} + + )} + {author && author.trim() && !["by", "by,", "by ,"].includes(author.trim().toLowerCase()) && ( + <> + + {author.replace(/^by\s+/i, "").trim()} + + )} + {relativeTime && ( + <> + + + + )} +
+ + {/* Content row with title aligned with image */} +
+ {/* Main content */} +
+ {/* Title - links to discussion page */} +

+ + {title} + +

+ + {/* External URL */} + + {displayUrl} + + + + {/* Excerpt */} + {excerpt && ( +

+ {excerpt} +

+ )} + + {/* Action bar - smaller buttons with outlines */} +
+ {/* Vote buttons */} +
+ + 0 + ? "text-green-500" + : score < 0 + ? "text-red-500" + : "text-neutral-400 dark:text-neutral-500" + }`} + > + {score} + + +
+ + {/* Comments button */} + + + 0 + + + {/* Save button */} + + + {/* Share button */} + +
+
+ + {/* Thumbnail on right side - aligned with title, 16:9 aspect ratio with rounded corners */} + {showThumbnail && ( + + setImageError(true)} + loading="lazy" + /> + {/* External link icon overlay */} +
+ +
+
+ )} +
+
+ ); +}; + +export default FeedItemAggregated; diff --git a/components/Feed/FeedItemLoading.tsx b/components/Feed/FeedItemLoading.tsx new file mode 100644 index 00000000..e3e2ede9 --- /dev/null +++ b/components/Feed/FeedItemLoading.tsx @@ -0,0 +1,48 @@ +const FeedItemLoading = () => { + return ( +
+ {/* Source info row - full width above content */} +
+
+
+
+
+ + {/* Content row with title aligned with image */} +
+ {/* Main content skeleton */} +
+ {/* Title skeleton */} +
+
+
+
+ + {/* URL skeleton */} +
+ + {/* Excerpt skeleton */} +
+
+
+
+ + {/* Action bar skeleton - smaller buttons with outlines */} +
+
+
+
+
+
+
+ + {/* Thumbnail skeleton on right - aligned with title, 16:9 aspect ratio */} +
+
+
+
+
+ ); +}; + +export default FeedItemLoading; diff --git a/components/Feed/VoteButtons.tsx b/components/Feed/VoteButtons.tsx new file mode 100644 index 00000000..6be7ce70 --- /dev/null +++ b/components/Feed/VoteButtons.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/20/solid"; +import { signIn, useSession } from "next-auth/react"; + +type Props = { + upvotes: number; + downvotes: number; + userVote: "UP" | "DOWN" | null; + onVote: (voteType: "UP" | "DOWN" | null) => void; + isLoading?: boolean; +}; + +const VoteButtons = ({ + upvotes, + downvotes, + userVote, + onVote, + isLoading = false, +}: Props) => { + const { data: session } = useSession(); + const score = upvotes - downvotes; + + const handleVote = (voteType: "UP" | "DOWN") => { + if (!session) { + signIn(); + return; + } + // Toggle off if clicking the same vote, otherwise set to new vote + onVote(userVote === voteType ? null : voteType); + }; + + return ( +
+ + 0 + ? "text-orange-500" + : score < 0 + ? "text-blue-500" + : "text-neutral-500" + }`} + > + {score} + + +
+ ); +}; + +export default VoteButtons; diff --git a/components/Feed/index.ts b/components/Feed/index.ts new file mode 100644 index 00000000..32cfe85b --- /dev/null +++ b/components/Feed/index.ts @@ -0,0 +1,4 @@ +export { default as FeedItemAggregated } from "./FeedItemAggregated"; +export { default as FeedItemLoading } from "./FeedItemLoading"; +export { default as FeedFilters } from "./FeedFilters"; +export { default as VoteButtons } from "./VoteButtons"; diff --git a/lib/og-image.ts b/lib/og-image.ts new file mode 100644 index 00000000..e146c658 --- /dev/null +++ b/lib/og-image.ts @@ -0,0 +1,128 @@ +/** + * Utility for fetching Open Graph images from article URLs. + * Uses a lightweight regex-based approach to extract og:image meta tags. + */ + +const FETCH_TIMEOUT_MS = 5000; + +/** + * Fetches the OG image URL from an article URL. + * Returns null on any error or if no OG image is found. + * + * @param url - The article URL to fetch the OG image from + * @returns The OG image URL or null + */ +export async function fetchOgImage(url: string): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + const response = await fetch(url, { + signal: controller.signal, + headers: { + "User-Agent": + "Mozilla/5.0 (compatible; CoduBot/1.0; +https://codu.co)", + Accept: "text/html", + }, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + // Only read enough of the response to find meta tags (usually in ) + const reader = response.body?.getReader(); + if (!reader) return null; + + let html = ""; + const decoder = new TextDecoder(); + const maxBytes = 100000; // Read max 100KB (meta tags should be in first few KB) + + while (html.length < maxBytes) { + const { done, value } = await reader.read(); + if (done) break; + html += decoder.decode(value, { stream: true }); + + // If we've found , we can stop reading + if (html.includes("")) break; + } + + reader.cancel(); + + return extractOgImage(html); + } catch { + // Silently fail - network errors, timeouts, etc. are expected + return null; + } +} + +/** + * Extracts the og:image URL from HTML content. + * Tries multiple patterns to handle different HTML formats. + */ +function extractOgImage(html: string): string | null { + // Pattern 1: Standard og:image meta tag + // + const patterns = [ + // property before content + /]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i, + // content before property + /]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i, + // With spaces and variations + /]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i, + /]+content=["']([^"']+)["'][^>]+name=["']twitter:image["']/i, + ]; + + for (const pattern of patterns) { + const match = html.match(pattern); + if (match?.[1]) { + const imageUrl = match[1]; + // Validate it looks like a URL + if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) { + return imageUrl; + } + // Handle protocol-relative URLs + if (imageUrl.startsWith("//")) { + return `https:${imageUrl}`; + } + } + } + + return null; +} + +/** + * Fetches OG images for multiple URLs in parallel with rate limiting. + * + * @param urls - Array of article URLs + * @param concurrency - Max concurrent requests (default: 5) + * @returns Map of URL to OG image URL (or null) + */ +export async function fetchOgImagesInBatch( + urls: string[], + concurrency = 5, +): Promise> { + const results = new Map(); + + // Process in batches + for (let i = 0; i < urls.length; i += concurrency) { + const batch = urls.slice(i, i + concurrency); + const batchResults = await Promise.all( + batch.map(async (url) => { + const ogImage = await fetchOgImage(url); + return { url, ogImage }; + }), + ); + + for (const { url, ogImage } of batchResults) { + results.set(url, ogImage); + } + } + + return results; +} From 3ad783c3097aa1d895c568cdf457a767440eb99f Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Sun, 4 Jan 2026 14:42:06 +0000 Subject: [PATCH 04/38] feat(components): add unified Content card and Discussion voting - Add ContentCard component for rendering all content types - Add ContentMenu with report, share, and edit options - Update Discussion component with upvote/downvote - Update ReportModal with reason tag selector (spam, harassment, etc.) - Add loading skeletons for content cards --- components/Content/ContentCard.tsx | 482 ++++++++++++++++++ components/Content/ContentCardLoading.tsx | 42 ++ components/Content/index.ts | 2 + components/Discussion/DiscussionArea.tsx | 581 ++++++++++++++++++++++ components/ReportModal/ReportModal.tsx | 24 +- 5 files changed, 1123 insertions(+), 8 deletions(-) create mode 100644 components/Content/ContentCard.tsx create mode 100644 components/Content/ContentCardLoading.tsx create mode 100644 components/Content/index.ts create mode 100644 components/Discussion/DiscussionArea.tsx diff --git a/components/Content/ContentCard.tsx b/components/Content/ContentCard.tsx new file mode 100644 index 00000000..003aec51 --- /dev/null +++ b/components/Content/ContentCard.tsx @@ -0,0 +1,482 @@ +"use client"; + +import { useState, Fragment } from "react"; +import Link from "next/link"; +import * as Sentry from "@sentry/nextjs"; +import { + BookmarkIcon, + ArrowTopRightOnSquareIcon, + ChatBubbleLeftIcon, + ShareIcon, + ChevronUpIcon, + ChevronDownIcon, + EllipsisHorizontalIcon, +} from "@heroicons/react/20/solid"; +import { BookmarkIcon as BookmarkOutlineIcon } from "@heroicons/react/24/outline"; +import { + Menu, + MenuButton, + MenuItem, + MenuItems, + Transition, +} from "@headlessui/react"; +import { api } from "@/server/trpc/react"; +import { signIn, useSession } from "next-auth/react"; +import { toast } from "sonner"; + +type ContentType = "ARTICLE" | "LINK" | "QUESTION" | "VIDEO" | "DISCUSSION"; + +type Props = { + id: string; + type: ContentType; + title: string; + body?: string | null; + excerpt?: string | null; + externalUrl?: string | null; + imageUrl?: string | null; + publishedAt?: string | null; + upvotes: number; + downvotes: number; + discussionCount?: number; + // Author info (for user-created content) + userId?: string | null; + userName?: string | null; + userImage?: string | null; + username?: string | null; + // Source info (for RSS/external content) + sourceName?: string | null; + sourceSlug?: string | null; + sourceLogo?: string | null; + sourceWebsite?: string | null; + sourceAuthor?: string | null; + // User state + userVote?: "UP" | "DOWN" | null; + isBookmarked?: boolean; + // Options + showBookmark?: boolean; + onReport?: () => void; +}; + +// Get favicon URL from a website +const getFaviconUrl = (websiteUrl: string | null | undefined): string | null => { + if (!websiteUrl) return null; + try { + const url = new URL(websiteUrl); + return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32`; + } catch { + return null; + } +}; + +// Get relative time string +const getRelativeTime = (dateStr: string): string => { + const now = new Date(); + const date = new Date(dateStr); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return "just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +}; + +// Get display URL (truncated, without protocol) +const getDisplayUrl = (urlString: string): string => { + try { + const url = new URL(urlString); + const path = url.pathname === "/" ? "" : url.pathname; + const display = url.hostname + path; + return display.length > 50 ? display.substring(0, 50) + "..." : display; + } catch { + return urlString; + } +}; + +// Ensure image URL uses https +const ensureHttps = (url: string | null | undefined): string | null => { + if (!url) return null; + if (url.startsWith("http://")) { + return url.replace("http://", "https://"); + } + return url; +}; + +// Content type badge colors +const typeColors: Record = { + ARTICLE: { bg: "bg-blue-100 dark:bg-blue-900", text: "text-blue-700 dark:text-blue-300" }, + LINK: { bg: "bg-green-100 dark:bg-green-900", text: "text-green-700 dark:text-green-300" }, + QUESTION: { bg: "bg-purple-100 dark:bg-purple-900", text: "text-purple-700 dark:text-purple-300" }, + VIDEO: { bg: "bg-red-100 dark:bg-red-900", text: "text-red-700 dark:text-red-300" }, + DISCUSSION: { bg: "bg-yellow-100 dark:bg-yellow-900", text: "text-yellow-700 dark:text-yellow-300" }, +}; + +const typeLabels: Record = { + ARTICLE: "Article", + LINK: "Link", + QUESTION: "Question", + VIDEO: "Video", + DISCUSSION: "Discussion", +}; + +const ContentCard = ({ + id, + type, + title, + body, + excerpt, + externalUrl, + imageUrl: rawImageUrl, + publishedAt, + upvotes, + downvotes, + discussionCount = 0, + userId, + userName, + userImage, + username, + sourceName, + sourceSlug, + sourceLogo, + sourceWebsite, + sourceAuthor, + userVote, + isBookmarked: initialBookmarked = false, + showBookmark = true, + onReport, +}: Props) => { + const [imageError, setImageError] = useState(false); + const { data: session } = useSession(); + const utils = api.useUtils(); + + // Convert http to https for images + const imageUrl = ensureHttps(rawImageUrl); + + // Build content URL + const contentUrl = `/content/${id}`; + const isExternal = type === "LINK" || type === "VIDEO"; + + const { mutate: vote, status: voteStatus } = api.content.vote.useMutation({ + onSuccess: () => { + utils.content.getFeed.invalidate(); + }, + onError: (error) => { + toast.error("Failed to update vote"); + Sentry.captureException(error); + }, + }); + + const { mutate: bookmark, status: bookmarkStatus } = + api.content.bookmark.useMutation({ + onSuccess: () => { + utils.content.getFeed.invalidate(); + utils.content.mySavedContent.invalidate(); + }, + onError: (error) => { + toast.error("Failed to update bookmark"); + Sentry.captureException(error); + }, + }); + + const { mutate: trackClick } = api.content.trackClick.useMutation(); + + const handleClick = () => { + trackClick({ contentId: id }); + }; + + const handleVote = (voteType: "UP" | "DOWN" | null) => { + if (!session) { + signIn(); + return; + } + vote({ contentId: id, voteType }); + }; + + const handleBookmark = () => { + if (!session) { + signIn(); + return; + } + bookmark({ contentId: id, setBookmarked: !initialBookmarked }); + }; + + const handleShare = async () => { + const shareUrl = `${window.location.origin}${contentUrl}`; + try { + await navigator.clipboard.writeText(shareUrl); + toast.success("Link copied to clipboard"); + } catch { + toast.error("Failed to copy link"); + } + }; + + const relativeTime = publishedAt ? getRelativeTime(publishedAt) : null; + const faviconUrl = getFaviconUrl(sourceWebsite || externalUrl || null); + const displayUrl = externalUrl ? getDisplayUrl(externalUrl) : null; + const showThumbnail = imageUrl && !imageError; + const score = upvotes - downvotes; + + // Determine author info + const authorName = userName || sourceAuthor; + const authorImage = userImage || sourceLogo || (faviconUrl ? faviconUrl : null); + const authorLink = username ? `/${username}` : sourceSlug ? `/feed/${sourceSlug}` : null; + const displayName = sourceName || authorName || "Unknown"; + + return ( +
+ {/* Header row - source/author and metadata */} +
+ {/* Author/Source info */} + {authorLink ? ( + + {authorImage ? ( + + ) : ( +
+ {displayName.charAt(0).toUpperCase()} +
+ )} + {displayName} + + ) : ( + <> + {authorImage ? ( + + ) : ( +
+ {displayName.charAt(0).toUpperCase()} +
+ )} + {displayName} + + )} + + {/* Type badge - only show for non-articles */} + {type !== "ARTICLE" && ( + <> + + + {typeLabels[type]} + + + )} + + {/* Time */} + {relativeTime && ( + <> + + + + )} +
+ + {/* Content row */} +
+ {/* Main content */} +
+ {/* Title */} +

+ + {title} + +

+ + {/* External URL (for LINK and VIDEO types) */} + {isExternal && externalUrl && ( + + {displayUrl} + + + )} + + {/* Excerpt/body preview */} + {(excerpt || body) && ( +

+ {excerpt || body?.slice(0, 200)} +

+ )} + + {/* Action bar */} +
+ {/* Vote buttons */} +
+ + 0 + ? "text-green-500" + : score < 0 + ? "text-red-500" + : "text-neutral-400 dark:text-neutral-500" + }`} + > + {score} + + +
+ + {/* Comments button */} + + + {discussionCount} + + + {/* Save button */} + {showBookmark && ( + + )} + + {/* Share button */} + + + {/* Triple-dot menu */} + + + More options + + + + + {onReport && ( + + + + )} + + + + {isExternal && externalUrl && ( + + + Open original + + + )} + + + +
+
+ + {/* Thumbnail on right side */} + {showThumbnail && ( + + setImageError(true)} + loading="lazy" + /> + {isExternal && externalUrl && ( +
+ +
+ )} + + )} +
+
+ ); +}; + +export default ContentCard; diff --git a/components/Content/ContentCardLoading.tsx b/components/Content/ContentCardLoading.tsx new file mode 100644 index 00000000..858e1222 --- /dev/null +++ b/components/Content/ContentCardLoading.tsx @@ -0,0 +1,42 @@ +const ContentCardLoading = () => { + return ( +
+ {/* Header row */} +
+
+
+
+
+
+ + {/* Content row */} +
+ {/* Main content */} +
+ {/* Title */} +
+
+ + {/* Excerpt */} +
+
+ + {/* Action bar */} +
+
+
+
+
+
+
+ + {/* Thumbnail placeholder */} +
+
+
+
+
+ ); +}; + +export default ContentCardLoading; diff --git a/components/Content/index.ts b/components/Content/index.ts new file mode 100644 index 00000000..2873301d --- /dev/null +++ b/components/Content/index.ts @@ -0,0 +1,2 @@ +export { default as ContentCard } from "./ContentCard"; +export { default as ContentCardLoading } from "./ContentCardLoading"; diff --git a/components/Discussion/DiscussionArea.tsx b/components/Discussion/DiscussionArea.tsx new file mode 100644 index 00000000..f3d22375 --- /dev/null +++ b/components/Discussion/DiscussionArea.tsx @@ -0,0 +1,581 @@ +"use client"; + +import React, { useEffect } from "react"; +import { + Menu, + MenuButton, + MenuItem, + MenuItems, + Transition, +} from "@headlessui/react"; +import { + EllipsisHorizontalIcon, + ChevronUpIcon, + ChevronDownIcon, +} from "@heroicons/react/20/solid"; +import { signIn, useSession } from "next-auth/react"; +import { useForm } from "react-hook-form"; +import TextareaAutosize from "react-textarea-autosize"; +import { Fragment, useState } from "react"; +import { markdocComponents } from "@/markdoc/components"; +import { config } from "@/markdoc/config"; +import Markdoc from "@markdoc/markdoc"; +import { toast } from "sonner"; +import z, { ZodError } from "zod"; +import { ChatBubbleLeftIcon } from "@heroicons/react/20/solid"; +import Link from "next/link"; +import { Temporal } from "@js-temporal/polyfill"; +import { EditDiscussionSchema } from "@/schema/discussion"; +import { api } from "@/server/trpc/react"; +import { ReportModal } from "@/components/ReportModal/ReportModal"; + +const SaveSchema = z.object({ + body: z + .string() + .min(1, "Comment can't be empty!") + .max(5000, "We have a character limit of 5000 for comments.") + .trim() + .optional(), +}); + +export type SaveInput = { + comment: string; + reply: string; + edit: string; +}; + +interface Props { + targetType: "POST" | "ARTICLE"; + postId?: string; + articleId?: number; +} + +const DiscussionArea = ({ targetType, postId, articleId }: Props) => { + const [showCommentBoxId, setShowCommentBoxId] = useState(null); + const [editCommentBoxId, setEditCommentBoxId] = useState(null); + const [viewPreviewId, setViewPreviewId] = useState(null); + const [initiallyLoaded, setInitiallyLoaded] = useState(false); + + const { data: session } = useSession(); + + const { handleSubmit, register, getValues, resetField, setValue } = + useForm({ + mode: "onSubmit", + defaultValues: { + comment: "", + reply: "", + edit: "", + }, + }); + + const { + data: discussionsResponse, + refetch, + status: discussionStatus, + } = api.discussion.get.useQuery({ + targetType, + postId, + articleId, + }); + + const { mutate, status: createDiscussionStatus } = + api.discussion.create.useMutation({ + onSuccess: () => { + refetch(); + setShowCommentBoxId(null); + }, + }); + + const { mutate: vote, status: voteStatus } = api.discussion.vote.useMutation({ + onSettled() { + refetch(); + }, + onError() { + toast.error("Something went wrong, try again."); + }, + }); + + const voteDiscussion = (discussionId: number, voteType: "UP" | "DOWN" | null) => { + if (!session) return signIn(); + if (voteStatus === "pending") return; + vote({ discussionId, voteType }); + }; + + const discussions = discussionsResponse?.data; + + const { mutate: editDiscussion, status: editStatus } = + api.discussion.edit.useMutation({ + onSuccess: () => { + refetch(); + }, + }); + + const { mutate: deleteDiscussion } = api.discussion.delete.useMutation({ + onSuccess: () => { + refetch(); + }, + }); + + const firstChild = discussions?.[0]?.children; + + type Discussions = typeof discussions; + type Children = typeof firstChild; + type FieldName = "comment" | "reply" | "edit"; + + useEffect(() => { + if (initiallyLoaded) { + return; + } + setInitiallyLoaded(true); + }, [discussionStatus]); + + const onSubmit = async ( + body: string, + parentId: number | undefined, + fieldName: FieldName, + ) => { + // validate markdoc syntax + const ast = Markdoc.parse(body); + const errors = Markdoc.validate(ast, config).filter( + (e) => e.error.level === "critical", + ); + + if (errors.length > 0) { + errors.forEach((err) => { + toast.error(err.error.message); + }); + return; + } + + if (fieldName === "edit") { + try { + EditDiscussionSchema.parse({ body, id: editCommentBoxId }); + if (typeof editCommentBoxId !== "number") + throw new Error("Invalid edit."); + await editDiscussion({ body: body || "", id: editCommentBoxId }); + resetField(fieldName); + setEditCommentBoxId(null); + setViewPreviewId(null); + return; + } catch (err) { + if (err instanceof ZodError) { + return toast.error(err.issues[0].message); + } + toast.error("Something went wrong editing your comment."); + } + } + + try { + SaveSchema.parse({ body }); + await mutate({ + body: body || "", + targetType, + postId, + articleId, + parentId, + }); + resetField(fieldName); + setViewPreviewId(null); + } catch (err) { + if (err instanceof ZodError) { + return toast.error(err.issues[0].message); + } + toast.error("Something went wrong saving your comment."); + } + }; + + const generateDiscussions = ( + discussionsArr: Discussions | Children | undefined, + depth = 0, + ) => { + if (!discussionsArr) return null; + return discussionsArr.map( + ({ + body, + createdAt, + updatedAt, + id, + youLikedThis, + likeCount, + userVote, + score, + upvotes, + downvotes, + user: { name, image, username, id: odiserId }, + children, + }: { + body: string; + createdAt: string; + updatedAt: string; + id: number; + youLikedThis: boolean; + likeCount: number; + userVote: "UP" | "DOWN" | null; + score: number; + upvotes: number; + downvotes: number; + user: { name: string; image: string; username: string; id: string }; + children?: Children; + }) => { + const ast = Markdoc.parse(body); + const content = Markdoc.transform(ast, config); + const isCurrentUser = session?.user?.id === odiserId; + const dateTime = Temporal.Instant.from( + new Date(createdAt).toISOString(), + ); + const isCurrentYear = + new Date().getFullYear() === new Date(createdAt).getFullYear(); + const readableDate = dateTime.toLocaleString( + ["en-IE"], + isCurrentYear + ? { + month: "long", + day: "numeric", + } + : { + year: "numeric", + month: "long", + day: "numeric", + }, + ); + + const discussionUpdated = + new Date(createdAt).toISOString() !== + new Date(updatedAt).toISOString(); + return ( +
+ {editCommentBoxId !== id ? ( + <> +
+
+ + {`Avatar + + + {name} + + {isCurrentUser && ( +
+ YOU +
+ )} + + + + {discussionUpdated ? ( + <> + +
Edited
+ + ) : null} +
+ {isCurrentUser ? ( + +
+ + Open user menu + + +
+ + + <> + + + + + + + + + +
+ ) : null} +
+ +
+
+ {Markdoc.renderers.react(content, React, { + components: markdocComponents, + })} +
+ +
+ {/* Vote buttons */} +
+ + 0 + ? "text-green-500" + : score < 0 + ? "text-red-500" + : "text-neutral-400 dark:text-neutral-500" + }`} + > + {score} + + +
+ + {depth < 6 && ( + + )} +
+ + <> + {showCommentBoxId === id && ( +
+ { + resetField("reply"); + setShowCommentBoxId(null); + }} + loading={createDiscussionStatus === "pending"} + /> +
+ )} + + {!!children && generateDiscussions(children, depth + 1)} +
+ + ) : ( + setEditCommentBoxId(null)} + /> + )} +
+ ); + }, + ); + }; + + interface DiscussionInputProps { + onCancel?: () => void; + parentId?: number; + id: number | null; + name: FieldName; + editMode?: boolean; + loading?: boolean; + } + + const DiscussionInput = ({ + onCancel, + parentId, + id, + name, + editMode = false, + loading = false, + }: DiscussionInputProps) => { + return ( +
onSubmit(e[name], parentId, name))} + > + {loading && ( +
+
+
+
+
+ )} + {session?.user?.image && ( +
+ {`Avatar +
{session.user.name}
+
+ )} + {viewPreviewId === id ? ( +
+ {Markdoc.renderers.react( + Markdoc.transform(Markdoc.parse(getValues()[name]), config), + React, + { + components: markdocComponents, + }, + )} +
+ ) : ( + <> + + + + )} +
+ + + {onCancel && ( + + )} +
+ + ); + }; + + return ( +
+ {!initiallyLoaded && ( +
+
+
+ Loading +
+
+ )} +

+ + {initiallyLoaded + ? `Discussion (${discussionsResponse?.count || 0})` + : "Loading discussion..."} +

+
+ {session ? ( + + ) : ( +
+

Hey! 👋

+

Got something to say?

+

+ {" "} + or{" "} + {" "} + to leave a comment. +

+
+ )} +
+
{generateDiscussions(discussions)}
+
+ ); +}; + +export default DiscussionArea; diff --git a/components/ReportModal/ReportModal.tsx b/components/ReportModal/ReportModal.tsx index 7077787b..d771382e 100644 --- a/components/ReportModal/ReportModal.tsx +++ b/components/ReportModal/ReportModal.tsx @@ -10,7 +10,7 @@ import { DialogTitle, } from "@headlessui/react"; -type Props = Post | Comment; +type Props = Post | Comment | Discussion; type Post = { type: "post"; @@ -24,6 +24,12 @@ type Comment = { id: number; }; +type Discussion = { + type: "discussion"; + comment: string; + id: number; +}; + export const ReportModal = (props: Props) => { const { mutate: sendEmail } = api.report.send.useMutation({ onSuccess: () => { @@ -45,7 +51,9 @@ export const ReportModal = (props: Props) => { const { type, id } = props; const isComment = type === "comment" && typeof id === "number"; + const isDiscussion = type === "discussion" && typeof id === "number"; const isPost = type === "post" && typeof id === "string"; + const isCommentLike = isComment || isDiscussion; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -55,9 +63,9 @@ export const ReportModal = (props: Props) => { try { if (!session) return signIn(); - if (isComment) { + if (isCommentLike) { await sendEmail({ - type, + type: "comment", // Treat discussions same as comments for reporting body: reportBody, id, }); @@ -75,7 +83,7 @@ export const ReportModal = (props: Props) => { setReportBody(""); setLoading(false); - if (!isComment && !isPost) { + if (!isCommentLike && !isPost) { throw new Error("Invalid report"); } } catch (error) { @@ -85,7 +93,7 @@ export const ReportModal = (props: Props) => { return ( <> - {isComment && ( + {isCommentLike && ( )} - {!isComment && ( + {!isCommentLike && ( + + + +
+ + {/* Reports List */} +
+ {isLoading && ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+ ))} +
+ )} + + {!isLoading && data?.reports.length === 0 && ( +
+ +

+ No reports found +

+

+ {statusFilter + ? `No ${statusFilter.toLowerCase()} reports` + : "All caught up!"} +

+
+ )} + + {data?.reports.map((report) => ( +
+ {/* Header */} +
+ + {reasonLabels[report.reason as ReportReason]} + + + {report.status} + + + {getRelativeTime(report.createdAt!)} + +
+ + {/* Content Preview */} +
+ {report.content && ( +
+

+ {report.content.type} by @{report.content.user?.username} +

+

+ {report.content.title} +

+
+ )} + {report.discussion && ( +
+

+ Comment by @{report.discussion.user?.username} +

+

+ {report.discussion.body} +

+
+ )} +
+ + {/* Reporter Details */} + {report.details && ( +
+

+ Details: {report.details} +

+
+ )} + +
+

+ Reported by @{report.reporter?.username || "unknown"} +

+ + {/* Actions */} + {report.status === "PENDING" && ( +
+ + +
+ )} + + {report.status !== "PENDING" && report.reviewedBy && ( +

+ Reviewed by @{report.reviewedBy.username} +

+ )} +
+
+ ))} +
+
+ ); +}; + +export default ModerationQueue; diff --git a/app/(app)/admin/moderation/page.tsx b/app/(app)/admin/moderation/page.tsx new file mode 100644 index 00000000..e2dbe2af --- /dev/null +++ b/app/(app)/admin/moderation/page.tsx @@ -0,0 +1,18 @@ +import { getServerAuthSession } from "@/server/auth"; +import { redirect } from "next/navigation"; +import ModerationQueue from "./_client"; + +export const metadata = { + title: "Moderation Queue - Codú Admin", + description: "Review and manage reported content", +}; + +export default async function Page() { + const session = await getServerAuthSession(); + + if (!session?.user || session.user.role !== "ADMIN") { + redirect("/"); + } + + return ; +} diff --git a/app/(app)/admin/page.tsx b/app/(app)/admin/page.tsx new file mode 100644 index 00000000..4e26b90f --- /dev/null +++ b/app/(app)/admin/page.tsx @@ -0,0 +1,18 @@ +import { getServerAuthSession } from "@/server/auth"; +import { redirect } from "next/navigation"; +import AdminDashboard from "./_client"; + +export const metadata = { + title: "Admin Dashboard - Codú", + description: "Admin dashboard for managing Codú platform", +}; + +export default async function Page() { + const session = await getServerAuthSession(); + + if (!session?.user || session.user.role !== "ADMIN") { + redirect("/"); + } + + return ; +} diff --git a/app/(app)/admin/sources/_client.tsx b/app/(app)/admin/sources/_client.tsx new file mode 100644 index 00000000..3b462b89 --- /dev/null +++ b/app/(app)/admin/sources/_client.tsx @@ -0,0 +1,395 @@ +"use client"; + +import { useState } from "react"; +import { api } from "@/server/trpc/react"; +import { toast } from "sonner"; +import { + PlusIcon, + CheckCircleIcon, + XCircleIcon, + PauseCircleIcon, + TrashIcon, + ArrowPathIcon, + CloudArrowDownIcon, +} from "@heroicons/react/20/solid"; + +const statusColors = { + ACTIVE: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", + PAUSED: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300", + ERROR: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300", +}; + +const statusIcons = { + ACTIVE: CheckCircleIcon, + PAUSED: PauseCircleIcon, + ERROR: XCircleIcon, +}; + +const AdminSourcesPage = () => { + const [showAddForm, setShowAddForm] = useState(false); + const [syncingAll, setSyncingAll] = useState(false); + const [syncingSourceId, setSyncingSourceId] = useState(null); + const [formData, setFormData] = useState({ + name: "", + url: "", + websiteUrl: "", + logoUrl: "", + category: "", + }); + + const utils = api.useUtils(); + + // Sync all sources + const handleSyncAll = async () => { + setSyncingAll(true); + try { + const response = await fetch("/api/admin/sync-feeds", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }); + const data = await response.json(); + + if (data.success) { + toast.success( + `Synced ${data.stats.sourcesProcessed} sources. Added ${data.stats.articlesAdded} articles.` + ); + if (data.stats.errors.length > 0) { + toast.error(`${data.stats.errors.length} sources had errors`); + } + refetch(); + } else { + toast.error(data.error || "Sync failed"); + } + } catch { + toast.error("Failed to sync feeds"); + } finally { + setSyncingAll(false); + } + }; + + // Sync single source + const handleSyncSource = async (sourceId: number, sourceName: string) => { + setSyncingSourceId(sourceId); + try { + const response = await fetch("/api/admin/sync-feeds", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sourceId }), + }); + const data = await response.json(); + + if (data.success) { + toast.success( + `${sourceName}: Added ${data.stats.articlesAdded} articles` + ); + refetch(); + } else { + toast.error(data.error || "Sync failed"); + } + } catch { + toast.error(`Failed to sync ${sourceName}`); + } finally { + setSyncingSourceId(null); + } + }; + + // Fetch sources with stats + const { data: sources, status, refetch } = api.feed.getSourceStats.useQuery(); + + // Mutations + const createSource = api.feed.createSource.useMutation({ + onSuccess: () => { + toast.success("Feed source added successfully"); + setShowAddForm(false); + setFormData({ name: "", url: "", websiteUrl: "", logoUrl: "", category: "" }); + refetch(); + }, + onError: (error) => { + toast.error(error.message || "Failed to add feed source"); + }, + }); + + const updateSource = api.feed.updateSource.useMutation({ + onSuccess: () => { + toast.success("Feed source updated"); + refetch(); + }, + onError: (error) => { + toast.error(error.message || "Failed to update feed source"); + }, + }); + + const deleteSource = api.feed.deleteSource.useMutation({ + onSuccess: () => { + toast.success("Feed source deleted"); + refetch(); + }, + onError: (error) => { + toast.error(error.message || "Failed to delete feed source"); + }, + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + createSource.mutate({ + name: formData.name, + url: formData.url, + websiteUrl: formData.websiteUrl || undefined, + logoUrl: formData.logoUrl || undefined, + category: formData.category || undefined, + }); + }; + + const handleStatusToggle = (id: number, currentStatus: string) => { + const newStatus = currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE"; + updateSource.mutate({ id, status: newStatus as "ACTIVE" | "PAUSED" | "ERROR" }); + }; + + const handleDelete = (id: number, name: string) => { + if (confirm(`Are you sure you want to delete "${name}"? This will also delete all associated articles.`)) { + deleteSource.mutate({ id }); + } + }; + + return ( +
+
+
+

+ Feed Sources +

+

+ Manage RSS feed sources for the content aggregator +

+
+
+ + +
+
+ + {/* Add Source Form */} + {showAddForm && ( +
+

+ Add New Feed Source +

+
+
+ + setFormData({ ...formData, name: e.target.value })} + required + className="w-full rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-700" + placeholder="e.g., Josh Comeau's Blog" + /> +
+
+ + setFormData({ ...formData, url: e.target.value })} + required + className="w-full rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-700" + placeholder="https://example.com/rss.xml" + /> +
+
+ + setFormData({ ...formData, websiteUrl: e.target.value })} + className="w-full rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-700" + placeholder="https://example.com" + /> +
+
+ + setFormData({ ...formData, logoUrl: e.target.value })} + className="w-full rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-700" + placeholder="https://example.com/logo.png" + /> +
+
+ + setFormData({ ...formData, category: e.target.value })} + className="w-full rounded-lg border border-neutral-300 px-3 py-2 dark:border-neutral-600 dark:bg-neutral-700" + placeholder="e.g., frontend, react, career" + /> +
+
+ + +
+
+
+ )} + + {/* Sources Table */} + {status === "pending" && ( +
+ +
+ )} + + {status === "error" && ( +
+ Failed to load feed sources. Please refresh the page. +
+ )} + + {status === "success" && ( +
+ + + + + + + + + + + + + + {sources?.map((source) => { + const StatusIcon = statusIcons[source.status as keyof typeof statusIcons]; + return ( + + + + + + + + + + ); + })} + +
+ Source + + Category + + Status + + Articles + + Last Fetched + + Errors + + Actions +
+
+ {source.sourceName} +
+
+ {/* Category would need to be fetched separately or added to stats */} + - + + + + {source.status} + + + {source.articleCount} + + {source.lastFetchedAt + ? new Date(source.lastFetchedAt).toLocaleDateString() + : "Never"} + + {source.errorCount} + +
+ + + +
+
+ {sources?.length === 0 && ( +
+ No feed sources yet. Add your first source above. +
+ )} +
+ )} +
+ ); +}; + +export default AdminSourcesPage; diff --git a/app/(app)/admin/sources/page.tsx b/app/(app)/admin/sources/page.tsx new file mode 100644 index 00000000..c16ee4dd --- /dev/null +++ b/app/(app)/admin/sources/page.tsx @@ -0,0 +1,19 @@ +import Content from "./_client"; +import { getServerAuthSession } from "@/server/auth"; +import { redirect } from "next/navigation"; + +export const metadata = { + title: "Feed Sources - Admin", + description: "Manage RSS feed sources for the content aggregator", +}; + +export default async function Page() { + const session = await getServerAuthSession(); + + // Redirect non-admin users + if (!session?.user || session.user.role !== "ADMIN") { + redirect("/"); + } + + return ; +} diff --git a/app/(app)/admin/users/_client.tsx b/app/(app)/admin/users/_client.tsx new file mode 100644 index 00000000..164575bd --- /dev/null +++ b/app/(app)/admin/users/_client.tsx @@ -0,0 +1,285 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { + MagnifyingGlassIcon, + ArrowLeftIcon, + ShieldExclamationIcon, + ShieldCheckIcon, +} from "@heroicons/react/24/outline"; +import { api } from "@/server/trpc/react"; +import { toast } from "sonner"; +import { useSearchParams } from "next/navigation"; + +const UserManagement = () => { + const searchParams = useSearchParams(); + const initialFilter = searchParams?.get("filter"); + const [search, setSearch] = useState(""); + const [showBannedOnly, setShowBannedOnly] = useState( + initialFilter === "banned", + ); + const [banNote, setBanNote] = useState(""); + const [selectedUserId, setSelectedUserId] = useState(null); + const utils = api.useUtils(); + + const { data: usersData, isLoading } = api.admin.getUsers.useQuery({ + search: search || undefined, + limit: 20, + }); + + const { data: bannedUsers } = api.admin.getBannedUsers.useQuery(undefined, { + enabled: showBannedOnly, + }); + + const { mutate: banUser, isPending: isBanning } = api.admin.ban.useMutation({ + onSuccess: () => { + toast.success("User banned successfully"); + utils.admin.getUsers.invalidate(); + utils.admin.getBannedUsers.invalidate(); + setSelectedUserId(null); + setBanNote(""); + }, + onError: () => { + toast.error("Failed to ban user"); + }, + }); + + const { mutate: unbanUser, isPending: isUnbanning } = + api.admin.unban.useMutation({ + onSuccess: () => { + toast.success("User unbanned successfully"); + utils.admin.getUsers.invalidate(); + utils.admin.getBannedUsers.invalidate(); + }, + onError: () => { + toast.error("Failed to unban user"); + }, + }); + + const handleBan = (userId: string) => { + if (!banNote.trim()) { + toast.error("Please provide a reason for the ban"); + return; + } + banUser({ userId, note: banNote }); + }; + + const handleUnban = (userId: string) => { + unbanUser({ userId }); + }; + + const getRelativeTime = (dateStr: string): string => { + const now = new Date(); + const date = new Date(dateStr); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffDays < 1) return "today"; + if (diffDays < 7) return `${diffDays}d ago`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`; + return date.toLocaleDateString("en-US", { month: "short", year: "numeric" }); + }; + + const displayUsers = showBannedOnly + ? bannedUsers?.map((b) => ({ + ...b.user, + isBanned: true, + bannedAt: b.createdAt, + banNote: b.note, + bannedBy: b.bannedBy, + })) + : usersData?.users; + + return ( +
+
+ + + +
+

+ User Management +

+

+ Search and manage platform users +

+
+
+ + {/* Search and Filters */} +
+
+ + setSearch(e.target.value)} + className="w-full rounded-lg border border-neutral-300 bg-white py-2 pl-10 pr-4 text-neutral-900 placeholder-neutral-400 focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-neutral-600 dark:bg-neutral-800 dark:text-white dark:placeholder-neutral-500" + /> +
+ + +
+ + {/* Users List */} +
+ {isLoading && ( +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ )} + + {!isLoading && displayUsers?.length === 0 && ( +
+

+ No users found +

+

+ {search + ? "Try a different search term" + : showBannedOnly + ? "No banned users" + : "No users yet"} +

+
+ )} + + {displayUsers?.map((user) => ( +
+
+
+ +
+
+ + {user.name || user.username} + + {"role" in user && user.role === "ADMIN" && ( + + Admin + + )} + {"isBanned" in user && user.isBanned && ( + + Banned + + )} +
+

+ @{user.username} · {user.email} + {"createdAt" in user && user.createdAt && ( + <> · Joined {getRelativeTime(user.createdAt)} + )} +

+
+
+ +
+ {"isBanned" in user && user.isBanned ? ( + + ) : !("role" in user) || user.role !== "ADMIN" ? ( + selectedUserId === user.id ? ( +
+ setBanNote(e.target.value)} + className="w-48 rounded-lg border border-neutral-300 px-2 py-1 text-sm dark:border-neutral-600 dark:bg-neutral-700" + /> + + +
+ ) : ( + + ) + ) : null} +
+
+ + {/* Ban details if banned */} + {"banNote" in user && user.banNote && ( +
+

+ Ban reason: {user.banNote} +

+ {"bannedBy" in user && user.bannedBy && ( +

+ Banned by @{user.bannedBy.username} +

+ )} +
+ )} +
+ ))} +
+
+ ); +}; + +export default UserManagement; diff --git a/app/(app)/admin/users/page.tsx b/app/(app)/admin/users/page.tsx new file mode 100644 index 00000000..37d62e58 --- /dev/null +++ b/app/(app)/admin/users/page.tsx @@ -0,0 +1,18 @@ +import { getServerAuthSession } from "@/server/auth"; +import { redirect } from "next/navigation"; +import UserManagement from "./_client"; + +export const metadata = { + title: "User Management - Codú Admin", + description: "Search and manage users", +}; + +export default async function Page() { + const session = await getServerAuthSession(); + + if (!session?.user || session.user.role !== "ADMIN") { + redirect("/"); + } + + return ; +} diff --git a/app/api/admin/sync-feeds/route.ts b/app/api/admin/sync-feeds/route.ts new file mode 100644 index 00000000..3eadeae0 --- /dev/null +++ b/app/api/admin/sync-feeds/route.ts @@ -0,0 +1,227 @@ +import { NextResponse } from "next/server"; +import { getServerAuthSession } from "@/server/auth"; +import { db } from "@/server/db"; +import { feed_source, aggregated_article, aggregated_article_tag, tag } from "@/server/db/schema"; +import { eq } from "drizzle-orm"; +import Parser from "rss-parser"; +import { customAlphabet } from "nanoid"; +import { fetchOgImage } from "@/lib/og-image"; + +// Generate Reddit-style short IDs: lowercase + numbers, 7 characters +const generateShortId = customAlphabet( + "0123456789abcdefghijklmnopqrstuvwxyz", + 7, +); + +// Keyword to tag mapping for auto-tagging +const TAG_KEYWORDS: Record = { + JAVASCRIPT: ["javascript", "js", "node", "nodejs", "deno", "bun", "npm", "yarn"], + REACT: ["react", "nextjs", "next.js", "remix", "gatsby"], + VUE: ["vue", "nuxt", "vuejs"], + TYPESCRIPT: ["typescript", "ts"], + PYTHON: ["python", "django", "flask", "fastapi"], + CSS: ["css", "tailwind", "sass", "scss", "styling", "styled-components"], + "WEB DEV": ["web", "frontend", "backend", "fullstack", "api", "rest", "graphql"], + DEVOPS: ["docker", "kubernetes", "k8s", "ci/cd", "aws", "azure", "gcp", "cloud"], + CAREER: ["career", "job", "interview", "resume", "hiring", "salary"], + TUTORIAL: ["tutorial", "guide", "how to", "learn", "beginner", "getting started"], + AI: ["ai", "machine learning", "ml", "gpt", "llm", "openai", "claude", "chatgpt"], + DATABASE: ["database", "sql", "postgres", "mongodb", "redis", "prisma", "drizzle"], +}; + +const parser = new Parser({ + customFields: { + item: [ + ["media:content", "mediaContent", { keepArray: false }], + ["media:thumbnail", "mediaThumbnail", { keepArray: false }], + ["enclosure", "enclosure", { keepArray: false }], + ], + }, +}); + +function extractTags(title: string, content: string): string[] { + const text = `${title} ${content}`.toLowerCase(); + const tags: string[] = []; + + for (const [tagName, keywords] of Object.entries(TAG_KEYWORDS)) { + if (keywords.some((keyword) => text.includes(keyword))) { + tags.push(tagName); + } + } + + return tags.slice(0, 5); +} + +function extractExcerpt(content: string | undefined, maxLength = 500): string { + if (!content) return ""; + const text = content.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim(); + if (text.length <= maxLength) return text; + return text.substring(0, maxLength).trim() + "..."; +} + +function extractImageUrl(item: Parser.Item): string | null { + const mediaContent = (item as Record).mediaContent as + | { $?: { url?: string } } + | undefined; + const mediaThumbnail = (item as Record).mediaThumbnail as + | { $?: { url?: string } } + | undefined; + const enclosure = item.enclosure as { url?: string; type?: string } | undefined; + + if (mediaContent?.$?.url) return mediaContent.$.url; + if (mediaThumbnail?.$?.url) return mediaThumbnail.$.url; + if (enclosure?.url && enclosure.type?.startsWith("image/")) return enclosure.url; + + return null; +} + +export async function POST(request: Request) { + try { + // Check admin auth + const session = await getServerAuthSession(); + if (!session?.user || session.user.role !== "ADMIN") { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await request.json().catch(() => ({})); + const sourceId = body.sourceId as number | undefined; + + const stats = { + sourcesProcessed: 0, + articlesAdded: 0, + articlesSkipped: 0, + ogImagesFetched: 0, + errors: [] as string[], + }; + + // Get sources to sync + const sources = sourceId + ? await db.query.feed_source.findMany({ + where: eq(feed_source.id, sourceId), + }) + : await db.query.feed_source.findMany({ + where: eq(feed_source.status, "ACTIVE"), + }); + + for (const source of sources) { + try { + const feed = await parser.parseURL(source.url); + let newArticles = 0; + + for (const item of feed.items) { + if (!item.link || !item.title) continue; + + // Skip old articles (30 days) + if (item.pubDate) { + const publishedDate = new Date(item.pubDate); + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + if (publishedDate < thirtyDaysAgo) continue; + } + + // Check if exists + const existing = await db.query.aggregated_article.findFirst({ + where: eq(aggregated_article.url, item.link), + }); + + if (existing) { + stats.articlesSkipped++; + continue; + } + + const contentSnippet = item.contentSnippet || item.content || item.summary || ""; + const excerpt = extractExcerpt(contentSnippet); + const imageUrl = extractImageUrl(item); + + const [newArticle] = await db + .insert(aggregated_article) + .values({ + sourceId: source.id, + shortId: generateShortId(), + title: item.title.substring(0, 500), + excerpt, + url: item.link, + imageUrl, + author: item.creator || null, + publishedAt: item.pubDate ? new Date(item.pubDate).toISOString() : null, + }) + .returning(); + + // Auto-tag + const tags = extractTags(item.title, contentSnippet); + for (const tagTitle of tags) { + try { + let existingTag = await db.query.tag.findFirst({ + where: eq(tag.title, tagTitle), + }); + + if (!existingTag) { + const [newTag] = await db.insert(tag).values({ title: tagTitle }).returning(); + existingTag = newTag; + } + + await db + .insert(aggregated_article_tag) + .values({ articleId: newArticle.id, tagId: existingTag.id }) + .onConflictDoNothing(); + } catch { + // Ignore tag errors + } + } + + // Fetch OG image from the article URL + try { + const ogImageUrl = await fetchOgImage(item.link); + if (ogImageUrl) { + await db + .update(aggregated_article) + .set({ ogImageUrl }) + .where(eq(aggregated_article.id, newArticle.id)); + stats.ogImagesFetched++; + } + } catch { + // Silently skip OG image fetch errors + } + + newArticles++; + stats.articlesAdded++; + } + + // Update source status + await db + .update(feed_source) + .set({ + lastFetchedAt: new Date().toISOString(), + lastSuccessAt: new Date().toISOString(), + errorCount: 0, + lastError: null, + }) + .where(eq(feed_source.id, source.id)); + + stats.sourcesProcessed++; + } catch (error) { + stats.errors.push(`${source.name}: ${(error as Error).message}`); + + await db + .update(feed_source) + .set({ + lastFetchedAt: new Date().toISOString(), + errorCount: source.errorCount + 1, + lastError: (error as Error).message.substring(0, 500), + }) + .where(eq(feed_source.id, source.id)); + } + } + + return NextResponse.json({ + success: true, + stats, + }); + } catch (error) { + console.error("Sync error:", error); + return NextResponse.json( + { error: "Sync failed", details: (error as Error).message }, + { status: 500 }, + ); + } +} From 394b8b30840a8d3d899a4132a3606dac628d3d22 Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Sun, 4 Jan 2026 14:43:29 +0000 Subject: [PATCH 06/38] feat(articles): unify filters and add Reddit-style voting UX - Replace article page sidebar with bottom action bar - Add ArticleActionBar with upvote/downvote, bookmark, share, report - Update articles list to use FeedFilters component - Add trending sort option with hot score algorithm - Unify filter dropdowns between feed and articles pages - Map sort options: recent/trending/popular --- app/(app)/articles/[slug]/page.tsx | 33 +- app/(app)/articles/_client.tsx | 454 ++++++++++++++++-- .../ArticleActionBar/ArticleActionBar.tsx | 273 +++++++++++ .../ArticleActionBarWrapper.tsx | 52 ++ components/ArticleActionBar/index.ts | 2 + 5 files changed, 749 insertions(+), 65 deletions(-) create mode 100644 components/ArticleActionBar/ArticleActionBar.tsx create mode 100644 components/ArticleActionBar/ArticleActionBarWrapper.tsx create mode 100644 components/ArticleActionBar/index.ts diff --git a/app/(app)/articles/[slug]/page.tsx b/app/(app)/articles/[slug]/page.tsx index 0fc774c2..87c0bc50 100644 --- a/app/(app)/articles/[slug]/page.tsx +++ b/app/(app)/articles/[slug]/page.tsx @@ -6,7 +6,7 @@ import BioBar from "@/components/BioBar/BioBar"; import { markdocComponents } from "@/markdoc/components"; import { config } from "@/markdoc/config"; import CommentsArea from "@/components/Comments/CommentsArea"; -import ArticleMenu from "@/components/ArticleMenu/ArticleMenu"; +import { ArticleActionBarWrapper } from "@/components/ArticleActionBar"; import { headers } from "next/headers"; import { notFound } from "next/navigation"; import { getServerAuthSession } from "@/server/auth"; @@ -127,13 +127,6 @@ const ArticlePage = async (props: Props) => { return ( <> -
{!isTiptapContent &&

{post.title}

} @@ -167,16 +160,26 @@ const ArticlePage = async (props: Props) => { ))} )} +
- {post.showComments ? ( - - ) : ( -

- Comments are disabled for this post -

- )} +
+ {post.showComments ? ( + + ) : ( +

+ Comments are disabled for this post +

+ )} +
{session && session?.user?.role === "ADMIN" && ( diff --git a/app/(app)/articles/_client.tsx b/app/(app)/articles/_client.tsx index 1eea1c83..fd101bdb 100644 --- a/app/(app)/articles/_client.tsx +++ b/app/(app)/articles/_client.tsx @@ -1,45 +1,383 @@ "use client"; -import { Fragment, useEffect } from "react"; +import { Fragment, useEffect, useState } from "react"; import { TagIcon } from "@heroicons/react/20/solid"; -import ArticlePreview from "@/components/ArticlePreview/ArticlePreview"; -import ArticleLoading from "@/components/ArticlePreview/ArticleLoading"; +import { + ChevronUpIcon, + ChevronDownIcon, + BookmarkIcon, + ChatBubbleLeftIcon, + ShareIcon, + EllipsisHorizontalIcon, +} from "@heroicons/react/20/solid"; +import { BookmarkIcon as BookmarkOutlineIcon } from "@heroicons/react/24/outline"; +import { + Menu, + MenuButton, + MenuItem, + MenuItems, + Transition, +} from "@headlessui/react"; import { useInView } from "react-intersection-observer"; import { useSearchParams, useRouter } from "next/navigation"; import Link from "next/link"; import { api } from "@/server/trpc/react"; import SideBarSavedPosts from "@/components/SideBar/SideBarSavedPosts"; -import { useSession } from "next-auth/react"; +import { useSession, signIn } from "next-auth/react"; import { getCamelCaseFromLower } from "@/utils/utils"; import PopularTagsLoading from "@/components/PopularTags/PopularTagsLoading"; import CoduChallenge from "@/components/CoduChallenge/CoduChallenge"; +import { toast } from "sonner"; +import * as Sentry from "@sentry/nextjs"; +import { FeedFilters } from "@/components/Feed"; + +// Get relative time string +const getRelativeTime = (dateStr: string): string => { + const now = new Date(); + const date = new Date(dateStr); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return "just now"; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +}; + +// Article card component with voting +type ArticleCardProps = { + id: string; + slug: string; + title: string; + excerpt: string | null; + name: string; + username: string; + image: string; + date: string; + readTime: number; + upvotes: number; + downvotes: number; + userVote: "UP" | "DOWN" | null; + isBookmarked: boolean; + discussionCount?: number; +}; + +const ArticleCard = ({ + id, + slug, + title, + excerpt, + name, + username, + image, + date, + readTime, + upvotes, + downvotes, + userVote: initialUserVote, + isBookmarked: initialBookmarked, + discussionCount = 0, +}: ArticleCardProps) => { + const { data: session } = useSession(); + const utils = api.useUtils(); + const [userVote, setUserVote] = useState(initialUserVote); + const [votes, setVotes] = useState({ upvotes, downvotes }); + const [isBookmarked, setIsBookmarked] = useState(initialBookmarked); + + const { mutate: vote, status: voteStatus } = api.post.vote.useMutation({ + onMutate: async ({ voteType }) => { + // Optimistic update + const oldVote = userVote; + setUserVote(voteType); + + setVotes((prev) => { + let newUpvotes = prev.upvotes; + let newDownvotes = prev.downvotes; + + // Remove old vote + if (oldVote === "UP") newUpvotes--; + if (oldVote === "DOWN") newDownvotes--; + + // Add new vote + if (voteType === "UP") newUpvotes++; + if (voteType === "DOWN") newDownvotes++; + + return { upvotes: newUpvotes, downvotes: newDownvotes }; + }); + }, + onError: (error) => { + // Revert on error + setUserVote(initialUserVote); + setVotes({ upvotes, downvotes }); + toast.error("Failed to update vote"); + Sentry.captureException(error); + }, + onSettled: () => { + utils.post.published.invalidate(); + }, + }); + + const { mutate: bookmark, status: bookmarkStatus } = + api.post.bookmark.useMutation({ + onMutate: async ({ setBookmarked }) => { + setIsBookmarked(setBookmarked); + }, + onError: (error) => { + setIsBookmarked(initialBookmarked); + toast.error("Failed to update bookmark"); + Sentry.captureException(error); + }, + onSettled: () => { + utils.post.myBookmarks.invalidate(); + }, + }); + + const handleVote = (voteType: "UP" | "DOWN" | null) => { + if (!session) { + signIn(); + return; + } + vote({ postId: id, voteType }); + }; + + const handleBookmark = () => { + if (!session) { + signIn(); + return; + } + bookmark({ postId: id, setBookmarked: !isBookmarked }); + }; + + const handleShare = async () => { + const shareUrl = `${window.location.origin}/articles/${slug}`; + try { + await navigator.clipboard.writeText(shareUrl); + toast.success("Link copied to clipboard"); + } catch { + toast.error("Failed to copy link"); + } + }; + + const relativeTime = getRelativeTime(date); + const score = votes.upvotes - votes.downvotes; + + return ( +
+ {/* Header row - author and metadata */} +
+ + + {name} + + + + {readTime > 0 && ( + <> + + {readTime} min read + + )} +
+ + {/* Title */} +

+ + {title} + +

+ + {/* Excerpt */} + {excerpt && ( +

+ {excerpt} +

+ )} + + {/* Action bar */} +
+ {/* Vote buttons */} +
+ + 0 + ? "text-green-500" + : score < 0 + ? "text-red-500" + : "text-neutral-400 dark:text-neutral-500" + }`} + > + {score} + + +
+ + {/* Comments button */} + + + {discussionCount} + + + {/* Save button */} + + + {/* Share button */} + + + {/* Triple-dot menu */} + + + More options + + + + + + + + + + +
+
+ ); +}; + +// Loading skeleton +const ArticleCardLoading = () => ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+); + +// Sort option types - unified between feed and articles +type UISortOption = "recent" | "trending" | "popular"; +type APISortOption = "newest" | "oldest" | "top" | "trending"; + +// Map UI sort to API sort +const sortUIToAPI: Record = { + recent: "newest", + trending: "trending", + popular: "top", +}; + +// Map API sort to UI sort (for URL params) +const sortAPIToUI: Record = { + newest: "recent", + oldest: "recent", // fallback + top: "popular", + trending: "trending", +}; + +const validUISorts: UISortOption[] = ["recent", "trending", "popular"]; const ArticlesPage = () => { const searchParams = useSearchParams(); const router = useRouter(); const { data: session } = useSession(); - const filter = searchParams?.get("filter"); + const sortParam = searchParams?.get("sort"); const dirtyTag = searchParams?.get("tag"); const tag = typeof dirtyTag === "string" ? dirtyTag : null; - type Filter = "newest" | "oldest" | "top"; - const filters: Filter[] = ["newest", "oldest", "top"]; - const getSortBy = () => { - if (typeof filter === "string") { - const hasFilter = filters.some((f) => f === filter); - if (hasFilter) return filter as Filter; - } - return "newest"; - }; + // Get UI sort from URL param + const uiSort: UISortOption = validUISorts.includes(sortParam as UISortOption) + ? (sortParam as UISortOption) + : "recent"; - const selectedSortFilter = getSortBy(); + // Convert to API sort for the query + const apiSort = sortUIToAPI[uiSort]; const { status, data, isFetchingNextPage, fetchNextPage, hasNextPage } = api.post.published.useInfiniteQuery( { limit: 15, - sort: selectedSortFilter, + sort: apiSort, tag, }, { @@ -55,7 +393,27 @@ const ArticlesPage = () => { if (inView && hasNextPage) { fetchNextPage(); } - }, [inView]); + }, [inView, hasNextPage, fetchNextPage]); + + // Handle filter changes + const handleSortChange = (newSort: UISortOption) => { + const params = new URLSearchParams(); + if (newSort !== "recent") params.set("sort", newSort); + if (tag) params.set("tag", tag); + const queryString = params.toString(); + router.push(`/articles${queryString ? `?${queryString}` : ""}`); + }; + + const handleTagChange = (newTag: string | null) => { + const params = new URLSearchParams(); + if (uiSort !== "recent") params.set("sort", uiSort); + if (newTag) params.set("tag", newTag); + const queryString = params.toString(); + router.push(`/articles${queryString ? `?${queryString}` : ""}`); + }; + + // Get tags list for the filter dropdown + const tagsList = tagsData?.data.map((t) => t.title.toLowerCase()) || []; return ( <> @@ -71,41 +429,27 @@ const ArticlesPage = () => { "Articles" )} -
- - -
+
{status === "error" && ( -
+
Something went wrong... Please refresh your page.
)} {status === "pending" && - Array.from({ length: 7 }, (_, i) => )} + Array.from({ length: 7 }, (_, i) => ( + + ))} {status === "success" && data.pages.map((page) => { return ( @@ -120,12 +464,13 @@ const ArticlesPage = () => { readTimeMins, id, currentUserBookmarkedPost, - likes, + upvotes, + downvotes, + userVote, }) => { - // TODO: Bump posts that were recently updated to the top and show that they were updated recently if (!published) return null; return ( - { image={user?.image || ""} date={published} readTime={readTimeMins} - bookmarkedInitialState={currentUserBookmarkedPost} - likes={likes} + upvotes={upvotes ?? 0} + downvotes={downvotes ?? 0} + userVote={userVote ?? null} + isBookmarked={currentUserBookmarkedPost} /> ); }, @@ -146,9 +493,16 @@ const ArticlesPage = () => { ); })} {status === "success" && !data.pages[0].posts.length && ( -

No results founds

+
+

+ No articles found +

+

+ Check back later for new content. +

+
)} - {isFetchingNextPage ? : null} + {isFetchingNextPage && } intersection observer marker diff --git a/components/ArticleActionBar/ArticleActionBar.tsx b/components/ArticleActionBar/ArticleActionBar.tsx new file mode 100644 index 00000000..a799e929 --- /dev/null +++ b/components/ArticleActionBar/ArticleActionBar.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { Fragment, useState } from "react"; +import { + ChevronUpIcon, + ChevronDownIcon, + BookmarkIcon, + ChatBubbleLeftIcon, + ShareIcon, + EllipsisHorizontalIcon, +} from "@heroicons/react/20/solid"; +import { BookmarkIcon as BookmarkOutlineIcon } from "@heroicons/react/24/outline"; +import { + Menu, + MenuButton, + MenuItem, + MenuItems, + Transition, +} from "@headlessui/react"; +import { api } from "@/server/trpc/react"; +import { useSession, signIn } from "next-auth/react"; +import { toast } from "sonner"; +import * as Sentry from "@sentry/nextjs"; +import { ReportModal } from "../ReportModal/ReportModal"; + +interface ArticleActionBarProps { + postId: string; + postTitle: string; + postUrl: string; + postUsername: string; + initialUpvotes: number; + initialDownvotes: number; + initialUserVote: "UP" | "DOWN" | null; + initialBookmarked: boolean; + discussionCount?: number; +} + +const ArticleActionBar = ({ + postId, + postTitle, + postUrl, + postUsername, + initialUpvotes, + initialDownvotes, + initialUserVote, + initialBookmarked, + discussionCount = 0, +}: ArticleActionBarProps) => { + const { data: session } = useSession(); + const utils = api.useUtils(); + const [userVote, setUserVote] = useState(initialUserVote); + const [votes, setVotes] = useState({ + upvotes: initialUpvotes, + downvotes: initialDownvotes, + }); + const [isBookmarked, setIsBookmarked] = useState(initialBookmarked); + + const { mutate: vote, status: voteStatus } = api.post.vote.useMutation({ + onMutate: async ({ voteType }) => { + const oldVote = userVote; + setUserVote(voteType); + + setVotes((prev) => { + let newUpvotes = prev.upvotes; + let newDownvotes = prev.downvotes; + + if (oldVote === "UP") newUpvotes--; + if (oldVote === "DOWN") newDownvotes--; + + if (voteType === "UP") newUpvotes++; + if (voteType === "DOWN") newDownvotes++; + + return { upvotes: newUpvotes, downvotes: newDownvotes }; + }); + }, + onError: (error) => { + setUserVote(initialUserVote); + setVotes({ upvotes: initialUpvotes, downvotes: initialDownvotes }); + toast.error("Failed to update vote"); + Sentry.captureException(error); + }, + onSettled: () => { + utils.post.sidebarData.invalidate(); + }, + }); + + const { mutate: bookmark, status: bookmarkStatus } = + api.post.bookmark.useMutation({ + onMutate: async ({ setBookmarked }) => { + setIsBookmarked(setBookmarked); + }, + onError: (error) => { + setIsBookmarked(initialBookmarked); + toast.error("Failed to update bookmark"); + Sentry.captureException(error); + }, + onSettled: () => { + utils.post.myBookmarks.invalidate(); + }, + }); + + const handleVote = (voteType: "UP" | "DOWN" | null) => { + if (!session) { + signIn(); + return; + } + vote({ postId, voteType }); + }; + + const handleBookmark = () => { + if (!session) { + signIn(); + return; + } + bookmark({ postId, setBookmarked: !isBookmarked }); + }; + + const handleCopyLink = async () => { + try { + await navigator.clipboard.writeText(postUrl); + toast.success("Link copied to clipboard"); + } catch { + toast.error("Failed to copy link"); + } + }; + + const score = votes.upvotes - votes.downvotes; + + return ( +
+ {/* Vote buttons */} +
+ + 0 + ? "text-green-500" + : score < 0 + ? "text-red-500" + : "text-neutral-400 dark:text-neutral-500" + }`} + > + {score} + + +
+ + {/* Comments button */} + + + {discussionCount} Comments + + + {/* Bookmark button */} + + + {/* Share button */} + + + + Share + + + + + + Share to X + + + + + Share to LinkedIn + + + + + + + + + + {/* More options menu */} + + + More options + + + + + +
+ +
+
+
+
+
+
+ ); +}; + +export default ArticleActionBar; diff --git a/components/ArticleActionBar/ArticleActionBarWrapper.tsx b/components/ArticleActionBar/ArticleActionBarWrapper.tsx new file mode 100644 index 00000000..a0d8b729 --- /dev/null +++ b/components/ArticleActionBar/ArticleActionBarWrapper.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { api } from "@/server/trpc/react"; +import ArticleActionBar from "./ArticleActionBar"; + +interface ArticleActionBarWrapperProps { + postId: string; + postTitle: string; + postUrl: string; + postUsername: string; + initialUpvotes: number; + initialDownvotes: number; +} + +const ArticleActionBarWrapper = ({ + postId, + postTitle, + postUrl, + postUsername, + initialUpvotes, + initialDownvotes, +}: ArticleActionBarWrapperProps) => { + const { data, isLoading } = api.post.sidebarData.useQuery({ + id: postId, + }); + + if (isLoading) { + return ( +
+
+
+
+
+
+ ); + } + + return ( + + ); +}; + +export default ArticleActionBarWrapper; diff --git a/components/ArticleActionBar/index.ts b/components/ArticleActionBar/index.ts new file mode 100644 index 00000000..014540f0 --- /dev/null +++ b/components/ArticleActionBar/index.ts @@ -0,0 +1,2 @@ +export { default as ArticleActionBar } from "./ArticleActionBar"; +export { default as ArticleActionBarWrapper } from "./ArticleActionBarWrapper"; From d6ce206637e144be5d1ab80ed7f13a57139612b9 Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Sun, 4 Jan 2026 14:44:13 +0000 Subject: [PATCH 07/38] =?UTF-8?q?fix(seo):=20update=20branding=20from=20Co?= =?UTF-8?q?du=20to=20Cod=C3=BA=20in=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(app)/company/[slug]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(app)/company/[slug]/page.tsx b/app/(app)/company/[slug]/page.tsx index ec38a34f..6d6a9e73 100644 --- a/app/(app)/company/[slug]/page.tsx +++ b/app/(app)/company/[slug]/page.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { companies } from "./config"; export const metadata = { - title: "Ninedots Recruitment | Codu", + title: "Ninedots Recruitment | Codú", description: "Explore our community sponsors. Ninedots Recruitment connects top talent with leading companies in the tech industry.", }; From c3493e79f2e4b05efd4181c411c7cb7226ceb14b Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Sun, 4 Jan 2026 14:45:17 +0000 Subject: [PATCH 08/38] chore(deps): update dependencies and Next.js types --- next-env.d.ts | 2 +- package-lock.json | 48 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 5 ++++- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package-lock.json b/package-lock.json index 6916a995..e334a966 100644 --- a/package-lock.json +++ b/package-lock.json @@ -85,6 +85,7 @@ "react-moveable": "^0.56.0", "react-textarea-autosize": "^8.5.3", "rss": "^1.2.2", + "rss-parser": "^3.13.0", "sanitize-html": "^2.17.0", "server-only": "^0.0.1", "sharp": "^0.33.5", @@ -19460,6 +19461,25 @@ "xml": "1.0.1" } }, + "node_modules/rss-parser": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.13.0.tgz", + "integrity": "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==", + "license": "MIT", + "dependencies": { + "entities": "^2.0.3", + "xml2js": "^0.5.0" + } + }, + "node_modules/rss-parser/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -19573,6 +19593,12 @@ "postcss": "^8.3.11" } }, + "node_modules/sax": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz", + "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", + "license": "BlueOak-1.0.0" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -21834,6 +21860,28 @@ "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", "license": "MIT" }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index e923f0d2..7e472bd5 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "db:migrate": "npx tsx -r dotenv/config ./drizzle/migrate.ts", "db:seed": "npx tsx -r dotenv/config ./drizzle/seed.ts", "db:nuke": "npx tsx -r dotenv/config ./drizzle/nuke.ts", - "drizzle:up": "drizzle-kit up" + "drizzle:up": "drizzle-kit up", + "feed:fetch": "npx tsx -r dotenv/config ./scripts/fetch-rss.ts", + "algolia:test": "npx tsx -r dotenv/config ./scripts/test-algolia-index.ts" }, "lint-staged": { "**/*": "prettier --write --ignore-unknown", @@ -103,6 +105,7 @@ "react-moveable": "^0.56.0", "react-textarea-autosize": "^8.5.3", "rss": "^1.2.2", + "rss-parser": "^3.13.0", "sanitize-html": "^2.17.0", "server-only": "^0.0.1", "sharp": "^0.33.5", From dc999d8b0c5f4c492f75e7e72c203082bf74cfcf Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Sun, 4 Jan 2026 14:46:51 +0000 Subject: [PATCH 09/38] chore(deps): update dependencies and add RSS fetch utility - Update package dependencies - Add local fetch-rss.ts script for testing RSS feeds without Lambda --- scripts/fetch-rss.ts | 152 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 scripts/fetch-rss.ts diff --git a/scripts/fetch-rss.ts b/scripts/fetch-rss.ts new file mode 100644 index 00000000..9508502c --- /dev/null +++ b/scripts/fetch-rss.ts @@ -0,0 +1,152 @@ +/** + * Local script to fetch RSS feeds and populate the aggregated_article table. + * Use this for testing without running the Lambda cron. + * + * Usage: npx tsx scripts/fetch-rss.ts + */ + +import { db } from "../server/db"; +import { feed_source, aggregated_article } from "../server/db/schema"; +import { eq, and } from "drizzle-orm"; +import { nanoid } from "nanoid"; +import Parser from "rss-parser"; + +const parser = new Parser({ + timeout: 10000, + headers: { + "User-Agent": "Mozilla/5.0 (compatible; CoduBot/1.0; +https://codu.co)", + }, +}); + +// Simple excerpt extraction +function extractExcerpt(content: string, maxLength = 200): string { + // Remove HTML tags + const text = content.replace(/<[^>]*>/g, "").trim(); + if (text.length <= maxLength) return text; + return text.substring(0, maxLength).trim() + "..."; +} + +// Extract image from content or enclosure +function extractImage(item: Parser.Item): string | null { + // Check enclosure + if (item.enclosure?.url) { + return item.enclosure.url; + } + + // Check media:content + const mediaContent = (item as Record)["media:content"]; + if (mediaContent && typeof mediaContent === "object" && "url" in (mediaContent as Record)) { + return (mediaContent as Record).url; + } + + // Try to extract from content + const content = item.content || item["content:encoded"] || ""; + const imgMatch = content.match(/]+src=["']([^"']+)["']/i); + if (imgMatch) { + return imgMatch[1]; + } + + return null; +} + +async function fetchAndProcessFeed(source: typeof feed_source.$inferSelect) { + console.log(`\nFetching: ${source.name} (${source.feedUrl})`); + + try { + const feed = await parser.parseURL(source.feedUrl); + console.log(` Found ${feed.items.length} items`); + + let newCount = 0; + let skippedCount = 0; + + for (const item of feed.items) { + if (!item.link || !item.title) { + skippedCount++; + continue; + } + + // Check if article already exists + const existing = await db.query.aggregated_article.findFirst({ + where: and( + eq(aggregated_article.url, item.link), + eq(aggregated_article.sourceId, source.id) + ), + }); + + if (existing) { + skippedCount++; + continue; + } + + // Extract data + const excerpt = extractExcerpt( + item.contentSnippet || item.content || item.summary || "" + ); + const imageUrl = extractImage(item); + const publishedAt = item.pubDate + ? new Date(item.pubDate) + : new Date(); + + // Insert new article + await db.insert(aggregated_article).values({ + shortId: nanoid(8), + sourceId: source.id, + title: item.title.substring(0, 500), + url: item.link, + excerpt: excerpt || null, + author: item.creator || item.author || null, + imageUrl: imageUrl, + publishedAt: publishedAt.toISOString(), + fetchedAt: new Date().toISOString(), + }); + + newCount++; + } + + console.log(` Added: ${newCount}, Skipped: ${skippedCount}`); + return { success: true, newCount, skippedCount }; + } catch (error) { + console.error(` Error: ${error instanceof Error ? error.message : "Unknown error"}`); + return { success: false, error }; + } +} + +async function main() { + console.log("=== RSS Feed Fetcher ===\n"); + + // Get all active sources + const sources = await db.query.feed_source.findMany({ + where: eq(feed_source.isActive, true), + }); + + console.log(`Found ${sources.length} active feed sources`); + + const results = { + total: sources.length, + successful: 0, + failed: 0, + newArticles: 0, + }; + + for (const source of sources) { + const result = await fetchAndProcessFeed(source); + if (result.success) { + results.successful++; + results.newArticles += result.newCount || 0; + } else { + results.failed++; + } + } + + console.log("\n=== Summary ==="); + console.log(`Sources processed: ${results.successful}/${results.total}`); + console.log(`Failed: ${results.failed}`); + console.log(`New articles added: ${results.newArticles}`); + + process.exit(0); +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); From d4a7a1273d3fb362e4b8c24b37c4b727f4954bae Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Tue, 6 Jan 2026 07:35:34 +0000 Subject: [PATCH 10/38] feat(db): add unified content system and sponsor inquiry schemas Consolidate migrations into unified content table supporting posts and aggregated links. Add sponsor inquiry schema for advertiser contact form. --- drizzle/0011_unified_content_system.sql | 356 +++++ drizzle/0012_sponsor_inquiry.sql | 34 + drizzle/meta/_journal.json | 36 +- drizzle/seed.ts | 320 +++-- schema/comment.ts | 92 +- schema/content.ts | 55 +- schema/discussion.ts | 24 +- schema/feed.ts | 108 +- schema/post.ts | 200 ++- schema/report.ts | 5 + schema/sponsor.ts | 65 + server/db/schema.ts | 1730 +++++++++++++---------- 12 files changed, 2031 insertions(+), 994 deletions(-) create mode 100644 drizzle/0011_unified_content_system.sql create mode 100644 drizzle/0012_sponsor_inquiry.sql create mode 100644 schema/sponsor.ts diff --git a/drizzle/0011_unified_content_system.sql b/drizzle/0011_unified_content_system.sql new file mode 100644 index 00000000..58f147b7 --- /dev/null +++ b/drizzle/0011_unified_content_system.sql @@ -0,0 +1,356 @@ +-- Schema Redesign: Posts & Comments +-- This migration creates a clean, unified content system with lowercase naming + +-- ============================================ +-- PART 1: Extensions and Enum Types +-- ============================================ + +-- Enable ltree for hierarchical comment paths +CREATE EXTENSION IF NOT EXISTS ltree; + +-- Post type enum (lowercase) +DO $$ BEGIN + CREATE TYPE post_type AS ENUM ('article', 'discussion', 'link', 'resource'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Post status enum +DO $$ BEGIN + CREATE TYPE post_status AS ENUM ('draft', 'published', 'scheduled', 'unlisted'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Vote type enum +DO $$ BEGIN + CREATE TYPE vote_type AS ENUM ('up', 'down'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Feed source status enum +DO $$ BEGIN + CREATE TYPE feed_source_status AS ENUM ('active', 'paused', 'error'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Report reason enum +DO $$ BEGIN + CREATE TYPE report_reason AS ENUM ('spam', 'harassment', 'hate_speech', 'misinformation', 'copyright', 'nsfw', 'off_topic', 'other'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Report status enum +DO $$ BEGIN + CREATE TYPE report_status AS ENUM ('pending', 'reviewed', 'dismissed', 'actioned'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- ============================================ +-- PART 2: Feed Sources Table +-- ============================================ + +CREATE TABLE IF NOT EXISTS feed_sources ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + url TEXT NOT NULL UNIQUE, + website_url TEXT, + logo_url TEXT, + slug VARCHAR(100) UNIQUE, + category VARCHAR(50), + description TEXT, + status feed_source_status DEFAULT 'active' NOT NULL, + last_fetched_at TIMESTAMPTZ, + last_success_at TIMESTAMPTZ, + error_count INTEGER DEFAULT 0 NOT NULL, + last_error TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL +); + +-- ============================================ +-- PART 3: Posts Table +-- ============================================ + +CREATE TABLE IF NOT EXISTS posts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + type post_type NOT NULL, + + author_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + + title VARCHAR(500) NOT NULL, + slug VARCHAR(300) NOT NULL, + excerpt TEXT, + body TEXT, + canonical_url TEXT, + cover_image TEXT, + + -- For link/resource types (external URLs) + external_url VARCHAR(2000), + + -- RSS import metadata + source_id INTEGER REFERENCES feed_sources(id) ON DELETE SET NULL, + source_author VARCHAR(200), + + -- Metadata + reading_time INTEGER, + + -- Denormalized counters (updated via triggers) + upvotes_count INTEGER DEFAULT 0 NOT NULL, + downvotes_count INTEGER DEFAULT 0 NOT NULL, + comments_count INTEGER DEFAULT 0 NOT NULL, + views_count INTEGER DEFAULT 0 NOT NULL, + + -- Publishing + status post_status NOT NULL DEFAULT 'draft', + published_at TIMESTAMPTZ, + + -- Feature flags + featured BOOLEAN DEFAULT FALSE NOT NULL, + pinned_until TIMESTAMPTZ, + show_comments BOOLEAN DEFAULT TRUE NOT NULL, + + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL +); + +-- Posts indexes +CREATE INDEX IF NOT EXISTS posts_author_id_idx ON posts(author_id); +CREATE UNIQUE INDEX IF NOT EXISTS posts_slug_idx ON posts(slug); +CREATE INDEX IF NOT EXISTS posts_status_idx ON posts(status); +CREATE INDEX IF NOT EXISTS posts_published_at_idx ON posts(published_at); +CREATE INDEX IF NOT EXISTS posts_type_idx ON posts(type); +CREATE INDEX IF NOT EXISTS posts_source_id_idx ON posts(source_id); +CREATE INDEX IF NOT EXISTS posts_featured_idx ON posts(featured) WHERE featured = TRUE; +CREATE UNIQUE INDEX IF NOT EXISTS posts_external_url_source_idx ON posts(external_url, source_id) + WHERE external_url IS NOT NULL; + +-- ============================================ +-- PART 4: Comments Table +-- ============================================ + +CREATE TABLE IF NOT EXISTS comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + author_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + parent_id UUID REFERENCES comments(id) ON DELETE CASCADE, + + -- Materialized path for efficient tree queries + path LTREE NOT NULL, + depth INTEGER DEFAULT 0 NOT NULL, + + body TEXT NOT NULL, + + -- Denormalized counters + upvotes_count INTEGER DEFAULT 0 NOT NULL, + downvotes_count INTEGER DEFAULT 0 NOT NULL, + + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + deleted_at TIMESTAMPTZ -- Soft delete for "[deleted]" placeholders +); + +-- Comments indexes +CREATE INDEX IF NOT EXISTS comments_post_id_idx ON comments(post_id); +CREATE INDEX IF NOT EXISTS comments_author_id_idx ON comments(author_id); +CREATE INDEX IF NOT EXISTS comments_path_idx ON comments USING GIST(path); +CREATE INDEX IF NOT EXISTS comments_parent_id_idx ON comments(parent_id); +CREATE INDEX IF NOT EXISTS comments_created_at_idx ON comments(created_at); + +-- ============================================ +-- PART 5: Voting Tables +-- ============================================ + +CREATE TABLE IF NOT EXISTS post_votes ( + id SERIAL PRIMARY KEY, + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + vote_type vote_type NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + UNIQUE(post_id, user_id) +); + +CREATE INDEX IF NOT EXISTS post_votes_post_id_idx ON post_votes(post_id); + +CREATE TABLE IF NOT EXISTS comment_votes ( + id SERIAL PRIMARY KEY, + comment_id UUID NOT NULL REFERENCES comments(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + vote_type vote_type NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + UNIQUE(comment_id, user_id) +); + +CREATE INDEX IF NOT EXISTS comment_votes_comment_id_idx ON comment_votes(comment_id); + +-- ============================================ +-- PART 6: Bookmarks Table +-- ============================================ + +CREATE TABLE IF NOT EXISTS bookmarks ( + id SERIAL PRIMARY KEY, + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + UNIQUE(post_id, user_id) +); + +CREATE INDEX IF NOT EXISTS bookmarks_user_id_idx ON bookmarks(user_id); +CREATE INDEX IF NOT EXISTS bookmarks_post_id_idx ON bookmarks(post_id); + +-- ============================================ +-- PART 7: Post Tags Junction Table +-- ============================================ + +CREATE TABLE IF NOT EXISTS post_tags ( + id SERIAL PRIMARY KEY, + post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE, + tag_id INTEGER NOT NULL REFERENCES "Tag"(id) ON DELETE CASCADE, + UNIQUE(post_id, tag_id) +); + +CREATE INDEX IF NOT EXISTS post_tags_post_id_idx ON post_tags(post_id); +CREATE INDEX IF NOT EXISTS post_tags_tag_id_idx ON post_tags(tag_id); + +-- ============================================ +-- PART 8: Reports Table +-- ============================================ + +CREATE TABLE IF NOT EXISTS reports ( + id SERIAL PRIMARY KEY, + post_id UUID REFERENCES posts(id) ON DELETE CASCADE, + comment_id UUID REFERENCES comments(id) ON DELETE CASCADE, + reporter_id TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + reason report_reason NOT NULL, + details TEXT, + status report_status DEFAULT 'pending' NOT NULL, + reviewed_by_id TEXT REFERENCES "user"(id) ON DELETE SET NULL, + reviewed_at TIMESTAMPTZ, + action_taken TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + CONSTRAINT reports_target_check CHECK ( + (post_id IS NOT NULL AND comment_id IS NULL) OR + (post_id IS NULL AND comment_id IS NOT NULL) + ) +); + +CREATE INDEX IF NOT EXISTS reports_status_idx ON reports(status); +CREATE INDEX IF NOT EXISTS reports_reporter_id_idx ON reports(reporter_id); +CREATE INDEX IF NOT EXISTS reports_post_id_idx ON reports(post_id); +CREATE INDEX IF NOT EXISTS reports_comment_id_idx ON reports(comment_id); + +-- ============================================ +-- PART 9: Triggers for Denormalized Counters +-- ============================================ + +-- Post vote count trigger +CREATE OR REPLACE FUNCTION update_post_vote_counts() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.vote_type = 'up' THEN + UPDATE posts SET upvotes_count = upvotes_count + 1 WHERE id = NEW.post_id; + ELSE + UPDATE posts SET downvotes_count = downvotes_count + 1 WHERE id = NEW.post_id; + END IF; + ELSIF TG_OP = 'DELETE' THEN + IF OLD.vote_type = 'up' THEN + UPDATE posts SET upvotes_count = GREATEST(upvotes_count - 1, 0) WHERE id = OLD.post_id; + ELSE + UPDATE posts SET downvotes_count = GREATEST(downvotes_count - 1, 0) WHERE id = OLD.post_id; + END IF; + ELSIF TG_OP = 'UPDATE' AND OLD.vote_type IS DISTINCT FROM NEW.vote_type THEN + IF OLD.vote_type = 'up' AND NEW.vote_type = 'down' THEN + UPDATE posts SET + upvotes_count = GREATEST(upvotes_count - 1, 0), + downvotes_count = downvotes_count + 1 + WHERE id = NEW.post_id; + ELSIF OLD.vote_type = 'down' AND NEW.vote_type = 'up' THEN + UPDATE posts SET + upvotes_count = upvotes_count + 1, + downvotes_count = GREATEST(downvotes_count - 1, 0) + WHERE id = NEW.post_id; + END IF; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS tr_post_vote_counts ON post_votes; +CREATE TRIGGER tr_post_vote_counts +AFTER INSERT OR UPDATE OR DELETE ON post_votes +FOR EACH ROW EXECUTE FUNCTION update_post_vote_counts(); + +-- Comment vote count trigger +CREATE OR REPLACE FUNCTION update_comment_vote_counts() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + IF NEW.vote_type = 'up' THEN + UPDATE comments SET upvotes_count = upvotes_count + 1 WHERE id = NEW.comment_id; + ELSE + UPDATE comments SET downvotes_count = downvotes_count + 1 WHERE id = NEW.comment_id; + END IF; + ELSIF TG_OP = 'DELETE' THEN + IF OLD.vote_type = 'up' THEN + UPDATE comments SET upvotes_count = GREATEST(upvotes_count - 1, 0) WHERE id = OLD.comment_id; + ELSE + UPDATE comments SET downvotes_count = GREATEST(downvotes_count - 1, 0) WHERE id = OLD.comment_id; + END IF; + ELSIF TG_OP = 'UPDATE' AND OLD.vote_type IS DISTINCT FROM NEW.vote_type THEN + IF OLD.vote_type = 'up' AND NEW.vote_type = 'down' THEN + UPDATE comments SET + upvotes_count = GREATEST(upvotes_count - 1, 0), + downvotes_count = downvotes_count + 1 + WHERE id = NEW.comment_id; + ELSIF OLD.vote_type = 'down' AND NEW.vote_type = 'up' THEN + UPDATE comments SET + upvotes_count = upvotes_count + 1, + downvotes_count = GREATEST(downvotes_count - 1, 0) + WHERE id = NEW.comment_id; + END IF; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS tr_comment_vote_counts ON comment_votes; +CREATE TRIGGER tr_comment_vote_counts +AFTER INSERT OR UPDATE OR DELETE ON comment_votes +FOR EACH ROW EXECUTE FUNCTION update_comment_vote_counts(); + +-- Comments count trigger on posts +CREATE OR REPLACE FUNCTION update_post_comments_count() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + UPDATE posts SET comments_count = comments_count + 1 WHERE id = NEW.post_id; + ELSIF TG_OP = 'DELETE' THEN + UPDATE posts SET comments_count = GREATEST(comments_count - 1, 0) WHERE id = OLD.post_id; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS tr_post_comments_count ON comments; +CREATE TRIGGER tr_post_comments_count +AFTER INSERT OR DELETE ON comments +FOR EACH ROW EXECUTE FUNCTION update_post_comments_count(); + +-- ============================================ +-- PART 10: Data Migration (SKIPPED FOR FRESH INSTALL) +-- ============================================ +-- Data migration from legacy tables is skipped for fresh installs. +-- Use the seed script (npm run db:seed) to populate data. +-- If migrating from existing data, this section would need to be updated +-- to match the actual legacy table column names. + +-- ============================================ +-- Done! +-- Old tables are preserved for rollback safety. +-- They can be dropped in a future migration after verification. +-- ============================================ diff --git a/drizzle/0012_sponsor_inquiry.sql b/drizzle/0012_sponsor_inquiry.sql new file mode 100644 index 00000000..1673ec09 --- /dev/null +++ b/drizzle/0012_sponsor_inquiry.sql @@ -0,0 +1,34 @@ +-- Sponsor Inquiry System +-- Creates the sponsorship inquiry table with all fields + +-- Create budget range enum +DO $$ BEGIN + CREATE TYPE "SponsorBudgetRange" AS ENUM ('EXPLORING', 'UNDER_500', 'BETWEEN_500_2000', 'BETWEEN_2000_5000', 'OVER_5000'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Create sponsor inquiry status enum +DO $$ BEGIN + CREATE TYPE "SponsorInquiryStatus" AS ENUM ('PENDING', 'CONTACTED', 'CONVERTED', 'CLOSED'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Create SponsorInquiry table with all final fields +CREATE TABLE IF NOT EXISTS "SponsorInquiry" ( + "id" SERIAL PRIMARY KEY NOT NULL, + "name" VARCHAR(100) NOT NULL, + "email" VARCHAR(255) NOT NULL, + "company" VARCHAR(100), + "phone" VARCHAR(50), + "interests" TEXT, + "budgetRange" "SponsorBudgetRange" DEFAULT 'EXPLORING', + "goals" TEXT, + "status" "SponsorInquiryStatus" DEFAULT 'PENDING' NOT NULL, + "createdAt" TIMESTAMP(3) WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS "SponsorInquiry_status_index" ON "SponsorInquiry" ("status"); +CREATE INDEX IF NOT EXISTS "SponsorInquiry_email_index" ON "SponsorInquiry" ("email"); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index f810e61d..212a1123 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -82,43 +82,15 @@ { "idx": 11, "version": "7", - "when": 1735938000000, - "tag": "0011_content_aggregator", + "when": 1736100000000, + "tag": "0011_unified_content_system", "breakpoints": true }, { "idx": 12, "version": "7", - "when": 1735942800000, - "tag": "0012_alter_excerpt_to_text", - "breakpoints": true - }, - { - "idx": 13, - "version": "7", - "when": 1735984800000, - "tag": "0013_add_og_image_url", - "breakpoints": true - }, - { - "idx": 14, - "version": "7", - "when": 1735986000000, - "tag": "0014_reddit_style_migration", - "breakpoints": true - }, - { - "idx": 15, - "version": "7", - "when": 1736000000000, - "tag": "0015_unified_content", - "breakpoints": true - }, - { - "idx": 16, - "version": "7", - "when": 1736002800000, - "tag": "0016_post_voting", + "when": 1736100001000, + "tag": "0012_sponsor_inquiry", "breakpoints": true } ] diff --git a/drizzle/seed.ts b/drizzle/seed.ts index 42eb4ac5..93012508 100644 --- a/drizzle/seed.ts +++ b/drizzle/seed.ts @@ -1,15 +1,13 @@ import { nanoid, customAlphabet } from "nanoid"; import { Chance } from "chance"; import { - post, + posts, user, tag, - like, - post_tag, + post_tags, session, - feed_source, - aggregated_article, - discussion, + feed_sources, + comments, } from "../server/db/schema"; import { sql, eq } from "drizzle-orm"; @@ -59,6 +57,7 @@ const main = async () => { "BACKEND", ]; + // Generate posts for the new posts table const randomPosts = (count = 10) => { return Array(count) .fill(null) @@ -66,28 +65,29 @@ const main = async () => { const title = chance.sentence({ words: chance.integer({ min: 4, max: 8 }), }); + const shortId = generateShortId(); + const isPublished = chance.bool({ likelihood: 70 }); + const publishedDate = isPublished + ? new Date(chance.date({ year: 2024 })).toISOString() + : null; + return { - id: nanoid(8), + type: "article" as const, title: title, - published: chance.pickone([ - new Date(chance.date({ year: 2023 })).toISOString(), - undefined, - ]), - excerpt: chance.sentence({ - words: chance.integer({ min: 10, max: 20 }), - }), - updatedAt: new Date().toISOString(), slug: `${title .toLowerCase() .replace(/ /g, "-") - .replace(/[^\w-]+/g, "")}-${chance.string({ - length: 5, - alpha: true, - casing: "lower", - })}`, - likes: chance.integer({ min: 0, max: 1000 }), - readTimeMins: chance.integer({ min: 1, max: 10 }), - // The body needs this indentation or it all appears as codeblocks when rendered + .replace(/[^\w-]+/g, "") + .substring(0, 100)}-${shortId}`, + excerpt: chance.sentence({ + words: chance.integer({ min: 10, max: 20 }), + }), + readingTime: chance.integer({ min: 1, max: 10 }), + status: isPublished ? ("published" as const) : ("draft" as const), + publishedAt: publishedDate, + upvotesCount: isPublished ? chance.integer({ min: 0, max: 50 }) : 0, + downvotesCount: isPublished ? chance.integer({ min: 0, max: 5 }) : 0, + showComments: true, body: `Hello world - ${chance.paragraph()} ## ${chance.sentence({ words: 6 })} @@ -139,75 +139,31 @@ ${chance.paragraph()} const userData = generateUserData(); const addUserData = async () => { - const tags = sampleTags.map((title) => ({ title })); + const tagsData = sampleTags.map((title) => ({ title })); const tagResponse = await db .insert(tag) - .values(tags) + .values(tagsData) .onConflictDoNothing() .returning({ id: tag.id, title: tag.title }); const usersResponse = await db.insert(user).values(userData).returning(); - for (let i = 0; i < usersResponse.length; i++) { - const posts = randomPosts( - chance.integer({ - min: 1, - max: 5, - }), - ).map((post) => ({ ...post, userId: usersResponse[i].id })); - - const postsResponse = await db - .insert(post) - .values(posts) - .onConflictDoNothing() - .returning(); - - for (let j = 0; j < postsResponse.length; j++) { - const randomTag = tagResponse[chance.integer({ min: 0, max: 9 })]; - await db - .insert(post_tag) - .values({ postId: postsResponse[j].id, tagId: randomTag.id }) - .onConflictDoNothing(); - } - } - - const posts = await db.select().from(post); - - for (let i = 0; i < usersResponse.length; i++) { - const numberOfLikedPosts = chance.integer({ - min: 1, - max: posts.length / 2, - }); - - const likedPosts: Array = []; - - for (let j = 0; j < numberOfLikedPosts; j++) { - likedPosts.push( - posts[ - chance.integer({ - min: 0, - max: posts.length - 1, - }) - ].id, - ); - } + const postsData = randomPosts(60); + const postsToInsert = postsData.map((p, index) => ({ + ...p, + authorId: usersResponse[index % usersResponse.length].id, + })); - await Promise.all( - likedPosts.map((post) => - db - .insert(like) - .values({ userId: usersResponse[i].id, postId: post }) - .onConflictDoNothing(), - ), - ); - } + const postResponse = await db + .insert(posts) + .values(postsToInsert) + .onConflictDoNothing() + .returning(); - console.log(`Added ${usersResponse.length} users with posts and likes`); + console.log(`Added ${usersResponse.length} users and ${postResponse.length} posts`); - // Return posts for discussion seeding - const allPosts = await db.select().from(post); - return { users: usersResponse, posts: allPosts }; + return { users: usersResponse, posts: postResponse, tags: tagResponse }; }; // Initial RSS feed sources for content aggregator @@ -614,7 +570,7 @@ ${chance.paragraph()} const addFeedSources = async () => { const sourcesResponse = await db - .insert(feed_source) + .insert(feed_sources) .values(feedSources) .onConflictDoNothing() .returning(); @@ -623,13 +579,20 @@ ${chance.paragraph()} return sourcesResponse; }; - // Add sample aggregated articles for testing - const addSampleArticles = async (sourceIds: { id: number; websiteUrl: string | null }[]) => { + // Add sample LINK posts directly to posts table for testing + const addSampleLinks = async ( + sourceIds: { id: number; websiteUrl: string | null; slug: string | null }[], + users: { id: string }[], + ) => { if (sourceIds.length === 0) { - console.log("No sources to add articles for, fetching from DB..."); - const existingSources = await db.select({ id: feed_source.id, websiteUrl: feed_source.websiteUrl }).from(feed_source); + console.log("No sources to add links for, fetching from DB..."); + const existingSources = await db.select({ + id: feed_sources.id, + websiteUrl: feed_sources.websiteUrl, + slug: feed_sources.slug, + }).from(feed_sources); if (existingSources.length === 0) { - console.log("No feed sources found, skipping sample articles"); + console.log("No feed sources found, skipping sample links"); return []; } sourceIds = existingSources; @@ -638,109 +601,168 @@ ${chance.paragraph()} // Filter to only sources with valid websiteUrls const validSources = sourceIds.filter(s => s.websiteUrl && s.websiteUrl.length > 0); if (validSources.length === 0) { - console.log("No sources with valid websiteUrls, skipping sample articles"); + console.log("No sources with valid websiteUrls, skipping sample links"); return []; } - const sampleArticles = []; + if (users.length === 0) { + console.log("No users to assign as authors, skipping sample links"); + return []; + } + + const sampleLinks = []; const now = new Date(); - // Add 3 sample articles per source (first 5 sources only to keep it manageable) - for (let i = 0; i < Math.min(5, validSources.length); i++) { + // Sample article titles + const sampleTitles = [ + "Building Scalable React Applications with Server Components", + "The Complete Guide to CSS Grid Layout in 2024", + "Understanding TypeScript Generics: A Practical Guide", + "10 JavaScript Performance Tips Every Developer Should Know", + "How We Migrated Our Monolith to Microservices", + "Introduction to Edge Computing for Web Developers", + "Mastering Git Branching Strategies for Large Teams", + "Deep Dive into Node.js Event Loop", + "Creating Accessible Forms: Best Practices", + "The Future of Web Development: Trends to Watch", + "Optimizing Database Queries in PostgreSQL", + "Building Real-time Applications with WebSockets", + "Container Security Best Practices for Kubernetes", + "A Beginner's Guide to GraphQL APIs", + "Testing React Applications with Vitest and Testing Library", + "Modern Authentication Patterns with OAuth 2.0 and OIDC", + "Deploying Next.js Applications to the Edge", + "State Management in 2024: Redux vs Zustand vs Jotai", + "Web Performance Optimization: Core Web Vitals Deep Dive", + "Building Design Systems with Tailwind CSS", + "Understanding React Concurrent Features", + "Advanced CSS Animations and Transitions", + "Migrating from REST to GraphQL: Lessons Learned", + "Serverless Architecture Patterns for Modern Apps", + "End-to-End Testing with Playwright", + "TypeScript 5.0: New Features and Migration Guide", + "CI/CD Pipeline Best Practices for JavaScript Projects", + "Implementing Dark Mode: A Complete Guide", + "Web Components vs React: When to Use Each", + "Database Indexing Strategies for High Performance", + "Building CLI Tools with Node.js", + "WebAssembly for JavaScript Developers", + "Micro-Frontends Architecture in Practice", + "API Rate Limiting and Throttling Techniques", + "Debugging Production Issues in Node.js", + "React Query vs SWR: Data Fetching Compared", + "Secure Coding Practices for Web Developers", + "Progressive Web Apps in 2024", + "Understanding the JavaScript Module System", + "Building Accessible Navigation Components", + "Docker Best Practices for Development Teams", + "Functional Programming Patterns in JavaScript", + "Real-time Collaboration with CRDTs", + "Monitoring and Observability for Web Apps", + "Code Review Best Practices for Remote Teams", + ]; + + // Add 3 sample links per source + for (let i = 0; i < Math.min(15, validSources.length); i++) { const source = validSources[i]; for (let j = 0; j < 3; j++) { const daysAgo = chance.integer({ min: 1, max: 14 }); const publishedDate = new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000); + const shortId = generateShortId(); + const title = sampleTitles[(i * 3 + j) % sampleTitles.length]; + const slug = `${title.toLowerCase().replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").substring(0, 100)}-${shortId}`; - sampleArticles.push({ - sourceId: source.id, - shortId: generateShortId(), - title: chance.sentence({ words: chance.integer({ min: 5, max: 12 }) }), + sampleLinks.push({ + type: "link" as const, + title, excerpt: chance.paragraph(), - url: `${source.websiteUrl}/posts/${chance.word()}-${chance.word()}-${chance.integer({ min: 1000, max: 9999 })}`, - author: chance.name(), + externalUrl: `${source.websiteUrl}/posts/${chance.word()}-${chance.word()}-${chance.integer({ min: 1000, max: 9999 })}`, + sourceId: source.id, + sourceAuthor: chance.name(), + slug, + authorId: users[0].id, // Use first user as author for external links + status: "published" as const, publishedAt: publishedDate.toISOString(), - upvotes: chance.integer({ min: 0, max: 100 }), - downvotes: chance.integer({ min: 0, max: 10 }), - clickCount: chance.integer({ min: 0, max: 500 }), + upvotesCount: chance.integer({ min: 0, max: 100 }), + downvotesCount: chance.integer({ min: 0, max: 10 }), + viewsCount: chance.integer({ min: 0, max: 500 }), + readingTime: chance.integer({ min: 3, max: 15 }), + showComments: true, }); } } - const articlesResponse = await db - .insert(aggregated_article) - .values(sampleArticles) + const linksResponse = await db + .insert(posts) + .values(sampleLinks) .onConflictDoNothing() .returning(); - console.log(`Added ${articlesResponse.length} sample articles`); - return articlesResponse; + console.log(`Added ${linksResponse.length} sample LINK posts`); + return linksResponse; }; - // Add sample discussions on posts and articles - const addSampleDiscussions = async (users: { id: string }[], articles: { id: number }[], posts: { id: string }[]) => { + // Add sample comments on posts + const addSampleComments = async (users: { id: string }[], postItems: { id: string }[]) => { if (users.length === 0) { - console.log("No users found, skipping discussions"); + console.log("No users found, skipping comments"); return; } - const discussions = []; - - // Add discussions on articles - for (const article of articles.slice(0, 10)) { - const numComments = chance.integer({ min: 1, max: 5 }); - for (let i = 0; i < numComments; i++) { - discussions.push({ - body: chance.paragraph(), - targetType: "ARTICLE" as const, - articleId: article.id, - userId: users[chance.integer({ min: 0, max: users.length - 1 })].id, - }); - } + if (postItems.length === 0) { + console.log("No posts found, skipping comments"); + return; } - // Add discussions on posts - for (const p of posts.slice(0, 10)) { - const numComments = chance.integer({ min: 1, max: 3 }); + const commentsData = []; + + // Add comments on posts + for (const postItem of postItems.slice(0, 10)) { + const numComments = chance.integer({ min: 1, max: 5 }); for (let i = 0; i < numComments; i++) { - discussions.push({ + // Generate a unique path for each comment (ltree format: alphanumeric, underscores) + const pathId = generateShortId().replace(/[^a-zA-Z0-9]/g, ""); + commentsData.push({ body: chance.paragraph(), - targetType: "POST" as const, - postId: p.id, - userId: users[chance.integer({ min: 0, max: users.length - 1 })].id, + postId: postItem.id, + authorId: users[chance.integer({ min: 0, max: users.length - 1 })].id, + path: pathId, + depth: 0, }); } } - if (discussions.length === 0) { - console.log("No discussions to add"); + if (commentsData.length === 0) { + console.log("No comments to add"); return; } - const discussionsResponse = await db - .insert(discussion) - .values(discussions) + const commentsResponse = await db + .insert(comments) + .values(commentsData) .onConflictDoNothing() .returning(); - console.log(`Added ${discussionsResponse.length} sample discussions`); + console.log(`Added ${commentsResponse.length} sample comments`); // Add some nested replies - if (discussionsResponse.length > 0) { + if (commentsResponse.length > 0) { const replies = []; - for (const disc of discussionsResponse.slice(0, 5)) { + for (const comment of commentsResponse.slice(0, 5)) { + // Generate reply path by appending to parent's path + const replyPathId = generateShortId().replace(/[^a-zA-Z0-9]/g, ""); replies.push({ body: chance.sentence({ words: chance.integer({ min: 10, max: 30 }) }), - targetType: disc.targetType, - articleId: disc.articleId, - postId: disc.postId, - userId: users[chance.integer({ min: 0, max: users.length - 1 })].id, - parentId: disc.id, + postId: comment.postId, + authorId: users[chance.integer({ min: 0, max: users.length - 1 })].id, + parentId: comment.id, + path: `${comment.path}.${replyPathId}`, + depth: 1, }); } const repliesResponse = await db - .insert(discussion) + .insert(comments) .values(replies) .onConflictDoNothing() .returning(); @@ -753,18 +775,20 @@ ${chance.paragraph()} console.log(`Start seeding, please wait... `); try { - // Add users and posts + // Add users and posts (to new posts table) const userData = await addUserData(); // Add feed sources with slugs const sources = await addFeedSources(); - // Add sample articles for testing (sources already has id and websiteUrl from returning()) - const articles = await addSampleArticles(sources); + // Add sample LINK posts for testing (directly to posts table) + await addSampleLinks(sources, userData.users); - // Add sample discussions on posts and articles - if (userData && articles.length > 0) { - await addSampleDiscussions(userData.users, articles, userData.posts); + // Add sample comments on posts + if (userData && userData.posts.length > 0) { + // Filter to only published posts + const publishedPosts = userData.posts.filter(p => p.status === "published"); + await addSampleComments(userData.users, publishedPosts); } } catch (error) { console.log("Error:", error); diff --git a/schema/comment.ts b/schema/comment.ts index 62bcee0e..5614f935 100644 --- a/schema/comment.ts +++ b/schema/comment.ts @@ -1,33 +1,109 @@ import z from "zod"; +import { VoteTypeSchema } from "./post"; +// Create Comment Schema +export const CreateCommentSchema = z.object({ + body: z.string().min(1).max(5000).trim(), + postId: z.string(), + parentId: z.string().optional(), // UUID for parent comment +}); + +export type CreateCommentInput = z.TypeOf; + +// Save Comment Schema (alias for create/edit) export const SaveCommentSchema = z.object({ body: z.string().trim().min(1).max(5000), - parentId: z.number().optional(), + parentId: z.string().optional(), postId: z.string(), - commentId: z.number().optional(), + commentId: z.string().optional(), // For editing existing comment }); +export type SaveCommentInput = z.TypeOf; + +// Edit Comment Schema export const EditCommentSchema = z.object({ - body: z.string().trim().min(1).max(5000), - id: z.number(), + id: z.string(), + body: z.string().min(1).max(5000).trim(), }); +export type EditCommentInput = z.TypeOf; + +// Delete Comment Schema (soft delete) export const DeleteCommentSchema = z.object({ - id: z.number(), + id: z.string(), }); +export type DeleteCommentInput = z.TypeOf; + +// Get Comments for a Post Schema export const GetCommentsSchema = z.object({ postId: z.string(), + sort: z.enum(["best", "top", "new", "old", "controversial"]).default("best").optional(), + limit: z.number().min(1).max(100).default(50).optional(), + cursor: z + .object({ + id: z.string(), + createdAt: z.string().optional(), + score: z.number().optional(), + }) + .nullish(), }); +export type GetCommentsInput = z.TypeOf; + +// Get Comment Replies Schema +export const GetRepliesSchema = z.object({ + parentId: z.string(), + limit: z.number().min(1).max(50).default(10), + cursor: z + .object({ + id: z.string(), + createdAt: z.string().optional(), + }) + .nullish(), +}); + +export type GetRepliesInput = z.TypeOf; + +// Vote Comment Schema (Reddit-style) +export const VoteCommentSchema = z.object({ + commentId: z.string(), + voteType: VoteTypeSchema.nullable(), // null removes the vote +}); + +export type VoteCommentInput = z.TypeOf; + +// Get Comment Count for Post Schema +export const GetCommentCountSchema = z.object({ + postId: z.string(), +}); + +export type GetCommentCountInput = z.TypeOf; + +// Get User's Comments Schema +export const GetUserCommentsSchema = z.object({ + authorId: z.string(), + limit: z.number().min(1).max(100).default(20), + cursor: z + .object({ + id: z.string(), + createdAt: z.string().optional(), + }) + .nullish(), +}); + +export type GetUserCommentsInput = z.TypeOf; + +// Legacy schemas (for backward compatibility) export const LikeCommentSchema = z.object({ - commentId: z.number(), + commentId: z.string(), }); +export type LikeCommentInput = z.TypeOf; + export const SendEmailSchema = z.object({ htmlMessage: z.string(), subject: z.string(), }); -export type SaveCommentInput = z.TypeOf; -export type EditCommentInput = z.TypeOf; +export type SendEmailInput = z.TypeOf; diff --git a/schema/content.ts b/schema/content.ts index 128c3b64..422c8c5c 100644 --- a/schema/content.ts +++ b/schema/content.ts @@ -1,12 +1,15 @@ import z from "zod"; // Content Type enum matching the database +// POST/ARTICLE = user-created articles, LINK = external/RSS content +// ARTICLE is an alias for POST (frontend uses ARTICLE, backend uses POST) export const ContentTypeSchema = z.enum([ - "ARTICLE", + "POST", "LINK", "QUESTION", "VIDEO", "DISCUSSION", + "ARTICLE", // Alias for POST ]); export type ContentType = z.TypeOf; @@ -88,7 +91,7 @@ export type DeleteContentInput = z.TypeOf; // Vote Schema export const VoteContentSchema = z.object({ contentId: z.string(), - voteType: z.enum(["UP", "DOWN"]).nullable(), // null removes the vote + voteType: z.enum(["up", "down"]).nullable(), // null removes the vote }); export type VoteContentInput = z.TypeOf; @@ -135,3 +138,51 @@ export const GetSavedContentSchema = z.object({ }); export type GetSavedContentInput = z.TypeOf; + +// Edit Draft Schema - get user's own content by ID for editing +export const EditDraftContentSchema = z.object({ + id: z.string(), +}); + +export type EditDraftContentInput = z.TypeOf; + +// Publish Content Schema - separate publish action +export const PublishContentSchema = z.object({ + id: z.string(), + published: z.boolean(), + publishTime: z.date().optional(), // For scheduling +}); + +export type PublishContentInput = z.TypeOf; + +// Confirm Content Schema - validation before publishing +export const ConfirmContentSchema = z.object({ + body: z.string().trim().min(50, "Content is too short. Minimum of 50 characters."), + title: z.string().trim().max(500).min(10, "Title is too short. Minimum of 10 characters."), + excerpt: z.string().trim().max(300).optional(), + canonicalUrl: z.string().trim().url().optional().or(z.literal("")), + tags: z.string().array().max(5).optional(), +}); + +export type ConfirmContentInput = z.TypeOf; + +// My Drafts Schema +export const MyDraftsContentSchema = z.object({ + limit: z.number().min(1).max(100).default(20), +}); + +export type MyDraftsContentInput = z.TypeOf; + +// My Published Schema +export const MyPublishedContentSchema = z.object({ + limit: z.number().min(1).max(100).default(20), +}); + +export type MyPublishedContentInput = z.TypeOf; + +// My Scheduled Schema +export const MyScheduledContentSchema = z.object({ + limit: z.number().min(1).max(100).default(20), +}); + +export type MyScheduledContentInput = z.TypeOf; diff --git a/schema/discussion.ts b/schema/discussion.ts index 26237f19..0a9c5348 100644 --- a/schema/discussion.ts +++ b/schema/discussion.ts @@ -2,45 +2,35 @@ import z from "zod"; export const CreateDiscussionSchema = z.object({ body: z.string().min(1).max(5000).trim(), - targetType: z.enum(["POST", "ARTICLE"]), - postId: z.string().optional(), - articleId: z.number().optional(), - parentId: z.number().optional(), + contentId: z.string(), + parentId: z.string().uuid().optional(), }); export type CreateDiscussionInput = z.TypeOf; export const EditDiscussionSchema = z.object({ - id: z.number(), + id: z.string().uuid(), body: z.string().min(1).max(5000).trim(), }); export type EditDiscussionInput = z.TypeOf; export const DeleteDiscussionSchema = z.object({ - id: z.number(), + id: z.string().uuid(), }); export type DeleteDiscussionInput = z.TypeOf; export const GetDiscussionsSchema = z.object({ - targetType: z.enum(["POST", "ARTICLE"]), - postId: z.string().optional(), - articleId: z.number().optional(), + contentId: z.string(), }); export type GetDiscussionsInput = z.TypeOf; -export const LikeDiscussionSchema = z.object({ - discussionId: z.number(), -}); - -export type LikeDiscussionInput = z.TypeOf; - // Reddit-style voting schema export const VoteDiscussionSchema = z.object({ - discussionId: z.number(), - voteType: z.enum(["UP", "DOWN"]).nullable(), // null removes the vote + discussionId: z.string().uuid(), // Changed from number to UUID + voteType: z.enum(["up", "down"]).nullable(), // null removes the vote }); export type VoteDiscussionInput = z.TypeOf; diff --git a/schema/feed.ts b/schema/feed.ts index cec501a1..4d56366d 100644 --- a/schema/feed.ts +++ b/schema/feed.ts @@ -1,35 +1,34 @@ import z from "zod"; -// Feed Query Schema +// Vote type for feed (lowercase to match new schema) +export const FeedVoteTypeSchema = z.enum(["up", "down"]); + +// Get Feed Schema (for RSS aggregated content) export const GetFeedSchema = z.object({ - limit: z.number().min(1).max(100).nullish(), - cursor: z - .object({ - id: z.number(), - publishedAt: z.string().optional(), - score: z.number().optional(), - }) - .nullish(), - sort: z.enum(["recent", "trending", "popular"]), - category: z.string().nullish(), - tag: z.string().nullish(), - sourceId: z.number().nullish(), - includeCommunity: z.boolean().default(true), + limit: z.number().min(1).max(100).optional(), + cursor: z.object({ + id: z.string(), + publishedAt: z.string().optional(), + score: z.number().optional(), + }).nullish(), + sort: z.enum(["recent", "trending", "popular"]).default("recent"), + sourceId: z.number().optional(), + category: z.string().optional(), }); export type GetFeedInput = z.TypeOf; -// Vote Schema +// Vote on Article Schema export const VoteArticleSchema = z.object({ - articleId: z.number(), - voteType: z.enum(["UP", "DOWN"]).nullable(), + articleId: z.string(), + voteType: FeedVoteTypeSchema.nullable(), // null to remove vote }); export type VoteArticleInput = z.TypeOf; -// Bookmark Schema +// Bookmark Article Schema export const BookmarkArticleSchema = z.object({ - articleId: z.number(), + articleId: z.string(), setBookmarked: z.boolean(), }); @@ -37,23 +36,62 @@ export type BookmarkArticleInput = z.TypeOf; // Track Click Schema export const TrackClickSchema = z.object({ - articleId: z.number(), + articleId: z.string(), }); export type TrackClickInput = z.TypeOf; // Get Article by ID Schema export const GetArticleByIdSchema = z.object({ - id: z.number(), + id: z.string(), +}); + +export type GetArticleByIdInput = z.TypeOf; + +// Get Article by Slug Schema +export const GetArticleBySlugSchema = z.object({ + slug: z.string().min(1).max(350), +}); + +export type GetArticleBySlugInput = z.TypeOf; + +// Get Article by Source Slug and ShortId Schema (Reddit-style URLs) +export const GetArticleBySlugAndShortIdSchema = z.object({ + sourceSlug: z.string().min(1).max(100), + shortId: z.string().min(1).max(20), +}); + +export type GetArticleBySlugAndShortIdInput = z.TypeOf; + +// Get Articles by Source Schema +export const GetArticlesBySourceSchema = z.object({ + sourceSlug: z.string().min(1).max(100), + limit: z.number().min(1).max(100).optional(), + cursor: z.object({ + id: z.string(), + publishedAt: z.string().optional(), + }).nullish(), + sort: z.enum(["recent", "trending", "popular"]).default("recent"), +}); + +export type GetArticlesBySourceInput = z.TypeOf; + +// Get Article by Source and Article Slug +export const GetArticleBySourceAndArticleSlugSchema = z.object({ + sourceSlug: z.string().min(1).max(100), + articleSlug: z.string().min(1).max(350), }); -// Feed Source Schemas +export type GetArticleBySourceAndArticleSlugInput = z.TypeOf; + +// Feed Source Schemas (RSS source management) export const CreateFeedSourceSchema = z.object({ name: z.string().min(1).max(100), url: z.string().url(), websiteUrl: z.string().url().optional(), logoUrl: z.string().url().optional(), category: z.string().max(50).optional(), + description: z.string().max(500).optional(), }); export type CreateFeedSourceInput = z.TypeOf; @@ -65,6 +103,7 @@ export const UpdateFeedSourceSchema = z.object({ category: z.string().max(50).optional(), logoUrl: z.string().url().optional(), websiteUrl: z.string().url().optional(), + description: z.string().max(500).optional(), }); export type UpdateFeedSourceInput = z.TypeOf; @@ -84,14 +123,6 @@ export const DeleteFeedSourceSchema = z.object({ export type DeleteFeedSourceInput = z.TypeOf; -// Get Article by Slug and ShortId (Reddit-style URL) -export const GetArticleBySlugSchema = z.object({ - sourceSlug: z.string().min(1).max(100), - shortId: z.string().min(1).max(7), -}); - -export type GetArticleBySlugInput = z.TypeOf; - // Get Source Profile by Slug export const GetSourceBySlugSchema = z.object({ slug: z.string().min(1).max(100), @@ -99,19 +130,12 @@ export const GetSourceBySlugSchema = z.object({ export type GetSourceBySlugInput = z.TypeOf; -// Get Articles by Source (paginated) -export const GetArticlesBySourceSchema = z.object({ +// Get Content by Source Slug and Content Slug (for LINK type content) +export const GetLinkContentBySourceAndSlugSchema = z.object({ sourceSlug: z.string().min(1).max(100), - limit: z.number().min(1).max(100).default(20), - cursor: z - .object({ - id: z.number(), - publishedAt: z.string().optional(), - }) - .nullish(), - sort: z.enum(["recent", "trending", "popular"]).default("recent"), + contentSlug: z.string().min(1).max(350), }); -export type GetArticlesBySourceInput = z.TypeOf< - typeof GetArticlesBySourceSchema +export type GetLinkContentBySourceAndSlugInput = z.TypeOf< + typeof GetLinkContentBySourceAndSlugSchema >; diff --git a/schema/post.ts b/schema/post.ts index c14d3d5c..092915db 100644 --- a/schema/post.ts +++ b/schema/post.ts @@ -1,66 +1,167 @@ import z from "zod"; +// Post type enum matching the database (lowercase) +export const PostTypeSchema = z.enum(["article", "discussion", "link", "resource"]); +export type PostType = z.TypeOf; + +// Post status enum matching the database +export const PostStatusSchema = z.enum(["draft", "published", "scheduled", "unlisted"]); +export type PostStatus = z.TypeOf; + +// Vote type enum (lowercase) +export const VoteTypeSchema = z.enum(["up", "down"]); +export type VoteType = z.TypeOf; + +// Get Feed Schema - unified feed with type filtering +export const GetFeedSchema = z.object({ + limit: z.number().min(1).max(100).nullish(), + cursor: z + .object({ + id: z.string().uuid(), + publishedAt: z.string().optional(), + score: z.number().optional(), + }) + .nullish(), + sort: z.enum(["recent", "trending", "popular"]).default("recent"), + type: PostTypeSchema.nullish(), // Filter by post type + category: z.string().nullish(), + tag: z.string().nullish(), + sourceId: z.number().nullish(), + authorId: z.string().nullish(), // Filter by author +}); + +export type GetFeedInput = z.TypeOf; + +// Get Post by ID +export const GetPostByIdSchema = z.object({ + id: z.string().uuid(), +}); + +export type GetPostByIdInput = z.TypeOf; + +// Get Post by Slug +export const GetPostBySlugSchema = z.object({ + slug: z.string().min(1).max(300), +}); + +export type GetPostBySlugInput = z.TypeOf; + +// Create Post Schema export const CreatePostSchema = z.object({ + type: PostTypeSchema, + title: z.string().min(1).max(500), + body: z.string().nullish(), // Required for article, optional for others + excerpt: z.string().max(300).nullish(), + externalUrl: z.string().url().max(2000).nullish(), // Required for link, resource + coverImage: z.string().url().nullish(), + tags: z.array(z.string()).max(5).optional(), + status: PostStatusSchema.default("draft"), + showComments: z.boolean().default(true), + canonicalUrl: z.string().url().nullish(), +}); + +export type CreatePostInput = z.TypeOf; + +// Update/Save Post Schema +export const SavePostSchema = z.object({ + id: z.string(), + title: z.string().trim().max(500, "Max title length is 500 characters."), body: z.string().trim(), - title: z.string().trim().max(100, "Max title length is 100 characters."), + excerpt: z.optional(z.string().trim().max(300, "Max length is 300 characters.")), + canonicalUrl: z.optional(z.string().trim().url()), + tags: z.string().array().max(5).optional(), + status: PostStatusSchema.optional(), + publishedAt: z.string().datetime().optional(), }); -export const LikePostSchema = z.object({ - postId: z.string(), - setLiked: z.boolean(), +export type SavePostInput = z.TypeOf; + +// Delete Post Schema +export const DeletePostSchema = z.object({ + id: z.string(), }); +export type DeletePostInput = z.TypeOf; + +// Vote Schema export const VotePostSchema = z.object({ postId: z.string(), - voteType: z.enum(["UP", "DOWN"]).nullable(), + voteType: VoteTypeSchema.nullable(), // null removes the vote }); +export type VotePostInput = z.TypeOf; + +// Bookmark Schema export const BookmarkPostSchema = z.object({ postId: z.string(), setBookmarked: z.boolean(), }); -export const SavePostSchema = z.object({ - body: z.string().trim(), +export type BookmarkPostInput = z.TypeOf; + +// Track View Schema +export const TrackViewPostSchema = z.object({ + postId: z.string(), +}); + +export type TrackViewPostInput = z.TypeOf; + +// Get User's Posts Schema +export const GetUserPostsSchema = z.object({ + authorId: z.string(), + type: PostTypeSchema.nullish(), + limit: z.number().min(1).max(100).default(20), + cursor: z + .object({ + id: z.string(), + publishedAt: z.string().optional(), + }) + .nullish(), +}); + +export type GetUserPostsInput = z.TypeOf; + +// Get Bookmarked Posts Schema +export const GetBookmarkedPostsSchema = z.object({ + limit: z.number().min(1).max(100).default(20), + cursor: z + .object({ + id: z.string(), + createdAt: z.string().optional(), + }) + .nullish(), +}); + +export type GetBookmarkedPostsInput = z.TypeOf; + +// Get Draft by ID Schema - get user's own post for editing +export const GetByIdSchema = z.object({ id: z.string(), - title: z.string().trim().max(100, "Max title length is 100 characters."), - excerpt: z.optional( - z.string().trim().max(156, "Max length is 156 characters."), - ), - canonicalUrl: z.optional(z.string().trim().url()), - tags: z.string().array().max(5).optional(), - published: z.string().datetime().optional(), }); +export type GetByIdInput = z.TypeOf; + +// Publish Post Schema export const PublishPostSchema = z.object({ id: z.string(), published: z.boolean(), publishTime: z.date().optional(), }); +export type PublishPostInput = z.TypeOf; + +// Confirm Post Schema - validation before publishing export const ConfirmPostSchema = z.object({ - body: z - .string() - .trim() - .min(50, "Content is too short. Minimum of 50 characters."), - title: z - .string() - .trim() - .max(100, "Max title length is 100 characters.") - .min(10, "Title is too short. Minimum of 10 characters."), - excerpt: z - .string() - .trim() - .max(156, "Max length is 156 characters.") - .optional(), - canonicalUrl: z.string().trim().url().optional(), + body: z.string().trim().min(50, "Content is too short. Minimum of 50 characters."), + title: z.string().trim().max(500).min(10, "Title is too short. Minimum of 10 characters."), + excerpt: z.string().trim().max(300).optional(), + canonicalUrl: z.string().trim().url().optional().or(z.literal("")), tags: z.string().array().max(5).optional(), }); -export const DeletePostSchema = z.object({ - id: z.string(), -}); +export type ConfirmPostInput = z.TypeOf; +// Get Posts Schema export const GetPostsSchema = z.object({ userId: z.string().optional(), limit: z.number().min(1).max(100).nullish(), @@ -76,17 +177,42 @@ export const GetPostsSchema = z.object({ tag: z.string().nullish(), }); -export type SavePostInput = z.TypeOf; -export type ConfirmPostInput = z.TypeOf; +export type GetPostsInput = z.TypeOf; +// Get Single Post Schema export const GetSinglePostSchema = z.object({ slug: z.string(), }); -export const GetByIdSchema = z.object({ - id: z.string(), -}); +export type GetSinglePostInput = z.TypeOf; +// Get Limit Side Posts export const GetLimitSidePosts = z.object({ limit: z.number().optional(), }); + +export type GetLimitSidePostsInput = z.TypeOf; + +// Feature Post Schema (admin only) +export const FeaturePostSchema = z.object({ + postId: z.string(), + featured: z.boolean(), +}); + +export type FeaturePostInput = z.TypeOf; + +// Pin Post Schema (admin only) +export const PinPostSchema = z.object({ + postId: z.string(), + pinnedUntil: z.date().nullable(), // null to unpin +}); + +export type PinPostInput = z.TypeOf; + +// Legacy schemas (for backward compatibility) +export const LikePostSchema = z.object({ + postId: z.string(), + setLiked: z.boolean(), +}); + +export type LikePostInput = z.TypeOf; diff --git a/schema/report.ts b/schema/report.ts index 6f31afb4..7884687e 100644 --- a/schema/report.ts +++ b/schema/report.ts @@ -28,6 +28,11 @@ export const ReportSchema = z.discriminatedUnion("type", [ id: z.number().int(), body: z.string(), }), + z.strictObject({ + type: z.literal("article"), + id: z.number().int(), + body: z.string(), + }), ]); export type ReportInput = z.TypeOf; diff --git a/schema/sponsor.ts b/schema/sponsor.ts new file mode 100644 index 00000000..be1ca19f --- /dev/null +++ b/schema/sponsor.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; + +export const sponsorInterests = [ + "NEWSLETTER", + "EVENTS", + "WEBSITE", + "CONTENT", +] as const; + +export const sponsorInterestLabels: Record = { + NEWSLETTER: "Newsletter Advertising", + EVENTS: "Event Sponsorship", + WEBSITE: "Website & Job Board", + CONTENT: "Content Collaboration", +}; + +export const sponsorBudgetRanges = [ + "EXPLORING", + "UNDER_500", + "BETWEEN_500_2000", + "BETWEEN_2000_5000", + "OVER_5000", +] as const; + +export const sponsorBudgetLabels: Record = { + EXPLORING: "Just exploring", + UNDER_500: "Under €500/month", + BETWEEN_500_2000: "€500 - €2,000/month", + BETWEEN_2000_5000: "€2,000 - €5,000/month", + OVER_5000: "€5,000+/month", +}; + +export const SponsorInquirySchema = z.object({ + // Step 1: Interests (multi-select) + interests: z + .array(z.enum(sponsorInterests)) + .min(1, "Please select at least one option"), + + // Step 2: Budget & Goals + budgetRange: z.enum(sponsorBudgetRanges), + goals: z + .string() + .max(2000, "Goals must be 2000 characters or less") + .optional(), + + // Step 3: Contact details + name: z + .string() + .min(1, "Name is required") + .max(100, "Name must be 100 characters or less"), + email: z + .string() + .email("Please enter a valid email address") + .max(255, "Email must be 255 characters or less"), + company: z + .string() + .max(100, "Company name must be 100 characters or less") + .optional(), + phone: z + .string() + .max(50, "Phone must be 50 characters or less") + .optional(), +}); + +export type SponsorInquiryInput = z.infer; diff --git a/server/db/schema.ts b/server/db/schema.ts index 96c8dc38..2eeb9632 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -13,13 +13,32 @@ import { primaryKey, varchar, unique, + uuid, } from "drizzle-orm/pg-core"; import { relations, sql } from "drizzle-orm"; import { type AdapterAccount } from "next-auth/adapters"; +// ============================================ +// ENUMS - Lowercase values +// ============================================ + export const role = pgEnum("Role", ["MODERATOR", "ADMIN", "USER"]); -export const voteType = pgEnum("VoteType", ["UP", "DOWN"]); + +// New lowercase enums for posts/comments system +export const postType = pgEnum("post_type", ["article", "discussion", "link", "resource"]); +export const postStatus = pgEnum("post_status", ["draft", "published", "scheduled", "unlisted"]); +export const voteType = pgEnum("vote_type", ["up", "down"]); +export const feedSourceStatus = pgEnum("feed_source_status", ["active", "paused", "error"]); +export const reportReason = pgEnum("report_reason", ["spam", "harassment", "hate_speech", "misinformation", "copyright", "nsfw", "off_topic", "other"]); +export const reportStatus = pgEnum("report_status", ["pending", "reviewed", "dismissed", "actioned"]); + +// Legacy enums (kept for backward compatibility during migration) +export const legacyVoteType = pgEnum("VoteType", ["UP", "DOWN"]); + +// ============================================ +// USER & AUTH TABLES +// ============================================ export const session = pgTable("session", { sessionToken: text("sessionToken").notNull().primaryKey(), @@ -29,7 +48,6 @@ export const session = pgTable("session", { expires: timestamp("expires", { mode: "date" }).notNull(), }); -// Add this new relation definition for the session table export const sessionRelations = relations(session, ({ one }) => ({ user: one(user, { fields: [session.userId], @@ -65,148 +83,6 @@ export const accountRelations = relations(account, ({ one }) => ({ user: one(user, { fields: [account.userId], references: [user.id] }), })); -export const post_tag = pgTable( - "PostTag", - { - id: serial("id").primaryKey().notNull(), - tagId: integer("tagId") - .notNull() - .references(() => tag.id, { onDelete: "cascade", onUpdate: "cascade" }), - postId: text("postId") - .notNull() - .references(() => post.id, { onDelete: "cascade", onUpdate: "cascade" }), - }, - (table) => { - return { - tagIdPostIdKey: uniqueIndex("PostTag_tagId_postId_key").on( - table.tagId, - table.postId, - ), - }; - }, -); - -export const post_tagRelations = relations(post_tag, ({ one, many }) => ({ - post: one(post, { fields: [post_tag.postId], references: [post.id] }), - tag: one(tag, { fields: [post_tag.tagId], references: [tag.id] }), -})); -export const tag = pgTable( - "Tag", - { - createdAt: timestamp("createdAt", { - precision: 3, - mode: "string", - withTimezone: true, - }) - .default(sql`CURRENT_TIMESTAMP`) - .notNull(), - id: serial("id").primaryKey().notNull().unique(), - title: varchar("title", { length: 20 }).notNull(), - }, - (table) => { - return { - titleKey: uniqueIndex("Tag_title_key").on(table.title), - }; - }, -); - -export const tagRelations = relations(tag, ({ one, many }) => ({ - PostTag: many(post_tag), - AggregatedArticleTag: many(aggregated_article_tag), -})); - -export const post = pgTable( - "Post", - { - id: text("id").notNull().unique(), - title: text("title").notNull(), - canonicalUrl: text("canonicalUrl"), - coverImage: text("coverImage"), - approved: boolean("approved").default(true).notNull(), - body: text("body").notNull(), - excerpt: varchar("excerpt", { length: 156 }).default("").notNull(), - readTimeMins: integer("readTimeMins").notNull(), - published: timestamp("published", { - precision: 3, - mode: "string", - withTimezone: true, - }), - createdAt: timestamp("createdAt", { - precision: 3, - mode: "string", - withTimezone: true, - }) - .default(sql`CURRENT_TIMESTAMP`) - .notNull(), - updatedAt: timestamp("updatedAt", { - precision: 3, - mode: "string", - withTimezone: true, - }) - .notNull() - .$onUpdate(() => new Date().toISOString()) - .default(sql`CURRENT_TIMESTAMP`), - slug: text("slug").notNull(), - userId: text("userId") - .notNull() - .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), - showComments: boolean("showComments").default(true).notNull(), - likes: integer("likes").default(0).notNull(), - upvotes: integer("upvotes").default(0).notNull(), - downvotes: integer("downvotes").default(0).notNull(), - }, - (table) => { - return { - idKey: uniqueIndex("Post_id_key").on(table.id), - slugKey: uniqueIndex("Post_slug_key").on(table.slug), - slugIndex: index("Post_slug_index").on(table.slug), - userIdIndex: index("Post_userId_index").on(table.userId), - }; - }, -); - -export const postRelations = relations(post, ({ one, many }) => ({ - bookmarks: many(bookmark), - comments: many(comment), - Flagged: many(flagged), - likes: many(like), - votes: many(post_vote), - notifications: many(notification), - user: one(user, { fields: [post.userId], references: [user.id] }), - tags: many(post_tag), -})); - -// POST VOTING (Reddit-style upvote/downvote) -export const post_vote = pgTable( - "PostVote", - { - id: serial("id").primaryKey().notNull(), - postId: text("postId") - .notNull() - .references(() => post.id, { onDelete: "cascade", onUpdate: "cascade" }), - userId: text("userId") - .notNull() - .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), - voteType: voteType("voteType").notNull(), - createdAt: timestamp("createdAt", { - precision: 3, - mode: "string", - withTimezone: true, - }) - .default(sql`CURRENT_TIMESTAMP`) - .notNull(), - }, - (table) => ({ - uniqueVote: unique("PostVote_postId_userId_key").on(table.postId, table.userId), - postIdIndex: index("PostVote_postId_index").on(table.postId), - }), -); - -export const postVoteRelations = relations(post_vote, ({ one }) => ({ - post: one(post, { fields: [post_vote.postId], references: [post.id] }), - user: one(user, { fields: [post_vote.userId], references: [user.id] }), -})); - export const user = pgTable( "user", { @@ -260,7 +136,7 @@ export const user = pgTable( usernameKey: uniqueIndex("User_username_key").on(table.username), emailKey: uniqueIndex("User_email_key").on(table.email), usernameIdIdx: index("User_username_id_idx").on(table.id, table.username), - usernameIndex: index("User_username_index").on(table.username), // Add this line + usernameIndex: index("User_username_index").on(table.username), }; }, ); @@ -272,64 +148,65 @@ export const userRelations = relations(user, ({ one, many }) => ({ fields: [user.id], references: [banned_users.userId], }), - bookmarks: many(bookmark), - comments: many(comment), - flaggedByUser: many(flagged, { relationName: "flaggedByUser" }), - flaggedContent: many(flagged, { relationName: "flaggedContent" }), - likes: many(like), - postVotes: many(post_vote), - notificationsCreated: many(notification, { - relationName: "notificationsCreated", - }), - notificationsReceived: many(notification, { - relationName: "notificationsReceived", - }), - posts: many(post), sessions: many(session), emailChangeRequests: many(emailChangeRequest), emailChangeHistory: many(emailChangeHistory), - aggregatedArticleVotes: many(aggregated_article_vote), - aggregatedArticleBookmarks: many(aggregated_article_bookmark), + // New posts/comments relations + posts: many(posts), + comments: many(comments), + postVotes: many(post_votes), + commentVotes: many(comment_votes), + bookmarks: many(bookmarks), + reportsMade: many(reports, { relationName: "reportsMade" }), + reportsReviewed: many(reports, { relationName: "reportsReviewed" }), + // Legacy relations (kept for backward compatibility) + legacyPosts: many(post), + legacyComments: many(comment), + legacyBookmarks: many(bookmark), + legacyLikes: many(like), + legacyPostVotes: many(post_vote), + flaggedByUser: many(flagged, { relationName: "flaggedByUser" }), + flaggedContent: many(flagged, { relationName: "flaggedContent" }), + notificationsCreated: many(notification, { relationName: "notificationsCreated" }), + notificationsReceived: many(notification, { relationName: "notificationsReceived" }), })); -export const bookmark = pgTable( - "Bookmark", - { - id: serial("id").primaryKey().notNull().unique(), - postId: text("postId") - .notNull() - .references(() => post.id, { onDelete: "cascade", onUpdate: "cascade" }), - userId: text("userId") - .notNull() - .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), - }, - (table) => { - return { - userIdPostIdKey: uniqueIndex("Bookmark_userId_postId_key").on( - table.postId, - table.userId, - ), - }; - }, -); +// ============================================ +// FEED SOURCES (RSS) +// ============================================ -export const bookmarkRelations = relations(bookmark, ({ one, many }) => ({ - post: one(post, { fields: [bookmark.postId], references: [post.id] }), - user: one(user, { fields: [bookmark.userId], references: [user.id] }), -})); -export const comment = pgTable( - "Comment", +export const feed_sources = pgTable( + "feed_sources", { - id: serial("id").primaryKey().notNull().unique(), - body: text("body").notNull(), - createdAt: timestamp("createdAt", { + id: serial("id").primaryKey().notNull(), + name: text("name").notNull(), + url: text("url").notNull(), + websiteUrl: text("website_url"), + logoUrl: text("logo_url"), + slug: varchar("slug", { length: 100 }), + category: varchar("category", { length: 50 }), + description: text("description"), + status: feedSourceStatus("status").default("active").notNull(), + lastFetchedAt: timestamp("last_fetched_at", { + precision: 3, + mode: "string", + withTimezone: true, + }), + lastSuccessAt: timestamp("last_success_at", { + precision: 3, + mode: "string", + withTimezone: true, + }), + errorCount: integer("error_count").default(0).notNull(), + lastError: text("last_error"), + createdAt: timestamp("created_at", { precision: 3, mode: "string", withTimezone: true, }) .default(sql`CURRENT_TIMESTAMP`) .notNull(), - updatedAt: timestamp("updatedAt", { + updatedAt: timestamp("updated_at", { precision: 3, mode: "string", withTimezone: true, @@ -337,86 +214,491 @@ export const comment = pgTable( .notNull() .$onUpdate(() => new Date().toISOString()) .default(sql`CURRENT_TIMESTAMP`), - - postId: text("postId") - .notNull() - .references(() => post.id, { onDelete: "cascade", onUpdate: "cascade" }), - userId: text("userId") - .notNull() - .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), - parentId: integer("parentId"), - }, - (table) => { - return { - commentParentIdFkey: foreignKey({ - columns: [table.parentId], - foreignColumns: [table.id], - name: "Comment_parentId_fkey", - }) - .onUpdate("cascade") - .onDelete("cascade"), - postIdIndex: index("Comment_postId_index").on(table.postId), // Add this line - }; }, + (table) => ({ + urlKey: uniqueIndex("feed_sources_url_key").on(table.url), + slugKey: uniqueIndex("feed_sources_slug_key").on(table.slug), + }), ); -export const commentRelations = relations(comment, ({ one, many }) => ({ - parent: one(comment, { - fields: [comment.parentId], - references: [comment.id], - relationName: "comments", - }), - children: many(comment, { relationName: "comments" }), - post: one(post, { fields: [comment.postId], references: [post.id] }), - user: one(user, { fields: [comment.userId], references: [user.id] }), - Flagged: many(flagged), - likes: many(like), - Notification: many(notification), +export const feedSourcesRelations = relations(feed_sources, ({ many }) => ({ + posts: many(posts), })); -export const like = pgTable( - "Like", +// ============================================ +// POSTS TABLE +// ============================================ + +export const posts = pgTable( + "posts", { - id: serial("id").primaryKey().notNull().unique(), - createdAt: timestamp("createdAt", { + id: uuid("id").primaryKey().defaultRandom(), + type: postType("type").notNull(), + + authorId: text("author_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + + title: varchar("title", { length: 500 }).notNull(), + slug: varchar("slug", { length: 300 }).notNull(), + excerpt: text("excerpt"), + body: text("body"), + canonicalUrl: text("canonical_url"), + coverImage: text("cover_image"), + + // For link/resource types (external URLs) + externalUrl: varchar("external_url", { length: 2000 }), + + // RSS import metadata + sourceId: integer("source_id").references(() => feed_sources.id, { + onDelete: "set null", + }), + sourceAuthor: varchar("source_author", { length: 200 }), + + // Metadata + readingTime: integer("reading_time"), + + // Denormalized counters (updated via triggers) + upvotesCount: integer("upvotes_count").default(0).notNull(), + downvotesCount: integer("downvotes_count").default(0).notNull(), + commentsCount: integer("comments_count").default(0).notNull(), + viewsCount: integer("views_count").default(0).notNull(), + + // Publishing + status: postStatus("status").default("draft").notNull(), + publishedAt: timestamp("published_at", { precision: 3, mode: "string", withTimezone: true, - }) - .default(sql`CURRENT_TIMESTAMP`) - .notNull(), - userId: text("userId") - .notNull() - .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), - postId: text("postId").references(() => post.id, { - onDelete: "cascade", - onUpdate: "cascade", }), - commentId: integer("commentId").references(() => comment.id, { - onDelete: "cascade", - onUpdate: "cascade", + + // Feature flags + featured: boolean("featured").default(false).notNull(), + pinnedUntil: timestamp("pinned_until", { + precision: 3, + mode: "string", + withTimezone: true, + }), + showComments: boolean("show_comments").default(true).notNull(), + + createdAt: timestamp("created_at", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedAt: timestamp("updated_at", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .notNull() + .$onUpdate(() => new Date().toISOString()) + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => ({ + authorIdIdx: index("posts_author_id_idx").on(table.authorId), + slugKey: uniqueIndex("posts_slug_idx").on(table.slug), + statusIdx: index("posts_status_idx").on(table.status), + publishedAtIdx: index("posts_published_at_idx").on(table.publishedAt), + typeIdx: index("posts_type_idx").on(table.type), + sourceIdIdx: index("posts_source_id_idx").on(table.sourceId), + featuredIdx: index("posts_featured_idx").on(table.featured), + }), +); + +export const postsRelations = relations(posts, ({ one, many }) => ({ + author: one(user, { fields: [posts.authorId], references: [user.id] }), + source: one(feed_sources, { fields: [posts.sourceId], references: [feed_sources.id] }), + comments: many(comments), + votes: many(post_votes), + bookmarks: many(bookmarks), + tags: many(post_tags), + reports: many(reports), +})); + +// ============================================ +// COMMENTS TABLE +// ============================================ + +export const comments = pgTable( + "comments", + { + id: uuid("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + postId: uuid("post_id") + .notNull() + .references(() => posts.id, { onDelete: "cascade" }), + authorId: text("author_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + parentId: uuid("parent_id"), + + // Materialized path for efficient tree queries (stored as text, ltree in DB) + path: text("path").notNull(), + depth: integer("depth").default(0).notNull(), + + body: text("body").notNull(), + + // Denormalized counters + upvotesCount: integer("upvotes_count").default(0).notNull(), + downvotesCount: integer("downvotes_count").default(0).notNull(), + + createdAt: timestamp("created_at", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedAt: timestamp("updated_at", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .notNull() + .$onUpdate(() => new Date().toISOString()) + .default(sql`CURRENT_TIMESTAMP`), + deletedAt: timestamp("deleted_at", { + precision: 3, + mode: "string", + withTimezone: true, + }), // Soft delete for "[deleted]" placeholders + }, + (table) => ({ + postIdIdx: index("comments_post_id_idx").on(table.postId), + authorIdIdx: index("comments_author_id_idx").on(table.authorId), + parentIdIdx: index("comments_parent_id_idx").on(table.parentId), + createdAtIdx: index("comments_created_at_idx").on(table.createdAt), + commentParentIdFkey: foreignKey({ + columns: [table.parentId], + foreignColumns: [table.id], + name: "comments_parent_id_fkey", + }) + .onUpdate("cascade") + .onDelete("cascade"), + }), +); + +export const commentsRelations = relations(comments, ({ one, many }) => ({ + post: one(posts, { fields: [comments.postId], references: [posts.id] }), + author: one(user, { fields: [comments.authorId], references: [user.id] }), + parent: one(comments, { + fields: [comments.parentId], + references: [comments.id], + relationName: "commentReplies", + }), + children: many(comments, { relationName: "commentReplies" }), + votes: many(comment_votes), + reports: many(reports), +})); + +// ============================================ +// POST VOTES +// ============================================ + +export const post_votes = pgTable( + "post_votes", + { + id: serial("id").primaryKey().notNull(), + postId: uuid("post_id") + .notNull() + .references(() => posts.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + voteType: voteType("vote_type").notNull(), + createdAt: timestamp("created_at", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (table) => ({ + uniqueVote: unique("post_votes_post_id_user_id_key").on(table.postId, table.userId), + postIdIdx: index("post_votes_post_id_idx").on(table.postId), + }), +); + +export const postVotesRelations = relations(post_votes, ({ one }) => ({ + post: one(posts, { fields: [post_votes.postId], references: [posts.id] }), + user: one(user, { fields: [post_votes.userId], references: [user.id] }), +})); + +// ============================================ +// COMMENT VOTES +// ============================================ + +export const comment_votes = pgTable( + "comment_votes", + { + id: serial("id").primaryKey().notNull(), + commentId: uuid("comment_id") + .notNull() + .references(() => comments.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + voteType: voteType("vote_type").notNull(), + createdAt: timestamp("created_at", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (table) => ({ + uniqueVote: unique("comment_votes_comment_id_user_id_key").on(table.commentId, table.userId), + commentIdIdx: index("comment_votes_comment_id_idx").on(table.commentId), + }), +); + +export const commentVotesRelations = relations(comment_votes, ({ one }) => ({ + comment: one(comments, { fields: [comment_votes.commentId], references: [comments.id] }), + user: one(user, { fields: [comment_votes.userId], references: [user.id] }), +})); + +// ============================================ +// BOOKMARKS +// ============================================ + +export const bookmarks = pgTable( + "bookmarks", + { + id: serial("id").primaryKey().notNull(), + postId: uuid("post_id") + .notNull() + .references(() => posts.id, { onDelete: "cascade" }), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (table) => ({ + uniqueBookmark: unique("bookmarks_post_id_user_id_key").on(table.postId, table.userId), + userIdIdx: index("bookmarks_user_id_idx").on(table.userId), + postIdIdx: index("bookmarks_post_id_idx").on(table.postId), + }), +); + +export const bookmarksRelations = relations(bookmarks, ({ one }) => ({ + post: one(posts, { fields: [bookmarks.postId], references: [posts.id] }), + user: one(user, { fields: [bookmarks.userId], references: [user.id] }), +})); + +// ============================================ +// POST TAGS +// ============================================ + +export const post_tags = pgTable( + "post_tags", + { + id: serial("id").primaryKey().notNull(), + postId: uuid("post_id") + .notNull() + .references(() => posts.id, { onDelete: "cascade" }), + tagId: integer("tag_id") + .notNull() + .references(() => tag.id, { onDelete: "cascade" }), + }, + (table) => ({ + uniquePostTag: unique("post_tags_post_id_tag_id_key").on(table.postId, table.tagId), + postIdIdx: index("post_tags_post_id_idx").on(table.postId), + tagIdIdx: index("post_tags_tag_id_idx").on(table.tagId), + }), +); + +export const postTagsRelations = relations(post_tags, ({ one }) => ({ + post: one(posts, { fields: [post_tags.postId], references: [posts.id] }), + tag: one(tag, { fields: [post_tags.tagId], references: [tag.id] }), +})); + +// ============================================ +// REPORTS +// ============================================ + +export const reports = pgTable( + "reports", + { + id: serial("id").primaryKey().notNull(), + postId: uuid("post_id").references(() => posts.id, { onDelete: "cascade" }), + commentId: uuid("comment_id").references(() => comments.id, { onDelete: "cascade" }), + reporterId: text("reporter_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + reason: reportReason("reason").notNull(), + details: text("details"), + status: reportStatus("status").default("pending").notNull(), + reviewedById: text("reviewed_by_id").references(() => user.id, { onDelete: "set null" }), + reviewedAt: timestamp("reviewed_at", { + precision: 3, + mode: "string", + withTimezone: true, }), + actionTaken: text("action_taken"), + createdAt: timestamp("created_at", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (table) => ({ + statusIdx: index("reports_status_idx").on(table.status), + reporterIdIdx: index("reports_reporter_id_idx").on(table.reporterId), + postIdIdx: index("reports_post_id_idx").on(table.postId), + commentIdIdx: index("reports_comment_id_idx").on(table.commentId), + }), +); + +export const reportsRelations = relations(reports, ({ one }) => ({ + post: one(posts, { fields: [reports.postId], references: [posts.id] }), + comment: one(comments, { fields: [reports.commentId], references: [comments.id] }), + reporter: one(user, { + fields: [reports.reporterId], + references: [user.id], + relationName: "reportsMade", + }), + reviewedBy: one(user, { + fields: [reports.reviewedById], + references: [user.id], + relationName: "reportsReviewed", + }), +})); + +// ============================================ +// TAGS (shared between legacy and new system) +// ============================================ + +export const tag = pgTable( + "Tag", + { + createdAt: timestamp("createdAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + id: serial("id").primaryKey().notNull().unique(), + title: varchar("title", { length: 20 }).notNull(), }, (table) => { return { - userIdCommentIdKey: uniqueIndex("Like_userId_commentId_key").on( - table.userId, - table.commentId, - ), - userIdPostIdKey: uniqueIndex("Like_userId_postId_key").on( - table.userId, - table.postId, - ), + titleKey: uniqueIndex("Tag_title_key").on(table.title), }; }, ); -export const likeRelations = relations(like, ({ one, many }) => ({ - comment: one(comment, { fields: [like.commentId], references: [comment.id] }), - post: one(post, { fields: [like.postId], references: [post.id] }), - user: one(user, { fields: [like.userId], references: [user.id] }), +export const tagRelations = relations(tag, ({ many }) => ({ + postTags: many(post_tags), + // Legacy + legacyPostTag: many(post_tag), + legacyContentTag: many(content_tag), })); +// ============================================ +// SPONSOR INQUIRY +// ============================================ + +export const sponsorInquiryStatus = pgEnum("SponsorInquiryStatus", [ + "PENDING", + "CONTACTED", + "CONVERTED", + "CLOSED", +]); + +export const sponsorBudgetRange = pgEnum("SponsorBudgetRange", [ + "EXPLORING", + "UNDER_500", + "BETWEEN_500_2000", + "BETWEEN_2000_5000", + "OVER_5000", +]); + +export const sponsor_inquiry = pgTable( + "SponsorInquiry", + { + id: serial("id").primaryKey().notNull(), + name: varchar("name", { length: 100 }).notNull(), + email: varchar("email", { length: 255 }).notNull(), + company: varchar("company", { length: 100 }), + phone: varchar("phone", { length: 50 }), + interests: text("interests"), + budgetRange: sponsorBudgetRange("budgetRange").default("EXPLORING"), + goals: text("goals"), + status: sponsorInquiryStatus("status").default("PENDING").notNull(), + createdAt: timestamp("createdAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + }, + (table) => ({ + statusIndex: index("SponsorInquiry_status_index").on(table.status), + emailIndex: index("SponsorInquiry_email_index").on(table.email), + }), +); + +// ============================================ +// EMAIL CHANGE TABLES +// ============================================ + +export const emailChangeRequest = pgTable("EmailChangeRequest", { + id: serial("id").primaryKey(), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + newEmail: text("newEmail").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("createdAt").defaultNow().notNull(), + expiresAt: timestamp("expiresAt").notNull(), +}); + +export const emailChangeRequestRelations = relations(emailChangeRequest, ({ one }) => ({ + user: one(user, { + fields: [emailChangeRequest.userId], + references: [user.id], + }), +})); + +export const emailChangeHistory = pgTable("EmailChangeHistory", { + id: serial("id").primaryKey(), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + oldEmail: text("oldEmail").notNull(), + newEmail: text("newEmail").notNull(), + changedAt: timestamp("changedAt").defaultNow().notNull(), + ipAddress: text("ipAddress"), + userAgent: text("userAgent"), +}); + +export const emailChangeHistoryRelations = relations(emailChangeHistory, ({ one }) => ({ + user: one(user, { + fields: [emailChangeHistory.userId], + references: [user.id], + }), +})); + +// ============================================ +// BANNED USERS +// ============================================ + export const banned_users = pgTable( "BannedUsers", { @@ -463,57 +745,9 @@ export const banned_usersRelations = relations(banned_users, ({ one }) => ({ }), })); -export const flagged = pgTable("Flagged", { - id: serial("id").primaryKey().notNull().unique(), - createdAt: timestamp("createdAt", { - precision: 3, - mode: "string", - withTimezone: true, - }) - .default(sql`CURRENT_TIMESTAMP`) - .notNull(), - updatedAt: timestamp("updatedAt", { - precision: 3, - mode: "string", - withTimezone: true, - }) - .notNull() - .$onUpdate(() => new Date().toISOString()) - .default(sql`CURRENT_TIMESTAMP`), - userId: text("userId") - .notNull() - .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), - notifierId: text("notifierId") - .notNull() - .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), - note: text("note"), - postId: text("postId").references(() => post.id, { - onDelete: "cascade", - onUpdate: "cascade", - }), - commentId: integer("commentId").references(() => comment.id, { - onDelete: "cascade", - onUpdate: "cascade", - }), -}); - -export const flaggedRelations = relations(flagged, ({ one }) => ({ - comment: one(comment, { - fields: [flagged.commentId], - references: [comment.id], - }), - notifier: one(user, { - fields: [flagged.notifierId], - references: [user.id], - relationName: "flaggedByUser", - }), - post: one(post, { fields: [flagged.postId], references: [post.id] }), - user: one(user, { - fields: [flagged.userId], - references: [user.id], - relationName: "flaggedContent", - }), -})); +// ============================================ +// NOTIFICATION +// ============================================ export const notification = pgTable( "Notification", @@ -576,235 +810,107 @@ export const notificationRelations = relations(notification, ({ one }) => ({ }), })); -export const emailChangeRequest = pgTable("EmailChangeRequest", { - id: serial("id").primaryKey(), - userId: text("userId") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - newEmail: text("newEmail").notNull(), - token: text("token").notNull().unique(), - createdAt: timestamp("createdAt").defaultNow().notNull(), - expiresAt: timestamp("expiresAt").notNull(), -}); - -export const emailChangeRequestRelations = relations( - emailChangeRequest, - ({ one }) => ({ - user: one(user, { - fields: [emailChangeRequest.userId], - references: [user.id], - }), - }), -); - -export const emailChangeHistory = pgTable("EmailChangeHistory", { - id: serial("id").primaryKey(), - userId: text("userId") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - oldEmail: text("oldEmail").notNull(), - newEmail: text("newEmail").notNull(), - changedAt: timestamp("changedAt").defaultNow().notNull(), - ipAddress: text("ipAddress"), - userAgent: text("userAgent"), -}); - -export const emailChangeHistoryRelations = relations( - emailChangeHistory, - ({ one }) => ({ - user: one(user, { - fields: [emailChangeHistory.userId], - references: [user.id], - }), - }), -); - -// ============================================ -// Content Aggregator Tables -// ============================================ - -export const feedSourceStatus = pgEnum("FeedSourceStatus", [ - "ACTIVE", - "PAUSED", - "ERROR", -]); +// ============================================================================ +// LEGACY TABLES (kept for backward compatibility during migration) +// These tables are preserved from the old schema. +// After migration verification, they can be removed. +// ============================================================================ -export const feed_source = pgTable( - "FeedSource", +export const post_tag = pgTable( + "PostTag", { - id: serial("id").primaryKey().notNull().unique(), - name: text("name").notNull(), - url: text("url").notNull(), - websiteUrl: text("websiteUrl"), - logoUrl: text("logoUrl"), - category: varchar("category", { length: 50 }), - slug: varchar("slug", { length: 100 }), - description: text("description"), - status: feedSourceStatus("status").default("ACTIVE").notNull(), - lastFetchedAt: timestamp("lastFetchedAt", { - precision: 3, - mode: "string", - withTimezone: true, - }), - lastSuccessAt: timestamp("lastSuccessAt", { - precision: 3, - mode: "string", - withTimezone: true, - }), - errorCount: integer("errorCount").default(0).notNull(), - lastError: text("lastError"), - createdAt: timestamp("createdAt", { - precision: 3, - mode: "string", - withTimezone: true, - }) - .default(sql`CURRENT_TIMESTAMP`) - .notNull(), - updatedAt: timestamp("updatedAt", { - precision: 3, - mode: "string", - withTimezone: true, - }) + id: serial("id").primaryKey().notNull(), + tagId: integer("tagId") .notNull() - .$onUpdate(() => new Date().toISOString()) - .default(sql`CURRENT_TIMESTAMP`), + .references(() => tag.id, { onDelete: "cascade", onUpdate: "cascade" }), + postId: text("postId") + .notNull() + .references(() => post.id, { onDelete: "cascade", onUpdate: "cascade" }), }, (table) => { return { - urlKey: uniqueIndex("FeedSource_url_key").on(table.url), - slugKey: uniqueIndex("FeedSource_slug_key").on(table.slug), - statusIndex: index("FeedSource_status_index").on(table.status), + tagIdPostIdKey: uniqueIndex("PostTag_tagId_postId_key").on(table.tagId, table.postId), }; }, ); -export const feedSourceRelations = relations(feed_source, ({ many }) => ({ - articles: many(aggregated_article), +export const post_tagRelations = relations(post_tag, ({ one }) => ({ + post: one(post, { fields: [post_tag.postId], references: [post.id] }), + tag: one(tag, { fields: [post_tag.tagId], references: [tag.id] }), })); -export const aggregated_article = pgTable( - "AggregatedArticle", +export const post = pgTable( + "Post", { - id: serial("id").primaryKey().notNull().unique(), - sourceId: integer("sourceId") - .notNull() - .references(() => feed_source.id, { - onDelete: "cascade", - onUpdate: "cascade", - }), + id: text("id").notNull().unique(), title: text("title").notNull(), - excerpt: text("excerpt"), - url: text("url").notNull(), - imageUrl: text("imageUrl"), - ogImageUrl: text("ogImageUrl"), - shortId: varchar("shortId", { length: 7 }), - author: text("author"), - publishedAt: timestamp("publishedAt", { - precision: 3, - mode: "string", - withTimezone: true, - }), - fetchedAt: timestamp("fetchedAt", { - precision: 3, - mode: "string", - withTimezone: true, - }) - .default(sql`CURRENT_TIMESTAMP`) - .notNull(), - upvotes: integer("upvotes").default(0).notNull(), - downvotes: integer("downvotes").default(0).notNull(), - clickCount: integer("clickCount").default(0).notNull(), - createdAt: timestamp("createdAt", { + canonicalUrl: text("canonicalUrl"), + coverImage: text("coverImage"), + approved: boolean("approved").default(true).notNull(), + body: text("body").notNull(), + excerpt: varchar("excerpt", { length: 156 }).default("").notNull(), + readTimeMins: integer("readTimeMins").notNull(), + published: timestamp("published", { precision: 3, mode: "string", withTimezone: true, - }) - .default(sql`CURRENT_TIMESTAMP`) - .notNull(), - }, - (table) => { - return { - urlKey: uniqueIndex("AggregatedArticle_url_key").on(table.url), - shortIdKey: uniqueIndex("AggregatedArticle_shortId_key").on(table.shortId), - sourceIdIndex: index("AggregatedArticle_sourceId_index").on( - table.sourceId, - ), - sourceIdShortIdIndex: index("AggregatedArticle_sourceId_shortId_index").on( - table.sourceId, - table.shortId, - ), - publishedAtIndex: index("AggregatedArticle_publishedAt_index").on( - table.publishedAt, - ), - upvotesIndex: index("AggregatedArticle_upvotes_index").on(table.upvotes), - }; - }, -); - -export const aggregatedArticleRelations = relations( - aggregated_article, - ({ one, many }) => ({ - source: one(feed_source, { - fields: [aggregated_article.sourceId], - references: [feed_source.id], - }), - tags: many(aggregated_article_tag), - votes: many(aggregated_article_vote), - bookmarks: many(aggregated_article_bookmark), - }), -); - -export const aggregated_article_tag = pgTable( - "AggregatedArticleTag", - { - id: serial("id").primaryKey().notNull(), - articleId: integer("articleId") + }), + createdAt: timestamp("createdAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedAt: timestamp("updatedAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) .notNull() - .references(() => aggregated_article.id, { - onDelete: "cascade", - onUpdate: "cascade", - }), - tagId: integer("tagId") + .$onUpdate(() => new Date().toISOString()) + .default(sql`CURRENT_TIMESTAMP`), + slug: text("slug").notNull(), + userId: text("userId") .notNull() - .references(() => tag.id, { onDelete: "cascade", onUpdate: "cascade" }), + .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), + showComments: boolean("showComments").default(true).notNull(), + likes: integer("likes").default(0).notNull(), + upvotes: integer("upvotes").default(0).notNull(), + downvotes: integer("downvotes").default(0).notNull(), }, (table) => { return { - articleIdTagIdKey: uniqueIndex( - "AggregatedArticleTag_articleId_tagId_key", - ).on(table.articleId, table.tagId), + idKey: uniqueIndex("Post_id_key").on(table.id), + slugKey: uniqueIndex("Post_slug_key").on(table.slug), + slugIndex: index("Post_slug_index").on(table.slug), + userIdIndex: index("Post_userId_index").on(table.userId), }; }, ); -export const aggregatedArticleTagRelations = relations( - aggregated_article_tag, - ({ one }) => ({ - article: one(aggregated_article, { - fields: [aggregated_article_tag.articleId], - references: [aggregated_article.id], - }), - tag: one(tag, { - fields: [aggregated_article_tag.tagId], - references: [tag.id], - }), - }), -); +export const postRelations = relations(post, ({ one, many }) => ({ + bookmarks: many(bookmark), + comments: many(comment), + Flagged: many(flagged), + likes: many(like), + votes: many(post_vote), + notifications: many(notification), + user: one(user, { fields: [post.userId], references: [user.id] }), + tags: many(post_tag), +})); -export const aggregated_article_vote = pgTable( - "AggregatedArticleVote", +export const post_vote = pgTable( + "PostVote", { - id: serial("id").primaryKey().notNull().unique(), - articleId: integer("articleId") + id: serial("id").primaryKey().notNull(), + postId: text("postId") .notNull() - .references(() => aggregated_article.id, { - onDelete: "cascade", - onUpdate: "cascade", - }), + .references(() => post.id, { onDelete: "cascade", onUpdate: "cascade" }), userId: text("userId") .notNull() .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), - voteType: voteType("voteType").notNull(), + voteType: legacyVoteType("voteType").notNull(), createdAt: timestamp("createdAt", { precision: 3, mode: "string", @@ -813,88 +919,42 @@ export const aggregated_article_vote = pgTable( .default(sql`CURRENT_TIMESTAMP`) .notNull(), }, - (table) => { - return { - userIdArticleIdKey: uniqueIndex( - "AggregatedArticleVote_userId_articleId_key", - ).on(table.userId, table.articleId), - articleIdIndex: index("AggregatedArticleVote_articleId_index").on( - table.articleId, - ), - }; - }, -); - -export const aggregatedArticleVoteRelations = relations( - aggregated_article_vote, - ({ one }) => ({ - article: one(aggregated_article, { - fields: [aggregated_article_vote.articleId], - references: [aggregated_article.id], - }), - user: one(user, { - fields: [aggregated_article_vote.userId], - references: [user.id], - }), + (table) => ({ + uniqueVote: unique("PostVote_postId_userId_key").on(table.postId, table.userId), + postIdIndex: index("PostVote_postId_index").on(table.postId), }), ); -export const aggregated_article_bookmark = pgTable( - "AggregatedArticleBookmark", +export const postVoteRelations = relations(post_vote, ({ one }) => ({ + post: one(post, { fields: [post_vote.postId], references: [post.id] }), + user: one(user, { fields: [post_vote.userId], references: [user.id] }), +})); + +export const bookmark = pgTable( + "Bookmark", { id: serial("id").primaryKey().notNull().unique(), - articleId: integer("articleId") + postId: text("postId") .notNull() - .references(() => aggregated_article.id, { - onDelete: "cascade", - onUpdate: "cascade", - }), + .references(() => post.id, { onDelete: "cascade", onUpdate: "cascade" }), userId: text("userId") .notNull() .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), - createdAt: timestamp("createdAt", { - precision: 3, - mode: "string", - withTimezone: true, - }) - .default(sql`CURRENT_TIMESTAMP`) - .notNull(), }, (table) => { return { - userIdArticleIdKey: uniqueIndex( - "AggregatedArticleBookmark_userId_articleId_key", - ).on(table.userId, table.articleId), + userIdPostIdKey: uniqueIndex("Bookmark_userId_postId_key").on(table.postId, table.userId), }; }, ); -export const aggregatedArticleBookmarkRelations = relations( - aggregated_article_bookmark, - ({ one }) => ({ - article: one(aggregated_article, { - fields: [aggregated_article_bookmark.articleId], - references: [aggregated_article.id], - }), - user: one(user, { - fields: [aggregated_article_bookmark.userId], - references: [user.id], - }), - }), -); - -// ============================================================================ -// DISCUSSION SYSTEM - Generic discussions for posts and articles -// ============================================================================ - -export const discussionTargetType = pgEnum("DiscussionTargetType", [ - "POST", - "ARTICLE", - "CONTENT", -]); +export const bookmarkRelations = relations(bookmark, ({ one }) => ({ + post: one(post, { fields: [bookmark.postId], references: [post.id] }), + user: one(user, { fields: [bookmark.userId], references: [user.id] }), +})); -export const discussion = pgTable( - "Discussion", +export const comment = pgTable( + "Comment", { id: serial("id").primaryKey().notNull().unique(), body: text("body").notNull(), @@ -913,73 +973,158 @@ export const discussion = pgTable( .notNull() .$onUpdate(() => new Date().toISOString()) .default(sql`CURRENT_TIMESTAMP`), - // Polymorphic target - either a post OR an article OR content - targetType: discussionTargetType("targetType").notNull(), + postId: text("postId") + .notNull() + .references(() => post.id, { onDelete: "cascade", onUpdate: "cascade" }), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), + parentId: integer("parentId"), + }, + (table) => { + return { + commentParentIdFkey: foreignKey({ + columns: [table.parentId], + foreignColumns: [table.id], + name: "Comment_parentId_fkey", + }) + .onUpdate("cascade") + .onDelete("cascade"), + postIdIndex: index("Comment_postId_index").on(table.postId), + }; + }, +); + +export const commentRelations = relations(comment, ({ one, many }) => ({ + parent: one(comment, { + fields: [comment.parentId], + references: [comment.id], + relationName: "comments", + }), + children: many(comment, { relationName: "comments" }), + post: one(post, { fields: [comment.postId], references: [post.id] }), + user: one(user, { fields: [comment.userId], references: [user.id] }), + Flagged: many(flagged), + likes: many(like), + Notification: many(notification), +})); + +export const like = pgTable( + "Like", + { + id: serial("id").primaryKey().notNull().unique(), + createdAt: timestamp("createdAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), postId: text("postId").references(() => post.id, { onDelete: "cascade", onUpdate: "cascade", }), - articleId: integer("articleId").references(() => aggregated_article.id, { + commentId: integer("commentId").references(() => comment.id, { onDelete: "cascade", onUpdate: "cascade", }), - contentId: text("contentId"), // Will be FK to content table - added after content table exists - // User who wrote the discussion - userId: text("userId") - .notNull() - .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), - // Self-referential for nested replies - parentId: integer("parentId"), - // Voting (denormalized for performance) - upvotes: integer("upvotes").default(0).notNull(), - downvotes: integer("downvotes").default(0).notNull(), }, - (table) => ({ - discussionParentIdFkey: foreignKey({ - columns: [table.parentId], - foreignColumns: [table.id], - name: "Discussion_parentId_fkey", - }) - .onUpdate("cascade") - .onDelete("cascade"), - postIdIndex: index("Discussion_postId_index").on(table.postId), - articleIdIndex: index("Discussion_articleId_index").on(table.articleId), - contentIdIndex: index("Discussion_contentId_index").on(table.contentId), - userIdIndex: index("Discussion_userId_index").on(table.userId), - }), + (table) => { + return { + userIdCommentIdKey: uniqueIndex("Like_userId_commentId_key").on(table.userId, table.commentId), + userIdPostIdKey: uniqueIndex("Like_userId_postId_key").on(table.userId, table.postId), + }; + }, ); -export const discussionRelations = relations(discussion, ({ one, many }) => ({ - parent: one(discussion, { - fields: [discussion.parentId], - references: [discussion.id], - relationName: "discussions", +export const likeRelations = relations(like, ({ one }) => ({ + comment: one(comment, { fields: [like.commentId], references: [comment.id] }), + post: one(post, { fields: [like.postId], references: [post.id] }), + user: one(user, { fields: [like.userId], references: [user.id] }), +})); + +export const flagged = pgTable("Flagged", { + id: serial("id").primaryKey().notNull().unique(), + createdAt: timestamp("createdAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedAt: timestamp("updatedAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .notNull() + .$onUpdate(() => new Date().toISOString()) + .default(sql`CURRENT_TIMESTAMP`), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), + notifierId: text("notifierId") + .notNull() + .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), + note: text("note"), + postId: text("postId").references(() => post.id, { + onDelete: "cascade", + onUpdate: "cascade", }), - children: many(discussion, { relationName: "discussions" }), - post: one(post, { fields: [discussion.postId], references: [post.id] }), - article: one(aggregated_article, { - fields: [discussion.articleId], - references: [aggregated_article.id], + commentId: integer("commentId").references(() => comment.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), +}); + +export const flaggedRelations = relations(flagged, ({ one }) => ({ + comment: one(comment, { + fields: [flagged.commentId], + references: [comment.id], + }), + notifier: one(user, { + fields: [flagged.notifierId], + references: [user.id], + relationName: "flaggedByUser", + }), + post: one(post, { fields: [flagged.postId], references: [post.id] }), + user: one(user, { + fields: [flagged.userId], + references: [user.id], + relationName: "flaggedContent", }), - user: one(user, { fields: [discussion.userId], references: [user.id] }), - likes: many(discussion_like), - votes: many(discussion_vote), - reports: many(content_report), })); -export const discussion_like = pgTable( - "DiscussionLike", +// Legacy FeedSource (for backward compatibility) +export const legacyFeedSourceStatus = pgEnum("FeedSourceStatus", ["ACTIVE", "PAUSED", "ERROR"]); + +export const feed_source = pgTable( + "FeedSource", { id: serial("id").primaryKey().notNull().unique(), - discussionId: integer("discussionId") - .notNull() - .references(() => discussion.id, { - onDelete: "cascade", - onUpdate: "cascade", - }), - userId: text("userId") - .notNull() - .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), + name: text("name").notNull(), + url: text("url").notNull(), + websiteUrl: text("websiteUrl"), + logoUrl: text("logoUrl"), + category: varchar("category", { length: 50 }), + slug: varchar("slug", { length: 100 }), + description: text("description"), + status: legacyFeedSourceStatus("status").default("ACTIVE").notNull(), + lastFetchedAt: timestamp("lastFetchedAt", { + precision: 3, + mode: "string", + withTimezone: true, + }), + lastSuccessAt: timestamp("lastSuccessAt", { + precision: 3, + mode: "string", + withTimezone: true, + }), + errorCount: integer("errorCount").default(0).notNull(), + lastError: text("lastError"), createdAt: timestamp("createdAt", { precision: 3, mode: "string", @@ -987,39 +1132,30 @@ export const discussion_like = pgTable( }) .default(sql`CURRENT_TIMESTAMP`) .notNull(), + updatedAt: timestamp("updatedAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .notNull() + .$onUpdate(() => new Date().toISOString()) + .default(sql`CURRENT_TIMESTAMP`), + }, + (table) => { + return { + urlKey: uniqueIndex("FeedSource_url_key").on(table.url), + slugKey: uniqueIndex("FeedSource_slug_key").on(table.slug), + statusIndex: index("FeedSource_status_index").on(table.status), + }; }, - (table) => ({ - userIdDiscussionIdKey: uniqueIndex( - "DiscussionLike_userId_discussionId_key", - ).on(table.userId, table.discussionId), - discussionIdIndex: index("DiscussionLike_discussionId_index").on( - table.discussionId, - ), - }), ); -export const discussionLikeRelations = relations(discussion_like, ({ one }) => ({ - discussion: one(discussion, { - fields: [discussion_like.discussionId], - references: [discussion.id], - }), - user: one(user, { - fields: [discussion_like.userId], - references: [user.id], - }), +export const feedSourceRelations = relations(feed_source, ({ many }) => ({ + content: many(content), })); -// ============================================================================ -// UNIFIED CONTENT SYSTEM -// ============================================================================ - -export const contentType = pgEnum("ContentType", [ - "ARTICLE", - "LINK", - "QUESTION", - "VIDEO", - "DISCUSSION", -]); +// Legacy Content table +export const legacyContentType = pgEnum("ContentType", ["POST", "LINK", "QUESTION", "VIDEO", "DISCUSSION"]); export const content = pgTable( "Content", @@ -1027,56 +1163,36 @@ export const content = pgTable( id: text("id") .primaryKey() .$defaultFn(() => crypto.randomUUID()), - type: contentType("type").notNull(), - - // Common fields + type: legacyContentType("type").notNull(), title: varchar("title", { length: 500 }).notNull(), - body: text("body"), // For articles/questions (Tiptap JSON or markdown) + body: text("body"), excerpt: text("excerpt"), - - // Author info userId: text("userId").references(() => user.id, { onDelete: "cascade", onUpdate: "cascade", - }), // null for RSS imports - - // External content (LINK, VIDEO, RSS articles) + }), externalUrl: varchar("externalUrl", { length: 2000 }), imageUrl: text("imageUrl"), ogImageUrl: text("ogImageUrl"), - - // Source info (for RSS content) sourceId: integer("sourceId").references(() => feed_source.id, { onDelete: "set null", onUpdate: "cascade", }), sourceAuthor: varchar("sourceAuthor", { length: 200 }), - - // Publishing published: boolean("published").default(false).notNull(), publishedAt: timestamp("publishedAt", { precision: 3, mode: "string", withTimezone: true, }), - - // Voting (denormalized for performance) upvotes: integer("upvotes").default(0).notNull(), downvotes: integer("downvotes").default(0).notNull(), - - // Metadata readTimeMins: integer("readTimeMins"), clickCount: integer("clickCount").default(0).notNull(), - - // SEO & URL slug: varchar("slug", { length: 300 }), canonicalUrl: text("canonicalUrl"), coverImage: text("coverImage"), - - // Settings showComments: boolean("showComments").default(true).notNull(), - - // Timestamps createdAt: timestamp("createdAt", { precision: 3, mode: "string", @@ -1113,6 +1229,7 @@ export const contentRelations = relations(content, ({ one, many }) => ({ bookmarks: many(content_bookmark), tags: many(content_tag), reports: many(content_report), + discussions: many(discussion), })); export const content_vote = pgTable( @@ -1125,7 +1242,7 @@ export const content_vote = pgTable( userId: text("userId") .notNull() .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), - voteType: voteType("voteType").notNull(), + voteType: legacyVoteType("voteType").notNull(), createdAt: timestamp("createdAt", { precision: 3, mode: "string", @@ -1135,10 +1252,7 @@ export const content_vote = pgTable( .notNull(), }, (table) => ({ - uniqueVote: unique("ContentVote_contentId_userId_key").on( - table.contentId, - table.userId, - ), + uniqueVote: unique("ContentVote_contentId_userId_key").on(table.contentId, table.userId), contentIdIndex: index("ContentVote_contentId_index").on(table.contentId), }), ); @@ -1170,10 +1284,7 @@ export const content_bookmark = pgTable( .notNull(), }, (table) => ({ - uniqueBookmark: unique("ContentBookmark_contentId_userId_key").on( - table.contentId, - table.userId, - ), + uniqueBookmark: unique("ContentBookmark_contentId_userId_key").on(table.contentId, table.userId), }), ); @@ -1197,10 +1308,7 @@ export const content_tag = pgTable( .references(() => tag.id, { onDelete: "cascade", onUpdate: "cascade" }), }, (table) => ({ - uniqueContentTag: unique("ContentTag_contentId_tagId_key").on( - table.contentId, - table.tagId, - ), + uniqueContentTag: unique("ContentTag_contentId_tagId_key").on(table.contentId, table.tagId), }), ); @@ -1212,9 +1320,68 @@ export const contentTagRelations = relations(content_tag, ({ one }) => ({ tag: one(tag, { fields: [content_tag.tagId], references: [tag.id] }), })); -// ============================================================================ -// DISCUSSION VOTING (Reddit-style upvote/downvote) -// ============================================================================ +// Legacy Discussion table +export const discussion = pgTable( + "Discussion", + { + id: serial("id").primaryKey().notNull().unique(), + body: text("body").notNull(), + createdAt: timestamp("createdAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + updatedAt: timestamp("updatedAt", { + precision: 3, + mode: "string", + withTimezone: true, + }) + .notNull() + .$onUpdate(() => new Date().toISOString()) + .default(sql`CURRENT_TIMESTAMP`), + contentId: text("contentId") + .notNull() + .references(() => content.id, { + onDelete: "cascade", + onUpdate: "cascade", + }), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), + parentId: integer("parentId"), + upvotes: integer("upvotes").default(0).notNull(), + downvotes: integer("downvotes").default(0).notNull(), + }, + (table) => ({ + discussionParentIdFkey: foreignKey({ + columns: [table.parentId], + foreignColumns: [table.id], + name: "Discussion_parentId_fkey", + }) + .onUpdate("cascade") + .onDelete("cascade"), + contentIdIndex: index("Discussion_contentId_index").on(table.contentId), + userIdIndex: index("Discussion_userId_index").on(table.userId), + }), +); + +export const discussionRelations = relations(discussion, ({ one, many }) => ({ + parent: one(discussion, { + fields: [discussion.parentId], + references: [discussion.id], + relationName: "discussions", + }), + children: many(discussion, { relationName: "discussions" }), + content: one(content, { + fields: [discussion.contentId], + references: [content.id], + }), + user: one(user, { fields: [discussion.userId], references: [user.id] }), + votes: many(discussion_vote), + reports: many(content_report), +})); export const discussion_vote = pgTable( "DiscussionVote", @@ -1229,7 +1396,7 @@ export const discussion_vote = pgTable( userId: text("userId") .notNull() .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), - voteType: voteType("voteType").notNull(), + voteType: legacyVoteType("voteType").notNull(), createdAt: timestamp("createdAt", { precision: 3, mode: "string", @@ -1239,13 +1406,8 @@ export const discussion_vote = pgTable( .notNull(), }, (table) => ({ - uniqueVote: unique("DiscussionVote_discussionId_userId_key").on( - table.discussionId, - table.userId, - ), - discussionIdIndex: index("DiscussionVote_discussionId_index").on( - table.discussionId, - ), + uniqueVote: unique("DiscussionVote_discussionId_userId_key").on(table.discussionId, table.userId), + discussionIdIndex: index("DiscussionVote_discussionId_index").on(table.discussionId), }), ); @@ -1257,11 +1419,8 @@ export const discussionVoteRelations = relations(discussion_vote, ({ one }) => ( user: one(user, { fields: [discussion_vote.userId], references: [user.id] }), })); -// ============================================================================ -// CONTENT REPORTING SYSTEM -// ============================================================================ - -export const reportReason = pgEnum("ReportReason", [ +// Legacy Content Report +export const legacyReportReason = pgEnum("ReportReason", [ "SPAM", "HARASSMENT", "HATE_SPEECH", @@ -1272,7 +1431,7 @@ export const reportReason = pgEnum("ReportReason", [ "OTHER", ]); -export const reportStatus = pgEnum("ReportStatus", [ +export const legacyReportStatus = pgEnum("ReportStatus", [ "PENDING", "REVIEWED", "DISMISSED", @@ -1283,7 +1442,6 @@ export const content_report = pgTable( "ContentReport", { id: serial("id").primaryKey().notNull(), - // Can report content OR discussion (one must be set) contentId: text("contentId").references(() => content.id, { onDelete: "cascade", onUpdate: "cascade", @@ -1292,17 +1450,12 @@ export const content_report = pgTable( onDelete: "cascade", onUpdate: "cascade", }), - // Legacy support for old Post/Article tables during migration - legacyPostId: text("legacyPostId"), - legacyArticleId: integer("legacyArticleId"), - // Reporter info reporterId: text("reporterId") .notNull() .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }), - reason: reportReason("reason").notNull(), - details: text("details"), // Optional additional info - // Review info - status: reportStatus("status").default("PENDING").notNull(), + reason: legacyReportReason("reason").notNull(), + details: text("details"), + status: legacyReportStatus("status").default("PENDING").notNull(), reviewedById: text("reviewedById").references(() => user.id, { onDelete: "set null", onUpdate: "cascade", @@ -1313,7 +1466,6 @@ export const content_report = pgTable( withTimezone: true, }), actionTaken: text("actionTaken"), - // Timestamps createdAt: timestamp("createdAt", { precision: 3, mode: "string", @@ -1326,9 +1478,7 @@ export const content_report = pgTable( statusIndex: index("ContentReport_status_index").on(table.status), reporterIdIndex: index("ContentReport_reporterId_index").on(table.reporterId), contentIdIndex: index("ContentReport_contentId_index").on(table.contentId), - discussionIdIndex: index("ContentReport_discussionId_index").on( - table.discussionId, - ), + discussionIdIndex: index("ContentReport_discussionId_index").on(table.discussionId), }), ); @@ -1352,3 +1502,167 @@ export const contentReportRelations = relations(content_report, ({ one }) => ({ relationName: "reportsReviewed", }), })); + +// ============================================ +// Legacy Tables for Backward Compatibility +// (RSS Aggregated Articles - to be migrated to posts table) +// ============================================ + +// Alias exports for new tables (camelCase naming convention) +export { + post_votes as postVotes, + comment_votes as commentVotes, + post_tags as postTags, + feed_sources as feedSources, +}; + +export const aggregated_article = pgTable( + "AggregatedArticle", + { + id: text("id") + .primaryKey() + .$defaultFn(() => crypto.randomUUID()), + sourceId: integer("sourceId") + .notNull() + .references(() => feed_source.id), + shortId: varchar("shortId", { length: 20 }), + title: text("title").notNull(), + slug: varchar("slug", { length: 350 }).notNull(), + excerpt: text("excerpt"), + externalUrl: varchar("externalUrl", { length: 2000 }).notNull(), + imageUrl: text("imageUrl"), + ogImageUrl: text("ogImageUrl"), + sourceAuthor: varchar("sourceAuthor", { length: 200 }), + publishedAt: timestamp("publishedAt", { withTimezone: true, mode: "string" }), + fetchedAt: timestamp("fetchedAt", { withTimezone: true, mode: "string" }), + upvotes: integer("upvotes").default(0).notNull(), + downvotes: integer("downvotes").default(0).notNull(), + clickCount: integer("clickCount").default(0).notNull(), + createdAt: timestamp("createdAt", { withTimezone: true, mode: "string" }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updatedAt", { withTimezone: true, mode: "string" }) + .defaultNow() + .notNull(), + }, + (table) => [ + index("aggregated_article_source_idx").on(table.sourceId), + index("aggregated_article_slug_idx").on(table.slug), + index("aggregated_article_published_idx").on(table.publishedAt), + uniqueIndex("aggregated_article_url_idx").on(table.externalUrl), + ] +); + +export const aggregatedArticleRelations = relations( + aggregated_article, + ({ one, many }) => ({ + source: one(feed_source, { + fields: [aggregated_article.sourceId], + references: [feed_source.id], + }), + votes: many(aggregated_article_vote), + bookmarks: many(aggregated_article_bookmark), + tags: many(aggregated_article_tag), + }) +); + +export const aggregated_article_vote = pgTable( + "AggregatedArticleVote", + { + id: serial("id").primaryKey(), + articleId: text("articleId") + .notNull() + .references(() => aggregated_article.id, { onDelete: "cascade" }), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + voteType: legacyVoteType("voteType").notNull(), + createdAt: timestamp("createdAt", { withTimezone: true, mode: "string" }) + .defaultNow() + .notNull(), + }, + (table) => [ + uniqueIndex("article_vote_unique").on(table.articleId, table.userId), + index("article_vote_article_idx").on(table.articleId), + index("article_vote_user_idx").on(table.userId), + ] +); + +export const aggregatedArticleVoteRelations = relations( + aggregated_article_vote, + ({ one }) => ({ + article: one(aggregated_article, { + fields: [aggregated_article_vote.articleId], + references: [aggregated_article.id], + }), + user: one(user, { + fields: [aggregated_article_vote.userId], + references: [user.id], + }), + }) +); + +export const aggregated_article_bookmark = pgTable( + "AggregatedArticleBookmark", + { + id: serial("id").primaryKey(), + articleId: text("articleId") + .notNull() + .references(() => aggregated_article.id, { onDelete: "cascade" }), + userId: text("userId") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + createdAt: timestamp("createdAt", { withTimezone: true, mode: "string" }) + .defaultNow() + .notNull(), + }, + (table) => [ + uniqueIndex("article_bookmark_unique").on(table.articleId, table.userId), + index("article_bookmark_user_idx").on(table.userId), + ] +); + +export const aggregatedArticleBookmarkRelations = relations( + aggregated_article_bookmark, + ({ one }) => ({ + article: one(aggregated_article, { + fields: [aggregated_article_bookmark.articleId], + references: [aggregated_article.id], + }), + user: one(user, { + fields: [aggregated_article_bookmark.userId], + references: [user.id], + }), + }) +); + +export const aggregated_article_tag = pgTable( + "AggregatedArticleTag", + { + id: serial("id").primaryKey(), + articleId: text("articleId") + .notNull() + .references(() => aggregated_article.id, { onDelete: "cascade" }), + tagId: integer("tagId") + .notNull() + .references(() => tag.id, { onDelete: "cascade" }), + }, + (table) => [ + uniqueIndex("article_tag_unique").on(table.articleId, table.tagId), + index("article_tag_article_idx").on(table.articleId), + ] +); + +export const aggregatedArticleTagRelations = relations( + aggregated_article_tag, + ({ one }) => ({ + article: one(aggregated_article, { + fields: [aggregated_article_tag.articleId], + references: [aggregated_article.id], + }), + tag: one(tag, { + fields: [aggregated_article_tag.tagId], + references: [tag.id], + }), + }) +); From 6675b9a89d5c6e1900dbdeac10718717d33898ae Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Tue, 6 Jan 2026 07:35:58 +0000 Subject: [PATCH 11/38] feat(api): update routers for unified content and voting system Add sponsor inquiry router, update content/feed routers for unified content table, and add Reddit-style voting endpoints. --- app/api/admin/sync-feeds/route.ts | 14 +- server/api/router/comment.ts | 881 +++++++++++---- server/api/router/content.ts | 899 ++++++++++----- server/api/router/discussion.ts | 554 ++++----- server/api/router/feed.ts | 849 ++++++++------ server/api/router/index.ts | 15 +- server/api/router/post.ts | 1739 +++++++++++++++++++++-------- server/api/router/report.ts | 76 +- server/api/router/sponsor.ts | 83 ++ server/lib/posts.ts | 150 ++- 10 files changed, 3643 insertions(+), 1617 deletions(-) create mode 100644 server/api/router/sponsor.ts diff --git a/app/api/admin/sync-feeds/route.ts b/app/api/admin/sync-feeds/route.ts index 3eadeae0..10efe08d 100644 --- a/app/api/admin/sync-feeds/route.ts +++ b/app/api/admin/sync-feeds/route.ts @@ -121,7 +121,7 @@ export async function POST(request: Request) { // Check if exists const existing = await db.query.aggregated_article.findFirst({ - where: eq(aggregated_article.url, item.link), + where: eq(aggregated_article.externalUrl, item.link), }); if (existing) { @@ -133,16 +133,24 @@ export async function POST(request: Request) { const excerpt = extractExcerpt(contentSnippet); const imageUrl = extractImageUrl(item); + // Generate slug from title + const slug = item.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/(^-|-$)/g, "") + .substring(0, 300); + const [newArticle] = await db .insert(aggregated_article) .values({ sourceId: source.id, shortId: generateShortId(), title: item.title.substring(0, 500), + slug, excerpt, - url: item.link, + externalUrl: item.link, imageUrl, - author: item.creator || null, + sourceAuthor: item.creator || null, publishedAt: item.pubDate ? new Date(item.pubDate).toISOString() : null, }) .returning(); diff --git a/server/api/router/comment.ts b/server/api/router/comment.ts index e67c5e96..9387c1cd 100644 --- a/server/api/router/comment.ts +++ b/server/api/router/comment.ts @@ -1,317 +1,746 @@ import { TRPCError } from "@trpc/server"; +import { z } from "zod"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { - SaveCommentSchema, + CreateCommentSchema, EditCommentSchema, DeleteCommentSchema, GetCommentsSchema, - LikeCommentSchema, + GetRepliesSchema, + VoteCommentSchema, } from "@/schema/comment"; import { NEW_COMMENT_ON_YOUR_POST, NEW_REPLY_TO_YOUR_COMMENT, } from "@/utils/notifications"; -import { comment, notification, like } from "@/server/db/schema"; -import { and, count, desc, eq, isNull } from "drizzle-orm"; +import { + comments, + commentVotes, + notification, + posts, + user, +} from "@/server/db/schema"; +import { and, count, desc, eq, isNull, sql, asc, gt, lt, like } from "drizzle-orm"; import { db } from "@/server/db"; +import { increment, decrement } from "./utils"; + +// Helper to generate ltree-safe ID (no dashes, alphanumeric only) +function generateLtreeId(): string { + return crypto.randomUUID().replace(/-/g, ""); +} + +// Helper to build child ltree path from parent path +function buildChildPath(parentPath: string | null, childId: string): string { + const safeChildId = childId.replace(/-/g, ""); + return parentPath ? `${parentPath}.${safeChildId}` : safeChildId; +} + +// Calculate depth from path +function calculateDepth(path: string): number { + return path.split(".").length - 1; +} export const commentRouter = createTRPCRouter({ + // Create a new comment create: protectedProcedure - .input(SaveCommentSchema) + .input(CreateCommentSchema) .mutation(async ({ input, ctx }) => { const { body, postId, parentId } = input; - const userId = ctx.session.user.id; + const authorId = ctx.session.user.id; - const postData = await ctx.db.query.post.findFirst({ - columns: { userId: true }, - where: (posts, { eq }) => eq(posts.id, postId), - }); + // Validate post exists + const postData = await ctx.db + .select({ id: posts.id, authorId: posts.authorId }) + .from(posts) + .where(eq(posts.id, postId)) + .limit(1); - const postOwnerId = postData?.userId; - const now = new Date().toISOString(); + if (postData.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Post not found", + }); + } - const [createdComment] = await ctx.db - .insert(comment) + // Get parent comment info if this is a reply + let parentPath: string | null = null; + let parentAuthorId: string | null = null; + + if (parentId) { + const parentComment = await ctx.db + .select({ + id: comments.id, + path: comments.path, + authorId: comments.authorId, + deletedAt: comments.deletedAt, + }) + .from(comments) + .where(eq(comments.id, parentId)) + .limit(1); + + if (parentComment.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Parent comment not found", + }); + } + + // Can reply to deleted comments (tree structure preservation) + parentPath = parentComment[0].path; + parentAuthorId = parentComment[0].authorId; + } + + // Calculate depth from parent path + const depth = parentPath ? parentPath.split(".").length : 0; + + // Insert the comment first to get the generated ID + const [insertedComment] = await ctx.db + .insert(comments) .values({ - userId, + authorId, postId, - body, parentId, - createdAt: now, - updatedAt: now, + path: "placeholder", // Will be updated after we have the ID + depth, + body, }) .returning(); - if (parentId) { - const commentData = await ctx.db.query.comment.findFirst({ - where: (posts, { eq }) => eq(posts.id, parentId), - columns: { userId: true }, - }); + // Build the ltree path using the returned ID + const ltreeSafeId = insertedComment.id.replace(/-/g, ""); + const newPath = parentPath ? `${parentPath}.${ltreeSafeId}` : ltreeSafeId; - if (commentData?.userId && commentData?.userId !== userId) { - await ctx.db.insert(notification).values({ - notifierId: userId, - type: NEW_REPLY_TO_YOUR_COMMENT, - postId, - userId: commentData.userId, - commentId: createdComment.id, - }); - } + // Update the path with the correct ltree value + const [createdComment] = await ctx.db + .update(comments) + .set({ path: newPath }) + .where(eq(comments.id, insertedComment.id)) + .returning(); + + // Update post comment count + await ctx.db + .update(posts) + .set({ commentsCount: increment(posts.commentsCount) }) + .where(eq(posts.id, postId)); + + // Send notifications + if (parentId && parentAuthorId && parentAuthorId !== authorId) { + // Notification for reply to comment + await ctx.db.insert(notification).values({ + notifierId: authorId, + type: NEW_REPLY_TO_YOUR_COMMENT, + userId: parentAuthorId, + }); } - if (!parentId && postOwnerId && postOwnerId !== userId) { + if (!parentId && postData[0].authorId && postData[0].authorId !== authorId) { + // Notification for new top-level comment on post await ctx.db.insert(notification).values({ - notifierId: userId, + notifierId: authorId, type: NEW_COMMENT_ON_YOUR_POST, - postId, - userId: postOwnerId, - commentId: createdComment.id, + userId: postData[0].authorId, }); } - return createdComment.id; + return createdComment; }), + + // Edit a comment edit: protectedProcedure .input(EditCommentSchema) .mutation(async ({ input, ctx }) => { - const { body, id } = input; + const { id, body } = input; + const authorId = ctx.session.user.id; + + const currentComment = await ctx.db + .select({ + id: comments.id, + authorId: comments.authorId, + body: comments.body, + deletedAt: comments.deletedAt, + }) + .from(comments) + .where(eq(comments.id, id)) + .limit(1); + + if (currentComment.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Comment not found", + }); + } - const currentComment = await ctx.db.query.comment.findFirst({ - where: (comments, { eq }) => eq(comments.id, id), - }); + if (currentComment[0].deletedAt) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Cannot edit a deleted comment", + }); + } - if (currentComment?.userId !== ctx.session.user.id) { + if (currentComment[0].authorId !== authorId) { throw new TRPCError({ code: "FORBIDDEN", + message: "You can only edit your own comments", }); } - if (currentComment.body === body) { - return currentComment; + if (currentComment[0].body === body) { + return currentComment[0]; } - const updatedComment = await ctx.db - .update(comment) + const [updatedComment] = await ctx.db + .update(comments) .set({ body, + updatedAt: new Date().toISOString(), }) - .where(eq(comment.id, id)); + .where(eq(comments.id, id)) + .returning(); return updatedComment; }), + + // Soft delete a comment delete: protectedProcedure .input(DeleteCommentSchema) .mutation(async ({ input, ctx }) => { const { id } = input; + const authorId = ctx.session.user.id; + const isAdmin = ctx.session.user.role === "ADMIN"; + + const currentComment = await ctx.db + .select({ + id: comments.id, + authorId: comments.authorId, + postId: comments.postId, + deletedAt: comments.deletedAt, + }) + .from(comments) + .where(eq(comments.id, id)) + .limit(1); + + if (currentComment.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Comment not found", + }); + } - const currentComment = await ctx.db.query.comment.findFirst({ - where: (comments, { eq }) => eq(comments.id, id), - }); + if (currentComment[0].deletedAt) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Comment already deleted", + }); + } - if (currentComment?.userId !== ctx.session.user.id) { + if (!isAdmin && currentComment[0].authorId !== authorId) { throw new TRPCError({ code: "FORBIDDEN", + message: "You can only delete your own comments", }); } + // Soft delete - set deletedAt timestamp + // This preserves the tree structure for replies const [deletedComment] = await ctx.db - .delete(comment) - .where(eq(comment.id, id)) + .update(comments) + .set({ + deletedAt: new Date().toISOString(), + }) + .where(eq(comments.id, id)) .returning(); - return deletedComment.id; + // Decrement post comment count + await ctx.db + .update(posts) + .set({ commentsCount: decrement(posts.commentsCount) }) + .where(eq(posts.id, currentComment[0].postId)); + + return { id: deletedComment.id, deletedAt: deletedComment.deletedAt }; }), - like: protectedProcedure - .input(LikeCommentSchema) + + // Vote on a comment (Reddit-style) + vote: protectedProcedure + .input(VoteCommentSchema) .mutation(async ({ input, ctx }) => { - const { commentId } = input; + const { commentId, voteType } = input; const userId = ctx.session.user.id; - const commentLiked = await ctx.db.query.like.findFirst({ - where: (likes, { eq }) => - and(eq(likes.userId, userId), eq(likes.commentId, commentId)), - }); - - const [res] = commentLiked - ? await ctx.db - .delete(like) - .where(and(eq(like.userId, userId), eq(like.commentId, commentId))) - .returning() - : await ctx.db - .insert(like) - .values({ - commentId, - userId, + // Check if comment exists and is not deleted + const commentItem = await ctx.db + .select({ + id: comments.id, + deletedAt: comments.deletedAt, + }) + .from(comments) + .where(eq(comments.id, commentId)) + .limit(1); + + if (commentItem.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Comment not found", + }); + } + + if (commentItem[0].deletedAt) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Cannot vote on a deleted comment", + }); + } + + // Get existing vote + const existingVote = await ctx.db + .select({ id: commentVotes.id, voteType: commentVotes.voteType }) + .from(commentVotes) + .where( + and( + eq(commentVotes.commentId, commentId), + eq(commentVotes.userId, userId) + ) + ) + .limit(1); + + if (voteType === null) { + // Remove vote + if (existingVote.length > 0) { + const oldVoteType = existingVote[0].voteType; + await ctx.db + .delete(commentVotes) + .where(eq(commentVotes.id, existingVote[0].id)); + + // Update vote counts + if (oldVoteType === "up") { + await ctx.db + .update(comments) + .set({ upvotesCount: decrement(comments.upvotesCount) }) + .where(eq(comments.id, commentId)); + } else { + await ctx.db + .update(comments) + .set({ downvotesCount: decrement(comments.downvotesCount) }) + .where(eq(comments.id, commentId)); + } + } + return { voteType: null }; + } else if (existingVote.length === 0) { + // New vote + await ctx.db.insert(commentVotes).values({ + commentId, + userId, + voteType, + }); + + // Update vote counts + if (voteType === "up") { + await ctx.db + .update(comments) + .set({ upvotesCount: increment(comments.upvotesCount) }) + .where(eq(comments.id, commentId)); + } else { + await ctx.db + .update(comments) + .set({ downvotesCount: increment(comments.downvotesCount) }) + .where(eq(comments.id, commentId)); + } + return { voteType }; + } else if (existingVote[0].voteType !== voteType) { + // Change vote + await ctx.db + .update(commentVotes) + .set({ voteType }) + .where(eq(commentVotes.id, existingVote[0].id)); + + // Update vote counts (flip both) + if (voteType === "up") { + await ctx.db + .update(comments) + .set({ + upvotesCount: increment(comments.upvotesCount), + downvotesCount: decrement(comments.downvotesCount), }) - .returning(); + .where(eq(comments.id, commentId)); + } else { + await ctx.db + .update(comments) + .set({ + upvotesCount: decrement(comments.upvotesCount), + downvotesCount: increment(comments.downvotesCount), + }) + .where(eq(comments.id, commentId)); + } + return { voteType }; + } - return res; + // Same vote, no change needed + return { voteType }; }), + + // Get comments for a post with tree structure get: publicProcedure .input(GetCommentsSchema) .query(async ({ ctx, input }) => { - const { postId } = input; + const { postId, sort = "best", limit = 50 } = input; const userId = ctx?.session?.user?.id; + // Get total count (excluding deleted) const [commentCount] = await db .select({ count: count() }) - .from(comment) - .where(eq(comment.postId, postId)); - - // @TODO fix type inference so we can use these everywhere - const columns = { - id: true, - body: true, - createdAt: true, - updatedAt: true, - }; + .from(comments) + .where( + and( + eq(comments.postId, postId), + isNull(comments.deletedAt) + ) + ); + + // Build user votes subquery if logged in + const userVotesSubquery = userId + ? db + .select({ + commentId: commentVotes.commentId, + voteType: commentVotes.voteType, + }) + .from(commentVotes) + .where(eq(commentVotes.userId, userId)) + .as("userVotes") + : null; - const userColumns = { - name: true, - image: true, - username: true, - id: true, - email: true, + // Order by clause based on sort + const getOrderBy = () => { + switch (sort) { + case "best": + case "top": + // Score = upvotes - downvotes + return desc(sql`${comments.upvotesCount} - ${comments.downvotesCount}`); + case "new": + return desc(comments.createdAt); + case "old": + return asc(comments.createdAt); + case "controversial": + // More votes but close to 50/50 ratio + return desc(sql` + ${comments.upvotesCount} + ${comments.downvotesCount} - + ABS(${comments.upvotesCount} - ${comments.downvotesCount}) + `); + default: + return desc(comments.createdAt); + } }; - const response = await db.query.comment.findMany({ - columns: { id: true, body: true, createdAt: true, updatedAt: true }, - - with: { - children: { - columns: { - id: true, - body: true, - createdAt: true, - updatedAt: true, - }, - with: { - children: { - columns: { - id: true, - body: true, - createdAt: true, - updatedAt: true, - }, - with: { - children: { - columns, - with: { - children: { - columns, - with: { - children: { - columns, - with: { - user: { - columns: userColumns, - }, - likes: { - columns: { userId: true }, - }, - }, - }, - user: { - columns: { - name: true, - image: true, - username: true, - id: true, - email: true, - }, - }, - likes: { - columns: { userId: true }, - }, - }, - }, - user: { - columns: userColumns, - }, - likes: { - columns: { userId: true }, - }, - }, - }, - user: { - columns: userColumns, - }, - likes: { - columns: { userId: true }, - }, - }, - }, - user: { - columns: { - name: true, - image: true, - username: true, - id: true, - email: true, - }, - }, - likes: { - columns: { userId: true, postId: true }, - }, - }, - }, - user: { - columns: { - name: true, - image: true, - username: true, - id: true, - email: true, - }, - }, - likes: { - columns: { userId: true }, - }, - }, - where: and(eq(comment.postId, postId), isNull(comment.parentId)), - orderBy: [desc(comment.createdAt)], - }); - - interface ShapedResponse { - user: { - id: string; - username: string | null; - name: string; - image: string; - email: string | null; - }; - youLikedThis: boolean; - likeCount: number; - id: number; - body: string; - createdAt: string; - updatedAt: string; - children?: ShapedResponse[]; + // Get top-level comments only (parentId is null) + let query; + if (userVotesSubquery) { + query = db + .select({ + id: comments.id, + postId: comments.postId, + authorId: comments.authorId, + parentId: comments.parentId, + path: comments.path, + depth: comments.depth, + body: comments.body, + upvotesCount: comments.upvotesCount, + downvotesCount: comments.downvotesCount, + createdAt: comments.createdAt, + updatedAt: comments.updatedAt, + deletedAt: comments.deletedAt, + // Author info + authorName: user.name, + authorUsername: user.username, + authorImage: user.image, + // User vote + userVote: userVotesSubquery.voteType, + }) + .from(comments) + .leftJoin(user, eq(comments.authorId, user.id)) + .leftJoin(userVotesSubquery, eq(comments.id, userVotesSubquery.commentId)) + .where( + and( + eq(comments.postId, postId), + isNull(comments.parentId) + ) + ) + .orderBy(getOrderBy()) + .limit(limit); + } else { + query = db + .select({ + id: comments.id, + postId: comments.postId, + authorId: comments.authorId, + parentId: comments.parentId, + path: comments.path, + depth: comments.depth, + body: comments.body, + upvotesCount: comments.upvotesCount, + downvotesCount: comments.downvotesCount, + createdAt: comments.createdAt, + updatedAt: comments.updatedAt, + deletedAt: comments.deletedAt, + // Author info + authorName: user.name, + authorUsername: user.username, + authorImage: user.image, + // User vote (null when not logged in) + userVote: sql<"up" | "down" | null>`NULL`, + }) + .from(comments) + .leftJoin(user, eq(comments.authorId, user.id)) + .where( + and( + eq(comments.postId, postId), + isNull(comments.parentId) + ) + ) + .orderBy(getOrderBy()) + .limit(limit); } - []; - - function shapeComments(commentsArr: typeof response): ShapedResponse[] { - const value = commentsArr.map((comment) => { - const { children, likes, ...rest } = comment; - - const shaped = { - youLikedThis: likes.some((obj) => obj.userId === userId), - likeCount: likes.length, - ...rest, - }; - if (children) { - return { - ...shaped, - children: shapeComments(children), - }; + + const topLevelComments = await query; + + // For each top-level comment, fetch all children using ltree path prefix + const commentsWithChildren = await Promise.all( + topLevelComments.map(async (topComment) => { + // Get all descendants using path prefix matching + // Use SQL LIKE for path matching since ltree might not be available in Drizzle + const pathPrefix = `${topComment.path}.%`; + + let childrenQuery; + if (userVotesSubquery) { + childrenQuery = db + .select({ + id: comments.id, + postId: comments.postId, + authorId: comments.authorId, + parentId: comments.parentId, + path: comments.path, + depth: comments.depth, + body: comments.body, + upvotesCount: comments.upvotesCount, + downvotesCount: comments.downvotesCount, + createdAt: comments.createdAt, + updatedAt: comments.updatedAt, + deletedAt: comments.deletedAt, + authorName: user.name, + authorUsername: user.username, + authorImage: user.image, + userVote: userVotesSubquery.voteType, + }) + .from(comments) + .leftJoin(user, eq(comments.authorId, user.id)) + .leftJoin(userVotesSubquery, eq(comments.id, userVotesSubquery.commentId)) + .where( + and( + eq(comments.postId, postId), + like(comments.path, pathPrefix) + ) + ) + .orderBy(asc(comments.path)); // Order by path for tree reconstruction + } else { + childrenQuery = db + .select({ + id: comments.id, + postId: comments.postId, + authorId: comments.authorId, + parentId: comments.parentId, + path: comments.path, + depth: comments.depth, + body: comments.body, + upvotesCount: comments.upvotesCount, + downvotesCount: comments.downvotesCount, + createdAt: comments.createdAt, + updatedAt: comments.updatedAt, + deletedAt: comments.deletedAt, + authorName: user.name, + authorUsername: user.username, + authorImage: user.image, + userVote: sql<"up" | "down" | null>`NULL`, + }) + .from(comments) + .leftJoin(user, eq(comments.authorId, user.id)) + .where( + and( + eq(comments.postId, postId), + like(comments.path, pathPrefix) + ) + ) + .orderBy(asc(comments.path)); } - return shaped; - }); - return value; + + const children = await childrenQuery; + + // Build tree structure from flat list + return buildCommentTree(topComment, children); + }) + ); + + return { + data: commentsWithChildren, + count: commentCount.count, + }; + }), + + // Get comment count for a post + getPostCommentCount: publicProcedure + .input(z.object({ postId: z.string() })) + .query(async ({ input }) => { + const [result] = await db + .select({ count: count() }) + .from(comments) + .where( + and( + eq(comments.postId, input.postId), + isNull(comments.deletedAt) + ) + ); + + return result.count; + }), + + // Get direct replies to a comment (for lazy loading) + getReplies: publicProcedure + .input(GetRepliesSchema) + .query(async ({ ctx, input }) => { + const { parentId, limit = 10 } = input; + const userId = ctx?.session?.user?.id; + + const userVotesSubquery = userId + ? db + .select({ + commentId: commentVotes.commentId, + voteType: commentVotes.voteType, + }) + .from(commentVotes) + .where(eq(commentVotes.userId, userId)) + .as("userVotes") + : null; + + let query; + if (userVotesSubquery) { + query = db + .select({ + id: comments.id, + postId: comments.postId, + authorId: comments.authorId, + parentId: comments.parentId, + path: comments.path, + depth: comments.depth, + body: comments.body, + upvotesCount: comments.upvotesCount, + downvotesCount: comments.downvotesCount, + createdAt: comments.createdAt, + updatedAt: comments.updatedAt, + deletedAt: comments.deletedAt, + authorName: user.name, + authorUsername: user.username, + authorImage: user.image, + userVote: userVotesSubquery.voteType, + }) + .from(comments) + .leftJoin(user, eq(comments.authorId, user.id)) + .leftJoin(userVotesSubquery, eq(comments.id, userVotesSubquery.commentId)) + .where(eq(comments.parentId, parentId)) + .orderBy(desc(sql`${comments.upvotesCount} - ${comments.downvotesCount}`)) + .limit(limit); + } else { + query = db + .select({ + id: comments.id, + postId: comments.postId, + authorId: comments.authorId, + parentId: comments.parentId, + path: comments.path, + depth: comments.depth, + body: comments.body, + upvotesCount: comments.upvotesCount, + downvotesCount: comments.downvotesCount, + createdAt: comments.createdAt, + updatedAt: comments.updatedAt, + deletedAt: comments.deletedAt, + authorName: user.name, + authorUsername: user.username, + authorImage: user.image, + userVote: sql<"up" | "down" | null>`NULL`, + }) + .from(comments) + .leftJoin(user, eq(comments.authorId, user.id)) + .where(eq(comments.parentId, parentId)) + .orderBy(desc(sql`${comments.upvotesCount} - ${comments.downvotesCount}`)) + .limit(limit); } - const comments = shapeComments(response); + const replies = await query; - return { data: comments, count: commentCount.count }; + return replies.map(shapeComment); }), }); + +// Shape a single comment for API response +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function shapeComment(comment: any) { + const isDeleted = !!comment.deletedAt; + + return { + id: comment.id, + postId: comment.postId, + parentId: comment.parentId, + depth: comment.depth, + // If deleted, hide body and author info + body: isDeleted ? null : comment.body, + author: isDeleted + ? null + : { + id: comment.authorId, + name: comment.authorName, + username: comment.authorUsername, + image: comment.authorImage, + }, + score: comment.upvotesCount - comment.downvotesCount, + upvotesCount: comment.upvotesCount, + downvotesCount: comment.downvotesCount, + userVote: comment.userVote ?? null, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + deletedAt: comment.deletedAt, + isDeleted, + }; +} + +// Build tree structure from flat list of comments +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function buildCommentTree(rootComment: any, descendants: any[]) { + // Create a map of id -> comment with children array + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const commentMap = new Map(); + + // Initialize root + const shapedRoot = { + ...shapeComment(rootComment), + children: [] as ReturnType[], + }; + commentMap.set(rootComment.id, shapedRoot); + + // Add all descendants to map + for (const comment of descendants) { + const shapedComment = { + ...shapeComment(comment), + children: [] as ReturnType[], + }; + commentMap.set(comment.id, shapedComment); + } + + // Build tree by connecting children to parents + for (const comment of descendants) { + if (comment.parentId && commentMap.has(comment.parentId)) { + const parent = commentMap.get(comment.parentId); + const child = commentMap.get(comment.id); + if (parent && child) { + parent.children.push(child); + } + } + } + + return shapedRoot; +} diff --git a/server/api/router/content.ts b/server/api/router/content.ts index c09d595a..739d5579 100644 --- a/server/api/router/content.ts +++ b/server/api/router/content.ts @@ -16,16 +16,21 @@ import { TrackClickContentSchema, GetUserContentSchema, GetSavedContentSchema, + EditDraftContentSchema, + PublishContentSchema, + MyDraftsContentSchema, + MyPublishedContentSchema, + MyScheduledContentSchema, } from "../../../schema/content"; import { - content, - content_vote, - content_bookmark, - content_tag, - feed_source, + posts, + post_votes, + bookmarks, + post_tags, + feed_sources, tag, user, - discussion, + comments, } from "@/server/db/schema"; import { and, @@ -33,13 +38,12 @@ import { desc, lt, lte, + gt, sql, isNotNull, count, - inArray, } from "drizzle-orm"; import { increment, decrement } from "./utils"; -import { db } from "@/server/db"; import crypto from "crypto"; // Helper to generate slug from title @@ -61,24 +65,52 @@ function calculateReadTime(body: string | null | undefined): number { return Math.max(1, Math.ceil(words / wordsPerMinute)); } +// Helper to convert frontend type (POST, LINK, ARTICLE) to DB type (article, link) +function toDbType(frontendType: string): "article" | "discussion" | "link" | "resource" { + const typeMap: Record = { + POST: "article", + ARTICLE: "article", // Alias for POST + LINK: "link", + QUESTION: "discussion", + VIDEO: "link", + DISCUSSION: "discussion", + article: "article", + link: "link", + discussion: "discussion", + resource: "resource", + }; + return typeMap[frontendType] || "article"; +} + +// Helper to convert DB type to frontend type (for backwards compatibility) +function toFrontendType(dbType: string): string { + const typeMap: Record = { + article: "POST", + link: "LINK", + discussion: "DISCUSSION", + resource: "LINK", + }; + return typeMap[dbType] || dbType.toUpperCase(); +} + export const contentRouter = createTRPCRouter({ // Get unified feed with optional type filtering getFeed: publicProcedure .input(GetUnifiedFeedSchema) .query(async ({ ctx, input }) => { const userId = ctx.session?.user?.id; - const limit = input?.limit ?? 20; - const { cursor, sort, type, category, sourceId } = input; + const limit = input?.limit ?? 25; + const { cursor, sort, type, sourceId, category } = input; // Build the vote subquery for current user const userVotes = userId ? ctx.db .select({ - contentId: content_vote.contentId, - voteType: content_vote.voteType, + postId: post_votes.postId, + voteType: post_votes.voteType, }) - .from(content_vote) - .where(eq(content_vote.userId, userId)) + .from(post_votes) + .where(eq(post_votes.userId, userId)) .as("userVotes") : null; @@ -86,25 +118,32 @@ export const contentRouter = createTRPCRouter({ const userBookmarks = userId ? ctx.db .select({ - contentId: content_bookmark.contentId, + postId: bookmarks.postId, }) - .from(content_bookmark) - .where(eq(content_bookmark.userId, userId)) + .from(bookmarks) + .where(eq(bookmarks.userId, userId)) .as("userBookmarks") : null; // Calculate score for trending - const scoreExpr = sql`(${content.upvotes} - ${content.downvotes})`; + const scoreExpr = sql`(${posts.upvotesCount} - ${posts.downvotesCount})`; - // Build conditions - const conditions = [eq(content.published, true)]; + // Build conditions - status = 'published' + const conditions = [eq(posts.status, "published")]; if (type) { - conditions.push(eq(content.type, type)); + // Convert frontend type (POST, LINK) to db type (article, link) + const dbType = toDbType(type); + conditions.push(eq(posts.type, dbType)); } if (sourceId) { - conditions.push(eq(content.sourceId, sourceId)); + conditions.push(eq(posts.sourceId, sourceId)); + } + + // Filter by category (matches source category) + if (category) { + conditions.push(eq(feed_sources.category, category)); } // Build order by and cursor conditions based on sort type @@ -112,9 +151,9 @@ export const contentRouter = createTRPCRouter({ switch (sort) { case "recent": return { - orderBy: desc(content.publishedAt), + orderBy: desc(posts.publishedAt), cursorCondition: cursor?.publishedAt - ? lte(content.publishedAt, cursor.publishedAt) + ? lte(posts.publishedAt, cursor.publishedAt) : undefined, }; case "trending": @@ -126,14 +165,14 @@ export const contentRouter = createTRPCRouter({ }; case "popular": return { - orderBy: desc(content.upvotes), + orderBy: desc(posts.upvotesCount), cursorCondition: cursor?.score !== undefined - ? lt(content.upvotes, cursor.score) + ? lt(posts.upvotesCount, cursor.score) : undefined, }; default: return { - orderBy: desc(content.publishedAt), + orderBy: desc(posts.publishedAt), cursorCondition: undefined, }; } @@ -150,84 +189,84 @@ export const contentRouter = createTRPCRouter({ if (userVotes && userBookmarks) { query = ctx.db .select({ - id: content.id, - type: content.type, - title: content.title, - excerpt: content.excerpt, - body: content.body, - externalUrl: content.externalUrl, - imageUrl: content.imageUrl, - ogImageUrl: content.ogImageUrl, - slug: content.slug, - publishedAt: content.publishedAt, - upvotes: content.upvotes, - downvotes: content.downvotes, - clickCount: content.clickCount, - readTimeMins: content.readTimeMins, - userId: content.userId, - sourceId: content.sourceId, - sourceAuthor: content.sourceAuthor, - createdAt: content.createdAt, + id: posts.id, + type: posts.type, + title: posts.title, + excerpt: posts.excerpt, + body: posts.body, + externalUrl: posts.externalUrl, + imageUrl: posts.coverImage, + ogImageUrl: posts.coverImage, + slug: posts.slug, + publishedAt: posts.publishedAt, + upvotes: posts.upvotesCount, + downvotes: posts.downvotesCount, + clickCount: posts.viewsCount, + readTimeMins: posts.readingTime, + userId: posts.authorId, + sourceId: posts.sourceId, + sourceAuthor: posts.sourceAuthor, + createdAt: posts.createdAt, // Source info - sourceName: feed_source.name, - sourceSlug: feed_source.slug, - sourceLogo: feed_source.logoUrl, - sourceWebsite: feed_source.websiteUrl, - sourceCategory: feed_source.category, + sourceName: feed_sources.name, + sourceSlug: feed_sources.slug, + sourceLogo: feed_sources.logoUrl, + sourceWebsite: feed_sources.websiteUrl, + sourceCategory: feed_sources.category, // Author info authorName: user.name, authorUsername: user.username, authorImage: user.image, // User-specific userVote: userVotes.voteType, - isBookmarked: sql`${userBookmarks.contentId} IS NOT NULL`, + isBookmarked: sql`${userBookmarks.postId} IS NOT NULL`, }) - .from(content) - .leftJoin(feed_source, eq(content.sourceId, feed_source.id)) - .leftJoin(user, eq(content.userId, user.id)) - .leftJoin(userVotes, eq(content.id, userVotes.contentId)) - .leftJoin(userBookmarks, eq(content.id, userBookmarks.contentId)) + .from(posts) + .leftJoin(feed_sources, eq(posts.sourceId, feed_sources.id)) + .leftJoin(user, eq(posts.authorId, user.id)) + .leftJoin(userVotes, eq(posts.id, userVotes.postId)) + .leftJoin(userBookmarks, eq(posts.id, userBookmarks.postId)) .where(and(...conditions)) .orderBy(orderBy) .limit(limit + 1); } else { query = ctx.db .select({ - id: content.id, - type: content.type, - title: content.title, - excerpt: content.excerpt, - body: content.body, - externalUrl: content.externalUrl, - imageUrl: content.imageUrl, - ogImageUrl: content.ogImageUrl, - slug: content.slug, - publishedAt: content.publishedAt, - upvotes: content.upvotes, - downvotes: content.downvotes, - clickCount: content.clickCount, - readTimeMins: content.readTimeMins, - userId: content.userId, - sourceId: content.sourceId, - sourceAuthor: content.sourceAuthor, - createdAt: content.createdAt, + id: posts.id, + type: posts.type, + title: posts.title, + excerpt: posts.excerpt, + body: posts.body, + externalUrl: posts.externalUrl, + imageUrl: posts.coverImage, + ogImageUrl: posts.coverImage, + slug: posts.slug, + publishedAt: posts.publishedAt, + upvotes: posts.upvotesCount, + downvotes: posts.downvotesCount, + clickCount: posts.viewsCount, + readTimeMins: posts.readingTime, + userId: posts.authorId, + sourceId: posts.sourceId, + sourceAuthor: posts.sourceAuthor, + createdAt: posts.createdAt, // Source info - sourceName: feed_source.name, - sourceSlug: feed_source.slug, - sourceLogo: feed_source.logoUrl, - sourceWebsite: feed_source.websiteUrl, - sourceCategory: feed_source.category, + sourceName: feed_sources.name, + sourceSlug: feed_sources.slug, + sourceLogo: feed_sources.logoUrl, + sourceWebsite: feed_sources.websiteUrl, + sourceCategory: feed_sources.category, // Author info authorName: user.name, authorUsername: user.username, authorImage: user.image, // User-specific (null when not logged in) - userVote: sql<"UP" | "DOWN" | null>`NULL`, + userVote: sql<"up" | "down" | null>`NULL`, isBookmarked: sql`FALSE`, }) - .from(content) - .leftJoin(feed_source, eq(content.sourceId, feed_source.id)) - .leftJoin(user, eq(content.userId, user.id)) + .from(posts) + .leftJoin(feed_sources, eq(posts.sourceId, feed_sources.id)) + .leftJoin(user, eq(posts.authorId, user.id)) .where(and(...conditions)) .orderBy(orderBy) .limit(limit + 1); @@ -247,8 +286,14 @@ export const contentRouter = createTRPCRouter({ }; } + // Map types from DB format (article, link) to frontend format (POST, LINK) + const mappedItems = results.map((item) => ({ + ...item, + type: toFrontendType(item.type), + })); + return { - items: results, + items: mappedItems, nextCursor, }; }), @@ -261,43 +306,43 @@ export const contentRouter = createTRPCRouter({ const results = await ctx.db .select({ - id: content.id, - type: content.type, - title: content.title, - body: content.body, - excerpt: content.excerpt, - externalUrl: content.externalUrl, - imageUrl: content.imageUrl, - ogImageUrl: content.ogImageUrl, - slug: content.slug, - canonicalUrl: content.canonicalUrl, - coverImage: content.coverImage, - publishedAt: content.publishedAt, - upvotes: content.upvotes, - downvotes: content.downvotes, - clickCount: content.clickCount, - readTimeMins: content.readTimeMins, - showComments: content.showComments, - userId: content.userId, - sourceId: content.sourceId, - sourceAuthor: content.sourceAuthor, - createdAt: content.createdAt, - updatedAt: content.updatedAt, + id: posts.id, + type: posts.type, + title: posts.title, + body: posts.body, + excerpt: posts.excerpt, + externalUrl: posts.externalUrl, + imageUrl: posts.coverImage, + ogImageUrl: posts.coverImage, + slug: posts.slug, + canonicalUrl: posts.canonicalUrl, + coverImage: posts.coverImage, + publishedAt: posts.publishedAt, + upvotes: posts.upvotesCount, + downvotes: posts.downvotesCount, + clickCount: posts.viewsCount, + readTimeMins: posts.readingTime, + showComments: posts.showComments, + userId: posts.authorId, + sourceId: posts.sourceId, + sourceAuthor: posts.sourceAuthor, + createdAt: posts.createdAt, + updatedAt: posts.updatedAt, // Source info - sourceName: feed_source.name, - sourceSlug: feed_source.slug, - sourceLogo: feed_source.logoUrl, - sourceWebsite: feed_source.websiteUrl, - sourceCategory: feed_source.category, + sourceName: feed_sources.name, + sourceSlug: feed_sources.slug, + sourceLogo: feed_sources.logoUrl, + sourceWebsite: feed_sources.websiteUrl, + sourceCategory: feed_sources.category, // Author info authorName: user.name, authorUsername: user.username, authorImage: user.image, }) - .from(content) - .leftJoin(feed_source, eq(content.sourceId, feed_source.id)) - .leftJoin(user, eq(content.userId, user.id)) - .where(eq(content.id, input.id)) + .from(posts) + .leftJoin(feed_sources, eq(posts.sourceId, feed_sources.id)) + .leftJoin(user, eq(posts.authorId, user.id)) + .where(eq(posts.id, input.id)) .limit(1); if (results.length === 0) { @@ -310,28 +355,28 @@ export const contentRouter = createTRPCRouter({ const item = results[0]; // Get user vote if logged in - let userVote: "UP" | "DOWN" | null = null; + let userVote: "up" | "down" | null = null; let isBookmarked = false; if (userId) { const [voteResult, bookmarkResult] = await Promise.all([ ctx.db - .select({ voteType: content_vote.voteType }) - .from(content_vote) + .select({ voteType: post_votes.voteType }) + .from(post_votes) .where( and( - eq(content_vote.contentId, input.id), - eq(content_vote.userId, userId) + eq(post_votes.postId, input.id), + eq(post_votes.userId, userId) ) ) .limit(1), ctx.db - .select({ id: content_bookmark.id }) - .from(content_bookmark) + .select({ id: bookmarks.id }) + .from(bookmarks) .where( and( - eq(content_bookmark.contentId, input.id), - eq(content_bookmark.userId, userId) + eq(bookmarks.postId, input.id), + eq(bookmarks.userId, userId) ) ) .limit(1), @@ -341,17 +386,18 @@ export const contentRouter = createTRPCRouter({ isBookmarked = bookmarkResult.length > 0; } - // Get discussion count - const discussionCountResult = await ctx.db + // Get comments count + const commentsCountResult = await ctx.db .select({ count: count() }) - .from(discussion) - .where(eq(discussion.contentId, input.id)); + .from(comments) + .where(eq(comments.postId, input.id)); return { ...item, + type: toFrontendType(item.type), userVote, isBookmarked, - discussionCount: discussionCountResult[0]?.count ?? 0, + discussionCount: commentsCountResult[0]?.count ?? 0, }; }), @@ -359,10 +405,47 @@ export const contentRouter = createTRPCRouter({ getBySlug: publicProcedure .input(GetContentBySlugSchema) .query(async ({ ctx, input }) => { + const userId = ctx.session?.user?.id; + const results = await ctx.db - .select({ id: content.id }) - .from(content) - .where(eq(content.slug, input.slug)) + .select({ + id: posts.id, + type: posts.type, + title: posts.title, + body: posts.body, + excerpt: posts.excerpt, + externalUrl: posts.externalUrl, + imageUrl: posts.coverImage, + ogImageUrl: posts.coverImage, + slug: posts.slug, + canonicalUrl: posts.canonicalUrl, + coverImage: posts.coverImage, + publishedAt: posts.publishedAt, + upvotes: posts.upvotesCount, + downvotes: posts.downvotesCount, + clickCount: posts.viewsCount, + readTimeMins: posts.readingTime, + showComments: posts.showComments, + userId: posts.authorId, + sourceId: posts.sourceId, + sourceAuthor: posts.sourceAuthor, + createdAt: posts.createdAt, + updatedAt: posts.updatedAt, + // Source info + sourceName: feed_sources.name, + sourceSlug: feed_sources.slug, + sourceLogo: feed_sources.logoUrl, + sourceWebsite: feed_sources.websiteUrl, + sourceCategory: feed_sources.category, + // Author info + authorName: user.name, + authorUsername: user.username, + authorImage: user.image, + }) + .from(posts) + .leftJoin(feed_sources, eq(posts.sourceId, feed_sources.id)) + .leftJoin(user, eq(posts.authorId, user.id)) + .where(eq(posts.slug, input.slug)) .limit(1); if (results.length === 0) { @@ -372,10 +455,53 @@ export const contentRouter = createTRPCRouter({ }); } - // Reuse getById logic - return ctx.db.query.content.findFirst({ - where: eq(content.slug, input.slug), - }); + const item = results[0]; + + // Get user vote if logged in + let userVote: "up" | "down" | null = null; + let isBookmarked = false; + + if (userId) { + const [voteResult, bookmarkResult] = await Promise.all([ + ctx.db + .select({ voteType: post_votes.voteType }) + .from(post_votes) + .where( + and( + eq(post_votes.postId, item.id), + eq(post_votes.userId, userId) + ) + ) + .limit(1), + ctx.db + .select({ id: bookmarks.id }) + .from(bookmarks) + .where( + and( + eq(bookmarks.postId, item.id), + eq(bookmarks.userId, userId) + ) + ) + .limit(1), + ]); + + userVote = voteResult[0]?.voteType ?? null; + isBookmarked = bookmarkResult.length > 0; + } + + // Get comments count + const commentsCountResult = await ctx.db + .select({ count: count() }) + .from(comments) + .where(eq(comments.postId, item.id)); + + return { + ...item, + type: toFrontendType(item.type), + userVote, + isBookmarked, + discussionCount: commentsCountResult[0]?.count ?? 0, + }; }), // Create new content @@ -385,10 +511,10 @@ export const contentRouter = createTRPCRouter({ const userId = ctx.session.user.id; // Validate based on content type - if (input.type === "ARTICLE" && !input.body) { + if (input.type === "POST" && !input.body) { throw new TRPCError({ code: "BAD_REQUEST", - message: "Body is required for articles", + message: "Body is required for posts", }); } @@ -400,33 +526,31 @@ export const contentRouter = createTRPCRouter({ } const slug = generateSlug(input.title); - const readTimeMins = calculateReadTime(input.body); + const readingTime = calculateReadTime(input.body); + const dbType = toDbType(input.type); const [newContent] = await ctx.db - .insert(content) + .insert(posts) .values({ - type: input.type, + type: dbType, title: input.title, body: input.body, excerpt: input.excerpt, externalUrl: input.externalUrl, - imageUrl: input.imageUrl, - coverImage: input.coverImage, + coverImage: input.imageUrl || input.coverImage, canonicalUrl: input.canonicalUrl, - userId, + authorId: userId, slug, - readTimeMins, - published: input.published, + readingTime, + status: input.published ? "published" : "draft", publishedAt: input.published ? new Date().toISOString() : null, - showComments: input.showComments, + showComments: input.showComments ?? true, }) .returning(); // Add tags if provided if (input.tags && input.tags.length > 0) { - // Get or create tags for (const tagName of input.tags) { - // Try to find existing tag const existingTags = await ctx.db .select({ id: tag.id }) .from(tag) @@ -437,7 +561,6 @@ export const contentRouter = createTRPCRouter({ if (existingTags.length > 0) { tagId = existingTags[0].id; } else { - // Create new tag const [newTag] = await ctx.db .insert(tag) .values({ title: tagName.toLowerCase() }) @@ -445,10 +568,9 @@ export const contentRouter = createTRPCRouter({ tagId = newTag.id; } - // Link tag to content await ctx.db - .insert(content_tag) - .values({ contentId: newContent.id, tagId }) + .insert(post_tags) + .values({ postId: newContent.id, tagId }) .onConflictDoNothing(); } } @@ -464,9 +586,9 @@ export const contentRouter = createTRPCRouter({ // Check ownership const existing = await ctx.db - .select({ userId: content.userId, type: content.type }) - .from(content) - .where(eq(content.id, input.id)) + .select({ authorId: posts.authorId, type: posts.type }) + .from(posts) + .where(eq(posts.id, input.id)) .limit(1); if (existing.length === 0) { @@ -476,7 +598,7 @@ export const contentRouter = createTRPCRouter({ }); } - if (existing[0].userId !== userId) { + if (existing[0].authorId !== userId) { throw new TRPCError({ code: "FORBIDDEN", message: "You can only edit your own content", @@ -487,35 +609,33 @@ export const contentRouter = createTRPCRouter({ if (input.title !== undefined) updateData.title = input.title; if (input.body !== undefined) { updateData.body = input.body; - updateData.readTimeMins = calculateReadTime(input.body); + updateData.readingTime = calculateReadTime(input.body); } if (input.excerpt !== undefined) updateData.excerpt = input.excerpt; if (input.externalUrl !== undefined) updateData.externalUrl = input.externalUrl; - if (input.imageUrl !== undefined) updateData.imageUrl = input.imageUrl; + if (input.imageUrl !== undefined) updateData.coverImage = input.imageUrl; if (input.coverImage !== undefined) updateData.coverImage = input.coverImage; if (input.canonicalUrl !== undefined) updateData.canonicalUrl = input.canonicalUrl; if (input.showComments !== undefined) updateData.showComments = input.showComments; if (input.published !== undefined) { - updateData.published = input.published; + updateData.status = input.published ? "published" : "draft"; if (input.published) { updateData.publishedAt = new Date().toISOString(); } } const [updated] = await ctx.db - .update(content) + .update(posts) .set(updateData) - .where(eq(content.id, input.id)) + .where(eq(posts.id, input.id)) .returning(); // Update tags if provided if (input.tags !== undefined) { - // Remove existing tags await ctx.db - .delete(content_tag) - .where(eq(content_tag.contentId, input.id)); + .delete(post_tags) + .where(eq(post_tags.postId, input.id)); - // Add new tags for (const tagName of input.tags) { const existingTags = await ctx.db .select({ id: tag.id }) @@ -535,8 +655,8 @@ export const contentRouter = createTRPCRouter({ } await ctx.db - .insert(content_tag) - .values({ contentId: input.id, tagId }) + .insert(post_tags) + .values({ postId: input.id, tagId }) .onConflictDoNothing(); } } @@ -552,9 +672,9 @@ export const contentRouter = createTRPCRouter({ // Check ownership const existing = await ctx.db - .select({ userId: content.userId }) - .from(content) - .where(eq(content.id, input.id)) + .select({ authorId: posts.authorId }) + .from(posts) + .where(eq(posts.id, input.id)) .limit(1); if (existing.length === 0) { @@ -564,14 +684,14 @@ export const contentRouter = createTRPCRouter({ }); } - if (existing[0].userId !== userId) { + if (existing[0].authorId !== userId) { throw new TRPCError({ code: "FORBIDDEN", message: "You can only delete your own content", }); } - await ctx.db.delete(content).where(eq(content.id, input.id)); + await ctx.db.delete(posts).where(eq(posts.id, input.id)); return { success: true }; }), @@ -585,9 +705,9 @@ export const contentRouter = createTRPCRouter({ // Check if content exists const contentItem = await ctx.db - .select({ id: content.id, upvotes: content.upvotes, downvotes: content.downvotes }) - .from(content) - .where(eq(content.id, contentId)) + .select({ id: posts.id, upvotes: posts.upvotesCount, downvotes: posts.downvotesCount }) + .from(posts) + .where(eq(posts.id, contentId)) .limit(1); if (contentItem.length === 0) { @@ -599,12 +719,12 @@ export const contentRouter = createTRPCRouter({ // Get existing vote const existingVote = await ctx.db - .select({ id: content_vote.id, voteType: content_vote.voteType }) - .from(content_vote) + .select({ id: post_votes.id, voteType: post_votes.voteType }) + .from(post_votes) .where( and( - eq(content_vote.contentId, contentId), - eq(content_vote.userId, userId) + eq(post_votes.postId, contentId), + eq(post_votes.userId, userId) ) ) .limit(1); @@ -614,66 +734,66 @@ export const contentRouter = createTRPCRouter({ if (existingVote.length > 0) { const oldVoteType = existingVote[0].voteType; await ctx.db - .delete(content_vote) - .where(eq(content_vote.id, existingVote[0].id)); + .delete(post_votes) + .where(eq(post_votes.id, existingVote[0].id)); // Update vote counts - if (oldVoteType === "UP") { + if (oldVoteType === "up") { await ctx.db - .update(content) - .set({ upvotes: decrement(content.upvotes) }) - .where(eq(content.id, contentId)); + .update(posts) + .set({ upvotesCount: decrement(posts.upvotesCount) }) + .where(eq(posts.id, contentId)); } else { await ctx.db - .update(content) - .set({ downvotes: decrement(content.downvotes) }) - .where(eq(content.id, contentId)); + .update(posts) + .set({ downvotesCount: decrement(posts.downvotesCount) }) + .where(eq(posts.id, contentId)); } } } else if (existingVote.length === 0) { // New vote - await ctx.db.insert(content_vote).values({ - contentId, + await ctx.db.insert(post_votes).values({ + postId: contentId, userId, - voteType, + voteType: voteType as "up" | "down", }); // Update vote counts - if (voteType === "UP") { + if (voteType === "up") { await ctx.db - .update(content) - .set({ upvotes: increment(content.upvotes) }) - .where(eq(content.id, contentId)); + .update(posts) + .set({ upvotesCount: increment(posts.upvotesCount) }) + .where(eq(posts.id, contentId)); } else { await ctx.db - .update(content) - .set({ downvotes: increment(content.downvotes) }) - .where(eq(content.id, contentId)); + .update(posts) + .set({ downvotesCount: increment(posts.downvotesCount) }) + .where(eq(posts.id, contentId)); } } else if (existingVote[0].voteType !== voteType) { // Change vote await ctx.db - .update(content_vote) - .set({ voteType }) - .where(eq(content_vote.id, existingVote[0].id)); + .update(post_votes) + .set({ voteType: voteType as "up" | "down" }) + .where(eq(post_votes.id, existingVote[0].id)); // Update vote counts (flip both) - if (voteType === "UP") { + if (voteType === "up") { await ctx.db - .update(content) + .update(posts) .set({ - upvotes: increment(content.upvotes), - downvotes: decrement(content.downvotes), + upvotesCount: increment(posts.upvotesCount), + downvotesCount: decrement(posts.downvotesCount), }) - .where(eq(content.id, contentId)); + .where(eq(posts.id, contentId)); } else { await ctx.db - .update(content) + .update(posts) .set({ - upvotes: decrement(content.upvotes), - downvotes: increment(content.downvotes), + upvotesCount: decrement(posts.upvotesCount), + downvotesCount: increment(posts.downvotesCount), }) - .where(eq(content.id, contentId)); + .where(eq(posts.id, contentId)); } } @@ -689,9 +809,9 @@ export const contentRouter = createTRPCRouter({ // Check if content exists const contentItem = await ctx.db - .select({ id: content.id }) - .from(content) - .where(eq(content.id, contentId)) + .select({ id: posts.id }) + .from(posts) + .where(eq(posts.id, contentId)) .limit(1); if (contentItem.length === 0) { @@ -703,16 +823,16 @@ export const contentRouter = createTRPCRouter({ if (setBookmarked) { await ctx.db - .insert(content_bookmark) - .values({ contentId, userId }) + .insert(bookmarks) + .values({ postId: contentId, userId }) .onConflictDoNothing(); } else { await ctx.db - .delete(content_bookmark) + .delete(bookmarks) .where( and( - eq(content_bookmark.contentId, contentId), - eq(content_bookmark.userId, userId) + eq(bookmarks.postId, contentId), + eq(bookmarks.userId, userId) ) ); } @@ -725,9 +845,9 @@ export const contentRouter = createTRPCRouter({ .input(TrackClickContentSchema) .mutation(async ({ ctx, input }) => { await ctx.db - .update(content) - .set({ clickCount: increment(content.clickCount) }) - .where(eq(content.id, input.contentId)); + .update(posts) + .set({ viewsCount: increment(posts.viewsCount) }) + .where(eq(posts.id, input.contentId)); return { success: true }; }), @@ -739,37 +859,38 @@ export const contentRouter = createTRPCRouter({ const { userId: targetUserId, type, limit, cursor } = input; const currentUserId = ctx.session?.user?.id; - const conditions = [eq(content.userId, targetUserId)]; + const conditions = [eq(posts.authorId, targetUserId)]; // Only show published content unless viewing own profile if (currentUserId !== targetUserId) { - conditions.push(eq(content.published, true)); + conditions.push(eq(posts.status, "published")); } if (type) { - conditions.push(eq(content.type, type)); + const dbType = toDbType(type); + conditions.push(eq(posts.type, dbType)); } if (cursor?.publishedAt) { - conditions.push(lte(content.publishedAt, cursor.publishedAt)); + conditions.push(lte(posts.publishedAt, cursor.publishedAt)); } const results = await ctx.db .select({ - id: content.id, - type: content.type, - title: content.title, - excerpt: content.excerpt, - slug: content.slug, - publishedAt: content.publishedAt, - upvotes: content.upvotes, - downvotes: content.downvotes, - published: content.published, - createdAt: content.createdAt, + id: posts.id, + type: posts.type, + title: posts.title, + excerpt: posts.excerpt, + slug: posts.slug, + publishedAt: posts.publishedAt, + upvotes: posts.upvotesCount, + downvotes: posts.downvotesCount, + published: sql`${posts.status} = 'published'`, + createdAt: posts.createdAt, }) - .from(content) + .from(posts) .where(and(...conditions)) - .orderBy(desc(content.publishedAt)) + .orderBy(desc(posts.publishedAt)) .limit(limit + 1); let nextCursor: { id: string; publishedAt?: string } | undefined; @@ -781,8 +902,14 @@ export const contentRouter = createTRPCRouter({ }; } + // Map types from DB format to frontend format + const mappedItems = results.map((item) => ({ + ...item, + type: toFrontendType(item.type), + })); + return { - items: results, + items: mappedItems, nextCursor, }; }), @@ -794,35 +921,35 @@ export const contentRouter = createTRPCRouter({ const userId = ctx.session.user.id; const { limit, cursor } = input; - const conditions = [eq(content_bookmark.userId, userId)]; + const conditions = [eq(bookmarks.userId, userId)]; if (cursor?.createdAt) { - conditions.push(lte(content_bookmark.createdAt, cursor.createdAt)); + conditions.push(lte(bookmarks.createdAt, cursor.createdAt)); } const results = await ctx.db .select({ - id: content.id, - type: content.type, - title: content.title, - excerpt: content.excerpt, - externalUrl: content.externalUrl, - slug: content.slug, - publishedAt: content.publishedAt, - upvotes: content.upvotes, - downvotes: content.downvotes, - sourceName: feed_source.name, - sourceSlug: feed_source.slug, + id: posts.id, + type: posts.type, + title: posts.title, + excerpt: posts.excerpt, + externalUrl: posts.externalUrl, + slug: posts.slug, + publishedAt: posts.publishedAt, + upvotes: posts.upvotesCount, + downvotes: posts.downvotesCount, + sourceName: feed_sources.name, + sourceSlug: feed_sources.slug, authorName: user.name, authorUsername: user.username, - bookmarkedAt: content_bookmark.createdAt, + bookmarkedAt: bookmarks.createdAt, }) - .from(content_bookmark) - .innerJoin(content, eq(content_bookmark.contentId, content.id)) - .leftJoin(feed_source, eq(content.sourceId, feed_source.id)) - .leftJoin(user, eq(content.userId, user.id)) + .from(bookmarks) + .innerJoin(posts, eq(bookmarks.postId, posts.id)) + .leftJoin(feed_sources, eq(posts.sourceId, feed_sources.id)) + .leftJoin(user, eq(posts.authorId, user.id)) .where(and(...conditions)) - .orderBy(desc(content_bookmark.createdAt)) + .orderBy(desc(bookmarks.createdAt)) .limit(limit + 1); let nextCursor: { id: string; createdAt?: string } | undefined; @@ -834,8 +961,14 @@ export const contentRouter = createTRPCRouter({ }; } + // Map types from DB format to frontend format + const mappedItems = results.map((item) => ({ + ...item, + type: toFrontendType(item.type), + })); + return { - items: results, + items: mappedItems, nextCursor, }; }), @@ -843,9 +976,9 @@ export const contentRouter = createTRPCRouter({ // Get categories (from sources) getCategories: publicProcedure.query(async ({ ctx }) => { const results = await ctx.db - .selectDistinct({ category: feed_source.category }) - .from(feed_source) - .where(isNotNull(feed_source.category)); + .selectDistinct({ category: feed_sources.category }) + .from(feed_sources) + .where(isNotNull(feed_sources.category)); return results .map((r) => r.category) @@ -857,13 +990,247 @@ export const contentRouter = createTRPCRouter({ getTypeCounts: publicProcedure.query(async ({ ctx }) => { const results = await ctx.db .select({ - type: content.type, + type: posts.type, count: count(), }) - .from(content) - .where(eq(content.published, true)) - .groupBy(content.type); + .from(posts) + .where(eq(posts.status, "published")) + .groupBy(posts.type); return results; }), + + // Edit Draft - get user's own content by ID for editing + editDraft: protectedProcedure + .input(EditDraftContentSchema) + .query(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + + const results = await ctx.db + .select({ + id: posts.id, + type: posts.type, + title: posts.title, + body: posts.body, + excerpt: posts.excerpt, + externalUrl: posts.externalUrl, + imageUrl: posts.coverImage, + canonicalUrl: posts.canonicalUrl, + coverImage: posts.coverImage, + slug: posts.slug, + published: sql`${posts.status} = 'published'`, + publishedAt: posts.publishedAt, + showComments: posts.showComments, + readTimeMins: posts.readingTime, + createdAt: posts.createdAt, + updatedAt: posts.updatedAt, + }) + .from(posts) + .where( + and( + eq(posts.id, input.id), + eq(posts.authorId, userId) + ) + ) + .limit(1); + + if (results.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Content not found or you don't have permission to edit it", + }); + } + + // Get tags for this content + const contentTags = await ctx.db + .select({ + tag: { + id: tag.id, + title: tag.title, + }, + }) + .from(post_tags) + .innerJoin(tag, eq(post_tags.tagId, tag.id)) + .where(eq(post_tags.postId, input.id)); + + return { + ...results[0], + type: toFrontendType(results[0].type), + tags: contentTags, + }; + }), + + // My Drafts - get user's unpublished ARTICLE content + myDrafts: protectedProcedure + .input(MyDraftsContentSchema) + .query(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + + const results = await ctx.db + .select({ + id: posts.id, + type: posts.type, + title: posts.title, + excerpt: posts.excerpt, + slug: posts.slug, + published: sql`${posts.status} = 'published'`, + publishedAt: posts.publishedAt, + createdAt: posts.createdAt, + updatedAt: posts.updatedAt, + }) + .from(posts) + .where( + and( + eq(posts.authorId, userId), + eq(posts.type, "article"), + eq(posts.status, "draft") + ) + ) + .orderBy(desc(posts.updatedAt)) + .limit(input.limit); + + // Map types from DB format to frontend format + return results.map((item) => ({ + ...item, + type: toFrontendType(item.type), + })); + }), + + // My Published - get user's published ARTICLE content + myPublished: protectedProcedure + .input(MyPublishedContentSchema) + .query(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + const now = new Date().toISOString(); + + const results = await ctx.db + .select({ + id: posts.id, + type: posts.type, + title: posts.title, + excerpt: posts.excerpt, + slug: posts.slug, + published: sql`${posts.status} = 'published'`, + publishedAt: posts.publishedAt, + upvotes: posts.upvotesCount, + downvotes: posts.downvotesCount, + createdAt: posts.createdAt, + updatedAt: posts.updatedAt, + }) + .from(posts) + .where( + and( + eq(posts.authorId, userId), + eq(posts.type, "article"), + eq(posts.status, "published"), + lte(posts.publishedAt, now) + ) + ) + .orderBy(desc(posts.publishedAt)) + .limit(input.limit); + + // Map types from DB format to frontend format + return results.map((item) => ({ + ...item, + type: toFrontendType(item.type), + })); + }), + + // My Scheduled - get user's scheduled ARTICLE content (publishedAt > now) + myScheduled: protectedProcedure + .input(MyScheduledContentSchema) + .query(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + const now = new Date().toISOString(); + + const results = await ctx.db + .select({ + id: posts.id, + type: posts.type, + title: posts.title, + excerpt: posts.excerpt, + slug: posts.slug, + published: sql`${posts.status} = 'published'`, + publishedAt: posts.publishedAt, + createdAt: posts.createdAt, + updatedAt: posts.updatedAt, + }) + .from(posts) + .where( + and( + eq(posts.authorId, userId), + eq(posts.type, "article"), + eq(posts.status, "scheduled"), + gt(posts.publishedAt, now) + ) + ) + .orderBy(desc(posts.publishedAt)) + .limit(input.limit); + + // Map types from DB format to frontend format + return results.map((item) => ({ + ...item, + type: toFrontendType(item.type), + })); + }), + + // Publish - separate mutation to publish/unpublish content + publish: protectedProcedure + .input(PublishContentSchema) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + + // Check ownership + const existing = await ctx.db + .select({ + id: posts.id, + authorId: posts.authorId, + title: posts.title, + slug: posts.slug, + status: posts.status, + }) + .from(posts) + .where(eq(posts.id, input.id)) + .limit(1); + + if (existing.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Content not found", + }); + } + + if (existing[0].authorId !== userId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can only publish your own content", + }); + } + + const updateData: Record = { + status: input.published ? "published" : "draft", + }; + + // Set publishedAt when publishing + if (input.published) { + if (input.publishTime) { + updateData.publishedAt = input.publishTime.toISOString(); + } else { + updateData.publishedAt = new Date().toISOString(); + } + + // Generate new slug if this is the first time publishing + if (existing[0].status === "draft" && existing[0].title) { + updateData.slug = generateSlug(existing[0].title); + } + } + + const [updated] = await ctx.db + .update(posts) + .set(updateData) + .where(eq(posts.id, input.id)) + .returning(); + + return updated; + }), }); diff --git a/server/api/router/discussion.ts b/server/api/router/discussion.ts index 2bb43edd..aaff5c61 100644 --- a/server/api/router/discussion.ts +++ b/server/api/router/discussion.ts @@ -6,7 +6,6 @@ import { EditDiscussionSchema, DeleteDiscussionSchema, GetDiscussionsSchema, - LikeDiscussionSchema, VoteDiscussionSchema, } from "@/schema/discussion"; import { @@ -14,95 +13,117 @@ import { NEW_REPLY_TO_YOUR_COMMENT, } from "@/utils/notifications"; import { - discussion, - discussion_like, - discussion_vote, + comments, + comment_votes, notification, - post, - aggregated_article, + posts, + user, } from "@/server/db/schema"; -import { and, count, desc, eq, isNull } from "drizzle-orm"; +import { and, count, desc, eq, isNull, sql } from "drizzle-orm"; import { db } from "@/server/db"; import { increment, decrement } from "./utils"; +// Helper to generate ltree path +function generatePath(parentPath: string | null, id: string): string { + const cleanId = id.replace(/-/g, ""); + if (parentPath) { + return `${parentPath}.${cleanId}`; + } + return cleanId; +} + +// Helper to calculate depth from path +function calculateDepth(path: string): number { + return path.split(".").length - 1; +} + export const discussionRouter = createTRPCRouter({ create: protectedProcedure .input(CreateDiscussionSchema) .mutation(async ({ input, ctx }) => { - const { body, targetType, postId, articleId, parentId } = input; + const { body, contentId, parentId } = input; const userId = ctx.session.user.id; - // Validate target exists - if (targetType === "POST" && postId) { - const postData = await ctx.db.query.post.findFirst({ - where: (posts, { eq }) => eq(posts.id, postId), - }); - if (!postData) { - throw new TRPCError({ code: "NOT_FOUND", message: "Post not found" }); - } - } else if (targetType === "ARTICLE" && articleId) { - const articleData = await ctx.db.query.aggregated_article.findFirst({ - where: (articles, { eq }) => eq(articles.id, articleId), + // Validate post exists (using new posts table) + const postData = await ctx.db + .select({ id: posts.id, authorId: posts.authorId }) + .from(posts) + .where(eq(posts.id, contentId)) + .limit(1); + + if (postData.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Post not found", }); - if (!articleData) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Article not found", - }); + } + + // Get parent comment if replying + let parentPath: string | null = null; + let parentAuthorId: string | null = null; + + if (parentId) { + const parentComment = await ctx.db + .select({ path: comments.path, authorId: comments.authorId }) + .from(comments) + .where(eq(comments.id, parentId)) + .limit(1); + + if (parentComment.length > 0) { + parentPath = parentComment[0].path; + parentAuthorId = parentComment[0].authorId; } } const now = new Date().toISOString(); - const [createdDiscussion] = await ctx.db - .insert(discussion) + // First insert the comment without the path (we need the generated ID for the path) + const [createdComment] = await ctx.db + .insert(comments) .values({ - userId, + authorId: userId, body, - targetType, - postId: targetType === "POST" ? postId : null, - articleId: targetType === "ARTICLE" ? articleId : null, - parentId, + postId: contentId, + parentId: parentId || null, + path: "temp", // Will update after we have the ID + depth: parentPath ? parentPath.split(".").length : 0, createdAt: now, updatedAt: now, }) .returning(); + // Now update with the correct path + const finalPath = generatePath(parentPath, createdComment.id); + await ctx.db + .update(comments) + .set({ path: finalPath }) + .where(eq(comments.id, createdComment.id)); + + // Update post's comment count + await ctx.db + .update(posts) + .set({ commentsCount: increment(posts.commentsCount) }) + .where(eq(posts.id, contentId)); + // Send notifications for replies - if (parentId) { - const parentDiscussion = await ctx.db.query.discussion.findFirst({ - where: (discussions, { eq }) => eq(discussions.id, parentId), - columns: { userId: true }, + if (parentId && parentAuthorId && parentAuthorId !== userId) { + await ctx.db.insert(notification).values({ + notifierId: userId, + type: NEW_REPLY_TO_YOUR_COMMENT, + userId: parentAuthorId, }); - - if (parentDiscussion?.userId && parentDiscussion.userId !== userId) { - await ctx.db.insert(notification).values({ - notifierId: userId, - type: NEW_REPLY_TO_YOUR_COMMENT, - postId: targetType === "POST" ? postId : null, - userId: parentDiscussion.userId, - }); - } } - // Send notification for new top-level discussion on posts - if (!parentId && targetType === "POST" && postId) { - const postData = await ctx.db.query.post.findFirst({ - where: (posts, { eq }) => eq(posts.id, postId), - columns: { userId: true }, + // Send notification for new top-level comment on user's post + if (!parentId && postData[0].authorId && postData[0].authorId !== userId) { + await ctx.db.insert(notification).values({ + notifierId: userId, + type: NEW_COMMENT_ON_YOUR_POST, + userId: postData[0].authorId, }); - - if (postData?.userId && postData.userId !== userId) { - await ctx.db.insert(notification).values({ - notifierId: userId, - type: NEW_COMMENT_ON_YOUR_POST, - postId, - userId: postData.userId, - }); - } } - return createdDiscussion.id; + return createdComment.id; }), edit: protectedProcedure @@ -110,25 +131,27 @@ export const discussionRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { const { body, id } = input; - const currentDiscussion = await ctx.db.query.discussion.findFirst({ - where: (discussions, { eq }) => eq(discussions.id, id), - }); + const currentComment = await ctx.db + .select({ authorId: comments.authorId, body: comments.body }) + .from(comments) + .where(eq(comments.id, id)) + .limit(1); - if (currentDiscussion?.userId !== ctx.session.user.id) { + if (currentComment.length === 0 || currentComment[0].authorId !== ctx.session.user.id) { throw new TRPCError({ code: "FORBIDDEN" }); } - if (currentDiscussion.body === body) { - return currentDiscussion; + if (currentComment[0].body === body) { + return { id, body }; } - const [updatedDiscussion] = await ctx.db - .update(discussion) - .set({ body }) - .where(eq(discussion.id, id)) + const [updatedComment] = await ctx.db + .update(comments) + .set({ body, updatedAt: new Date().toISOString() }) + .where(eq(comments.id, id)) .returning(); - return updatedDiscussion; + return updatedComment; }), delete: protectedProcedure @@ -136,81 +159,60 @@ export const discussionRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { const { id } = input; - const currentDiscussion = await ctx.db.query.discussion.findFirst({ - where: (discussions, { eq }) => eq(discussions.id, id), - }); + const currentComment = await ctx.db + .select({ authorId: comments.authorId, postId: comments.postId }) + .from(comments) + .where(eq(comments.id, id)) + .limit(1); - if (currentDiscussion?.userId !== ctx.session.user.id) { + if (currentComment.length === 0 || currentComment[0].authorId !== ctx.session.user.id) { throw new TRPCError({ code: "FORBIDDEN" }); } - const [deletedDiscussion] = await ctx.db - .delete(discussion) - .where(eq(discussion.id, id)) - .returning(); + // Soft delete: set deletedAt timestamp + await ctx.db + .update(comments) + .set({ deletedAt: new Date().toISOString() }) + .where(eq(comments.id, id)); - return deletedDiscussion.id; - }), + // Decrement post's comment count + await ctx.db + .update(posts) + .set({ commentsCount: decrement(posts.commentsCount) }) + .where(eq(posts.id, currentComment[0].postId)); - like: protectedProcedure - .input(LikeDiscussionSchema) - .mutation(async ({ input, ctx }) => { - const { discussionId } = input; - const userId = ctx.session.user.id; - - const existingLike = await ctx.db.query.discussion_like.findFirst({ - where: (likes, { eq }) => - and(eq(likes.userId, userId), eq(likes.discussionId, discussionId)), - }); - - if (existingLike) { - await ctx.db - .delete(discussion_like) - .where( - and( - eq(discussion_like.userId, userId), - eq(discussion_like.discussionId, discussionId), - ), - ); - return { liked: false }; - } else { - await ctx.db.insert(discussion_like).values({ - discussionId, - userId, - }); - return { liked: true }; - } + return id; }), // Reddit-style voting (upvote/downvote) vote: protectedProcedure .input(VoteDiscussionSchema) .mutation(async ({ input, ctx }) => { - const { discussionId, voteType } = input; + const { discussionId: commentId, voteType } = input; const userId = ctx.session.user.id; - // Check if discussion exists - const discussionItem = await ctx.db - .select({ id: discussion.id, upvotes: discussion.upvotes, downvotes: discussion.downvotes }) - .from(discussion) - .where(eq(discussion.id, discussionId)) + // Check if comment exists + const commentItem = await ctx.db + .select({ id: comments.id, upvotesCount: comments.upvotesCount, downvotesCount: comments.downvotesCount }) + .from(comments) + .where(eq(comments.id, commentId)) .limit(1); - if (discussionItem.length === 0) { + if (commentItem.length === 0) { throw new TRPCError({ code: "NOT_FOUND", - message: "Discussion not found", + message: "Comment not found", }); } // Get existing vote const existingVote = await ctx.db - .select({ id: discussion_vote.id, voteType: discussion_vote.voteType }) - .from(discussion_vote) + .select({ id: comment_votes.id, voteType: comment_votes.voteType }) + .from(comment_votes) .where( and( - eq(discussion_vote.discussionId, discussionId), - eq(discussion_vote.userId, userId) + eq(comment_votes.commentId, commentId), + eq(comment_votes.userId, userId) ) ) .limit(1); @@ -220,68 +222,68 @@ export const discussionRouter = createTRPCRouter({ if (existingVote.length > 0) { const oldVoteType = existingVote[0].voteType; await ctx.db - .delete(discussion_vote) - .where(eq(discussion_vote.id, existingVote[0].id)); + .delete(comment_votes) + .where(eq(comment_votes.id, existingVote[0].id)); // Update vote counts - if (oldVoteType === "UP") { + if (oldVoteType === "up") { await ctx.db - .update(discussion) - .set({ upvotes: decrement(discussion.upvotes) }) - .where(eq(discussion.id, discussionId)); + .update(comments) + .set({ upvotesCount: decrement(comments.upvotesCount) }) + .where(eq(comments.id, commentId)); } else { await ctx.db - .update(discussion) - .set({ downvotes: decrement(discussion.downvotes) }) - .where(eq(discussion.id, discussionId)); + .update(comments) + .set({ downvotesCount: decrement(comments.downvotesCount) }) + .where(eq(comments.id, commentId)); } } return { voteType: null }; } else if (existingVote.length === 0) { // New vote - await ctx.db.insert(discussion_vote).values({ - discussionId, + await ctx.db.insert(comment_votes).values({ + commentId, userId, - voteType, + voteType: voteType as "up" | "down", }); // Update vote counts - if (voteType === "UP") { + if (voteType === "up") { await ctx.db - .update(discussion) - .set({ upvotes: increment(discussion.upvotes) }) - .where(eq(discussion.id, discussionId)); + .update(comments) + .set({ upvotesCount: increment(comments.upvotesCount) }) + .where(eq(comments.id, commentId)); } else { await ctx.db - .update(discussion) - .set({ downvotes: increment(discussion.downvotes) }) - .where(eq(discussion.id, discussionId)); + .update(comments) + .set({ downvotesCount: increment(comments.downvotesCount) }) + .where(eq(comments.id, commentId)); } return { voteType }; } else if (existingVote[0].voteType !== voteType) { // Change vote await ctx.db - .update(discussion_vote) - .set({ voteType }) - .where(eq(discussion_vote.id, existingVote[0].id)); + .update(comment_votes) + .set({ voteType: voteType as "up" | "down" }) + .where(eq(comment_votes.id, existingVote[0].id)); // Update vote counts (flip both) - if (voteType === "UP") { + if (voteType === "up") { await ctx.db - .update(discussion) + .update(comments) .set({ - upvotes: increment(discussion.upvotes), - downvotes: decrement(discussion.downvotes), + upvotesCount: increment(comments.upvotesCount), + downvotesCount: decrement(comments.downvotesCount), }) - .where(eq(discussion.id, discussionId)); + .where(eq(comments.id, commentId)); } else { await ctx.db - .update(discussion) + .update(comments) .set({ - upvotes: decrement(discussion.upvotes), - downvotes: increment(discussion.downvotes), + upvotesCount: decrement(comments.upvotesCount), + downvotesCount: increment(comments.downvotesCount), }) - .where(eq(discussion.id, discussionId)); + .where(eq(comments.id, commentId)); } return { voteType }; } @@ -293,187 +295,105 @@ export const discussionRouter = createTRPCRouter({ get: publicProcedure .input(GetDiscussionsSchema) .query(async ({ ctx, input }) => { - const { targetType, postId, articleId } = input; + const { contentId } = input; const userId = ctx?.session?.user?.id; - // Build where clause based on target type - const whereClause = - targetType === "POST" && postId - ? and( - eq(discussion.targetType, "POST"), - eq(discussion.postId, postId), - isNull(discussion.parentId), - ) - : targetType === "ARTICLE" && articleId - ? and( - eq(discussion.targetType, "ARTICLE"), - eq(discussion.articleId, articleId), - isNull(discussion.parentId), - ) - : undefined; - - if (!whereClause) { - return { data: [], count: 0 }; + // Get total count (excluding soft-deleted) + const [commentCount] = await db + .select({ count: count() }) + .from(comments) + .where(and( + eq(comments.postId, contentId), + isNull(comments.deletedAt) + )); + + // Fetch all comments for this post (flat list, we'll build tree in JS) + const allComments = await db + .select({ + id: comments.id, + body: comments.body, + parentId: comments.parentId, + path: comments.path, + depth: comments.depth, + upvotesCount: comments.upvotesCount, + downvotesCount: comments.downvotesCount, + createdAt: comments.createdAt, + updatedAt: comments.updatedAt, + deletedAt: comments.deletedAt, + authorId: comments.authorId, + authorName: user.name, + authorImage: user.image, + authorUsername: user.username, + authorEmail: user.email, + }) + .from(comments) + .leftJoin(user, eq(comments.authorId, user.id)) + .where(eq(comments.postId, contentId)) + .orderBy(comments.path); // Order by path for tree structure + + // Get all votes for this user on these comments + let userVotes: Map = new Map(); + if (userId) { + const votes = await db + .select({ commentId: comment_votes.commentId, voteType: comment_votes.voteType }) + .from(comment_votes) + .where(eq(comment_votes.userId, userId)); + + userVotes = new Map(votes.map(v => [v.commentId, v.voteType])); } - // Get total count - const [discussionCount] = await db - .select({ count: count() }) - .from(discussion) - .where( - targetType === "POST" && postId - ? and( - eq(discussion.targetType, "POST"), - eq(discussion.postId, postId), - ) - : and( - eq(discussion.targetType, "ARTICLE"), - eq(discussion.articleId, articleId!), - ), - ); - - const columns = { - id: true, - body: true, - createdAt: true, - updatedAt: true, - upvotes: true, - downvotes: true, - }; - - const userColumns = { - name: true, - image: true, - username: true, - id: true, - email: true, - }; - - // Fetch discussions with nested children (6 levels deep like comments) - const response = await db.query.discussion.findMany({ - columns, - with: { - children: { - columns, - with: { - children: { - columns, - with: { - children: { - columns, - with: { - children: { - columns, - with: { - children: { - columns, - with: { - user: { columns: userColumns }, - likes: { columns: { userId: true } }, - votes: { columns: { userId: true, voteType: true } }, - }, - }, - user: { columns: userColumns }, - likes: { columns: { userId: true } }, - votes: { columns: { userId: true, voteType: true } }, - }, - }, - user: { columns: userColumns }, - likes: { columns: { userId: true } }, - votes: { columns: { userId: true, voteType: true } }, - }, - }, - user: { columns: userColumns }, - likes: { columns: { userId: true } }, - votes: { columns: { userId: true, voteType: true } }, - }, - }, - user: { columns: userColumns }, - likes: { columns: { userId: true } }, - votes: { columns: { userId: true, voteType: true } }, - }, + // Build tree structure + const commentMap = new Map(); + const rootComments: any[] = []; + + // First pass: create all comment objects + for (const comment of allComments) { + const shaped = { + id: comment.id, + body: comment.deletedAt ? null : comment.body, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + deletedAt: comment.deletedAt, + upvotes: comment.upvotesCount, + downvotes: comment.downvotesCount, + score: comment.upvotesCount - comment.downvotesCount, + userVote: userVotes.get(comment.id) || null, + user: comment.deletedAt ? null : { + id: comment.authorId, + name: comment.authorName, + image: comment.authorImage, + username: comment.authorUsername, + email: comment.authorEmail, }, - user: { columns: userColumns }, - likes: { columns: { userId: true } }, - votes: { columns: { userId: true, voteType: true } }, - }, - where: whereClause, - orderBy: [desc(discussion.createdAt)], - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function shapeDiscussions(discussionsArr: any[]): any[] { - return discussionsArr.map((disc) => { - const { children, likes, votes, upvotes, downvotes, ...rest } = disc; - - // Find the current user's vote - const userVoteRecord = votes?.find( - (v: { userId: string; voteType: string }) => v.userId === userId, - ); - - const shaped = { - // Legacy like support (for backwards compatibility) - youLikedThis: likes?.some( - (obj: { userId: string }) => obj.userId === userId, - ), - likeCount: likes?.length ?? 0, - // Reddit-style voting - userVote: userVoteRecord?.voteType ?? null, - score: (upvotes ?? 0) - (downvotes ?? 0), - upvotes: upvotes ?? 0, - downvotes: downvotes ?? 0, - ...rest, - }; - - if (children && children.length > 0) { - return { - ...shaped, - children: shapeDiscussions(children), - }; - } - return shaped; - }); + children: [], + }; + commentMap.set(comment.id, shaped); } - const discussions = shapeDiscussions(response); - - return { data: discussions, count: discussionCount.count }; - }), - - // Get discussion count for an article (useful for feed display) - getArticleDiscussionCount: publicProcedure - .input( - GetDiscussionsSchema.pick({ articleId: true }).extend({ - articleId: GetDiscussionsSchema.shape.articleId.unwrap(), - }), - ) - .query(async ({ input }) => { - const [result] = await db - .select({ count: count() }) - .from(discussion) - .where( - and( - eq(discussion.targetType, "ARTICLE"), - eq(discussion.articleId, input.articleId), - ), - ); + // Second pass: build tree + for (const comment of allComments) { + const shaped = commentMap.get(comment.id); + if (comment.parentId && commentMap.has(comment.parentId)) { + commentMap.get(comment.parentId).children.push(shaped); + } else if (!comment.parentId) { + rootComments.push(shaped); + } + } - return result.count; + return { data: rootComments, count: commentCount.count }; }), - // Get discussion count for unified content (useful for feed display) + // Get comment count for content (useful for feed display) getContentDiscussionCount: publicProcedure .input(z.object({ contentId: z.string() })) .query(async ({ input }) => { const [result] = await db .select({ count: count() }) - .from(discussion) - .where( - and( - eq(discussion.targetType, "CONTENT"), - eq(discussion.contentId, input.contentId), - ), - ); + .from(comments) + .where(and( + eq(comments.postId, input.contentId), + isNull(comments.deletedAt) + )); return result.count; }), diff --git a/server/api/router/feed.ts b/server/api/router/feed.ts index caae1963..7866fff1 100644 --- a/server/api/router/feed.ts +++ b/server/api/router/feed.ts @@ -16,19 +16,20 @@ import { DeleteFeedSourceSchema, GetArticleByIdSchema, GetArticleBySlugSchema, + GetArticleBySlugAndShortIdSchema, GetSourceBySlugSchema, GetArticlesBySourceSchema, + GetArticleBySourceAndArticleSlugSchema, + GetLinkContentBySourceAndSlugSchema, } from "../../../schema/feed"; import { - aggregated_article, - aggregated_article_vote, - aggregated_article_bookmark, - feed_source, - aggregated_article_tag, + posts, + post_votes, + bookmarks, + feed_sources, tag, - post, + post_tags, user, - bookmark, } from "@/server/db/schema"; import { and, @@ -45,21 +46,21 @@ import { increment, decrement } from "./utils"; import { db } from "@/server/db"; export const feedRouter = createTRPCRouter({ - // Get aggregated feed with optional community posts + // Get feed of link posts (external/RSS content) getFeed: publicProcedure.input(GetFeedSchema).query(async ({ ctx, input }) => { const userId = ctx.session?.user?.id; const limit = input?.limit ?? 20; - const { cursor, sort, category, tag: tagFilter, includeCommunity } = input; + const { cursor, sort, category } = input; // Build the vote subquery for current user const userVotes = userId ? ctx.db .select({ - articleId: aggregated_article_vote.articleId, - voteType: aggregated_article_vote.voteType, + postId: post_votes.postId, + voteType: post_votes.voteType, }) - .from(aggregated_article_vote) - .where(eq(aggregated_article_vote.userId, userId)) + .from(post_votes) + .where(eq(post_votes.userId, userId)) .as("userVotes") : null; @@ -67,24 +68,24 @@ export const feedRouter = createTRPCRouter({ const userBookmarks = userId ? ctx.db .select({ - articleId: aggregated_article_bookmark.articleId, + postId: bookmarks.postId, }) - .from(aggregated_article_bookmark) - .where(eq(aggregated_article_bookmark.userId, userId)) + .from(bookmarks) + .where(eq(bookmarks.userId, userId)) .as("userBookmarks") : null; // Calculate score for trending - const scoreExpr = sql`(${aggregated_article.upvotes} - ${aggregated_article.downvotes})`; + const scoreExpr = sql`(${posts.upvotesCount} - ${posts.downvotesCount})`; // Build order by and cursor conditions based on sort type const getOrderAndCursor = () => { switch (sort) { case "recent": return { - orderBy: desc(aggregated_article.publishedAt), + orderBy: desc(posts.publishedAt), cursorCondition: cursor - ? lte(aggregated_article.publishedAt, cursor.publishedAt as string) + ? lte(posts.publishedAt, cursor.publishedAt as string) : undefined, }; case "trending": @@ -97,14 +98,14 @@ export const feedRouter = createTRPCRouter({ }; case "popular": return { - orderBy: desc(aggregated_article.upvotes), + orderBy: desc(posts.upvotesCount), cursorCondition: cursor - ? lt(aggregated_article.upvotes, cursor.score as number) + ? lt(posts.upvotesCount, cursor.score as number) : undefined, }; default: return { - orderBy: desc(aggregated_article.publishedAt), + orderBy: desc(posts.publishedAt), cursorCondition: undefined, }; } @@ -112,52 +113,53 @@ export const feedRouter = createTRPCRouter({ const { orderBy, cursorCondition } = getOrderAndCursor(); - // Base query for aggregated articles + // Base query for link posts (external content) let query = ctx.db .select({ - id: aggregated_article.id, - shortId: aggregated_article.shortId, - title: aggregated_article.title, - excerpt: aggregated_article.excerpt, - url: aggregated_article.url, - imageUrl: aggregated_article.imageUrl, - ogImageUrl: aggregated_article.ogImageUrl, - author: aggregated_article.author, - publishedAt: aggregated_article.publishedAt, - upvotes: aggregated_article.upvotes, - downvotes: aggregated_article.downvotes, - clickCount: aggregated_article.clickCount, - sourceName: feed_source.name, - sourceSlug: feed_source.slug, - sourceLogo: feed_source.logoUrl, - sourceWebsite: feed_source.websiteUrl, - sourceCategory: feed_source.category, + id: posts.id, + slug: posts.slug, + title: posts.title, + excerpt: posts.excerpt, + url: posts.externalUrl, + imageUrl: posts.coverImage, + author: posts.sourceAuthor, + publishedAt: posts.publishedAt, + upvotes: posts.upvotesCount, + downvotes: posts.downvotesCount, + viewsCount: posts.viewsCount, + sourceName: feed_sources.name, + sourceSlug: feed_sources.slug, + sourceLogo: feed_sources.logoUrl, + sourceWebsite: feed_sources.websiteUrl, + sourceCategory: feed_sources.category, userVote: userVotes ? userVotes.voteType : sql`NULL`, isBookmarked: userBookmarks - ? sql`CASE WHEN ${userBookmarks.articleId} IS NOT NULL THEN TRUE ELSE FALSE END` + ? sql`CASE WHEN ${userBookmarks.postId} IS NOT NULL THEN TRUE ELSE FALSE END` : sql`FALSE`, }) - .from(aggregated_article) - .leftJoin(feed_source, eq(aggregated_article.sourceId, feed_source.id)); + .from(posts) + .leftJoin(feed_sources, eq(posts.sourceId, feed_sources.id)); // Add user joins if logged in if (userVotes) { query = query.leftJoin( userVotes, - eq(aggregated_article.id, userVotes.articleId), + eq(posts.id, userVotes.postId), ) as typeof query; } if (userBookmarks) { query = query.leftJoin( userBookmarks, - eq(aggregated_article.id, userBookmarks.articleId), + eq(posts.id, userBookmarks.postId), ) as typeof query; } - // Build where conditions + // Build where conditions - only link type posts with sources const whereConditions = [ - eq(feed_source.status, "ACTIVE"), - category ? eq(feed_source.category, category) : undefined, + eq(posts.type, "link"), + eq(posts.status, "published"), + isNotNull(posts.sourceId), + category ? eq(feed_sources.category, category) : undefined, cursorCondition, ].filter(Boolean); @@ -170,23 +172,24 @@ export const feedRouter = createTRPCRouter({ const articles = response.map((item) => ({ type: "aggregated" as const, id: item.id, - shortId: item.shortId, + shortId: null, // Not used in new schema + slug: item.slug, title: item.title, excerpt: item.excerpt, url: item.url, - imageUrl: item.ogImageUrl || item.imageUrl, + imageUrl: item.imageUrl, author: item.author, publishedAt: item.publishedAt, upvotes: item.upvotes, downvotes: item.downvotes, score: item.upvotes - item.downvotes, - clickCount: item.clickCount, + clickCount: item.viewsCount, sourceName: item.sourceName, sourceSlug: item.sourceSlug, sourceLogo: item.sourceLogo, sourceWebsite: item.sourceWebsite, sourceCategory: item.sourceCategory, - userVote: item.userVote as "UP" | "DOWN" | null, + userVote: item.userVote as "up" | "down" | null, isBookmarked: Boolean(item.isBookmarked), })); @@ -206,139 +209,95 @@ export const feedRouter = createTRPCRouter({ return { articles, nextCursor }; }), - // Vote on an aggregated article + // Vote on a post vote: protectedProcedure .input(VoteArticleSchema) .mutation(async ({ input, ctx }) => { const { articleId, voteType } = input; const userId = ctx.session.user.id; - // Verify article exists - const article = await ctx.db.query.aggregated_article.findFirst({ - where: eq(aggregated_article.id, articleId), + // Verify post exists + const postRecord = await ctx.db.query.posts.findFirst({ + where: eq(posts.id, articleId), }); - if (!article) { + if (!postRecord) { throw new TRPCError({ code: "NOT_FOUND", - message: "Article not found", + message: "Post not found", }); } // Check existing vote - const existingVote = - await ctx.db.query.aggregated_article_vote.findFirst({ - where: and( - eq(aggregated_article_vote.articleId, articleId), - eq(aggregated_article_vote.userId, userId), - ), - }); + const existingVote = await ctx.db.query.post_votes.findFirst({ + where: and( + eq(post_votes.postId, articleId), + eq(post_votes.userId, userId), + ), + }); - return await ctx.db.transaction(async (tx) => { - if (voteType === null) { - // Remove vote - if (existingVote) { - await tx - .delete(aggregated_article_vote) - .where(eq(aggregated_article_vote.id, existingVote.id)); - - // Update counts - if (existingVote.voteType === "UP") { - await tx - .update(aggregated_article) - .set({ upvotes: decrement(aggregated_article.upvotes) }) - .where(eq(aggregated_article.id, articleId)); - } else { - await tx - .update(aggregated_article) - .set({ downvotes: decrement(aggregated_article.downvotes) }) - .where(eq(aggregated_article.id, articleId)); - } - } - return { success: true, voteType: null }; - } + // Note: The new schema uses database triggers to update vote counts + // So we only need to insert/update/delete the vote record + if (voteType === null) { + // Remove vote if (existingVote) { - // Update existing vote if different - if (existingVote.voteType !== voteType) { - await tx - .update(aggregated_article_vote) - .set({ voteType }) - .where(eq(aggregated_article_vote.id, existingVote.id)); - - // Adjust counts (swap vote) - if (voteType === "UP") { - await tx - .update(aggregated_article) - .set({ - upvotes: increment(aggregated_article.upvotes), - downvotes: decrement(aggregated_article.downvotes), - }) - .where(eq(aggregated_article.id, articleId)); - } else { - await tx - .update(aggregated_article) - .set({ - upvotes: decrement(aggregated_article.upvotes), - downvotes: increment(aggregated_article.downvotes), - }) - .where(eq(aggregated_article.id, articleId)); - } - } - } else { - // Insert new vote - await tx - .insert(aggregated_article_vote) - .values({ articleId, userId, voteType }); - - if (voteType === "UP") { - await tx - .update(aggregated_article) - .set({ upvotes: increment(aggregated_article.upvotes) }) - .where(eq(aggregated_article.id, articleId)); - } else { - await tx - .update(aggregated_article) - .set({ downvotes: increment(aggregated_article.downvotes) }) - .where(eq(aggregated_article.id, articleId)); - } + await ctx.db + .delete(post_votes) + .where(eq(post_votes.id, existingVote.id)); + } + return { success: true, voteType: null }; + } + + if (existingVote) { + // Update existing vote if different + if (existingVote.voteType !== voteType) { + await ctx.db + .update(post_votes) + .set({ voteType: voteType as "up" | "down" }) + .where(eq(post_votes.id, existingVote.id)); } + } else { + // Insert new vote + await ctx.db + .insert(post_votes) + .values({ postId: articleId, userId, voteType: voteType as "up" | "down" }); + } - return { success: true, voteType }; - }); + return { success: true, voteType }; }), - // Bookmark an aggregated article + // Bookmark a post bookmark: protectedProcedure .input(BookmarkArticleSchema) .mutation(async ({ input, ctx }) => { const { articleId, setBookmarked } = input; const userId = ctx.session.user.id; - // Verify article exists - const article = await ctx.db.query.aggregated_article.findFirst({ - where: eq(aggregated_article.id, articleId), + // Verify post exists + const postRecord = await ctx.db.query.posts.findFirst({ + where: eq(posts.id, articleId), }); - if (!article) { + if (!postRecord) { throw new TRPCError({ code: "NOT_FOUND", - message: "Article not found", + message: "Post not found", }); } if (setBookmarked) { await ctx.db - .insert(aggregated_article_bookmark) - .values({ articleId, userId }) + .insert(bookmarks) + .values({ postId: articleId, userId }) .onConflictDoNothing(); } else { await ctx.db - .delete(aggregated_article_bookmark) + .delete(bookmarks) .where( and( - eq(aggregated_article_bookmark.articleId, articleId), - eq(aggregated_article_bookmark.userId, userId), + eq(bookmarks.postId, articleId), + eq(bookmarks.userId, userId), ), ); } @@ -346,118 +305,156 @@ export const feedRouter = createTRPCRouter({ return { success: true, bookmarked: setBookmarked }; }), - // Track article click + // Track post click/view trackClick: publicProcedure .input(TrackClickSchema) .mutation(async ({ input, ctx }) => { await ctx.db - .update(aggregated_article) - .set({ clickCount: increment(aggregated_article.clickCount) }) - .where(eq(aggregated_article.id, input.articleId)); + .update(posts) + .set({ viewsCount: increment(posts.viewsCount) }) + .where(eq(posts.id, input.articleId)); return { success: true }; }), - // Get user's saved/bookmarked aggregated articles + // Get user's saved/bookmarked articles (uses new unified tables) mySavedArticles: protectedProcedure.query(async ({ ctx }) => { const userId = ctx.session.user.id; + // Import from new schema + const { posts, bookmarks, feed_sources, user: userTable } = await import("@/server/db/schema"); + const saved = await ctx.db .select({ - id: aggregated_article.id, - shortId: aggregated_article.shortId, - title: aggregated_article.title, - excerpt: aggregated_article.excerpt, - url: aggregated_article.url, - imageUrl: aggregated_article.imageUrl, - ogImageUrl: aggregated_article.ogImageUrl, - author: aggregated_article.author, - publishedAt: aggregated_article.publishedAt, - upvotes: aggregated_article.upvotes, - downvotes: aggregated_article.downvotes, - sourceName: feed_source.name, - sourceSlug: feed_source.slug, - sourceLogo: feed_source.logoUrl, - sourceWebsite: feed_source.websiteUrl, - bookmarkedAt: aggregated_article_bookmark.createdAt, + id: posts.id, + shortId: sql`NULL`, // Not used in new schema + title: posts.title, + excerpt: posts.excerpt, + url: posts.externalUrl, + imageUrl: posts.coverImage, + ogImageUrl: posts.coverImage, + author: posts.sourceAuthor, + publishedAt: posts.publishedAt, + upvotes: posts.upvotesCount, + downvotes: posts.downvotesCount, + sourceName: feed_sources.name, + sourceSlug: feed_sources.slug, + sourceLogo: feed_sources.logoUrl, + sourceWebsite: feed_sources.websiteUrl, + bookmarkedAt: bookmarks.createdAt, }) - .from(aggregated_article_bookmark) - .innerJoin( - aggregated_article, - eq(aggregated_article_bookmark.articleId, aggregated_article.id), - ) - .leftJoin(feed_source, eq(aggregated_article.sourceId, feed_source.id)) - .where(eq(aggregated_article_bookmark.userId, userId)) - .orderBy(desc(aggregated_article_bookmark.createdAt)); + .from(bookmarks) + .innerJoin(posts, eq(bookmarks.postId, posts.id)) + .leftJoin(feed_sources, eq(posts.sourceId, feed_sources.id)) + .where(eq(bookmarks.userId, userId)) + .orderBy(desc(bookmarks.createdAt)); return saved; }), - // Get article by ID + // Get post by ID getById: publicProcedure .input(GetArticleByIdSchema) .query(async ({ input, ctx }) => { const userId = ctx.session?.user?.id; - const article = await ctx.db.query.aggregated_article.findFirst({ - where: eq(aggregated_article.id, input.id), - with: { - source: true, - tags: { - with: { - tag: true, - }, - }, - }, - }); + // Use select query instead of relations to avoid Drizzle inference issues + const postResults = await ctx.db + .select({ + id: posts.id, + title: posts.title, + slug: posts.slug, + excerpt: posts.excerpt, + externalUrl: posts.externalUrl, + coverImage: posts.coverImage, + sourceAuthor: posts.sourceAuthor, + sourceId: posts.sourceId, + publishedAt: posts.publishedAt, + upvotesCount: posts.upvotesCount, + downvotesCount: posts.downvotesCount, + sourceName: feed_sources.name, + sourceSlug: feed_sources.slug, + sourceLogo: feed_sources.logoUrl, + sourceWebsite: feed_sources.websiteUrl, + sourceCategory: feed_sources.category, + sourceDescription: feed_sources.description, + }) + .from(posts) + .leftJoin(feed_sources, eq(posts.sourceId, feed_sources.id)) + .where(eq(posts.id, input.id)) + .limit(1); + + const postRecord = postResults[0]; - if (!article) { + if (!postRecord) { throw new TRPCError({ code: "NOT_FOUND", - message: "Article not found", + message: "Post not found", }); } // Get user's vote and bookmark status - let userVote: "UP" | "DOWN" | null = null; + let userVote: "up" | "down" | null = null; let isBookmarked = false; if (userId) { - const vote = await ctx.db.query.aggregated_article_vote.findFirst({ - where: and( - eq(aggregated_article_vote.articleId, input.id), - eq(aggregated_article_vote.userId, userId), - ), - }); - userVote = vote?.voteType || null; - - const bookmarkRecord = - await ctx.db.query.aggregated_article_bookmark.findFirst({ - where: and( - eq(aggregated_article_bookmark.articleId, input.id), - eq(aggregated_article_bookmark.userId, userId), - ), - }); - isBookmarked = !!bookmarkRecord; + const voteResults = await ctx.db + .select({ voteType: post_votes.voteType }) + .from(post_votes) + .where(and( + eq(post_votes.postId, input.id), + eq(post_votes.userId, userId), + )) + .limit(1); + userVote = voteResults[0]?.voteType || null; + + const bookmarkResults = await ctx.db + .select({ id: bookmarks.id }) + .from(bookmarks) + .where(and( + eq(bookmarks.postId, input.id), + eq(bookmarks.userId, userId), + )) + .limit(1); + isBookmarked = bookmarkResults.length > 0; } + // Transform to match expected API shape return { - ...article, + id: postRecord.id, + title: postRecord.title, + slug: postRecord.slug, + excerpt: postRecord.excerpt, + externalUrl: postRecord.externalUrl, + imageUrl: postRecord.coverImage, + sourceAuthor: postRecord.sourceAuthor, + publishedAt: postRecord.publishedAt, + upvotes: postRecord.upvotesCount, + downvotes: postRecord.downvotesCount, + source: postRecord.sourceId ? { + id: postRecord.sourceId, + name: postRecord.sourceName, + slug: postRecord.sourceSlug, + logoUrl: postRecord.sourceLogo, + websiteUrl: postRecord.sourceWebsite, + category: postRecord.sourceCategory, + description: postRecord.sourceDescription, + } : null, userVote, isBookmarked, }; }), - // Get article by source slug and shortId (Reddit-style URL) + // Get post by source slug and shortId (legacy support - redirects to slug) getBySlugAndShortId: publicProcedure - .input(GetArticleBySlugSchema) + .input(GetArticleBySlugAndShortIdSchema) .query(async ({ input, ctx }) => { const userId = ctx.session?.user?.id; const { sourceSlug, shortId } = input; // Find the source by slug - const source = await ctx.db.query.feed_source.findFirst({ - where: eq(feed_source.slug, sourceSlug), + const source = await ctx.db.query.feed_sources.findFirst({ + where: eq(feed_sources.slug, sourceSlug), }); if (!source) { @@ -467,54 +464,154 @@ export const feedRouter = createTRPCRouter({ }); } - // Find the article by shortId and sourceId - const article = await ctx.db.query.aggregated_article.findFirst({ - where: and( - eq(aggregated_article.shortId, shortId), - eq(aggregated_article.sourceId, source.id), - ), - with: { - source: true, - tags: { - with: { - tag: true, - }, - }, - }, - }); + // Try to find post by slug (shortId could be the slug in new schema) + const postResults = await ctx.db + .select() + .from(posts) + .where(and( + eq(posts.slug, shortId), + eq(posts.sourceId, source.id), + )) + .limit(1); + + const postRecord = postResults[0]; - if (!article) { + if (!postRecord) { throw new TRPCError({ code: "NOT_FOUND", - message: "Article not found", + message: "Post not found", }); } // Get user's vote and bookmark status - let userVote: "UP" | "DOWN" | null = null; + let userVote: "up" | "down" | null = null; let isBookmarked = false; if (userId) { - const vote = await ctx.db.query.aggregated_article_vote.findFirst({ - where: and( - eq(aggregated_article_vote.articleId, article.id), - eq(aggregated_article_vote.userId, userId), - ), + const voteResults = await ctx.db + .select({ voteType: post_votes.voteType }) + .from(post_votes) + .where(and( + eq(post_votes.postId, postRecord.id), + eq(post_votes.userId, userId), + )) + .limit(1); + userVote = voteResults[0]?.voteType || null; + + const bookmarkResults = await ctx.db + .select({ id: bookmarks.id }) + .from(bookmarks) + .where(and( + eq(bookmarks.postId, postRecord.id), + eq(bookmarks.userId, userId), + )) + .limit(1); + isBookmarked = bookmarkResults.length > 0; + } + + return { + id: postRecord.id, + title: postRecord.title, + slug: postRecord.slug, + excerpt: postRecord.excerpt, + externalUrl: postRecord.externalUrl, + imageUrl: postRecord.coverImage, + sourceAuthor: postRecord.sourceAuthor, + publishedAt: postRecord.publishedAt, + upvotes: postRecord.upvotesCount, + downvotes: postRecord.downvotesCount, + source: source, // Use the source we already fetched + userVote, + isBookmarked, + }; + }), + + // Get post by source slug and post slug (SEO-friendly URL) + getBySourceAndArticleSlug: publicProcedure + .input(GetArticleBySourceAndArticleSlugSchema) + .query(async ({ input, ctx }) => { + const userId = ctx.session?.user?.id; + const { sourceSlug, articleSlug } = input; + + // Find the source by slug + const source = await ctx.db.query.feed_sources.findFirst({ + where: eq(feed_sources.slug, sourceSlug), + }); + + if (!source) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Source not found", }); - userVote = vote?.voteType || null; + } - const bookmarkRecord = - await ctx.db.query.aggregated_article_bookmark.findFirst({ - where: and( - eq(aggregated_article_bookmark.articleId, article.id), - eq(aggregated_article_bookmark.userId, userId), - ), - }); - isBookmarked = !!bookmarkRecord; + // Find the post by slug and sourceId using a select query instead of relations + const postResults = await ctx.db + .select() + .from(posts) + .where(and( + eq(posts.slug, articleSlug), + eq(posts.sourceId, source.id), + )) + .limit(1); + + const postRecord = postResults[0]; + + if (!postRecord) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Post not found", + }); + } + + // Fetch tags separately + const tagsResult = await ctx.db + .select({ tagId: post_tags.tagId, title: tag.title, id: tag.id }) + .from(post_tags) + .innerJoin(tag, eq(post_tags.tagId, tag.id)) + .where(eq(post_tags.postId, postRecord.id)); + + const postTags = tagsResult.map(t => ({ tag: { id: t.id, title: t.title } })); + + // Get user's vote and bookmark status + let userVote: "up" | "down" | null = null; + let isBookmarked = false; + + if (userId) { + const voteResults = await ctx.db + .select({ voteType: post_votes.voteType }) + .from(post_votes) + .where(and( + eq(post_votes.postId, postRecord.id), + eq(post_votes.userId, userId), + )) + .limit(1); + userVote = voteResults[0]?.voteType || null; + + const bookmarkResults = await ctx.db + .select({ id: bookmarks.id }) + .from(bookmarks) + .where(and( + eq(bookmarks.postId, postRecord.id), + eq(bookmarks.userId, userId), + )) + .limit(1); + isBookmarked = bookmarkResults.length > 0; } return { - ...article, + id: postRecord.id, + title: postRecord.title, + slug: postRecord.slug, + excerpt: postRecord.excerpt, + externalUrl: postRecord.externalUrl, + imageUrl: postRecord.coverImage, + sourceAuthor: postRecord.sourceAuthor, + publishedAt: postRecord.publishedAt, + upvotes: postRecord.upvotesCount, + downvotes: postRecord.downvotesCount, + source: source, // Use the source we already fetched + tags: postTags, userVote, isBookmarked, }; @@ -526,8 +623,8 @@ export const feedRouter = createTRPCRouter({ .query(async ({ input }) => { const { slug } = input; - const source = await db.query.feed_source.findFirst({ - where: eq(feed_source.slug, slug), + const source = await db.query.feed_sources.findFirst({ + where: eq(feed_sources.slug, slug), }); if (!source) { @@ -540,17 +637,23 @@ export const feedRouter = createTRPCRouter({ // Get article count for this source const [articleCountResult] = await db .select({ count: count() }) - .from(aggregated_article) - .where(eq(aggregated_article.sourceId, source.id)); + .from(posts) + .where(and( + eq(posts.sourceId, source.id), + eq(posts.status, "published"), + )); - // Get total upvotes across all articles from this source + // Get total upvotes across all posts from this source const [totalVotesResult] = await db .select({ - totalUpvotes: sql`COALESCE(SUM(${aggregated_article.upvotes}), 0)`, - totalDownvotes: sql`COALESCE(SUM(${aggregated_article.downvotes}), 0)`, + totalUpvotes: sql`COALESCE(SUM(${posts.upvotesCount}), 0)`, + totalDownvotes: sql`COALESCE(SUM(${posts.downvotesCount}), 0)`, }) - .from(aggregated_article) - .where(eq(aggregated_article.sourceId, source.id)); + .from(posts) + .where(and( + eq(posts.sourceId, source.id), + eq(posts.status, "published"), + )); return { ...source, @@ -560,16 +663,17 @@ export const feedRouter = createTRPCRouter({ }; }), - // Get paginated articles by source + // Get paginated posts by source getArticlesBySource: publicProcedure .input(GetArticlesBySourceSchema) .query(async ({ input, ctx }) => { const userId = ctx.session?.user?.id; - const { sourceSlug, limit, cursor, sort } = input; + const { sourceSlug, cursor, sort } = input; + const limit = input.limit ?? 20; // Find the source by slug - const source = await ctx.db.query.feed_source.findFirst({ - where: eq(feed_source.slug, sourceSlug), + const source = await ctx.db.query.feed_sources.findFirst({ + where: eq(feed_sources.slug, sourceSlug), }); if (!source) { @@ -583,11 +687,11 @@ export const feedRouter = createTRPCRouter({ const userVotes = userId ? ctx.db .select({ - articleId: aggregated_article_vote.articleId, - voteType: aggregated_article_vote.voteType, + postId: post_votes.postId, + voteType: post_votes.voteType, }) - .from(aggregated_article_vote) - .where(eq(aggregated_article_vote.userId, userId)) + .from(post_votes) + .where(eq(post_votes.userId, userId)) .as("userVotes") : null; @@ -595,25 +699,25 @@ export const feedRouter = createTRPCRouter({ const userBookmarks = userId ? ctx.db .select({ - articleId: aggregated_article_bookmark.articleId, + postId: bookmarks.postId, }) - .from(aggregated_article_bookmark) - .where(eq(aggregated_article_bookmark.userId, userId)) + .from(bookmarks) + .where(eq(bookmarks.userId, userId)) .as("userBookmarks") : null; // Calculate score for trending - const scoreExpr = sql`(${aggregated_article.upvotes} - ${aggregated_article.downvotes})`; + const scoreExpr = sql`(${posts.upvotesCount} - ${posts.downvotesCount})`; // Build order by and cursor conditions based on sort type const getOrderAndCursor = () => { switch (sort) { case "recent": return { - orderBy: desc(aggregated_article.publishedAt), + orderBy: desc(posts.publishedAt), cursorCondition: cursor ? lte( - aggregated_article.publishedAt, + posts.publishedAt, cursor.publishedAt as string, ) : undefined, @@ -625,12 +729,12 @@ export const feedRouter = createTRPCRouter({ }; case "popular": return { - orderBy: desc(aggregated_article.upvotes), + orderBy: desc(posts.upvotesCount), cursorCondition: undefined, }; default: return { - orderBy: desc(aggregated_article.publishedAt), + orderBy: desc(posts.publishedAt), cursorCondition: undefined, }; } @@ -638,51 +742,51 @@ export const feedRouter = createTRPCRouter({ const { orderBy, cursorCondition } = getOrderAndCursor(); - // Base query for aggregated articles + // Base query for posts let query = ctx.db .select({ - id: aggregated_article.id, - shortId: aggregated_article.shortId, - title: aggregated_article.title, - excerpt: aggregated_article.excerpt, - url: aggregated_article.url, - imageUrl: aggregated_article.imageUrl, - ogImageUrl: aggregated_article.ogImageUrl, - author: aggregated_article.author, - publishedAt: aggregated_article.publishedAt, - upvotes: aggregated_article.upvotes, - downvotes: aggregated_article.downvotes, - clickCount: aggregated_article.clickCount, - sourceName: feed_source.name, - sourceSlug: feed_source.slug, - sourceLogo: feed_source.logoUrl, - sourceWebsite: feed_source.websiteUrl, - sourceCategory: feed_source.category, + id: posts.id, + slug: posts.slug, + title: posts.title, + excerpt: posts.excerpt, + url: posts.externalUrl, + imageUrl: posts.coverImage, + author: posts.sourceAuthor, + publishedAt: posts.publishedAt, + upvotes: posts.upvotesCount, + downvotes: posts.downvotesCount, + viewsCount: posts.viewsCount, + sourceName: feed_sources.name, + sourceSlug: feed_sources.slug, + sourceLogo: feed_sources.logoUrl, + sourceWebsite: feed_sources.websiteUrl, + sourceCategory: feed_sources.category, userVote: userVotes ? userVotes.voteType : sql`NULL`, isBookmarked: userBookmarks - ? sql`CASE WHEN ${userBookmarks.articleId} IS NOT NULL THEN TRUE ELSE FALSE END` + ? sql`CASE WHEN ${userBookmarks.postId} IS NOT NULL THEN TRUE ELSE FALSE END` : sql`FALSE`, }) - .from(aggregated_article) - .leftJoin(feed_source, eq(aggregated_article.sourceId, feed_source.id)); + .from(posts) + .leftJoin(feed_sources, eq(posts.sourceId, feed_sources.id)); // Add user joins if logged in if (userVotes) { query = query.leftJoin( userVotes, - eq(aggregated_article.id, userVotes.articleId), + eq(posts.id, userVotes.postId), ) as typeof query; } if (userBookmarks) { query = query.leftJoin( userBookmarks, - eq(aggregated_article.id, userBookmarks.articleId), + eq(posts.id, userBookmarks.postId), ) as typeof query; } // Build where conditions const whereConditions = [ - eq(aggregated_article.sourceId, source.id), + eq(posts.sourceId, source.id), + eq(posts.status, "published"), cursorCondition, ].filter(Boolean); @@ -695,23 +799,24 @@ export const feedRouter = createTRPCRouter({ const articles = response.map((item) => ({ type: "aggregated" as const, id: item.id, - shortId: item.shortId, + shortId: null, + slug: item.slug, title: item.title, excerpt: item.excerpt, url: item.url, - imageUrl: item.ogImageUrl || item.imageUrl, + imageUrl: item.imageUrl, author: item.author, publishedAt: item.publishedAt, upvotes: item.upvotes, downvotes: item.downvotes, score: item.upvotes - item.downvotes, - clickCount: item.clickCount, + clickCount: item.viewsCount, sourceName: item.sourceName, sourceSlug: item.sourceSlug, sourceLogo: item.sourceLogo, sourceWebsite: item.sourceWebsite, sourceCategory: item.sourceCategory, - userVote: item.userVote as "UP" | "DOWN" | null, + userVote: item.userVote as "up" | "down" | null, isBookmarked: Boolean(item.isBookmarked), })); @@ -737,25 +842,27 @@ export const feedRouter = createTRPCRouter({ const whereConditions = []; if (input?.status) { - whereConditions.push(eq(feed_source.status, input.status)); + // Convert uppercase status to lowercase for new schema + const statusLower = input.status.toLowerCase() as "active" | "paused" | "error"; + whereConditions.push(eq(feed_sources.status, statusLower)); } if (input?.category) { - whereConditions.push(eq(feed_source.category, input.category)); + whereConditions.push(eq(feed_sources.category, input.category)); } - return await ctx.db.query.feed_source.findMany({ + return await ctx.db.query.feed_sources.findMany({ where: whereConditions.length > 0 ? and(...whereConditions) : undefined, - orderBy: asc(feed_source.name), + orderBy: asc(feed_sources.name), }); }), // Get distinct categories getCategories: publicProcedure.query(async ({ ctx }) => { const categories = await ctx.db - .selectDistinct({ category: feed_source.category }) - .from(feed_source) + .selectDistinct({ category: feed_sources.category }) + .from(feed_sources) .where( - and(eq(feed_source.status, "ACTIVE"), isNotNull(feed_source.category)), + and(eq(feed_sources.status, "active"), isNotNull(feed_sources.category)), ); return categories @@ -767,20 +874,20 @@ export const feedRouter = createTRPCRouter({ getSourceStats: adminOnlyProcedure.query(async ({ ctx }) => { const stats = await ctx.db .select({ - sourceId: feed_source.id, - sourceName: feed_source.name, - status: feed_source.status, - articleCount: count(aggregated_article.id), - lastFetchedAt: feed_source.lastFetchedAt, - errorCount: feed_source.errorCount, + sourceId: feed_sources.id, + sourceName: feed_sources.name, + status: feed_sources.status, + articleCount: count(posts.id), + lastFetchedAt: feed_sources.lastFetchedAt, + errorCount: feed_sources.errorCount, }) - .from(feed_source) + .from(feed_sources) .leftJoin( - aggregated_article, - eq(feed_source.id, aggregated_article.sourceId), + posts, + eq(feed_sources.id, posts.sourceId), ) - .groupBy(feed_source.id) - .orderBy(desc(feed_source.createdAt)); + .groupBy(feed_sources.id) + .orderBy(desc(feed_sources.createdAt)); return stats; }), @@ -790,8 +897,8 @@ export const feedRouter = createTRPCRouter({ .input(CreateFeedSourceSchema) .mutation(async ({ input, ctx }) => { // Check if URL already exists - const existing = await ctx.db.query.feed_source.findFirst({ - where: eq(feed_source.url, input.url), + const existing = await ctx.db.query.feed_sources.findFirst({ + where: eq(feed_sources.url, input.url), }); if (existing) { @@ -802,8 +909,11 @@ export const feedRouter = createTRPCRouter({ } const [newSource] = await ctx.db - .insert(feed_source) - .values(input) + .insert(feed_sources) + .values({ + ...input, + status: "active", // Default status for new sources + }) .returning(); return newSource; @@ -815,8 +925,8 @@ export const feedRouter = createTRPCRouter({ .mutation(async ({ input, ctx }) => { const { id, ...data } = input; - const existing = await ctx.db.query.feed_source.findFirst({ - where: eq(feed_source.id, id), + const existing = await ctx.db.query.feed_sources.findFirst({ + where: eq(feed_sources.id, id), }); if (!existing) { @@ -826,10 +936,16 @@ export const feedRouter = createTRPCRouter({ }); } + // Build update data, converting status to lowercase if provided + const updateData: Record = { ...data }; + if (data.status) { + updateData.status = data.status.toLowerCase() as "active" | "paused" | "error"; + } + const [updated] = await ctx.db - .update(feed_source) - .set(data) - .where(eq(feed_source.id, id)) + .update(feed_sources) + .set(updateData) + .where(eq(feed_sources.id, id)) .returning(); return updated; @@ -839,8 +955,8 @@ export const feedRouter = createTRPCRouter({ deleteSource: adminOnlyProcedure .input(DeleteFeedSourceSchema) .mutation(async ({ input, ctx }) => { - const existing = await ctx.db.query.feed_source.findFirst({ - where: eq(feed_source.id, input.id), + const existing = await ctx.db.query.feed_sources.findFirst({ + where: eq(feed_sources.id, input.id), }); if (!existing) { @@ -850,8 +966,91 @@ export const feedRouter = createTRPCRouter({ }); } - await ctx.db.delete(feed_source).where(eq(feed_source.id, input.id)); + await ctx.db.delete(feed_sources).where(eq(feed_sources.id, input.id)); return { success: true }; }), + + // Get link post by source slug and post slug + getLinkContentBySourceAndSlug: publicProcedure + .input(GetLinkContentBySourceAndSlugSchema) + .query(async ({ input, ctx }) => { + const userId = ctx.session?.user?.id; + const { sourceSlug, contentSlug } = input; + + // Find the source by slug + const source = await ctx.db.query.feed_sources.findFirst({ + where: eq(feed_sources.slug, sourceSlug), + }); + + if (!source) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Source not found", + }); + } + + // Find the link post by slug and source using select instead of relations + const postResults = await ctx.db + .select() + .from(posts) + .where(and( + eq(posts.slug, contentSlug), + eq(posts.sourceId, source.id), + eq(posts.type, "link"), + eq(posts.status, "published"), + )) + .limit(1); + + const postRecord = postResults[0]; + + if (!postRecord) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Link post not found", + }); + } + + // Get user's vote and bookmark status + let userVote: "up" | "down" | null = null; + let isBookmarked = false; + + if (userId) { + const voteResults = await ctx.db + .select({ voteType: post_votes.voteType }) + .from(post_votes) + .where(and( + eq(post_votes.postId, postRecord.id), + eq(post_votes.userId, userId), + )) + .limit(1); + userVote = voteResults[0]?.voteType || null; + + const bookmarkResults = await ctx.db + .select({ id: bookmarks.id }) + .from(bookmarks) + .where(and( + eq(bookmarks.postId, postRecord.id), + eq(bookmarks.userId, userId), + )) + .limit(1); + isBookmarked = bookmarkResults.length > 0; + } + + return { + id: postRecord.id, + title: postRecord.title, + slug: postRecord.slug, + excerpt: postRecord.excerpt, + externalUrl: postRecord.externalUrl, + imageUrl: postRecord.coverImage, + sourceAuthor: postRecord.sourceAuthor, + publishedAt: postRecord.publishedAt, + upvotes: postRecord.upvotesCount, + downvotes: postRecord.downvotesCount, + source: source, // Use the source we already fetched + userVote, + isBookmarked, + }; + }), }); diff --git a/server/api/router/index.ts b/server/api/router/index.ts index ef59de57..d08ce101 100644 --- a/server/api/router/index.ts +++ b/server/api/router/index.ts @@ -2,25 +2,32 @@ import { createTRPCRouter } from "../trpc"; import { postRouter } from "./post"; import { profileRouter } from "./profile"; import { commentRouter } from "./comment"; -import { discussionRouter } from "./discussion"; - import { notificationRouter } from "./notification"; import { adminRouter } from "./admin"; import { reportRouter } from "./report"; import { tagRouter } from "./tag"; import { feedRouter } from "./feed"; +import { sponsorRouter } from "./sponsor"; + +// Legacy routers (kept for backward compatibility during migration) +import { discussionRouter } from "./discussion"; import { contentRouter } from "./content"; export const appRouter = createTRPCRouter({ + // Primary routers (using new schema) post: postRouter, - profile: profileRouter, comment: commentRouter, - discussion: discussionRouter, + profile: profileRouter, notification: notificationRouter, admin: adminRouter, report: reportRouter, tag: tagRouter, feed: feedRouter, + sponsor: sponsorRouter, + + // Legacy routers (for backward compatibility) + // TODO: Remove once all frontend is migrated + discussion: discussionRouter, content: contentRouter, }); diff --git a/server/api/router/post.ts b/server/api/router/post.ts index d1e78c2d..ebb8a5b5 100644 --- a/server/api/router/post.ts +++ b/server/api/router/post.ts @@ -1,400 +1,1313 @@ -import { nanoid } from "nanoid"; import { TRPCError } from "@trpc/server"; -import { readingTime } from "@/utils/readingTime"; import { createTRPCRouter, publicProcedure, protectedProcedure } from "../trpc"; import { - PublishPostSchema, - SavePostSchema, + GetFeedSchema, + GetPostByIdSchema, + GetPostBySlugSchema, CreatePostSchema, + SavePostSchema, DeletePostSchema, - GetPostsSchema, - LikePostSchema, VotePostSchema, BookmarkPostSchema, + GetUserPostsSchema, + GetBookmarkedPostsSchema, GetByIdSchema, + PublishPostSchema, + GetPostsSchema, GetLimitSidePosts, -} from "../../../schema/post"; -import { removeMarkdown } from "../../../utils/removeMarkdown"; -import { bookmark, like, post, post_tag, post_vote, tag, user } from "@/server/db/schema"; + FeaturePostSchema, + PinPostSchema, +} from "@/schema/post"; +import { + posts, + postVotes, + bookmarks, + postTags, + feedSources, + comments, + tag, + user, +} from "@/server/db/schema"; import { and, eq, - gt, - inArray, - isNotNull, - isNull, - lte, desc, lt, - asc, + lte, + gt, gte, sql, + isNotNull, + count, + inArray, + asc, + isNull, } from "drizzle-orm"; -import { decrement, increment } from "./utils"; +import { increment, decrement } from "./utils"; +import crypto from "crypto"; + +// Helper to generate slug from title +function generateSlug(title: string): string { + const baseSlug = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .substring(0, 80); + const uniqueId = crypto.randomBytes(3).toString("hex"); + return `${baseSlug}-${uniqueId}`; +} + +// Helper to calculate read time +function calculateReadTime(body: string | null | undefined): number { + if (!body) return 1; + const wordsPerMinute = 200; + const words = body.trim().split(/\s+/).length; + return Math.max(1, Math.ceil(words / wordsPerMinute)); +} export const postRouter = createTRPCRouter({ - create: protectedProcedure - .input(CreatePostSchema) - .mutation(async ({ input, ctx }) => { - const { body } = input; - const id = nanoid(8); - const [newPost] = await ctx.db - .insert(post) - .values({ - ...input, - id, - readTimeMins: readingTime(body), - slug: id, - userId: ctx.session.user.id, - }) - .returning(); + // Get unified feed with optional type filtering + getFeed: publicProcedure + .input(GetFeedSchema) + .query(async ({ ctx, input }) => { + const userId = ctx.session?.user?.id; + const limit = input?.limit ?? 25; + const { cursor, sort, type, category, sourceId, tag: tagFilter, authorId } = input; - return newPost; + // Build the vote subquery for current user + const userVotesSubquery = userId + ? ctx.db + .select({ + postId: postVotes.postId, + voteType: postVotes.voteType, + }) + .from(postVotes) + .where(eq(postVotes.userId, userId)) + .as("userVotes") + : null; + + // Build the bookmark subquery for current user + const userBookmarksSubquery = userId + ? ctx.db + .select({ + postId: bookmarks.postId, + }) + .from(bookmarks) + .where(eq(bookmarks.userId, userId)) + .as("userBookmarks") + : null; + + // Calculate score for trending + const scoreExpr = sql`(${posts.upvotesCount} - ${posts.downvotesCount})`; + + // Build conditions + const conditions = [eq(posts.status, "published")]; + + if (type) { + conditions.push(eq(posts.type, type)); + } + + if (sourceId) { + conditions.push(eq(posts.sourceId, sourceId)); + } + + if (authorId) { + conditions.push(eq(posts.authorId, authorId)); + } + + // Build order by and cursor conditions based on sort type + const getOrderAndCursor = () => { + switch (sort) { + case "recent": + return { + orderBy: desc(posts.publishedAt), + cursorCondition: cursor?.publishedAt + ? lte(posts.publishedAt, cursor.publishedAt) + : undefined, + }; + case "trending": + return { + orderBy: desc(scoreExpr), + cursorCondition: cursor?.score !== undefined + ? lt(scoreExpr, cursor.score) + : undefined, + }; + case "popular": + return { + orderBy: desc(posts.upvotesCount), + cursorCondition: cursor?.score !== undefined + ? lt(posts.upvotesCount, cursor.score) + : undefined, + }; + default: + return { + orderBy: desc(posts.publishedAt), + cursorCondition: undefined, + }; + } + }; + + const { orderBy, cursorCondition } = getOrderAndCursor(); + + if (cursorCondition) { + conditions.push(cursorCondition); + } + + // Build query + let query; + if (userVotesSubquery && userBookmarksSubquery) { + query = ctx.db + .select({ + id: posts.id, + type: posts.type, + title: posts.title, + excerpt: posts.excerpt, + body: posts.body, + externalUrl: posts.externalUrl, + coverImage: posts.coverImage, + slug: posts.slug, + publishedAt: posts.publishedAt, + upvotesCount: posts.upvotesCount, + downvotesCount: posts.downvotesCount, + commentsCount: posts.commentsCount, + viewsCount: posts.viewsCount, + readingTime: posts.readingTime, + authorId: posts.authorId, + sourceId: posts.sourceId, + sourceAuthor: posts.sourceAuthor, + featured: posts.featured, + pinnedUntil: posts.pinnedUntil, + createdAt: posts.createdAt, + // Source info + sourceName: feedSources.name, + sourceSlug: feedSources.slug, + sourceLogo: feedSources.logoUrl, + sourceWebsite: feedSources.websiteUrl, + sourceCategory: feedSources.category, + // Author info + authorName: user.name, + authorUsername: user.username, + authorImage: user.image, + // User-specific + userVote: userVotesSubquery.voteType, + isBookmarked: sql`${userBookmarksSubquery.postId} IS NOT NULL`, + }) + .from(posts) + .leftJoin(feedSources, eq(posts.sourceId, feedSources.id)) + .leftJoin(user, eq(posts.authorId, user.id)) + .leftJoin(userVotesSubquery, eq(posts.id, userVotesSubquery.postId)) + .leftJoin(userBookmarksSubquery, eq(posts.id, userBookmarksSubquery.postId)) + .where(and(...conditions)) + .orderBy(orderBy) + .limit(limit + 1); + } else { + query = ctx.db + .select({ + id: posts.id, + type: posts.type, + title: posts.title, + excerpt: posts.excerpt, + body: posts.body, + externalUrl: posts.externalUrl, + coverImage: posts.coverImage, + slug: posts.slug, + publishedAt: posts.publishedAt, + upvotesCount: posts.upvotesCount, + downvotesCount: posts.downvotesCount, + commentsCount: posts.commentsCount, + viewsCount: posts.viewsCount, + readingTime: posts.readingTime, + authorId: posts.authorId, + sourceId: posts.sourceId, + sourceAuthor: posts.sourceAuthor, + featured: posts.featured, + pinnedUntil: posts.pinnedUntil, + createdAt: posts.createdAt, + // Source info + sourceName: feedSources.name, + sourceSlug: feedSources.slug, + sourceLogo: feedSources.logoUrl, + sourceWebsite: feedSources.websiteUrl, + sourceCategory: feedSources.category, + // Author info + authorName: user.name, + authorUsername: user.username, + authorImage: user.image, + // User-specific (null when not logged in) + userVote: sql<"up" | "down" | null>`NULL`, + isBookmarked: sql`FALSE`, + }) + .from(posts) + .leftJoin(feedSources, eq(posts.sourceId, feedSources.id)) + .leftJoin(user, eq(posts.authorId, user.id)) + .where(and(...conditions)) + .orderBy(orderBy) + .limit(limit + 1); + } + + const results = await query; + + // Check if there's a next page + let nextCursor: { id: string; publishedAt?: string; score?: number } | undefined; + if (results.length > limit) { + const lastItem = results.pop()!; + const score = lastItem.upvotesCount - lastItem.downvotesCount; + nextCursor = { + id: lastItem.id, + publishedAt: lastItem.publishedAt || undefined, + score, + }; + } + + return { + items: results, + nextCursor, + }; }), - update: protectedProcedure - .input(SavePostSchema) - .mutation(async ({ input, ctx }) => { - const { id, body, title, excerpt, canonicalUrl, tags = [] } = input; - const currentPost = await ctx.db.query.post.findFirst({ - where: (posts, { eq }) => eq(posts.id, id), - }); + // Get post by ID + getById: publicProcedure + .input(GetPostByIdSchema) + .query(async ({ ctx, input }) => { + const userId = ctx.session?.user?.id; + + const results = await ctx.db + .select({ + id: posts.id, + type: posts.type, + title: posts.title, + body: posts.body, + excerpt: posts.excerpt, + externalUrl: posts.externalUrl, + coverImage: posts.coverImage, + slug: posts.slug, + canonicalUrl: posts.canonicalUrl, + publishedAt: posts.publishedAt, + upvotesCount: posts.upvotesCount, + downvotesCount: posts.downvotesCount, + commentsCount: posts.commentsCount, + viewsCount: posts.viewsCount, + readingTime: posts.readingTime, + showComments: posts.showComments, + authorId: posts.authorId, + sourceId: posts.sourceId, + sourceAuthor: posts.sourceAuthor, + status: posts.status, + featured: posts.featured, + pinnedUntil: posts.pinnedUntil, + createdAt: posts.createdAt, + updatedAt: posts.updatedAt, + // Source info + sourceName: feedSources.name, + sourceSlug: feedSources.slug, + sourceLogo: feedSources.logoUrl, + sourceWebsite: feedSources.websiteUrl, + sourceCategory: feedSources.category, + // Author info + authorName: user.name, + authorUsername: user.username, + authorImage: user.image, + }) + .from(posts) + .leftJoin(feedSources, eq(posts.sourceId, feedSources.id)) + .leftJoin(user, eq(posts.authorId, user.id)) + .where(eq(posts.id, input.id)) + .limit(1); - if (currentPost?.userId !== ctx.session.user.id) { + if (results.length === 0) { throw new TRPCError({ - code: "FORBIDDEN", + code: "NOT_FOUND", + message: "Post not found", }); } - // if user doesnt link any tags to the article no point in doing the tag operations - // This also makes autosave during writing faster - if (tags.length > 0) { - const existingTags = await ctx.db - .select() - .from(tag) - .where(inArray(tag.title, tags)); - - const tagResponse = ( - await Promise.all( - tags.map((tagTitle) => - ctx.db - .insert(tag) - .values({ title: tagTitle }) - .onConflictDoNothing({ - target: [tag.title], - }) - .returning(), - ), - ) - ).flat(2); + const item = results[0]; - const tagsToLinkToPost = [...tagResponse, ...existingTags]; + // Get user vote if logged in + let userVote: "up" | "down" | null = null; + let isBookmarked = false; - await ctx.db.delete(post_tag).where(eq(post_tag.postId, id)); + if (userId) { + const [voteResult, bookmarkResult] = await Promise.all([ + ctx.db + .select({ voteType: postVotes.voteType }) + .from(postVotes) + .where( + and( + eq(postVotes.postId, input.id), + eq(postVotes.userId, userId) + ) + ) + .limit(1), + ctx.db + .select({ id: bookmarks.id }) + .from(bookmarks) + .where( + and( + eq(bookmarks.postId, input.id), + eq(bookmarks.userId, userId) + ) + ) + .limit(1), + ]); - await Promise.all( - tagsToLinkToPost.map((tag) => - ctx.db.insert(post_tag).values({ - tagId: tag.id, - postId: id, - }), - ), - ); + userVote = voteResult[0]?.voteType ?? null; + isBookmarked = bookmarkResult.length > 0; } - const getExcerptValue = (): string | undefined => { - if (currentPost.published) { - return excerpt && excerpt.length > 0 - ? excerpt - : // @Todo why is body string | null ? - removeMarkdown(currentPost.body as string, {}).substring(0, 156); - } - return excerpt; + return { + ...item, + userVote, + isBookmarked, }; + }), - const postResponse = await ctx.db - .update(post) - .set({ - id, - body, - title, - excerpt: getExcerptValue() || "", - readTimeMins: readingTime(body), - canonicalUrl: !!canonicalUrl ? canonicalUrl : null, + // Get post by slug + getBySlug: publicProcedure + .input(GetPostBySlugSchema) + .query(async ({ ctx, input }) => { + const userId = ctx.session?.user?.id; + + const results = await ctx.db + .select({ + id: posts.id, + type: posts.type, + title: posts.title, + body: posts.body, + excerpt: posts.excerpt, + externalUrl: posts.externalUrl, + coverImage: posts.coverImage, + slug: posts.slug, + canonicalUrl: posts.canonicalUrl, + publishedAt: posts.publishedAt, + upvotesCount: posts.upvotesCount, + downvotesCount: posts.downvotesCount, + commentsCount: posts.commentsCount, + viewsCount: posts.viewsCount, + readingTime: posts.readingTime, + showComments: posts.showComments, + authorId: posts.authorId, + sourceId: posts.sourceId, + sourceAuthor: posts.sourceAuthor, + status: posts.status, + featured: posts.featured, + pinnedUntil: posts.pinnedUntil, + createdAt: posts.createdAt, + updatedAt: posts.updatedAt, + // Source info + sourceName: feedSources.name, + sourceSlug: feedSources.slug, + sourceLogo: feedSources.logoUrl, + sourceWebsite: feedSources.websiteUrl, + sourceCategory: feedSources.category, + // Author info + authorName: user.name, + authorUsername: user.username, + authorImage: user.image, }) - .where(eq(post.id, id)); + .from(posts) + .leftJoin(feedSources, eq(posts.sourceId, feedSources.id)) + .leftJoin(user, eq(posts.authorId, user.id)) + .where(eq(posts.slug, input.slug)) + .limit(1); - return postResponse; - }), - publish: protectedProcedure - .input(PublishPostSchema) - .mutation(async ({ input, ctx }) => { - const { published, id, publishTime } = input; + if (results.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Post not found", + }); + } - const getPublishedTime = () => { - if (!published) { - return null; - } - if (publishTime) { - return new Date(publishTime).toISOString(); - } - return new Date().toISOString(); + const item = results[0]; + + // Get user vote if logged in + let userVote: "up" | "down" | null = null; + let isBookmarked = false; + + if (userId) { + const [voteResult, bookmarkResult] = await Promise.all([ + ctx.db + .select({ voteType: postVotes.voteType }) + .from(postVotes) + .where( + and( + eq(postVotes.postId, item.id), + eq(postVotes.userId, userId) + ) + ) + .limit(1), + ctx.db + .select({ id: bookmarks.id }) + .from(bookmarks) + .where( + and( + eq(bookmarks.postId, item.id), + eq(bookmarks.userId, userId) + ) + ) + .limit(1), + ]); + + userVote = voteResult[0]?.voteType ?? null; + isBookmarked = bookmarkResult.length > 0; + } + + return { + ...item, + userVote, + isBookmarked, }; + }), - const currentPost = await ctx.db.query.post.findFirst({ - where: (posts, { eq }) => eq(posts.id, id), - }); + // Create new post + create: protectedProcedure + .input(CreatePostSchema) + .mutation(async ({ ctx, input }) => { + const authorId = ctx.session.user.id; - if (currentPost?.userId !== ctx.session.user.id) { + // Validate based on post type + if (input.type === "article" && !input.body) { throw new TRPCError({ - code: "FORBIDDEN", + code: "BAD_REQUEST", + message: "Body is required for articles", }); } - const { excerpt, title } = currentPost; + if ((input.type === "link" || input.type === "resource") && !input.externalUrl) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "External URL is required for links and resources", + }); + } - const excerptOrCreatedExcerpt: string = - excerpt.length > 0 - ? excerpt - : removeMarkdown(currentPost.body, {}).substring(0, 156); + const slug = generateSlug(input.title); + const readingTime = calculateReadTime(input.body); - const [updatedPost] = await ctx.db - .update(post) - .set({ - slug: `${title.replace(/\W+/g, "-")}-${id}` - .toLowerCase() - .replace(/^-+|-+(?=-|$)/g, ""), - published: getPublishedTime(), - excerpt: excerptOrCreatedExcerpt, + const [newPost] = await ctx.db + .insert(posts) + .values({ + type: input.type, + title: input.title, + body: input.body, + excerpt: input.excerpt, + externalUrl: input.externalUrl, + coverImage: input.coverImage, + canonicalUrl: input.canonicalUrl, + authorId, + slug, + readingTime, + status: input.status, + publishedAt: input.status === "published" ? new Date().toISOString() : null, + showComments: input.showComments, }) - .where(eq(post.id, id)) .returning(); - return updatedPost; + // Add tags if provided + if (input.tags && input.tags.length > 0) { + for (const tagName of input.tags) { + // Try to find existing tag + const existingTags = await ctx.db + .select({ id: tag.id }) + .from(tag) + .where(eq(tag.title, tagName.toLowerCase())) + .limit(1); + + let tagId: number; + if (existingTags.length > 0) { + tagId = existingTags[0].id; + } else { + // Create new tag + const [newTag] = await ctx.db + .insert(tag) + .values({ title: tagName.toLowerCase() }) + .returning(); + tagId = newTag.id; + } + + // Link tag to post + await ctx.db + .insert(postTags) + .values({ postId: newPost.id, tagId }) + .onConflictDoNothing(); + } + } + + return newPost; }), - delete: protectedProcedure - .input(DeletePostSchema) - .mutation(async ({ input, ctx }) => { - const { id } = input; - const currentPost = await ctx.db.query.post.findFirst({ - where: (posts, { eq }) => eq(posts.id, id), - }); + // Update/save post + update: protectedProcedure + .input(SavePostSchema) + .mutation(async ({ ctx, input }) => { + const authorId = ctx.session.user.id; - const isAdmin = ctx.session.user.role === "ADMIN"; + // Check ownership + const existing = await ctx.db + .select({ authorId: posts.authorId, type: posts.type }) + .from(posts) + .where(eq(posts.id, input.id)) + .limit(1); - if (!isAdmin && currentPost?.userId !== ctx.session.user.id) { + if (existing.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Post not found", + }); + } + + if (existing[0].authorId !== authorId) { throw new TRPCError({ code: "FORBIDDEN", + message: "You can only edit your own posts", }); } - const [deletedPost] = await ctx.db - .delete(post) - .where(eq(post.id, id)) + const updateData: Record = { + updatedAt: new Date(), + }; + if (input.title !== undefined) updateData.title = input.title; + if (input.body !== undefined) { + updateData.body = input.body; + updateData.readingTime = calculateReadTime(input.body); + } + if (input.excerpt !== undefined) updateData.excerpt = input.excerpt; + if (input.canonicalUrl !== undefined) updateData.canonicalUrl = input.canonicalUrl || null; + if (input.status !== undefined) { + updateData.status = input.status; + if (input.status === "published" && input.publishedAt) { + updateData.publishedAt = input.publishedAt; + } else if (input.status === "published") { + updateData.publishedAt = new Date().toISOString(); + } + } + + const [updated] = await ctx.db + .update(posts) + .set(updateData) + .where(eq(posts.id, input.id)) .returning(); - return deletedPost; - }), - like: protectedProcedure - .input(LikePostSchema) - .mutation(async ({ input, ctx }) => { - const { postId, setLiked } = input; - const userId = ctx.session.user.id; - let res; - - setLiked - ? await ctx.db.transaction(async (tx) => { - res = await tx.insert(like).values({ postId, userId }).returning(); - await tx - .update(post) - .set({ - likes: increment(post.likes), - }) - .where(eq(post.id, postId)); - }) - : await ctx.db.transaction(async (tx) => { - res = await tx - .delete(like) - .where( - and( - eq(like.postId, postId), - eq(like.userId, ctx.session?.user?.id), - ), - ) + // Update tags if provided + if (input.tags !== undefined) { + // Remove existing tags + await ctx.db + .delete(postTags) + .where(eq(postTags.postId, input.id)); + + // Add new tags + for (const tagName of input.tags) { + const existingTags = await ctx.db + .select({ id: tag.id }) + .from(tag) + .where(eq(tag.title, tagName.toLowerCase())) + .limit(1); + + let tagId: number; + if (existingTags.length > 0) { + tagId = existingTags[0].id; + } else { + const [newTag] = await ctx.db + .insert(tag) + .values({ title: tagName.toLowerCase() }) .returning(); - if (res.length !== 0) { - await tx - .update(post) - .set({ - likes: decrement(post.likes), - }) - .where(eq(post.id, postId)); - } - }); - - return res; + tagId = newTag.id; + } + + await ctx.db + .insert(postTags) + .values({ postId: input.id, tagId }) + .onConflictDoNothing(); + } + } + + return updated; }), + + // Delete post + delete: protectedProcedure + .input(DeletePostSchema) + .mutation(async ({ ctx, input }) => { + const authorId = ctx.session.user.id; + const isAdmin = ctx.session.user.role === "ADMIN"; + + // Check ownership + const existing = await ctx.db + .select({ authorId: posts.authorId }) + .from(posts) + .where(eq(posts.id, input.id)) + .limit(1); + + if (existing.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Post not found", + }); + } + + if (!isAdmin && existing[0].authorId !== authorId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can only delete your own posts", + }); + } + + await ctx.db.delete(posts).where(eq(posts.id, input.id)); + + return { success: true }; + }), + + // Vote on post (Reddit-style) vote: protectedProcedure .input(VotePostSchema) - .mutation(async ({ input, ctx }) => { - const { postId, voteType } = input; + .mutation(async ({ ctx, input }) => { const userId = ctx.session.user.id; + const { postId, voteType } = input; - return await ctx.db.transaction(async (tx) => { - // Get existing vote - const [existingVote] = await tx - .select() - .from(post_vote) - .where( - and(eq(post_vote.postId, postId), eq(post_vote.userId, userId)), - ); + // Check if post exists + const postItem = await ctx.db + .select({ id: posts.id }) + .from(posts) + .where(eq(posts.id, postId)) + .limit(1); - // If removing vote (voteType is null) - if (voteType === null) { - if (existingVote) { - await tx - .delete(post_vote) - .where( - and(eq(post_vote.postId, postId), eq(post_vote.userId, userId)), - ); - - // Update counts - if (existingVote.voteType === "UP") { - await tx - .update(post) - .set({ upvotes: decrement(post.upvotes) }) - .where(eq(post.id, postId)); - } else { - await tx - .update(post) - .set({ downvotes: decrement(post.downvotes) }) - .where(eq(post.id, postId)); - } + if (postItem.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Post not found", + }); + } + + // Get existing vote + const existingVote = await ctx.db + .select({ id: postVotes.id, voteType: postVotes.voteType }) + .from(postVotes) + .where( + and( + eq(postVotes.postId, postId), + eq(postVotes.userId, userId) + ) + ) + .limit(1); + + // Triggers handle counter updates, but we still update manually for consistency + // until triggers are verified in production + if (voteType === null) { + // Remove vote + if (existingVote.length > 0) { + const oldVoteType = existingVote[0].voteType; + await ctx.db + .delete(postVotes) + .where(eq(postVotes.id, existingVote[0].id)); + + // Update vote counts manually (triggers should handle this too) + if (oldVoteType === "up") { + await ctx.db + .update(posts) + .set({ upvotesCount: decrement(posts.upvotesCount) }) + .where(eq(posts.id, postId)); + } else { + await ctx.db + .update(posts) + .set({ downvotesCount: decrement(posts.downvotesCount) }) + .where(eq(posts.id, postId)); } - return { voteType: null }; } + return { voteType: null }; + } else if (existingVote.length === 0) { + // New vote + await ctx.db.insert(postVotes).values({ + postId, + userId, + voteType, + }); - // If changing vote - if (existingVote) { - if (existingVote.voteType !== voteType) { - // Update vote type - await tx - .update(post_vote) - .set({ voteType }) - .where( - and(eq(post_vote.postId, postId), eq(post_vote.userId, userId)), - ); - - // Update counts (swap) - if (voteType === "UP") { - await tx - .update(post) - .set({ - upvotes: increment(post.upvotes), - downvotes: decrement(post.downvotes), - }) - .where(eq(post.id, postId)); - } else { - await tx - .update(post) - .set({ - upvotes: decrement(post.upvotes), - downvotes: increment(post.downvotes), - }) - .where(eq(post.id, postId)); - } - } + // Update vote counts + if (voteType === "up") { + await ctx.db + .update(posts) + .set({ upvotesCount: increment(posts.upvotesCount) }) + .where(eq(posts.id, postId)); } else { - // New vote - await tx.insert(post_vote).values({ postId, userId, voteType }); - - // Update counts - if (voteType === "UP") { - await tx - .update(post) - .set({ upvotes: increment(post.upvotes) }) - .where(eq(post.id, postId)); - } else { - await tx - .update(post) - .set({ downvotes: increment(post.downvotes) }) - .where(eq(post.id, postId)); - } + await ctx.db + .update(posts) + .set({ downvotesCount: increment(posts.downvotesCount) }) + .where(eq(posts.id, postId)); } + return { voteType }; + } else if (existingVote[0].voteType !== voteType) { + // Change vote + await ctx.db + .update(postVotes) + .set({ voteType }) + .where(eq(postVotes.id, existingVote[0].id)); + // Update vote counts (flip both) + if (voteType === "up") { + await ctx.db + .update(posts) + .set({ + upvotesCount: increment(posts.upvotesCount), + downvotesCount: decrement(posts.downvotesCount), + }) + .where(eq(posts.id, postId)); + } else { + await ctx.db + .update(posts) + .set({ + upvotesCount: decrement(posts.upvotesCount), + downvotesCount: increment(posts.downvotesCount), + }) + .where(eq(posts.id, postId)); + } return { voteType }; - }); + } + + // Same vote, no change needed + return { voteType }; }), + + // Bookmark post bookmark: protectedProcedure .input(BookmarkPostSchema) - .mutation(async ({ input, ctx }) => { + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; const { postId, setBookmarked } = input; - let res; - - setBookmarked - ? await ctx.db - .insert(bookmark) - .values({ postId, userId: ctx.session?.user?.id }) - : await ctx.db - .delete(bookmark) - .where( - and( - eq(bookmark.postId, postId), - eq(bookmark.userId, ctx.session?.user?.id), - ), - ); - return res; + + // Check if post exists + const postItem = await ctx.db + .select({ id: posts.id }) + .from(posts) + .where(eq(posts.id, postId)) + .limit(1); + + if (postItem.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Post not found", + }); + } + + if (setBookmarked) { + await ctx.db + .insert(bookmarks) + .values({ postId, userId }) + .onConflictDoNothing(); + } else { + await ctx.db + .delete(bookmarks) + .where( + and( + eq(bookmarks.postId, postId), + eq(bookmarks.userId, userId) + ) + ); + } + + return { success: true }; }), + + // Get sidebar data for a post sidebarData: publicProcedure .input(GetByIdSchema) - .query(async ({ input, ctx }) => { + .query(async ({ ctx, input }) => { const { id } = input; + const userId = ctx.session?.user?.id; - const [[postData], [userVoteData], [userBookedmarkedPost]] = - await Promise.all([ - ctx.db - .select({ - upvotes: post.upvotes, - downvotes: post.downvotes, - likes: post.likes, - }) - .from(post) - .where(eq(post.id, id)), - // Get user's vote on this post - ctx.session?.user?.id - ? ctx.db - .select({ voteType: post_vote.voteType }) - .from(post_vote) - .where( - and( - eq(post_vote.postId, id), - eq(post_vote.userId, ctx.session.user.id), - ), + const [[postData], [userVoteData], [userBookmark]] = await Promise.all([ + ctx.db + .select({ + upvotesCount: posts.upvotesCount, + downvotesCount: posts.downvotesCount, + }) + .from(posts) + .where(eq(posts.id, id)), + userId + ? ctx.db + .select({ voteType: postVotes.voteType }) + .from(postVotes) + .where( + and( + eq(postVotes.postId, id), + eq(postVotes.userId, userId) ) - : [null], - // if user not logged in and they wont have any bookmarked posts so default to a count of 0 - ctx.session?.user?.id - ? ctx.db - .selectDistinct() - .from(bookmark) - .where( - and( - eq(bookmark.postId, id), - eq(bookmark.userId, ctx.session.user.id), - ), + ) + : [null], + userId + ? ctx.db + .select({ id: bookmarks.id }) + .from(bookmarks) + .where( + and( + eq(bookmarks.postId, id), + eq(bookmarks.userId, userId) ) - : [false], - ]); + ) + : [null], + ]); + + return { + upvotes: postData?.upvotesCount ?? 0, + downvotes: postData?.downvotesCount ?? 0, + userVote: (userVoteData as { voteType: "up" | "down" } | null)?.voteType ?? null, + currentUserBookmarked: !!userBookmark, + }; + }), + + // Get user's posts + getUserPosts: publicProcedure + .input(GetUserPostsSchema) + .query(async ({ ctx, input }) => { + const { authorId: targetAuthorId, type, limit, cursor } = input; + const currentUserId = ctx.session?.user?.id; + + const conditions = [eq(posts.authorId, targetAuthorId)]; + + // Only show published posts unless viewing own profile + if (currentUserId !== targetAuthorId) { + conditions.push(eq(posts.status, "published")); + } + + if (type) { + conditions.push(eq(posts.type, type)); + } + + if (cursor?.publishedAt) { + conditions.push(lte(posts.publishedAt, cursor.publishedAt)); + } + + const results = await ctx.db + .select({ + id: posts.id, + type: posts.type, + title: posts.title, + excerpt: posts.excerpt, + slug: posts.slug, + publishedAt: posts.publishedAt, + upvotesCount: posts.upvotesCount, + downvotesCount: posts.downvotesCount, + commentsCount: posts.commentsCount, + status: posts.status, + createdAt: posts.createdAt, + }) + .from(posts) + .where(and(...conditions)) + .orderBy(desc(posts.publishedAt)) + .limit(limit + 1); + + let nextCursor: { id: string; publishedAt?: string } | undefined; + if (results.length > limit) { + const lastItem = results.pop()!; + nextCursor = { + id: lastItem.id, + publishedAt: lastItem.publishedAt || undefined, + }; + } + + return { + items: results, + nextCursor, + }; + }), + + // Get bookmarked posts for current user + myBookmarks: protectedProcedure + .input(GetBookmarkedPostsSchema) + .query(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + const { limit, cursor } = input; + + const conditions = [eq(bookmarks.userId, userId)]; + + if (cursor?.createdAt) { + conditions.push(lte(bookmarks.createdAt, cursor.createdAt)); + } + + const results = await ctx.db + .select({ + id: posts.id, + type: posts.type, + title: posts.title, + excerpt: posts.excerpt, + externalUrl: posts.externalUrl, + slug: posts.slug, + publishedAt: posts.publishedAt, + upvotesCount: posts.upvotesCount, + downvotesCount: posts.downvotesCount, + readingTime: posts.readingTime, + sourceName: feedSources.name, + sourceSlug: feedSources.slug, + authorName: user.name, + authorUsername: user.username, + authorImage: user.image, + bookmarkedAt: bookmarks.createdAt, + }) + .from(bookmarks) + .innerJoin(posts, eq(bookmarks.postId, posts.id)) + .leftJoin(feedSources, eq(posts.sourceId, feedSources.id)) + .leftJoin(user, eq(posts.authorId, user.id)) + .where(and(...conditions)) + .orderBy(desc(bookmarks.createdAt)) + .limit(limit + 1); + + let nextCursor: { id: string; createdAt?: string } | undefined; + if (results.length > limit) { + const lastItem = results.pop()!; + nextCursor = { + id: lastItem.id, + createdAt: lastItem.bookmarkedAt || undefined, + }; + } + return { - upvotes: postData?.upvotes ?? 0, - downvotes: postData?.downvotes ?? 0, - likes: postData?.likes ?? 0, - userVote: (userVoteData as { voteType: "UP" | "DOWN" } | null)?.voteType ?? null, - currentUserLiked: false, // Deprecated, kept for backwards compatibility - currentUserBookmarked: !!userBookedmarkedPost, + items: results, + nextCursor, }; }), + + // Edit Draft - get user's own post by ID for editing + editDraft: protectedProcedure + .input(GetByIdSchema) + .query(async ({ ctx, input }) => { + const authorId = ctx.session.user.id; + + const results = await ctx.db + .select({ + id: posts.id, + type: posts.type, + title: posts.title, + body: posts.body, + excerpt: posts.excerpt, + externalUrl: posts.externalUrl, + canonicalUrl: posts.canonicalUrl, + coverImage: posts.coverImage, + slug: posts.slug, + status: posts.status, + publishedAt: posts.publishedAt, + showComments: posts.showComments, + readingTime: posts.readingTime, + createdAt: posts.createdAt, + updatedAt: posts.updatedAt, + }) + .from(posts) + .where( + and( + eq(posts.id, input.id), + eq(posts.authorId, authorId) + ) + ) + .limit(1); + + if (results.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Post not found or you don't have permission to edit it", + }); + } + + // Get tags for this post + const postTagsResult = await ctx.db + .select({ + tag: { + id: tag.id, + title: tag.title, + }, + }) + .from(postTags) + .innerJoin(tag, eq(postTags.tagId, tag.id)) + .where(eq(postTags.postId, input.id)); + + return { + ...results[0], + tags: postTagsResult, + }; + }), + + // My Drafts - get user's draft posts (articles) + myDrafts: protectedProcedure.query(async ({ ctx }) => { + const authorId = ctx.session.user.id; + + return await ctx.db + .select({ + id: posts.id, + type: posts.type, + title: posts.title, + excerpt: posts.excerpt, + slug: posts.slug, + status: posts.status, + publishedAt: posts.publishedAt, + createdAt: posts.createdAt, + updatedAt: posts.updatedAt, + }) + .from(posts) + .where( + and( + eq(posts.authorId, authorId), + eq(posts.type, "article"), + eq(posts.status, "draft") + ) + ) + .orderBy(desc(posts.updatedAt)); + }), + + // My Published - get user's published posts (articles) + myPublished: protectedProcedure.query(async ({ ctx }) => { + const authorId = ctx.session.user.id; + const now = new Date().toISOString(); + + return await ctx.db + .select({ + id: posts.id, + type: posts.type, + title: posts.title, + excerpt: posts.excerpt, + slug: posts.slug, + status: posts.status, + publishedAt: posts.publishedAt, + upvotesCount: posts.upvotesCount, + downvotesCount: posts.downvotesCount, + commentsCount: posts.commentsCount, + createdAt: posts.createdAt, + updatedAt: posts.updatedAt, + }) + .from(posts) + .where( + and( + eq(posts.authorId, authorId), + eq(posts.type, "article"), + eq(posts.status, "published"), + lte(posts.publishedAt, now) + ) + ) + .orderBy(desc(posts.publishedAt)); + }), + + // My Scheduled - get user's scheduled posts (publishedAt > now) + myScheduled: protectedProcedure.query(async ({ ctx }) => { + const authorId = ctx.session.user.id; + const now = new Date().toISOString(); + + return await ctx.db + .select({ + id: posts.id, + type: posts.type, + title: posts.title, + excerpt: posts.excerpt, + slug: posts.slug, + status: posts.status, + publishedAt: posts.publishedAt, + createdAt: posts.createdAt, + updatedAt: posts.updatedAt, + }) + .from(posts) + .where( + and( + eq(posts.authorId, authorId), + eq(posts.type, "article"), + eq(posts.status, "scheduled"), + gt(posts.publishedAt, now) + ) + ) + .orderBy(asc(posts.publishedAt)); + }), + + // Publish - publish/unpublish/schedule a post + publish: protectedProcedure + .input(PublishPostSchema) + .mutation(async ({ ctx, input }) => { + const authorId = ctx.session.user.id; + + // Check ownership + const existing = await ctx.db + .select({ + id: posts.id, + authorId: posts.authorId, + title: posts.title, + slug: posts.slug, + status: posts.status, + }) + .from(posts) + .where(eq(posts.id, input.id)) + .limit(1); + + if (existing.length === 0) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Post not found", + }); + } + + if (existing[0].authorId !== authorId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You can only publish your own posts", + }); + } + + const updateData: Record = {}; + + if (input.published) { + updateData.status = "published"; + if (input.publishTime) { + updateData.publishedAt = input.publishTime.toISOString(); + // If publish time is in the future, mark as scheduled + if (input.publishTime > new Date()) { + updateData.status = "scheduled"; + } + } else { + updateData.publishedAt = new Date().toISOString(); + } + + // Generate new slug if this is the first time publishing + if (existing[0].status === "draft" && existing[0].title) { + updateData.slug = generateSlug(existing[0].title); + } + } else { + updateData.status = "draft"; + } + + const [updated] = await ctx.db + .update(posts) + .set(updateData) + .where(eq(posts.id, input.id)) + .returning(); + + return updated; + }), + + // Get categories (from sources) + getCategories: publicProcedure.query(async ({ ctx }) => { + const results = await ctx.db + .selectDistinct({ category: feedSources.category }) + .from(feedSources) + .where(isNotNull(feedSources.category)); + + return results + .map((r) => r.category) + .filter((c): c is string => c !== null) + .sort(); + }), + + // Get post types count + getTypeCounts: publicProcedure.query(async ({ ctx }) => { + const results = await ctx.db + .select({ + type: posts.type, + count: count(), + }) + .from(posts) + .where(eq(posts.status, "published")) + .groupBy(posts.type); + + return results; + }), + + // Track view on a post + trackView: publicProcedure + .input(GetByIdSchema) + .mutation(async ({ ctx, input }) => { + await ctx.db + .update(posts) + .set({ viewsCount: increment(posts.viewsCount) }) + .where(eq(posts.id, input.id)); + + return { success: true }; + }), + + // Feature a post (admin only) + feature: protectedProcedure + .input(FeaturePostSchema) + .mutation(async ({ ctx, input }) => { + const isAdmin = ctx.session.user.role === "ADMIN"; + + if (!isAdmin) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only admins can feature posts", + }); + } + + const [updated] = await ctx.db + .update(posts) + .set({ featured: input.featured }) + .where(eq(posts.id, input.postId)) + .returning(); + + return updated; + }), + + // Pin a post (admin only) + pin: protectedProcedure + .input(PinPostSchema) + .mutation(async ({ ctx, input }) => { + const isAdmin = ctx.session.user.role === "ADMIN"; + + if (!isAdmin) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only admins can pin posts", + }); + } + + const [updated] = await ctx.db + .update(posts) + .set({ pinnedUntil: input.pinnedUntil?.toISOString() ?? null }) + .where(eq(posts.id, input.postId)) + .returning(); + + return updated; + }), + + // Get featured posts + getFeatured: publicProcedure + .input(GetLimitSidePosts) + .query(async ({ ctx, input }) => { + const limit = input?.limit ?? 5; + + return await ctx.db + .select({ + id: posts.id, + type: posts.type, + title: posts.title, + excerpt: posts.excerpt, + slug: posts.slug, + publishedAt: posts.publishedAt, + upvotesCount: posts.upvotesCount, + downvotesCount: posts.downvotesCount, + commentsCount: posts.commentsCount, + authorName: user.name, + authorUsername: user.username, + authorImage: user.image, + }) + .from(posts) + .leftJoin(user, eq(posts.authorId, user.id)) + .where( + and( + eq(posts.status, "published"), + eq(posts.featured, true) + ) + ) + .orderBy(desc(posts.publishedAt)) + .limit(limit); + }), + + // Get comment count for a post + getCommentCount: publicProcedure + .input(GetByIdSchema) + .query(async ({ ctx, input }) => { + const [result] = await ctx.db + .select({ count: count() }) + .from(comments) + .where( + and( + eq(comments.postId, input.id), + isNull(comments.deletedAt) + ) + ); + + return result.count; + }), + + // Legacy: Get published posts (for backwards compatibility) published: publicProcedure .input(GetPostsSchema) .query(async ({ ctx, input }) => { @@ -403,102 +1316,98 @@ export const postRouter = createTRPCRouter({ const { cursor, sort, tag: tagFilter } = input; // Reddit-style hot score calculation - // Formula: log10(max(|score|, 1)) + sign(score) * seconds / 45000 - // This makes recent content with votes rank higher than old content with many votes const hotScoreExpr = sql` - LOG(GREATEST(ABS(${post.upvotes} - ${post.downvotes}), 1)) + - SIGN(${post.upvotes} - ${post.downvotes}) * - EXTRACT(EPOCH FROM (${post.published}::timestamp - '2024-01-01'::timestamp)) / 45000 + LOG(GREATEST(ABS(${posts.upvotesCount} - ${posts.downvotesCount}), 1)) + + SIGN(${posts.upvotesCount} - ${posts.downvotesCount}) * + EXTRACT(EPOCH FROM (${posts.publishedAt}::timestamp - '2024-01-01'::timestamp)) / 45000 `; const paginationMapping = { newest: { - orderBy: desc(post.published), - cursor: lte(post.published, cursor?.published as string), + orderBy: desc(posts.publishedAt), + cursor: cursor?.published ? lte(posts.publishedAt, cursor.published) : undefined, }, oldest: { - orderBy: asc(post.published), - cursor: gte(post.published, cursor?.published as string), + orderBy: asc(posts.publishedAt), + cursor: cursor?.published ? gte(posts.publishedAt, cursor.published) : undefined, }, top: { - orderBy: desc(sql`${post.upvotes} - ${post.downvotes}`), - cursor: lt(sql`${post.upvotes} - ${post.downvotes}`, cursor?.likes as number), + orderBy: desc(sql`${posts.upvotesCount} - ${posts.downvotesCount}`), + cursor: cursor?.likes !== undefined + ? lt(sql`${posts.upvotesCount} - ${posts.downvotesCount}`, cursor.likes) + : undefined, }, trending: { orderBy: desc(hotScoreExpr), - cursor: cursor?.hotScore + cursor: cursor?.hotScore !== undefined ? lt(hotScoreExpr, cursor.hotScore) : undefined, }, }; - const bookmarked = ctx.db + const userBookmarksSubquery = ctx.db .select() - .from(bookmark) - // if user not logged in just default to searching for "" as user which will always result in post not being bookmarked - // TODO figure out a way to skip this entire block if user is not logged in - .where(eq(bookmark.userId, userId || "")) + .from(bookmarks) + .where(eq(bookmarks.userId, userId || "")) .as("bookmarked"); - const userVoteSubquery = ctx.db + const userVotesSubquery = ctx.db .select() - .from(post_vote) - .where(eq(post_vote.userId, userId || "")) + .from(postVotes) + .where(eq(postVotes.userId, userId || "")) .as("userVote"); const response = await ctx.db .select({ post: { - id: post.id, - slug: post.slug, - title: post.title, - excerpt: post.excerpt, - published: post.published, - readTimeMins: post.readTimeMins, - likes: post.likes, - upvotes: post.upvotes, - downvotes: post.downvotes, + id: posts.id, + slug: posts.slug, + title: posts.title, + excerpt: posts.excerpt, + published: posts.publishedAt, + readTimeMins: posts.readingTime, + upvotes: posts.upvotesCount, + downvotes: posts.downvotesCount, }, - bookmarked: { id: bookmarked.id }, - userVote: { voteType: userVoteSubquery.voteType }, + bookmarked: { id: userBookmarksSubquery.id }, + userVote: { voteType: userVotesSubquery.voteType }, user: { name: user.name, username: user.username, image: user.image }, }) - .from(post) - .leftJoin(user, eq(post.userId, user.id)) - .leftJoin(bookmarked, eq(bookmarked.postId, post.id)) - .leftJoin(userVoteSubquery, eq(userVoteSubquery.postId, post.id)) - .leftJoin(post_tag, eq(post.id, post_tag.postId)) - .leftJoin(tag, eq(post_tag.tagId, tag.id)) + .from(posts) + .leftJoin(user, eq(posts.authorId, user.id)) + .leftJoin(userBookmarksSubquery, eq(userBookmarksSubquery.postId, posts.id)) + .leftJoin(userVotesSubquery, eq(userVotesSubquery.postId, posts.id)) + .leftJoin(postTags, eq(posts.id, postTags.postId)) + .leftJoin(tag, eq(postTags.tagId, tag.id)) .where( and( - isNotNull(post.published), - lte(post.published, new Date().toISOString()), + eq(posts.status, "published"), + lte(posts.publishedAt, new Date().toISOString()), tagFilter ? eq(tag.title, tagFilter.toUpperCase()) : undefined, - cursor ? paginationMapping[sort].cursor : undefined, - ), + cursor ? paginationMapping[sort].cursor : undefined + ) ) .groupBy( - post.id, - post.slug, - post.title, - post.excerpt, - post.published, - post.readTimeMins, - post.likes, - post.upvotes, - post.downvotes, - bookmarked.id, - userVoteSubquery.voteType, - user.id, + posts.id, + posts.slug, + posts.title, + posts.excerpt, + posts.publishedAt, + posts.readingTime, + posts.upvotesCount, + posts.downvotesCount, + userBookmarksSubquery.id, + userVotesSubquery.voteType, + user.id ) .limit(limit + 1) .orderBy(paginationMapping[sort].orderBy); - // Calculate hotScore for each post (for pagination) + // Calculate hotScore for each post const calculateHotScore = ( upvotes: number, downvotes: number, - publishedAt: string, + publishedAt: string ): number => { const score = upvotes - downvotes; const sign = score > 0 ? 1 : score < 0 ? -1 : 0; @@ -513,7 +1422,7 @@ export const postRouter = createTRPCRouter({ const hotScore = calculateHotScore( elem.post.upvotes, elem.post.downvotes, - elem.post.published as string, + elem.post.published as string ); return { ...elem.post, @@ -521,6 +1430,8 @@ export const postRouter = createTRPCRouter({ currentUserBookmarkedPost, userVote: elem.userVote?.voteType ?? null, hotScore, + // Legacy field mappings + likes: elem.post.upvotes, }; }); @@ -538,102 +1449,4 @@ export const postRouter = createTRPCRouter({ return { posts: cleaned, nextCursor }; }), - myPublished: protectedProcedure.query(async ({ ctx }) => { - return await ctx.db.query.post.findMany({ - where: (posts, { lte, isNotNull, eq }) => - and( - isNotNull(posts.published), - lte(posts.published, new Date().toISOString()), - eq(posts.userId, ctx?.session?.user?.id), - ), - orderBy: (posts, { desc, sql }) => [ - desc(sql`GREATEST(${posts.updatedAt}, ${posts.published})`), - ], - }); - }), - myScheduled: protectedProcedure.query(async ({ ctx }) => { - return await ctx.db.query.post.findMany({ - where: (posts, { eq }) => - and( - gt(posts.published, new Date().toISOString()), - isNotNull(posts.published), - eq(posts.userId, ctx?.session?.user?.id), - ), - orderBy: (posts, { asc }) => [asc(posts.published)], - }); - }), - myDrafts: protectedProcedure.query(async ({ ctx }) => { - return ctx.db.query.post.findMany({ - where: (posts, { eq }) => - and(eq(posts.userId, ctx.session.user.id), isNull(posts.published)), - orderBy: (posts, { desc }) => [desc(posts.updatedAt)], - }); - }), - editDraft: protectedProcedure - .input(GetByIdSchema) - .query(async ({ input, ctx }) => { - const { id } = input; - - const currentPost = await ctx.db.query.post.findFirst({ - where: (posts, { eq }) => eq(posts.id, id), - with: { - tags: { with: { tag: true } }, - }, - }); - - if (currentPost?.userId !== ctx.session.user.id) { - throw new TRPCError({ - code: "FORBIDDEN", - }); - } - - return currentPost; - }), - myBookmarks: protectedProcedure - .input(GetLimitSidePosts) - .query(async ({ ctx, input }) => { - const limit = input?.limit ?? undefined; - - const response = await ctx.db.query.bookmark.findMany({ - columns: { - id: true, - }, - where: (bookmarks, { eq }) => eq(bookmarks.userId, ctx.session.user.id), - with: { - post: { - columns: { - id: true, - title: true, - excerpt: true, - updatedAt: true, - published: true, - readTimeMins: true, - slug: true, - }, - with: { - user: { - columns: { - name: true, - username: true, - image: true, - }, - }, - }, - }, - }, - orderBy: (bookmarks, { desc }) => [desc(bookmarks.id)], - }); - - const totalCount = response.length; - - const bookmarksResponse = response.slice(0, limit || response.length); - - return { - totalCount, - bookmarks: bookmarksResponse.map(({ id, post }) => ({ - bookmarkId: id, - ...post, - })), - }; - }), }); diff --git a/server/api/router/report.ts b/server/api/router/report.ts index 73a0471a..52e0c844 100644 --- a/server/api/router/report.ts +++ b/server/api/router/report.ts @@ -21,6 +21,8 @@ import { content_report, content, discussion, + aggregated_article, + feed_source, } from "@/server/db/schema"; import { and, count, desc, eq, lt } from "drizzle-orm"; import { db } from "@/server/db"; @@ -52,26 +54,32 @@ export const reportRouter = createTRPCRouter({ const [commentDetails] = await ctx.db .select({ body: comment.body, - post: { slug: post.slug }, - user: { - email: user.email, - userId: user.id, - username: user.username, - }, + postSlug: post.slug, + postAuthorUsername: user.username, + commentUserEmail: user.email, + commentUserId: user.id, + commentUserUsername: user.username, }) .from(comment) .innerJoin(user, eq(user.id, comment.userId)) .innerJoin(post, eq(comment.postId, post.id)) .where(eq(comment.id, id)); + // Get post author username + const [postAuthor] = await ctx.db + .select({ username: user.username }) + .from(post) + .innerJoin(user, eq(user.id, post.userId)) + .where(eq(post.slug, commentDetails.postSlug)); + const report = { reason: body, - url: `${getBaseUrl()}/articles/${commentDetails.post.slug}`, + url: `${getBaseUrl()}/${postAuthor.username}/${commentDetails.postSlug}`, id, - email: commentDetails.user.email || "", + email: commentDetails.commentUserEmail || "", comment: commentDetails.body || "", - userId: commentDetails.user.userId || "", - username: commentDetails.user.username || "", + userId: commentDetails.commentUserId || "", + username: commentDetails.commentUserUsername || "", reportedBy: { username: reportingUser.username, id: reportingUser.id, @@ -106,7 +114,7 @@ export const reportRouter = createTRPCRouter({ const report = { reason: body, - url: `${getBaseUrl()}/articles/${postDetails.slug}`, + url: `${getBaseUrl()}/${postDetails.user.username}/${postDetails.slug}`, id, email: postDetails.user.email || "", title: postDetails.title, @@ -127,6 +135,52 @@ export const reportRouter = createTRPCRouter({ return { message: "Report has been sent!" }; } + if (type === "article" && typeof id === "string") { + const [articleDetails] = await ctx.db + .select({ + slug: aggregated_article.slug, + shortId: aggregated_article.shortId, + title: aggregated_article.title, + url: aggregated_article.externalUrl, + sourceSlug: feed_source.slug, + sourceName: feed_source.name, + }) + .from(aggregated_article) + .leftJoin(feed_source, eq(aggregated_article.sourceId, feed_source.id)) + .where(eq(aggregated_article.id, id)); + + if (!articleDetails) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Article not found", + }); + } + + // Use slug if available, fallback to shortId + const articlePath = articleDetails.slug || articleDetails.shortId; + const report = { + reason: body, + url: `${getBaseUrl()}/${articleDetails.sourceSlug}/${articlePath}`, + id: String(id), + email: "", // Feed articles don't have a user email + title: articleDetails.title, + userId: "", // Feed articles don't have a userId + username: articleDetails.sourceName || articleDetails.sourceSlug || "Unknown Source", + reportedBy: { + username: reportingUser.username, + id: reportingUser.id, + email: reportingUser?.email || "", + }, + }; + const htmlMessage = createArticleReportEmailTemplate(report); + await sendEmail({ + recipient: process.env.ADMIN_EMAIL, + htmlMessage, + subject: "A user has reported a feed article - codu.co", + }); + return { message: "Report has been sent!" }; + } + throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid report", diff --git a/server/api/router/sponsor.ts b/server/api/router/sponsor.ts new file mode 100644 index 00000000..55f82ae2 --- /dev/null +++ b/server/api/router/sponsor.ts @@ -0,0 +1,83 @@ +import { + SponsorInquirySchema, + sponsorInterestLabels, + sponsorBudgetLabels, +} from "@/schema/sponsor"; +import * as Sentry from "@sentry/nextjs"; +import sendEmail from "@/utils/sendEmail"; +import { createTRPCRouter, publicProcedure } from "../trpc"; +import { TRPCError } from "@trpc/server"; +import { createSponsorInquiryEmailTemplate } from "@/utils/createSponsorInquiryEmailTemplate"; +import { sponsor_inquiry } from "@/server/db/schema"; +import { db } from "@/server/db"; + +export const sponsorRouter = createTRPCRouter({ + submit: publicProcedure + .input(SponsorInquirySchema) + .mutation(async ({ input }) => { + try { + const { name, email, company, phone, interests, budgetRange, goals } = input; + const now = new Date(); + + // Convert interests array to comma-separated string for storage + const interestsString = interests.join(","); + + // Save to database + const [inquiry] = await db + .insert(sponsor_inquiry) + .values({ + name, + email, + company, + phone: phone ?? null, + interests: interestsString, + budgetRange, + goals: goals ?? null, + status: "PENDING", + createdAt: now.toISOString(), + }) + .returning(); + + // Send email notification + const adminEmail = process.env.ADMIN_EMAIL || "partnerships@codu.co"; + + // Convert interests to readable labels + const interestLabels = interests.map( + (interest) => sponsorInterestLabels[interest] + ); + + const htmlMessage = createSponsorInquiryEmailTemplate({ + name, + email, + company, + phone, + interests: interestLabels, + budgetRange: sponsorBudgetLabels[budgetRange], + goals, + submittedAt: now.toLocaleString("en-IE", { + dateStyle: "medium", + timeStyle: "short", + }), + }); + + await sendEmail({ + recipient: adminEmail, + htmlMessage, + subject: `New Sponsor Inquiry from ${company}`, + }); + + return { + success: true, + id: inquiry.id, + message: "Thank you for your interest! We'll be in touch soon.", + }; + } catch (error) { + Sentry.captureException(error); + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Failed to submit inquiry. Please try again or email us directly at partnerships@codu.co", + }); + } + }), +}); diff --git a/server/lib/posts.ts b/server/lib/posts.ts index 3fc0b888..c6406287 100644 --- a/server/lib/posts.ts +++ b/server/lib/posts.ts @@ -3,8 +3,8 @@ import { db } from "@/server/db/index"; import * as Sentry from "@sentry/nextjs"; import "server-only"; import { z } from "zod"; -import { bookmark, post, user } from "../db/schema"; -import { eq, and, isNotNull, lte, desc } from "drizzle-orm"; +import { bookmark, post, posts, user, aggregated_article, feed_source, feed_sources } from "../db/schema"; +import { eq, and, isNotNull, lte, desc, sql } from "drizzle-orm"; export const GetPostSchema = z.object({ slug: z.string(), @@ -176,3 +176,149 @@ export async function getTrending({ currentUserId }: GetTrending) { return null; } } + +export type TrendingItem = { + type: "post" | "feed"; + id: string | number; + slug: string; + title: string; + excerpt: string | null; + publishedAt: string | null; + readTimeMins: number; + upvotes: number; + downvotes: number; + score: number; + // For posts (user articles) + username?: string; + authorName?: string | null; + authorImage?: string | null; + // For feed articles + sourceSlug?: string; + sourceName?: string | null; + sourceImage?: string | null; + externalUrl?: string | null; + imageUrl?: string | null; + isBookmarked?: boolean; +}; + +export const GetUnifiedTrendingSchema = z.object({ + currentUserId: z.string().optional(), + limit: z.number().min(1).max(30).default(8), +}); + +type GetUnifiedTrending = z.infer; + +export async function getUnifiedTrending({ currentUserId, limit = 8 }: GetUnifiedTrending): Promise { + try { + GetUnifiedTrendingSchema.parse({ currentUserId, limit }); + + // Get trending user posts (using new posts table with type: "article") + const userPostsQuery = db + .select({ + id: posts.id, + slug: posts.slug, + title: posts.title, + excerpt: posts.excerpt, + publishedAt: posts.publishedAt, + readTimeMins: posts.readingTime, + upvotes: posts.upvotesCount, + downvotes: posts.downvotesCount, + username: user.username, + authorName: user.name, + authorImage: user.image, + }) + .from(posts) + .leftJoin(user, eq(posts.authorId, user.id)) + .where( + and( + eq(posts.type, "article"), + eq(posts.status, "published"), + isNotNull(posts.publishedAt), + lte(posts.publishedAt, new Date().toISOString()), + ), + ) + .orderBy(desc(sql`(${posts.upvotesCount} - ${posts.downvotesCount})`)) + .limit(limit); + + // Get trending feed articles (using new posts table with type: "link") + const feedQuery = db + .select({ + id: posts.id, + slug: posts.slug, + title: posts.title, + excerpt: posts.excerpt, + publishedAt: posts.publishedAt, + upvotes: posts.upvotesCount, + downvotes: posts.downvotesCount, + externalUrl: posts.externalUrl, + imageUrl: posts.coverImage, + sourceSlug: feed_sources.slug, + sourceName: feed_sources.name, + sourceImage: feed_sources.logoUrl, + }) + .from(posts) + .leftJoin(feed_sources, eq(posts.sourceId, feed_sources.id)) + .where( + and( + eq(posts.type, "link"), + eq(posts.status, "published"), + eq(feed_sources.status, "active"), + ), + ) + .orderBy(desc(sql`(${posts.upvotesCount} - ${posts.downvotesCount})`)) + .limit(limit); + + const [userPosts, feedArticles] = await Promise.all([userPostsQuery, feedQuery]); + + // Transform posts to unified format + const transformedPosts: TrendingItem[] = userPosts.map((p) => ({ + type: "post" as const, + id: p.id, + slug: p.slug, + title: p.title, + excerpt: p.excerpt, + publishedAt: p.publishedAt, + readTimeMins: p.readTimeMins ?? 5, + upvotes: p.upvotes ?? 0, + downvotes: p.downvotes ?? 0, + score: (p.upvotes ?? 0) - (p.downvotes ?? 0), + username: p.username ?? undefined, + authorName: p.authorName, + authorImage: p.authorImage, + })); + + // Transform feed articles to unified format + const transformedFeed: TrendingItem[] = feedArticles + .filter((f) => f.slug) // Filter out articles without valid slugs + .map((f) => ({ + type: "feed" as const, + id: f.id, + slug: f.slug as string, + title: f.title, + excerpt: f.excerpt, + publishedAt: f.publishedAt, + readTimeMins: 5, // Default read time for external articles + upvotes: f.upvotes ?? 0, + downvotes: f.downvotes ?? 0, + score: (f.upvotes ?? 0) - (f.downvotes ?? 0), + sourceSlug: f.sourceSlug ?? undefined, + sourceName: f.sourceName, + sourceImage: f.sourceImage, + externalUrl: f.externalUrl, + imageUrl: f.imageUrl, + })); + + // Combine and sort by score, then shuffle for variety + const combined = [...transformedPosts, ...transformedFeed]; + combined.sort((a, b) => b.score - a.score); + + // Take top items, then shuffle to add variety + const topItems = combined.slice(0, limit * 2); + const shuffled = topItems.sort(() => 0.5 - Math.random()); + + return shuffled.slice(0, limit); + } catch (error) { + Sentry.captureException(error); + return null; + } +} From 731d19d4148275af2166537ea168e2b7ae8745b6 Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Tue, 6 Jan 2026 07:40:03 +0000 Subject: [PATCH 12/38] feat(components): add content detail, sponsorship, and unified content components Add ContentDetail components for article/link display, DiscussionEditor for rich text discussions, SavedItemCard, Sponsorship page sections, and UnifiedContentCard for the feed. --- .../ContentDetail/ContentDetailLayout.tsx | 76 +++ .../ContentDetail/ContentMetaHeader.tsx | 162 ++++++ components/ContentDetail/ContentTypeBadge.tsx | 38 ++ components/ContentDetail/InlineAuthorBio.tsx | 53 ++ components/ContentDetail/SourceInfoCard.tsx | 78 +++ components/ContentDetail/UnifiedActionBar.tsx | 340 +++++++++++++ components/ContentDetail/index.ts | 6 + .../DiscussionEditor/DiscussionEditor.tsx | 185 +++++++ .../DiscussionEditorToolbar.tsx | 252 ++++++++++ .../DiscussionEditor/MarkdownHelpModal.tsx | 105 ++++ .../DiscussionEditor/extensions/index.ts | 86 ++++ .../hooks/useDiscussionEditor.ts | 132 +++++ .../Discussion/DiscussionEditor/index.ts | 2 + .../Discussion/DiscussionEditor/types.ts | 18 + components/SavedItemCard/SavedItemCard.tsx | 156 ++++++ components/SavedItemCard/index.ts | 2 + components/Sponsorship/ContactForm.tsx | 460 +++++++++++++++++ components/Sponsorship/ContactSection.tsx | 34 ++ components/Sponsorship/HeroSection.tsx | 44 ++ components/Sponsorship/MetricsSection.tsx | 53 ++ components/Sponsorship/OfferingsSection.tsx | 84 ++++ components/Sponsorship/SocialProofSection.tsx | 84 ++++ components/Sponsorship/index.ts | 6 + .../UnifiedContentCard/UnifiedContentCard.tsx | 474 ++++++++++++++++++ components/UnifiedContentCard/index.ts | 2 + 25 files changed, 2932 insertions(+) create mode 100644 components/ContentDetail/ContentDetailLayout.tsx create mode 100644 components/ContentDetail/ContentMetaHeader.tsx create mode 100644 components/ContentDetail/ContentTypeBadge.tsx create mode 100644 components/ContentDetail/InlineAuthorBio.tsx create mode 100644 components/ContentDetail/SourceInfoCard.tsx create mode 100644 components/ContentDetail/UnifiedActionBar.tsx create mode 100644 components/ContentDetail/index.ts create mode 100644 components/Discussion/DiscussionEditor/DiscussionEditor.tsx create mode 100644 components/Discussion/DiscussionEditor/DiscussionEditorToolbar.tsx create mode 100644 components/Discussion/DiscussionEditor/MarkdownHelpModal.tsx create mode 100644 components/Discussion/DiscussionEditor/extensions/index.ts create mode 100644 components/Discussion/DiscussionEditor/hooks/useDiscussionEditor.ts create mode 100644 components/Discussion/DiscussionEditor/index.ts create mode 100644 components/Discussion/DiscussionEditor/types.ts create mode 100644 components/SavedItemCard/SavedItemCard.tsx create mode 100644 components/SavedItemCard/index.ts create mode 100644 components/Sponsorship/ContactForm.tsx create mode 100644 components/Sponsorship/ContactSection.tsx create mode 100644 components/Sponsorship/HeroSection.tsx create mode 100644 components/Sponsorship/MetricsSection.tsx create mode 100644 components/Sponsorship/OfferingsSection.tsx create mode 100644 components/Sponsorship/SocialProofSection.tsx create mode 100644 components/Sponsorship/index.ts create mode 100644 components/UnifiedContentCard/UnifiedContentCard.tsx create mode 100644 components/UnifiedContentCard/index.ts diff --git a/components/ContentDetail/ContentDetailLayout.tsx b/components/ContentDetail/ContentDetailLayout.tsx new file mode 100644 index 00000000..4e6b5def --- /dev/null +++ b/components/ContentDetail/ContentDetailLayout.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { ReactNode } from "react"; +import Link from "next/link"; + +interface BreadcrumbItem { + label: string; + href?: string; +} + +interface ContentDetailLayoutProps { + breadcrumbs: BreadcrumbItem[]; + children: ReactNode; + actionBar?: ReactNode; + discussion?: ReactNode; + sideInfo?: ReactNode; +} + +const ContentDetailLayout = ({ + breadcrumbs, + children, + actionBar, + discussion, + sideInfo, +}: ContentDetailLayoutProps) => { + return ( +
+ {/* Breadcrumb navigation */} + {breadcrumbs.length > 0 && ( + + )} + + {/* Main content card */} +
+ {children} + + {/* Action bar */} + {actionBar && ( +
+ {actionBar} +
+ )} +
+ + {/* Side info (author bio or source info) */} + {sideInfo &&
{sideInfo}
} + + {/* Discussion section */} + {discussion && ( +
+ {discussion} +
+ )} +
+ ); +}; + +export default ContentDetailLayout; diff --git a/components/ContentDetail/ContentMetaHeader.tsx b/components/ContentDetail/ContentMetaHeader.tsx new file mode 100644 index 00000000..1ab9f1b0 --- /dev/null +++ b/components/ContentDetail/ContentMetaHeader.tsx @@ -0,0 +1,162 @@ +import Link from "next/link"; +import { Temporal } from "@js-temporal/polyfill"; + +// Get favicon URL from a website +const getFaviconUrl = (websiteUrl: string | null | undefined): string | null => { + if (!websiteUrl) return null; + try { + const url = new URL(websiteUrl); + return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32`; + } catch { + return null; + } +}; + +// Get hostname from URL +const getHostname = (urlString: string | null | undefined): string | null => { + if (!urlString) return null; + try { + const url = new URL(urlString); + return url.hostname; + } catch { + return null; + } +}; + +interface AuthorInfo { + name: string; + username: string; + image: string | null; +} + +interface SourceInfo { + name: string; + slug: string | null; + logo: string | null; + websiteUrl: string | null; + author?: string | null; +} + +interface ContentMetaHeaderProps { + publishedAt: string | null; + readTimeMins?: number | null; + externalUrl?: string | null; + author?: AuthorInfo | null; + source?: SourceInfo | null; +} + +const ContentMetaHeader = ({ + publishedAt, + readTimeMins, + externalUrl, + author, + source, +}: ContentMetaHeaderProps) => { + const dateTime = publishedAt + ? Temporal.Instant.from(new Date(publishedAt).toISOString()) + : null; + const readableDate = dateTime + ? dateTime.toLocaleString(["en-IE"], { + year: "numeric", + month: "long", + day: "numeric", + }) + : null; + + const faviconUrl = source ? getFaviconUrl(source.websiteUrl || externalUrl) : null; + const hostname = getHostname(externalUrl); + + // Render author info (for user posts) + if (author) { + return ( +
+ + {author.image ? ( + + ) : ( +
+ {author.name?.charAt(0).toUpperCase() || "?"} +
+ )} + {author.name} + + {readableDate && ( + <> + + + + )} + {readTimeMins && ( + <> + + {readTimeMins} min read + + )} +
+ ); + } + + // Render source info (for feed articles) + if (source) { + const sourceLink = source.slug ? `/feed/${source.slug}` : "#"; + return ( +
+ + {source.logo ? ( + + ) : faviconUrl ? ( + + ) : ( +
+ {source.name?.charAt(0).toUpperCase() || "?"} +
+ )} + {source.name || "Unknown Source"} + + {source.author && + source.author.trim() && + !["by", "by,", "by ,"].includes(source.author.trim().toLowerCase()) && ( + <> + + {source.author.replace(/^by\s+/i, "").trim()} + + )} + {readableDate && ( + <> + + + + )} + {hostname && ( + <> + + {hostname} + + )} +
+ ); + } + + // Fallback - just date + return readableDate ? ( +
+ +
+ ) : null; +}; + +export default ContentMetaHeader; diff --git a/components/ContentDetail/ContentTypeBadge.tsx b/components/ContentDetail/ContentTypeBadge.tsx new file mode 100644 index 00000000..aca8f305 --- /dev/null +++ b/components/ContentDetail/ContentTypeBadge.tsx @@ -0,0 +1,38 @@ +type ContentType = "article" | "link" | "community"; + +interface ContentTypeBadgeProps { + type: ContentType; + className?: string; +} + +const badgeStyles: Record = { + article: { + bg: "bg-gradient-to-r from-orange-400 to-pink-600", + text: "text-white", + label: "Article", + }, + link: { + bg: "bg-blue-100 dark:bg-blue-900", + text: "text-blue-700 dark:text-blue-300", + label: "Link", + }, + community: { + bg: "bg-green-100 dark:bg-green-900", + text: "text-green-700 dark:text-green-300", + label: "Community", + }, +}; + +const ContentTypeBadge = ({ type, className = "" }: ContentTypeBadgeProps) => { + const styles = badgeStyles[type]; + + return ( + + {styles.label} + + ); +}; + +export default ContentTypeBadge; diff --git a/components/ContentDetail/InlineAuthorBio.tsx b/components/ContentDetail/InlineAuthorBio.tsx new file mode 100644 index 00000000..38eeb551 --- /dev/null +++ b/components/ContentDetail/InlineAuthorBio.tsx @@ -0,0 +1,53 @@ +import Link from "next/link"; + +interface InlineAuthorBioProps { + name: string; + username: string; + image?: string | null; + bio?: string | null; +} + +const InlineAuthorBio = ({ + name, + username, + image, + bio, +}: InlineAuthorBioProps) => { + return ( +
+ + {image ? ( + + ) : ( +
+ {name?.charAt(0).toUpperCase() || "?"} +
+ )} + +
+
+ + {name} + + + @{username} + +
+ {bio && ( +

+ {bio} +

+ )} +
+
+ ); +}; + +export default InlineAuthorBio; diff --git a/components/ContentDetail/SourceInfoCard.tsx b/components/ContentDetail/SourceInfoCard.tsx new file mode 100644 index 00000000..b06ecf98 --- /dev/null +++ b/components/ContentDetail/SourceInfoCard.tsx @@ -0,0 +1,78 @@ +import Link from "next/link"; + +// Get favicon URL from a website +const getFaviconUrl = (websiteUrl: string | null | undefined): string | null => { + if (!websiteUrl) return null; + try { + const url = new URL(websiteUrl); + return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=64`; + } catch { + return null; + } +}; + +interface SourceInfoCardProps { + name: string; + slug: string | null; + description?: string | null; + logo?: string | null; + websiteUrl?: string | null; +} + +const SourceInfoCard = ({ + name, + slug, + description, + logo, + websiteUrl, +}: SourceInfoCardProps) => { + const faviconUrl = getFaviconUrl(websiteUrl); + const sourceLink = slug ? `/feed/${slug}` : "#"; + + return ( +
+
+ + {logo ? ( + + ) : faviconUrl ? ( + + ) : ( +
+ {name?.charAt(0).toUpperCase() || "?"} +
+ )} + +
+ + {name} + + {description && ( +

+ {description} +

+ )} + {websiteUrl && ( + + {new URL(websiteUrl).hostname} + + )} +
+
+
+ ); +}; + +export default SourceInfoCard; diff --git a/components/ContentDetail/UnifiedActionBar.tsx b/components/ContentDetail/UnifiedActionBar.tsx new file mode 100644 index 00000000..8495f167 --- /dev/null +++ b/components/ContentDetail/UnifiedActionBar.tsx @@ -0,0 +1,340 @@ +"use client"; + +import { Fragment, useState } from "react"; +import { + ChevronUpIcon, + ChevronDownIcon, + BookmarkIcon, + ChatBubbleLeftIcon, + ShareIcon, + EllipsisHorizontalIcon, +} from "@heroicons/react/20/solid"; +import { BookmarkIcon as BookmarkOutlineIcon } from "@heroicons/react/24/outline"; +import { + Menu, + MenuButton, + MenuItem, + MenuItems, + Transition, +} from "@headlessui/react"; +import { api } from "@/server/trpc/react"; +import { useSession, signIn } from "next-auth/react"; +import { toast } from "sonner"; +import * as Sentry from "@sentry/nextjs"; +import { ReportModal, useReportModal } from "@/components/ReportModal/ReportModal"; + +interface UnifiedActionBarProps { + contentType: "post" | "article"; + contentId: string | number; + initialUpvotes: number; + initialDownvotes: number; + initialUserVote: "up" | "down" | null; + initialBookmarked: boolean; + discussionCount: number; + shareUrl: string; + shareTitle: string; + shareUsername?: string; +} + +const UnifiedActionBar = ({ + contentType, + contentId, + initialUpvotes, + initialDownvotes, + initialUserVote, + initialBookmarked, + discussionCount, + shareUrl, + shareTitle, + shareUsername, +}: UnifiedActionBarProps) => { + const { data: session } = useSession(); + const utils = api.useUtils(); + const { openReport } = useReportModal(); + const [userVote, setUserVote] = useState(initialUserVote); + const [votes, setVotes] = useState({ + upvotes: initialUpvotes, + downvotes: initialDownvotes, + }); + const [isBookmarked, setIsBookmarked] = useState(initialBookmarked); + + // Post voting mutation + const { mutate: votePost, status: votePostStatus } = api.post.vote.useMutation({ + onMutate: async ({ voteType }) => { + const oldVote = userVote; + setUserVote(voteType); + setVotes((prev) => { + let newUpvotes = prev.upvotes; + let newDownvotes = prev.downvotes; + if (oldVote === "up") newUpvotes--; + if (oldVote === "down") newDownvotes--; + if (voteType === "up") newUpvotes++; + if (voteType === "down") newDownvotes++; + return { upvotes: newUpvotes, downvotes: newDownvotes }; + }); + }, + onError: (error) => { + setUserVote(initialUserVote); + setVotes({ upvotes: initialUpvotes, downvotes: initialDownvotes }); + toast.error("Failed to update vote"); + Sentry.captureException(error); + }, + onSettled: () => { + utils.post.sidebarData.invalidate(); + }, + }); + + // Article voting mutation + const { mutate: voteArticle, status: voteArticleStatus } = api.feed.vote.useMutation({ + onMutate: async ({ voteType }) => { + const oldVote = userVote; + setUserVote(voteType); + setVotes((prev) => { + let newUpvotes = prev.upvotes; + let newDownvotes = prev.downvotes; + if (oldVote === "up") newUpvotes--; + if (oldVote === "down") newDownvotes--; + if (voteType === "up") newUpvotes++; + if (voteType === "down") newDownvotes++; + return { upvotes: newUpvotes, downvotes: newDownvotes }; + }); + }, + onError: (error) => { + setUserVote(initialUserVote); + setVotes({ upvotes: initialUpvotes, downvotes: initialDownvotes }); + toast.error("Failed to update vote"); + Sentry.captureException(error); + }, + onSettled: () => { + utils.feed.getFeed.invalidate(); + }, + }); + + // Post bookmark mutation + const { mutate: bookmarkPost, status: bookmarkPostStatus } = api.post.bookmark.useMutation({ + onMutate: async ({ setBookmarked }) => { + setIsBookmarked(setBookmarked); + }, + onError: (error) => { + setIsBookmarked(initialBookmarked); + toast.error("Failed to update bookmark"); + Sentry.captureException(error); + }, + onSettled: () => { + utils.post.myBookmarks.invalidate(); + }, + }); + + // Article bookmark mutation + const { mutate: bookmarkArticle, status: bookmarkArticleStatus } = api.feed.bookmark.useMutation({ + onMutate: async ({ setBookmarked }) => { + setIsBookmarked(setBookmarked); + }, + onError: (error) => { + setIsBookmarked(initialBookmarked); + toast.error("Failed to update bookmark"); + Sentry.captureException(error); + }, + onSettled: () => { + utils.feed.mySavedArticles.invalidate(); + }, + }); + + const voteStatus = contentType === "post" ? votePostStatus : voteArticleStatus; + const bookmarkStatus = contentType === "post" ? bookmarkPostStatus : bookmarkArticleStatus; + + const handleVote = (voteType: "up" | "down" | null) => { + if (!session) { + signIn(); + return; + } + if (contentType === "post") { + votePost({ postId: contentId as string, voteType }); + } else { + voteArticle({ articleId: String(contentId), voteType }); + } + }; + + const handleBookmark = () => { + if (!session) { + signIn(); + return; + } + if (contentType === "post") { + bookmarkPost({ postId: contentId as string, setBookmarked: !isBookmarked }); + } else { + bookmarkArticle({ articleId: String(contentId), setBookmarked: !isBookmarked }); + } + }; + + const handleCopyLink = async () => { + try { + await navigator.clipboard.writeText(shareUrl); + toast.success("Link copied to clipboard"); + } catch { + toast.error("Failed to copy link"); + } + }; + + const handleReport = () => { + if (!session) { + signIn(); + return; + } + if (contentType === "post") { + openReport("post", contentId as string); + } else { + openReport("article", String(contentId)); + } + }; + + const score = votes.upvotes - votes.downvotes; + + return ( +
+ {/* Vote buttons */} +
+ + 0 + ? "text-green-500" + : score < 0 + ? "text-red-500" + : "text-neutral-500 dark:text-neutral-400" + }`} + > + {score} + + +
+ + {/* Comments count */} + + + {discussionCount} comments + + + {/* Bookmark button */} + + + {/* Share button */} + + + + Share + + + + + + Share to X + + + + + Share to LinkedIn + + + + + + + + + + {/* More options menu */} + + + More options + + + + + + + + + + +
+ ); +}; + +export default UnifiedActionBar; diff --git a/components/ContentDetail/index.ts b/components/ContentDetail/index.ts new file mode 100644 index 00000000..c02f86d4 --- /dev/null +++ b/components/ContentDetail/index.ts @@ -0,0 +1,6 @@ +export { default as ContentDetailLayout } from "./ContentDetailLayout"; +export { default as UnifiedActionBar } from "./UnifiedActionBar"; +export { default as ContentTypeBadge } from "./ContentTypeBadge"; +export { default as ContentMetaHeader } from "./ContentMetaHeader"; +export { default as SourceInfoCard } from "./SourceInfoCard"; +export { default as InlineAuthorBio } from "./InlineAuthorBio"; diff --git a/components/Discussion/DiscussionEditor/DiscussionEditor.tsx b/components/Discussion/DiscussionEditor/DiscussionEditor.tsx new file mode 100644 index 00000000..918787af --- /dev/null +++ b/components/Discussion/DiscussionEditor/DiscussionEditor.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { EditorContent } from "@tiptap/react"; +import TextareaAutosize from "react-textarea-autosize"; +import { InformationCircleIcon } from "@heroicons/react/20/solid"; +import { useDiscussionEditor } from "./hooks/useDiscussionEditor"; +import { DiscussionEditorToolbar } from "./DiscussionEditorToolbar"; +import { MarkdownHelpModal } from "./MarkdownHelpModal"; +import type { DiscussionEditorProps } from "./types"; + +export function DiscussionEditor({ + onSubmit, + onCancel, + initialContent = "", + autoExpand = false, + placeholder = "Join the conversation...", + submitLabel = "Comment", + disabled = false, +}: DiscussionEditorProps) { + const [showMarkdownHelp, setShowMarkdownHelp] = useState(false); + const [showToolbar, setShowToolbar] = useState(false); + + const { + editor, + isExpanded, + mode, + markdownContent, + isSubmitting, + setMarkdownContent, + toggleMode, + expand, + handleSubmit, + handleCancel, + isEmpty, + } = useDiscussionEditor({ + initialContent, + autoExpand, + placeholder: "What are your thoughts?", + onSubmit, + }); + + // Reset toolbar visibility when editor collapses so it opens clean + useEffect(() => { + if (!isExpanded) { + setShowToolbar(false); + } + }, [isExpanded]); + + // Collapsed state + if (!isExpanded) { + return ( + + ); + } + + // Expanded state + return ( +
+ {/* Rich text mode */} + {mode === "rich" && ( + <> + {/* Toolbar row - only show when showToolbar is true */} + {showToolbar && ( +
+ + +
+ )} + + {/* Editor content */} + + + )} + + {/* Markdown mode */} + {mode === "markdown" && ( + <> + {/* Header row */} +
+
+ + Markdown Editor + + +
+ +
+ + {/* Textarea */} + setMarkdownContent(e.target.value)} + placeholder="What are your thoughts?" + minRows={2} + className="w-full resize-y border-none bg-transparent px-3 py-1.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:outline-none focus:ring-0 dark:text-white" + /> + + )} + + {/* Action buttons row */} +
+ {/* Format toggle button (Aa) - only in rich text mode */} + {mode === "rich" ? ( + + ) : ( +
+ )} + + {/* Cancel and Submit buttons */} +
+ + +
+
+ + {/* Markdown help modal */} + setShowMarkdownHelp(false)} + /> +
+ ); +} diff --git a/components/Discussion/DiscussionEditor/DiscussionEditorToolbar.tsx b/components/Discussion/DiscussionEditor/DiscussionEditorToolbar.tsx new file mode 100644 index 00000000..8573243a --- /dev/null +++ b/components/Discussion/DiscussionEditor/DiscussionEditorToolbar.tsx @@ -0,0 +1,252 @@ +"use client"; + +import type { Editor } from "@tiptap/react"; +import { useState, useCallback } from "react"; + +interface ToolbarButtonProps { + onClick: () => void; + isActive?: boolean; + disabled?: boolean; + title: string; + children: React.ReactNode; +} + +function ToolbarButton({ + onClick, + isActive = false, + disabled = false, + title, + children, +}: ToolbarButtonProps) { + return ( + + ); +} + +function ToolbarDivider() { + return ( +
+ ); +} + +interface LinkInputProps { + onSubmit: (url: string) => void; + onCancel: () => void; +} + +function LinkInput({ onSubmit, onCancel }: LinkInputProps) { + const [url, setUrl] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (url.trim()) { + onSubmit(url.trim()); + } + }; + + return ( +
+ setUrl(e.target.value)} + placeholder="Enter URL..." + className="w-48 border-none bg-transparent text-sm text-neutral-900 placeholder:text-neutral-400 focus:outline-none dark:text-white" + autoFocus + /> + + +
+ ); +} + +interface DiscussionEditorToolbarProps { + editor: Editor | null; +} + +export function DiscussionEditorToolbar({ + editor, +}: DiscussionEditorToolbarProps) { + const [showLinkInput, setShowLinkInput] = useState(false); + + const handleAddLink = useCallback( + (url: string) => { + if (editor) { + editor.chain().focus().setLink({ href: url }).run(); + } + setShowLinkInput(false); + }, + [editor], + ); + + const insertTable = useCallback(() => { + if (editor) { + editor + .chain() + .focus() + .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + .run(); + } + }, [editor]); + + if (!editor) return null; + + if (showLinkInput) { + return ( + setShowLinkInput(false)} + /> + ); + } + + return ( +
+ {/* Bold */} + editor.chain().focus().toggleBold().run()} + isActive={editor.isActive("bold")} + title="Bold (Ctrl+B)" + > + + + + + + {/* Italic */} + editor.chain().focus().toggleItalic().run()} + isActive={editor.isActive("italic")} + title="Italic (Ctrl+I)" + > + + + + + + {/* Strikethrough */} + editor.chain().focus().toggleStrike().run()} + isActive={editor.isActive("strike")} + title="Strikethrough" + > + + + + + + {/* Superscript */} + editor.chain().focus().toggleSuperscript().run()} + isActive={editor.isActive("superscript")} + title="Superscript" + > + + + + + + {/* Link */} + { + if (editor.isActive("link")) { + editor.chain().focus().unsetLink().run(); + } else { + setShowLinkInput(true); + } + }} + isActive={editor.isActive("link")} + title="Link" + > + + + + + + + + {/* Bullet List */} + editor.chain().focus().toggleBulletList().run()} + isActive={editor.isActive("bulletList")} + title="Bullet List" + > + + + + + + {/* Numbered List */} + editor.chain().focus().toggleOrderedList().run()} + isActive={editor.isActive("orderedList")} + title="Numbered List" + > + + + + + + + + {/* Blockquote */} + editor.chain().focus().toggleBlockquote().run()} + isActive={editor.isActive("blockquote")} + title="Quote" + > + + + + + + {/* Code */} + editor.chain().focus().toggleCode().run()} + isActive={editor.isActive("code")} + title="Inline Code" + > + + + + + + {/* Table */} + + + + + +
+ ); +} diff --git a/components/Discussion/DiscussionEditor/MarkdownHelpModal.tsx b/components/Discussion/DiscussionEditor/MarkdownHelpModal.tsx new file mode 100644 index 00000000..e3a1fe6b --- /dev/null +++ b/components/Discussion/DiscussionEditor/MarkdownHelpModal.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { + Dialog, + DialogTitle, + DialogBody, + DialogActions, +} from "@/components/ui-components/dialog"; +import { Button } from "@/components/ui-components/button"; + +const MARKDOWN_SYNTAX = [ + { syntax: "**bold**", result: "bold", style: "font-bold" }, + { syntax: "*italic*", result: "italic", style: "italic" }, + { + syntax: "~~strikethrough~~", + result: "strikethrough", + style: "line-through", + }, + { + syntax: "^superscript", + result: "superscript", + style: "text-[0.7em] align-super", + }, + { syntax: "[text](url)", result: "link", style: "text-blue-500 underline" }, + { syntax: "- item", result: "bullet list", style: "" }, + { syntax: "1. item", result: "numbered list", style: "" }, + { + syntax: "> quote", + result: "blockquote", + style: "border-l-2 border-neutral-400 pl-2", + }, + { + syntax: "`code`", + result: "inline code", + style: + "font-mono bg-neutral-200 dark:bg-neutral-700 px-1 rounded text-sm", + }, + { syntax: "```\\ncode\\n```", result: "code block", style: "font-mono" }, +]; + +interface MarkdownHelpModalProps { + open: boolean; + onClose: () => void; +} + +export function MarkdownHelpModal({ open, onClose }: MarkdownHelpModalProps) { + return ( + + + Markdown Help + + + +

+ Markdown is a way to quickly format text using typed symbols instead + of a toolbar. +

+
+ + + + + + + + + {MARKDOWN_SYNTAX.map(({ syntax, result, style }) => ( + + + + + ))} + +
+ Type this + + To get this +
+ + {syntax} + + + {result} +
+
+
+ + + +
+ ); +} diff --git a/components/Discussion/DiscussionEditor/extensions/index.ts b/components/Discussion/DiscussionEditor/extensions/index.ts new file mode 100644 index 00000000..a71a0cf7 --- /dev/null +++ b/components/Discussion/DiscussionEditor/extensions/index.ts @@ -0,0 +1,86 @@ +import StarterKit from "@tiptap/starter-kit"; +import Link from "@tiptap/extension-link"; +import Superscript from "@tiptap/extension-superscript"; +import Table from "@tiptap/extension-table"; +import TableRow from "@tiptap/extension-table-row"; +import TableCell from "@tiptap/extension-table-cell"; +import TableHeader from "@tiptap/extension-table-header"; +import Placeholder from "@tiptap/extension-placeholder"; +import { Markdown } from "tiptap-markdown"; + +export const getDiscussionExtensions = ( + placeholder: string = "What are your thoughts?", +) => [ + StarterKit.configure({ + heading: false, + horizontalRule: false, + codeBlock: { + HTMLAttributes: { + class: + "rounded bg-neutral-100 dark:bg-neutral-800 p-3 font-mono text-sm", + }, + }, + code: { + HTMLAttributes: { + class: + "rounded bg-neutral-200 dark:bg-neutral-700 px-1.5 py-0.5 font-mono text-sm", + }, + }, + bulletList: { + HTMLAttributes: { + class: "list-disc list-outside ml-4", + }, + }, + orderedList: { + HTMLAttributes: { + class: "list-decimal list-outside ml-4", + }, + }, + blockquote: { + HTMLAttributes: { + class: + "border-l-4 border-neutral-300 dark:border-neutral-600 pl-4 italic", + }, + }, + }), + Link.configure({ + openOnClick: false, + HTMLAttributes: { + class: "text-pink-600 underline hover:text-pink-500 cursor-pointer", + }, + }), + Superscript, + Table.configure({ + resizable: false, + HTMLAttributes: { + class: "border-collapse w-full my-2", + }, + }), + TableRow, + TableCell.configure({ + HTMLAttributes: { + class: "border border-neutral-300 dark:border-neutral-600 p-2", + }, + }), + TableHeader.configure({ + HTMLAttributes: { + class: + "border border-neutral-300 dark:border-neutral-600 p-2 bg-neutral-100 dark:bg-neutral-800 font-semibold", + }, + }), + Placeholder.configure({ + placeholder: ({ editor }) => { + // Only show placeholder when editor is completely empty + const isEmpty = editor.state.doc.textContent.length === 0; + return isEmpty ? placeholder : ""; + }, + showOnlyWhenEditable: true, + emptyEditorClass: + "before:content-[attr(data-placeholder)] before:text-neutral-400 before:float-left before:h-0 before:pointer-events-none", + }), + Markdown.configure({ + html: false, + transformCopiedText: true, + transformPastedText: true, + }), +]; diff --git a/components/Discussion/DiscussionEditor/hooks/useDiscussionEditor.ts b/components/Discussion/DiscussionEditor/hooks/useDiscussionEditor.ts new file mode 100644 index 00000000..d385f359 --- /dev/null +++ b/components/Discussion/DiscussionEditor/hooks/useDiscussionEditor.ts @@ -0,0 +1,132 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { useEditor } from "@tiptap/react"; +import { getDiscussionExtensions } from "../extensions"; +import type { EditorMode } from "../types"; + +interface UseDiscussionEditorOptions { + initialContent?: string; + autoExpand?: boolean; + placeholder?: string; + onSubmit: (markdown: string) => Promise; +} + +export function useDiscussionEditor({ + initialContent = "", + autoExpand = false, + placeholder = "What are your thoughts?", + onSubmit, +}: UseDiscussionEditorOptions) { + const [isExpanded, setIsExpanded] = useState(autoExpand); + const [mode, setMode] = useState("rich"); + const [markdownContent, setMarkdownContent] = useState(initialContent); + const [isSubmitting, setIsSubmitting] = useState(false); + + const editor = useEditor({ + extensions: getDiscussionExtensions(placeholder), + content: initialContent, + editorProps: { + attributes: { + class: "focus:outline-none", + }, + }, + immediatelyRender: false, + }); + + // Sync initial content when editor is ready + useEffect(() => { + if (editor && initialContent && !editor.getText().trim()) { + editor.commands.setContent(initialContent); + } + }, [editor, initialContent]); + + const toggleMode = useCallback(() => { + if (!editor) return; + + if (mode === "rich") { + // Rich -> Markdown: Get markdown from TipTap + const markdown = + editor.storage.markdown?.getMarkdown() || editor.getText(); + setMarkdownContent(markdown); + setMode("markdown"); + } else { + // Markdown -> Rich: Set TipTap content from markdown + editor.commands.setContent(markdownContent); + setMode("rich"); + } + }, [mode, editor, markdownContent]); + + const expand = useCallback(() => { + setIsExpanded(true); + // Focus editor after expanding + setTimeout(() => editor?.commands.focus(), 50); + }, [editor]); + + const collapse = useCallback(() => { + setIsExpanded(false); + // Reset content + editor?.commands.clearContent(); + setMarkdownContent(""); + setMode("rich"); + }, [editor]); + + const getMarkdown = useCallback((): string => { + if (mode === "markdown") { + return markdownContent; + } + if (editor) { + return editor.storage.markdown?.getMarkdown() || editor.getText(); + } + return ""; + }, [mode, markdownContent, editor]); + + const isEmpty = useCallback((): boolean => { + const content = getMarkdown().trim(); + return content.length === 0; + }, [getMarkdown]); + + const handleSubmit = useCallback(async () => { + const markdown = getMarkdown().trim(); + if (!markdown) return; + + setIsSubmitting(true); + try { + await onSubmit(markdown); + // Reset after successful submit + editor?.commands.clearContent(); + setMarkdownContent(""); + setMode("rich"); + if (!autoExpand) { + setIsExpanded(false); + } + } finally { + setIsSubmitting(false); + } + }, [getMarkdown, onSubmit, editor, autoExpand]); + + const handleCancel = useCallback(() => { + editor?.commands.clearContent(); + setMarkdownContent(""); + setMode("rich"); + if (!autoExpand) { + setIsExpanded(false); + } + }, [editor, autoExpand]); + + return { + editor, + isExpanded, + mode, + markdownContent, + isSubmitting, + setMarkdownContent, + toggleMode, + expand, + collapse, + getMarkdown, + isEmpty, + handleSubmit, + handleCancel, + }; +} diff --git a/components/Discussion/DiscussionEditor/index.ts b/components/Discussion/DiscussionEditor/index.ts new file mode 100644 index 00000000..29c61e3d --- /dev/null +++ b/components/Discussion/DiscussionEditor/index.ts @@ -0,0 +1,2 @@ +export { DiscussionEditor } from "./DiscussionEditor"; +export type { DiscussionEditorProps, EditorMode } from "./types"; diff --git a/components/Discussion/DiscussionEditor/types.ts b/components/Discussion/DiscussionEditor/types.ts new file mode 100644 index 00000000..1534d299 --- /dev/null +++ b/components/Discussion/DiscussionEditor/types.ts @@ -0,0 +1,18 @@ +export type EditorMode = "rich" | "markdown"; + +export interface DiscussionEditorState { + isExpanded: boolean; + mode: EditorMode; + content: string; + isSubmitting: boolean; +} + +export interface DiscussionEditorProps { + onSubmit: (markdown: string) => Promise; + onCancel?: () => void; + initialContent?: string; + autoExpand?: boolean; + placeholder?: string; + submitLabel?: string; + disabled?: boolean; +} diff --git a/components/SavedItemCard/SavedItemCard.tsx b/components/SavedItemCard/SavedItemCard.tsx new file mode 100644 index 00000000..72f57f34 --- /dev/null +++ b/components/SavedItemCard/SavedItemCard.tsx @@ -0,0 +1,156 @@ +"use client"; + +import Link from "next/link"; +import { Temporal } from "@js-temporal/polyfill"; + +export interface SavedItemCardProps { + id: string; + title: string; + slug: string; + publishedAt: string | null; + // Source info (for external links) + sourceName?: string | null; + sourceLogo?: string | null; + sourceSlug?: string | null; + // Author info (for user posts) + authorName?: string | null; + authorUsername?: string | null; + authorImage?: string | null; + // For building the URL + type: "POST" | "LINK"; + // Optional remove callback + onRemove?: () => void; +} + +// Get relative time string +const getRelativeTime = (dateStr: string): string => { + const now = new Date(); + const date = new Date(dateStr); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); +}; + +// Get favicon URL from a website +const getFaviconUrl = (sourceLogo: string | null | undefined): string | null => { + if (!sourceLogo) return null; + return sourceLogo; +}; + +const SavedItemCard = ({ + id, + title, + slug, + publishedAt, + sourceName, + sourceLogo, + sourceSlug, + authorName, + authorUsername, + authorImage, + type, + onRemove, +}: SavedItemCardProps) => { + // Determine the URL for the card + const cardUrl = + type === "POST" + ? `/${authorUsername || ""}/${slug}` + : sourceSlug && slug + ? `/${sourceSlug}/${slug}` + : `/feed/${id}`; + + const dateTime = publishedAt + ? Temporal.Instant.from(new Date(publishedAt).toISOString()) + : null; + const relativeTime = publishedAt ? getRelativeTime(publishedAt) : null; + const readableDate = dateTime + ? dateTime.toLocaleString(["en-IE"], { + year: "numeric", + month: "short", + day: "numeric", + }) + : null; + + // Determine display info + const displayName = type === "POST" ? authorName : sourceName; + const displayImage = type === "POST" ? authorImage : sourceLogo; + const displayInitial = displayName?.charAt(0).toUpperCase() || "?"; + + return ( +
+ + {/* Attribution row */} +
+ {displayImage ? ( + + ) : ( +
+ {displayInitial} +
+ )} + {type === "POST" ? ( + + + {authorName} + + + ) : ( + + In + + {sourceName} + + + )} + {relativeTime && ( + <> + + + + )} +
+ + {/* Title */} +

+ {title} +

+ + + {/* Remove button (optional) */} + {onRemove && ( + + )} +
+ ); +}; + +export default SavedItemCard; diff --git a/components/SavedItemCard/index.ts b/components/SavedItemCard/index.ts new file mode 100644 index 00000000..1e90eae4 --- /dev/null +++ b/components/SavedItemCard/index.ts @@ -0,0 +1,2 @@ +export { default as SavedItemCard } from "./SavedItemCard"; +export type { SavedItemCardProps } from "./SavedItemCard"; diff --git a/components/Sponsorship/ContactForm.tsx b/components/Sponsorship/ContactForm.tsx new file mode 100644 index 00000000..2145373b --- /dev/null +++ b/components/Sponsorship/ContactForm.tsx @@ -0,0 +1,460 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { + SponsorInquirySchema, + type SponsorInquiryInput, + sponsorInterests, + sponsorInterestLabels, + sponsorBudgetRanges, + sponsorBudgetLabels, +} from "@/schema/sponsor"; +import { z } from "zod"; +import { Input } from "@/components/ui-components/input"; +import { Textarea } from "@/components/ui-components/textarea"; +import { Select } from "@/components/ui-components/select"; +import { Field, Label, ErrorMessage } from "@/components/ui-components/fieldset"; +import { api } from "@/server/trpc/react"; +import clsx from "clsx"; +import { + EnvelopeIcon, + CalendarDaysIcon, + GlobeAltIcon, + DocumentTextIcon, + CheckIcon, + ArrowLeftIcon, + ArrowRightIcon, +} from "@heroicons/react/24/outline"; + +const TOTAL_STEPS = 3; + +const interestIcons: Record = { + NEWSLETTER: EnvelopeIcon, + EVENTS: CalendarDaysIcon, + WEBSITE: GlobeAltIcon, + CONTENT: DocumentTextIcon, +}; + +const stepLabels = ["Interests", "Details", "Contact"]; + +function StepIndicator({ currentStep }: { currentStep: number }) { + return ( +
+
+ {[1, 2, 3].map((step) => ( +
+ {/* Circle with connecting lines */} +
+ {/* Left line */} + {step > 1 && ( +
+ )} + {/* Right line */} + {step < 3 && ( +
+ )} + {/* Circle */} +
+ {step < currentStep ? ( + + ) : ( + step + )} +
+
+ {/* Label */} + + {stepLabels[step - 1]} + +
+ ))} +
+
+ ); +} + +function Step1Interests({ + selectedInterests, + onToggle, + error, +}: { + selectedInterests: string[]; + onToggle: (interest: string) => void; + error?: string; +}) { + return ( +
+
+

What interests you?

+

+ Select all the advertising options you'd like to learn more about. +

+
+ +
+ {sponsorInterests.map((interest) => { + const Icon = interestIcons[interest]; + const isSelected = selectedInterests.includes(interest); + + return ( + + ); + })} +
+ + {error &&

{error}

} +
+ ); +} + +function Step2Details({ + register, + errors, + budgetValue, +}: { + register: ReturnType>["register"]; + errors: ReturnType>["formState"]["errors"]; + budgetValue: string; +}) { + return ( +
+
+

Tell us more

+

+ Help us understand your budget and goals so we can prepare the best options for you. +

+
+ + + + + + + + +