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 */}
+
+
+ Feed
+
+ /
+
+ {article.source?.name || sourceSlug}
+
+
+
+ {/* 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 && (
+ <>
+
·
+
{readableDate}
+ >
+ )}
+
+
+ {/* Title */}
+
+ {article.title}
+
+
+ {/* Excerpt */}
+ {article.excerpt && (
+
+ {article.excerpt}
+
+ )}
+
+ {/* Thumbnail image */}
+ {(ensureHttps(article.ogImageUrl) || ensureHttps(article.imageUrl)) && (
+
+
+
+
+ )}
+
+ {/* Read article CTA */}
+
+
+ Read Full Article at {hostname}
+
+
+ {/* Action bar */}
+
+ {/* Vote buttons */}
+
+
+ handleVote(article.userVote === "UP" ? null : "UP")
+ }
+ disabled={voteStatus === "pending"}
+ className={`rounded-l-full p-2 transition-colors hover:bg-neutral-200 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-700 ${
+ article.userVote === "UP"
+ ? "text-green-500"
+ : "text-neutral-500 dark:text-neutral-400"
+ }`}
+ aria-label="Upvote"
+ >
+
+
+ 0
+ ? "text-green-500"
+ : score < 0
+ ? "text-red-500"
+ : "text-neutral-500 dark:text-neutral-400"
+ }`}
+ >
+ {score}
+
+
+ handleVote(article.userVote === "DOWN" ? null : "DOWN")
+ }
+ disabled={voteStatus === "pending"}
+ className={`rounded-r-full p-2 transition-colors hover:bg-neutral-200 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-700 ${
+ article.userVote === "DOWN"
+ ? "text-red-500"
+ : "text-neutral-500 dark:text-neutral-400"
+ }`}
+ aria-label="Downvote"
+ >
+
+
+
+
+ {/* Comments count */}
+
+
+ {discussionCount ?? 0} comments
+
+
+ {/* Save button */}
+
+ {article.isBookmarked ? (
+
+ ) : (
+
+ )}
+ {article.isBookmarked ? "Saved" : "Save"}
+
+
+ {/* Share button */}
+
+
+ Share
+
+
+
+
+ {/* 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 ? (
+
+ ) : faviconUrl ? (
+
+ ) : (
+
+ {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) => (
+ setSort(sortOption)}
+ className={`rounded-full px-3 py-1 text-xs font-medium capitalize transition-colors ${
+ sort === sortOption
+ ? "bg-orange-500 text-white"
+ : "bg-neutral-100 text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
+ }`}
+ >
+ {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) => (
+
+ handleCategoryChange(category === cat ? null : cat)
+ }
+ className={`rounded border px-4 py-2 text-sm capitalize transition-colors ${
+ category === cat
+ ? "border-orange-500 bg-orange-50 text-orange-700 dark:border-orange-400 dark:bg-orange-950 dark:text-orange-300"
+ : "border-neutral-300 bg-white text-neutral-700 hover:border-neutral-400 dark:border-neutral-600 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:border-neutral-500"
+ }`}
+ >
+ {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 (
+
+ );
+};
+
+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 }) => (
+ onTypeChange(option.value)}
+ className={`flex w-full items-center gap-2 px-4 py-2 text-left text-sm ${
+ focus
+ ? "bg-neutral-100 text-neutral-900 dark:bg-neutral-700 dark:text-neutral-100"
+ : "text-neutral-700 dark:text-neutral-200"
+ } ${
+ type === option.value
+ ? "font-medium text-orange-600 dark:text-orange-400"
+ : ""
+ }`}
+ >
+
+ {option.label}
+
+ )}
+
+ ))}
+
+
+
+
+ )}
+
+ {/* Sort Dropdown */}
+
+
+
+ {currentSort.label}
+
+
+
+
+
+ {sortOptions.map((option) => (
+
+ {({ focus }) => (
+ onSortChange(option.value)}
+ className={`flex w-full items-center gap-2 px-4 py-2 text-left text-sm ${
+ focus
+ ? "bg-neutral-100 text-neutral-900 dark:bg-neutral-700 dark:text-neutral-100"
+ : "text-neutral-700 dark:text-neutral-200"
+ } ${
+ sort === option.value
+ ? "font-medium text-orange-600 dark:text-orange-400"
+ : ""
+ }`}
+ >
+
+ {option.label}
+
+ )}
+
+ ))}
+
+
+
+
+
+ {/* Category Dropdown */}
+ {categories.length > 0 && (
+
+
+ {category || "All Topics"}
+
+
+
+
+
+
+ {({ focus }) => (
+ onCategoryChange(null)}
+ className={`block w-full px-4 py-2 text-left text-sm ${
+ focus
+ ? "bg-neutral-100 text-neutral-900 dark:bg-neutral-700 dark:text-neutral-100"
+ : "text-neutral-700 dark:text-neutral-200"
+ } ${!category ? "font-medium text-orange-600 dark:text-orange-400" : ""}`}
+ >
+ All Topics
+
+ )}
+
+ {categories.map((cat) => (
+
+ {({ focus }) => (
+ onCategoryChange(cat)}
+ className={`block w-full px-4 py-2 text-left text-sm capitalize ${
+ focus
+ ? "bg-neutral-100 text-neutral-900 dark:bg-neutral-700 dark:text-neutral-100"
+ : "text-neutral-700 dark:text-neutral-200"
+ } ${
+ category === cat
+ ? "font-medium text-orange-600 dark:text-orange-400"
+ : ""
+ }`}
+ >
+ {cat}
+
+ )}
+
+ ))}
+
+
+
+
+ )}
+
+ );
+};
+
+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 && (
+ <>
+
·
+
+ {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 */}
+
+
+ handleVote(userVote === "UP" ? null : "UP")
+ }
+ disabled={voteStatus === "pending"}
+ className={`rounded-l-full p-1 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
+ userVote === "UP"
+ ? "text-green-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ aria-label="Upvote"
+ >
+
+
+ 0
+ ? "text-green-500"
+ : score < 0
+ ? "text-red-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ >
+ {score}
+
+
+ handleVote(userVote === "DOWN" ? null : "DOWN")
+ }
+ disabled={voteStatus === "pending"}
+ className={`rounded-r-full p-1 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
+ userVote === "DOWN"
+ ? "text-red-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ aria-label="Downvote"
+ >
+
+
+
+
+ {/* Comments button */}
+
+
+
0
+
+
+ {/* Save button */}
+
+ {initialBookmarked ? (
+
+ ) : (
+
+ )}
+
+ {initialBookmarked ? "Saved" : "Save"}
+
+
+
+ {/* Share button */}
+
+
+ Share
+
+
+
+
+ {/* 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 (
+
+ handleVote("UP")}
+ disabled={isLoading}
+ className={`rounded p-1 transition-colors hover:bg-neutral-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-700 ${
+ userVote === "UP" ? "text-orange-500" : "text-neutral-400"
+ }`}
+ aria-label="Upvote"
+ >
+
+
+ 0
+ ? "text-orange-500"
+ : score < 0
+ ? "text-blue-500"
+ : "text-neutral-500"
+ }`}
+ >
+ {score}
+
+ handleVote("DOWN")}
+ disabled={isLoading}
+ className={`rounded p-1 transition-colors hover:bg-neutral-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-700 ${
+ userVote === "DOWN" ? "text-blue-500" : "text-neutral-400"
+ }`}
+ aria-label="Downvote"
+ >
+
+
+
+ );
+};
+
+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 && (
+ <>
+
·
+
{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 */}
+
+ handleVote(userVote === "UP" ? null : "UP")}
+ disabled={voteStatus === "pending"}
+ className={`rounded-l-full p-1 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
+ userVote === "UP"
+ ? "text-green-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ aria-label="Upvote"
+ >
+
+
+ 0
+ ? "text-green-500"
+ : score < 0
+ ? "text-red-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ >
+ {score}
+
+ handleVote(userVote === "DOWN" ? null : "DOWN")}
+ disabled={voteStatus === "pending"}
+ className={`rounded-r-full p-1 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
+ userVote === "DOWN"
+ ? "text-red-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ aria-label="Downvote"
+ >
+
+
+
+
+ {/* Comments button */}
+
+
+
{discussionCount}
+
+
+ {/* Save button */}
+ {showBookmark && (
+
+ {initialBookmarked ? (
+
+ ) : (
+
+ )}
+
+ {initialBookmarked ? "Saved" : "Save"}
+
+
+ )}
+
+ {/* Share button */}
+
+
+ Share
+
+
+ {/* Triple-dot menu */}
+
+
+ More options
+
+
+
+
+ {onReport && (
+
+
+ Report
+
+
+ )}
+
+
+ Copy link
+
+
+ {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 ? (
+ <>
+
+
+
+
+
+
+ {name}
+
+ {isCurrentUser && (
+
+ YOU
+
+ )}
+
·
+
{readableDate}
+
+ {discussionUpdated ? (
+ <>
+
·
+
Edited
+ >
+ ) : null}
+
+ {isCurrentUser ? (
+
+
+
+ Open user menu
+
+
+
+
+
+ <>
+
+ {
+ if (id !== editCommentBoxId) {
+ setValue("edit", body);
+ }
+ setEditCommentBoxId(id);
+ setShowCommentBoxId(null);
+ }}
+ >
+ Edit comment
+
+
+
+ {
+ deleteDiscussion({ id });
+ }}
+ >
+ Delete comment
+
+
+ >
+
+
+
+ ) : null}
+
+
+
+
+ {Markdoc.renderers.react(content, React, {
+ components: markdocComponents,
+ })}
+
+
+
+ {/* Vote buttons */}
+
+
+ voteDiscussion(id, userVote === "UP" ? null : "UP")
+ }
+ disabled={voteStatus === "pending"}
+ className={`rounded-l-full p-1 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
+ userVote === "UP"
+ ? "text-green-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ aria-label="Upvote"
+ >
+
+
+ 0
+ ? "text-green-500"
+ : score < 0
+ ? "text-red-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ >
+ {score}
+
+
+ voteDiscussion(id, userVote === "DOWN" ? null : "DOWN")
+ }
+ disabled={voteStatus === "pending"}
+ className={`rounded-r-full p-1 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
+ userVote === "DOWN"
+ ? "text-red-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ aria-label="Downvote"
+ >
+
+
+
+
+ {depth < 6 && (
+
{
+ if (!session) return signIn();
+ if (showCommentBoxId !== id) {
+ resetField("reply");
+ setShowCommentBoxId((currentId) =>
+ currentId === id ? null : id,
+ );
+ }
+ }}
+ >
+ Reply
+
+ )}
+
+
+ <>
+ {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 (
+
+ );
+ };
+
+ return (
+
+ {!initiallyLoaded && (
+
+ )}
+
+
+ {initiallyLoaded
+ ? `Discussion (${discussionsResponse?.count || 0})`
+ : "Loading discussion..."}
+
+
+ {session ? (
+
+ ) : (
+
+
Hey! 👋
+
Got something to say?
+
+ signIn()}
+ className="cursor-pointer bg-gradient-to-r from-orange-400 to-pink-600 bg-clip-text tracking-wide text-transparent hover:from-orange-300 hover:to-pink-500"
+ >
+ Sign in
+ {" "}
+ or{" "}
+ signIn()}
+ className="cursor-pointer bg-gradient-to-r from-orange-400 to-pink-600 bg-clip-text tracking-wide text-transparent hover:from-orange-300 hover:to-pink-500"
+ >
+ sign up
+ {" "}
+ 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 && (
{
@@ -97,7 +105,7 @@ export const ReportModal = (props: Props) => {
)}
- {!isComment && (
+ {!isCommentLike && (
(session ? setIsModalOpen(true) : signIn())}
className="w-full rounded text-neutral-900 hover:bg-neutral-200 dark:text-neutral-700"
@@ -127,8 +135,8 @@ export const ReportModal = (props: Props) => {
{isPost ? "Article : " : "Comment : "}
- {isComment && props.comment}
- {isPost && props.title}
+ {isCommentLike && (props as Comment | Discussion).comment}
+ {isPost && (props as Post).title}
From 42720c0435de9b9e4fe869b3acaefca16ea6d47c Mon Sep 17 00:00:00 2001
From: NiallJoeMaher
Date: Sun, 4 Jan 2026 14:42:44 +0000
Subject: [PATCH 05/38] feat(admin): add admin dashboard, moderation queue, and
user management
- Add admin dashboard with platform stats overview
- Add moderation queue for reviewing reported content
- Add user management with search, ban/unban functionality
- Display ban reasons and reviewer info
- Filter reports by status (pending, actioned, dismissed)
---
app/(app)/admin/_client.tsx | 206 +++++++++++++
app/(app)/admin/moderation/_client.tsx | 306 +++++++++++++++++++
app/(app)/admin/moderation/page.tsx | 18 ++
app/(app)/admin/page.tsx | 18 ++
app/(app)/admin/sources/_client.tsx | 395 +++++++++++++++++++++++++
app/(app)/admin/sources/page.tsx | 19 ++
app/(app)/admin/users/_client.tsx | 285 ++++++++++++++++++
app/(app)/admin/users/page.tsx | 18 ++
app/api/admin/sync-feeds/route.ts | 227 ++++++++++++++
9 files changed, 1492 insertions(+)
create mode 100644 app/(app)/admin/_client.tsx
create mode 100644 app/(app)/admin/moderation/_client.tsx
create mode 100644 app/(app)/admin/moderation/page.tsx
create mode 100644 app/(app)/admin/page.tsx
create mode 100644 app/(app)/admin/sources/_client.tsx
create mode 100644 app/(app)/admin/sources/page.tsx
create mode 100644 app/(app)/admin/users/_client.tsx
create mode 100644 app/(app)/admin/users/page.tsx
create mode 100644 app/api/admin/sync-feeds/route.ts
diff --git a/app/(app)/admin/_client.tsx b/app/(app)/admin/_client.tsx
new file mode 100644
index 00000000..dd19944a
--- /dev/null
+++ b/app/(app)/admin/_client.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+import Link from "next/link";
+import {
+ UsersIcon,
+ DocumentTextIcon,
+ FlagIcon,
+ RssIcon,
+ ShieldExclamationIcon,
+ NewspaperIcon,
+ LinkIcon,
+} from "@heroicons/react/24/outline";
+import { api } from "@/server/trpc/react";
+
+const AdminDashboard = () => {
+ const { data: stats, isLoading } = api.admin.getStats.useQuery();
+ const { data: reportCounts } = api.report.getCounts.useQuery();
+
+ const StatCard = ({
+ title,
+ value,
+ icon: Icon,
+ href,
+ color = "blue",
+ }: {
+ title: string;
+ value: number | undefined;
+ icon: React.ComponentType<{ className?: string }>;
+ href?: string;
+ color?: "blue" | "green" | "yellow" | "red" | "purple" | "orange";
+ }) => {
+ const colorClasses = {
+ blue: "bg-blue-50 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400",
+ green:
+ "bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400",
+ yellow:
+ "bg-yellow-50 text-yellow-600 dark:bg-yellow-900/30 dark:text-yellow-400",
+ red: "bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400",
+ purple:
+ "bg-purple-50 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400",
+ orange:
+ "bg-orange-50 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400",
+ };
+
+ const content = (
+
+
+
+
+
+
+
+ {title}
+
+
+ {isLoading ? (
+
+ ) : (
+ value ?? 0
+ )}
+
+
+
+
+ );
+
+ if (href) {
+ return {content};
+ }
+
+ return content;
+ };
+
+ return (
+
+
+
+ Admin Dashboard
+
+
+ Manage and monitor the Codú platform
+
+
+
+ {/* Stats Grid */}
+
+
+
+
+
+
+
+ {/* Moderation Stats */}
+
+
+ Moderation
+
+
+
+
+
+
+
+
+
+ {/* Quick Links */}
+
+
+ Quick Actions
+
+
+
+
+
+
+ Moderation Queue
+
+
+ Review reported content
+
+
+
+
+
+
+
+
+ User Management
+
+
+ Search and manage users
+
+
+
+
+
+
+
+
+ Feed Sources
+
+
+ Manage RSS feed sources
+
+
+
+
+
+
+ );
+};
+
+export default AdminDashboard;
diff --git a/app/(app)/admin/moderation/_client.tsx b/app/(app)/admin/moderation/_client.tsx
new file mode 100644
index 00000000..92e389a9
--- /dev/null
+++ b/app/(app)/admin/moderation/_client.tsx
@@ -0,0 +1,306 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import {
+ FlagIcon,
+ CheckCircleIcon,
+ XCircleIcon,
+ ExclamationTriangleIcon,
+ ArrowLeftIcon,
+} from "@heroicons/react/24/outline";
+import { api } from "@/server/trpc/react";
+import { toast } from "sonner";
+
+type ReportStatus = "PENDING" | "REVIEWED" | "DISMISSED" | "ACTIONED";
+type ReportReason =
+ | "SPAM"
+ | "HARASSMENT"
+ | "HATE_SPEECH"
+ | "MISINFORMATION"
+ | "COPYRIGHT"
+ | "NSFW"
+ | "OFF_TOPIC"
+ | "OTHER";
+
+const reasonLabels: Record = {
+ SPAM: "Spam",
+ HARASSMENT: "Harassment",
+ HATE_SPEECH: "Hate Speech",
+ MISINFORMATION: "Misinformation",
+ COPYRIGHT: "Copyright",
+ NSFW: "NSFW",
+ OFF_TOPIC: "Off Topic",
+ OTHER: "Other",
+};
+
+const reasonColors: Record = {
+ SPAM: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400",
+ HARASSMENT: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
+ HATE_SPEECH: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
+ MISINFORMATION:
+ "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400",
+ COPYRIGHT:
+ "bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400",
+ NSFW: "bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-400",
+ OFF_TOPIC:
+ "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400",
+ OTHER: "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400",
+};
+
+const statusColors: Record = {
+ PENDING:
+ "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400",
+ REVIEWED:
+ "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400",
+ DISMISSED:
+ "bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400",
+ ACTIONED: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400",
+};
+
+const ModerationQueue = () => {
+ const [statusFilter, setStatusFilter] = useState(
+ "PENDING",
+ );
+ const utils = api.useUtils();
+
+ const { data, isLoading } = api.report.getAll.useQuery({
+ status: statusFilter,
+ limit: 20,
+ });
+
+ const { data: counts } = api.report.getCounts.useQuery();
+
+ const { mutate: reviewReport, isPending: isReviewing } =
+ api.report.review.useMutation({
+ onSuccess: () => {
+ toast.success("Report updated");
+ utils.report.getAll.invalidate();
+ utils.report.getCounts.invalidate();
+ },
+ onError: () => {
+ toast.error("Failed to update report");
+ },
+ });
+
+ const handleDismiss = (reportId: number) => {
+ reviewReport({
+ reportId,
+ status: "DISMISSED",
+ actionTaken: "Report dismissed by admin",
+ });
+ };
+
+ const handleAction = (reportId: number) => {
+ reviewReport({
+ reportId,
+ status: "ACTIONED",
+ actionTaken: "Content removed or user warned",
+ });
+ };
+
+ 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" });
+ };
+
+ return (
+
+
+
+
+
+
+
+ Moderation Queue
+
+
+ Review and manage reported content
+
+
+
+
+ {/* Status Tabs */}
+
+ setStatusFilter("PENDING")}
+ className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
+ statusFilter === "PENDING"
+ ? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400"
+ : "bg-neutral-100 text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
+ }`}
+ >
+ Pending ({counts?.pending ?? 0})
+
+ setStatusFilter("ACTIONED")}
+ className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
+ statusFilter === "ACTIONED"
+ ? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
+ : "bg-neutral-100 text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
+ }`}
+ >
+ Actioned ({counts?.actioned ?? 0})
+
+ setStatusFilter("DISMISSED")}
+ className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
+ statusFilter === "DISMISSED"
+ ? "bg-neutral-200 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-200"
+ : "bg-neutral-100 text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
+ }`}
+ >
+ Dismissed ({counts?.dismissed ?? 0})
+
+ setStatusFilter(undefined)}
+ className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
+ statusFilter === undefined
+ ? "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400"
+ : "bg-neutral-100 text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
+ }`}
+ >
+ All ({counts?.total ?? 0})
+
+
+
+ {/* 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" && (
+
+ handleDismiss(report.id)}
+ disabled={isReviewing}
+ className="flex items-center gap-1 rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-medium text-neutral-600 transition-colors hover:bg-neutral-100 disabled:opacity-50 dark:border-neutral-600 dark:text-neutral-300 dark:hover:bg-neutral-700"
+ >
+
+ Dismiss
+
+ handleAction(report.id)}
+ disabled={isReviewing}
+ className="flex items-center gap-1 rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-red-700 disabled:opacity-50"
+ >
+
+ Take Action
+
+
+ )}
+
+ {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
+
+
+
+
+
+ {syncingAll ? "Syncing..." : "Sync All"}
+
+
setShowAddForm(!showAddForm)}
+ className="flex items-center gap-2 rounded-lg bg-orange-500 px-4 py-2 font-medium text-white transition-colors hover:bg-orange-600"
+ >
+
+ Add Source
+
+
+
+
+ {/* Add Source Form */}
+ {showAddForm && (
+
+
+ Add New Feed Source
+
+
+
+ )}
+
+ {/* Sources Table */}
+ {status === "pending" && (
+
+ )}
+
+ {status === "error" && (
+
+ Failed to load feed sources. Please refresh the page.
+
+ )}
+
+ {status === "success" && (
+
+
+
+
+
+ Source
+
+
+ Category
+
+
+ Status
+
+
+ Articles
+
+
+ Last Fetched
+
+
+ Errors
+
+
+ Actions
+
+
+
+
+ {sources?.map((source) => {
+ const StatusIcon = statusIcons[source.status as keyof typeof statusIcons];
+ return (
+
+
+
+ {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}
+
+
+
+
handleSyncSource(source.sourceId, source.sourceName)}
+ disabled={syncingSourceId === source.sourceId}
+ className="rounded p-1 text-blue-500 hover:bg-blue-50 hover:text-blue-700 disabled:opacity-50 dark:hover:bg-blue-950"
+ title="Sync now"
+ >
+
+
+
handleStatusToggle(source.sourceId, source.status)}
+ className="rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
+ title={source.status === "ACTIVE" ? "Pause" : "Activate"}
+ >
+ {source.status === "ACTIVE" ? (
+
+ ) : (
+
+ )}
+
+
handleDelete(source.sourceId, source.sourceName)}
+ className="rounded p-1 text-red-500 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950"
+ title="Delete"
+ >
+
+
+
+
+
+ );
+ })}
+
+
+ {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"
+ />
+
+
+
setShowBannedOnly(!showBannedOnly)}
+ className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
+ showBannedOnly
+ ? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
+ : "bg-neutral-100 text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
+ }`}
+ >
+
+ {showBannedOnly ? "Showing Banned Only" : "Show Banned Only"}
+
+
+
+ {/* 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 ? (
+
handleUnban(user.id)}
+ disabled={isUnbanning}
+ className="flex items-center gap-1 rounded-lg border border-green-300 px-3 py-1.5 text-sm font-medium text-green-700 transition-colors hover:bg-green-50 disabled:opacity-50 dark:border-green-700 dark:text-green-400 dark:hover:bg-green-900/20"
+ >
+
+ Unban
+
+ ) : !("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"
+ />
+ handleBan(user.id)}
+ disabled={isBanning}
+ className="rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-red-700 disabled:opacity-50"
+ >
+ Confirm
+
+ {
+ setSelectedUserId(null);
+ setBanNote("");
+ }}
+ className="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm font-medium text-neutral-600 hover:bg-neutral-100 dark:border-neutral-600 dark:text-neutral-400 dark:hover:bg-neutral-700"
+ >
+ Cancel
+
+
+ ) : (
+
setSelectedUserId(user.id)}
+ className="flex items-center gap-1 rounded-lg border border-red-300 px-3 py-1.5 text-sm font-medium text-red-700 transition-colors hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/20"
+ >
+
+ Ban
+
+ )
+ ) : 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
-
- )}
+
{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}
+
+
·
+
{relativeTime}
+ {readTime > 0 && (
+ <>
+
·
+
{readTime} min read
+ >
+ )}
+
+
+ {/* Title */}
+
+
+ {title}
+
+
+
+ {/* Excerpt */}
+ {excerpt && (
+
+ {excerpt}
+
+ )}
+
+ {/* Action bar */}
+
+ {/* Vote buttons */}
+
+ handleVote(userVote === "UP" ? null : "UP")}
+ disabled={voteStatus === "pending"}
+ className={`rounded-l-full p-1 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
+ userVote === "UP"
+ ? "text-green-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ aria-label="Upvote"
+ >
+
+
+ 0
+ ? "text-green-500"
+ : score < 0
+ ? "text-red-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ >
+ {score}
+
+ handleVote(userVote === "DOWN" ? null : "DOWN")}
+ disabled={voteStatus === "pending"}
+ className={`rounded-r-full p-1 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
+ userVote === "DOWN"
+ ? "text-red-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ aria-label="Downvote"
+ >
+
+
+
+
+ {/* Comments button */}
+
+
+
{discussionCount}
+
+
+ {/* Save button */}
+
+ {isBookmarked ? (
+
+ ) : (
+
+ )}
+
+ {isBookmarked ? "Saved" : "Save"}
+
+
+
+ {/* Share button */}
+
+
+ Share
+
+
+ {/* Triple-dot menu */}
+
+
+ More options
+
+
+
+
+
+
+ Copy link
+
+
+
+
+
+
+
+ );
+};
+
+// 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"
)}
-
-
- Location
-
- {
- router.push(
- `/articles?filter=${e.target.value}${
- tag ? `&tag=${tag}` : ""
- }`,
- );
- }}
- value={selectedSortFilter}
- >
- {filters.map((filter) => (
-
- {getCamelCaseFromLower(filter)}
-
- ))}
-
-
+
{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 */}
+
+ handleVote(userVote === "UP" ? null : "UP")}
+ disabled={voteStatus === "pending"}
+ className={`rounded-l-full p-2 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
+ userVote === "UP"
+ ? "text-green-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ aria-label="Upvote"
+ >
+
+
+ 0
+ ? "text-green-500"
+ : score < 0
+ ? "text-red-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ >
+ {score}
+
+ handleVote(userVote === "DOWN" ? null : "DOWN")}
+ disabled={voteStatus === "pending"}
+ className={`rounded-r-full p-2 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
+ userVote === "DOWN"
+ ? "text-red-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ aria-label="Downvote"
+ >
+
+
+
+
+ {/* Comments button */}
+
+
+ {discussionCount} Comments
+
+
+ {/* Bookmark button */}
+
+ {isBookmarked ? (
+
+ ) : (
+
+ )}
+ {isBookmarked ? "Saved" : "Save"}
+
+
+ {/* Share button */}
+
+
+
+ Share
+
+
+
+
+
+ Share to X
+
+
+
+
+ Share to LinkedIn
+
+
+
+
+ Copy link
+
+
+
+
+
+
+ {/* 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 && (
+
+ {breadcrumbs.map((item, index) => (
+
+ {index > 0 && / }
+ {item.href ? (
+
+ {item.label}
+
+ ) : (
+
+ {item.label}
+
+ )}
+
+ ))}
+
+ )}
+
+ {/* Main content card */}
+
+ {children}
+
+ {/* Action bar */}
+ {actionBar && (
+
+ {actionBar}
+
+ )}
+
+
+ {/* Side info (author bio or source info) */}
+ {sideInfo &&
{sideInfo}
}
+
+ {/* Discussion section */}
+ {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 && (
+ <>
+
·
+
{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 && (
+ <>
+
·
+
{readableDate}
+ >
+ )}
+ {hostname && (
+ <>
+
·
+
{hostname}
+ >
+ )}
+
+ );
+ }
+
+ // Fallback - just date
+ return readableDate ? (
+
+ {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() || "?"}
+
+ )}
+
+
+
+
+ );
+};
+
+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 */}
+
+ handleVote(userVote === "up" ? null : "up")}
+ disabled={voteStatus === "pending"}
+ className={`rounded-l-full p-2 transition-colors hover:bg-neutral-200 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-700 ${
+ userVote === "up"
+ ? "text-green-500"
+ : "text-neutral-500 dark:text-neutral-400"
+ }`}
+ aria-label="Upvote"
+ >
+
+
+ 0
+ ? "text-green-500"
+ : score < 0
+ ? "text-red-500"
+ : "text-neutral-500 dark:text-neutral-400"
+ }`}
+ >
+ {score}
+
+ handleVote(userVote === "down" ? null : "down")}
+ disabled={voteStatus === "pending"}
+ className={`rounded-r-full p-2 transition-colors hover:bg-neutral-200 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-700 ${
+ userVote === "down"
+ ? "text-red-500"
+ : "text-neutral-500 dark:text-neutral-400"
+ }`}
+ aria-label="Downvote"
+ >
+
+
+
+
+ {/* Comments count */}
+
+
+ {discussionCount} comments
+
+
+ {/* Bookmark button */}
+
+ {isBookmarked ? (
+
+ ) : (
+
+ )}
+ {isBookmarked ? "Saved" : "Save"}
+
+
+ {/* Share button */}
+
+
+
+ Share
+
+
+
+
+
+ Share to X
+
+
+
+
+ Share to LinkedIn
+
+
+
+
+ Copy link
+
+
+
+
+
+
+ {/* More options menu */}
+
+
+ More options
+
+
+
+
+
+
+ Report
+
+
+
+
+
+
+ );
+};
+
+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 (
+
+ {placeholder}
+
+ );
+ }
+
+ // Expanded state
+ return (
+
+ {/* Rich text mode */}
+ {mode === "rich" && (
+ <>
+ {/* Toolbar row - only show when showToolbar is true */}
+ {showToolbar && (
+
+
+
+ Switch to Markdown
+
+
+ )}
+
+ {/* Editor content */}
+
+ >
+ )}
+
+ {/* Markdown mode */}
+ {mode === "markdown" && (
+ <>
+ {/* Header row */}
+
+
+
+ Markdown Editor
+
+ setShowMarkdownHelp(true)}
+ className="text-neutral-400 transition-colors hover:text-neutral-600 dark:hover:text-neutral-300"
+ aria-label="Markdown help"
+ >
+
+
+
+
+ Switch to Rich Text 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" ? (
+
setShowToolbar(!showToolbar)}
+ className={`font-serif text-lg transition-colors ${
+ showToolbar
+ ? "text-pink-600"
+ : "text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
+ }`}
+ title={
+ showToolbar
+ ? "Hide formatting toolbar"
+ : "Show formatting toolbar"
+ }
+ >
+ Aa
+
+ ) : (
+
+ )}
+
+ {/* Cancel and Submit buttons */}
+
+ {
+ handleCancel();
+ onCancel?.();
+ }}
+ disabled={isSubmitting}
+ className="px-4 py-1.5 text-sm text-neutral-600 transition-colors hover:text-neutral-900 disabled:opacity-50 dark:text-neutral-400 dark:hover:text-white"
+ >
+ Cancel
+
+
+ {isSubmitting ? "Submitting..." : submitLabel}
+
+
+
+
+ {/* 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 (
+
+ {children}
+
+ );
+}
+
+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 (
+
+ );
+}
+
+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.
+
+
+
+
+
+
+ Type this
+
+
+ To get this
+
+
+
+
+ {MARKDOWN_SYNTAX.map(({ syntax, result, style }) => (
+
+
+
+ {syntax}
+
+
+
+ {result}
+
+
+ ))}
+
+
+
+
+
+
+ Close
+
+
+
+ );
+}
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 && (
+ <>
+
·
+
+ {relativeTime}
+
+ >
+ )}
+
+
+ {/* Title */}
+
+ {title}
+
+
+
+ {/* Remove button (optional) */}
+ {onRemove && (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ onRemove();
+ }}
+ className="absolute right-2 top-2 rounded p-1 text-neutral-400 opacity-0 transition-opacity hover:bg-neutral-100 hover:text-neutral-600 group-hover:opacity-100 dark:text-neutral-500 dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
+ aria-label="Remove saved item"
+ >
+
+
+
+
+ )}
+
+ );
+};
+
+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 (
+
onToggle(interest)}
+ className={clsx(
+ "flex items-start gap-4 rounded-xl border p-4 text-left transition-all",
+ isSelected
+ ? "border-orange-400/50 bg-gradient-to-br from-orange-400/10 to-pink-600/10"
+ : "border-neutral-700 bg-neutral-800/50 hover:border-neutral-600 hover:bg-neutral-800"
+ )}
+ >
+
+
+
+
+
+ {sponsorInterestLabels[interest]}
+
+
+
+ {isSelected && }
+
+
+ );
+ })}
+
+
+ {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.
+
+
+
+
+ Budget Range
+
+ {sponsorBudgetRanges.map((range) => (
+
+ {sponsorBudgetLabels[range]}
+
+ ))}
+
+
+
+
+ What are you hoping to achieve? (optional)
+
+ {errors.goals && {errors.goals.message} }
+
+
+ );
+}
+
+function Step3Contact({
+ register,
+ errors,
+}: {
+ register: ReturnType>["register"];
+ errors: ReturnType>["formState"]["errors"];
+}) {
+ return (
+
+ );
+}
+
+function SuccessState({ onReset }: { onReset: () => void }) {
+ return (
+
+
+
+
+
Thank you!
+
+ We've received your inquiry and will be in touch within 24 hours to discuss
+ how we can help you reach our developer community.
+
+
+ Submit another inquiry
+
+
+ );
+}
+
+export function ContactForm() {
+ const [currentStep, setCurrentStep] = useState(1);
+ const [submitted, setSubmitted] = useState(false);
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors, isSubmitting },
+ reset,
+ watch,
+ setValue,
+ clearErrors,
+ setError,
+ } = useForm({
+ defaultValues: {
+ interests: [],
+ budgetRange: "EXPLORING",
+ name: "",
+ email: "",
+ company: "",
+ phone: "",
+ goals: "",
+ },
+ });
+
+ const selectedInterests = watch("interests") || [];
+ const budgetValue = watch("budgetRange");
+
+ const submitMutation = api.sponsor.submit.useMutation({
+ onSuccess: () => {
+ setSubmitted(true);
+ },
+ });
+
+ const toggleInterest = (interest: string) => {
+ const current = selectedInterests as string[];
+ const newInterests = current.includes(interest)
+ ? current.filter((i) => i !== interest)
+ : [...current, interest];
+ setValue("interests", newInterests as typeof selectedInterests);
+ // Clear any existing interests error when user makes a selection
+ if (newInterests.length > 0) {
+ clearErrors("interests");
+ }
+ };
+
+ const handleNext = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (currentStep === 1) {
+ // Manual validation for step 1 - just check interests
+ if (selectedInterests.length === 0) {
+ setError("interests", {
+ type: "manual",
+ message: "Please select at least one option",
+ });
+ return;
+ }
+ clearErrors("interests");
+ setCurrentStep(2);
+ } else if (currentStep === 2) {
+ // Step 2 fields are optional or have defaults, so we can proceed
+ setCurrentStep(3);
+ }
+ };
+
+ const handleBack = () => {
+ if (currentStep > 1) setCurrentStep(currentStep - 1);
+ };
+
+ const onSubmit = async (data: SponsorInquiryInput) => {
+ // Validate with Zod before submission
+ const result = SponsorInquirySchema.safeParse(data);
+
+ if (!result.success) {
+ // Set errors from Zod validation
+ result.error.issues.forEach((issue) => {
+ const field = issue.path[0] as keyof SponsorInquiryInput;
+ setError(field, { type: "manual", message: issue.message });
+ });
+ return;
+ }
+
+ await submitMutation.mutateAsync(result.data);
+ };
+
+ const handleReset = () => {
+ setSubmitted(false);
+ setCurrentStep(1);
+ reset();
+ };
+
+ if (submitted) {
+ return ;
+ }
+
+ return (
+
+ );
+}
diff --git a/components/Sponsorship/ContactSection.tsx b/components/Sponsorship/ContactSection.tsx
new file mode 100644
index 00000000..f17759b2
--- /dev/null
+++ b/components/Sponsorship/ContactSection.tsx
@@ -0,0 +1,34 @@
+import { ContactForm } from "./ContactForm";
+
+export function ContactSection() {
+ return (
+
+ );
+}
diff --git a/components/Sponsorship/HeroSection.tsx b/components/Sponsorship/HeroSection.tsx
new file mode 100644
index 00000000..cd9e8139
--- /dev/null
+++ b/components/Sponsorship/HeroSection.tsx
@@ -0,0 +1,44 @@
+import Link from "next/link";
+
+export function HeroSection() {
+ return (
+
+ {/* Gradient background overlay */}
+
+
+
+
+ {/* Badge */}
+
+ Partner with Codú
+
+
+ {/* Headline */}
+
+ Reach{" "}
+
+ 20,000+
+ {" "}
+ Developers Every Month
+
+
+ {/* Subheadline */}
+
+ Connect your brand with one of the most engaged web
+ developer communities online. From newsletter ads to event sponsorships,
+ we help you hire and grow.
+
+
+ {/* Single CTA - research shows single CTA converts 266% better */}
+
+
+ Let's Talk
+
+
+
+
+ );
+}
diff --git a/components/Sponsorship/MetricsSection.tsx b/components/Sponsorship/MetricsSection.tsx
new file mode 100644
index 00000000..fa841a63
--- /dev/null
+++ b/components/Sponsorship/MetricsSection.tsx
@@ -0,0 +1,53 @@
+import {
+ UsersIcon,
+ GlobeAltIcon,
+ ChatBubbleLeftRightIcon,
+ CalendarDaysIcon,
+} from "@heroicons/react/24/outline";
+
+const metrics = [
+ {
+ label: "Community Members",
+ value: "4,000+",
+ icon: UsersIcon,
+ },
+ {
+ label: "Monthly Website Visits",
+ value: "20,000+",
+ icon: GlobeAltIcon,
+ },
+ {
+ label: "Social Media Reach",
+ value: "30,000+",
+ icon: ChatBubbleLeftRightIcon,
+ },
+ {
+ label: "Events Per Year",
+ value: "12+",
+ icon: CalendarDaysIcon,
+ },
+];
+
+export function MetricsSection() {
+ return (
+
+
+
+ Your Audience Awaits
+
+
+
+ {metrics.map((metric) => (
+
+
+
+ {metric.value}
+
+
{metric.label}
+
+ ))}
+
+
+
+ );
+}
diff --git a/components/Sponsorship/OfferingsSection.tsx b/components/Sponsorship/OfferingsSection.tsx
new file mode 100644
index 00000000..340b7a4b
--- /dev/null
+++ b/components/Sponsorship/OfferingsSection.tsx
@@ -0,0 +1,84 @@
+import Link from "next/link";
+import {
+ EnvelopeOpenIcon,
+ CalendarDaysIcon,
+ GlobeAltIcon,
+ DocumentTextIcon,
+} from "@heroicons/react/24/outline";
+
+const offerings = [
+ {
+ icon: EnvelopeOpenIcon,
+ title: "Newsletter Advertising",
+ description:
+ "Feature your company in our weekly newsletter reaching 4,000+ engaged developers who actually read their emails.",
+ },
+ {
+ icon: CalendarDaysIcon,
+ title: "Event Sponsorship",
+ description:
+ "Put your brand in front of 100+ developers at our monthly meetups and annual hackathons. Build real connections.",
+ },
+ {
+ icon: GlobeAltIcon,
+ title: "Website & Job Board",
+ description:
+ "Reach 20,000+ monthly visitors with banner placements and job postings to a highly engaged developer audience.",
+ },
+ {
+ icon: DocumentTextIcon,
+ title: "Content Collaboration",
+ description:
+ "Co-create technical content that positions your brand as a thought leader. Authentic content that resonates.",
+ },
+];
+
+export function OfferingsSection() {
+ return (
+
+
+
+
+ Ways to Partner
+
+
+ Every partnership is tailored to your goals. Here's how we can
+ help you connect with our developer community.
+
+
+
+
+ {offerings.map((offering) => (
+
+
+
+
+
+
+
+ {offering.title}
+
+
+ {offering.description}
+
+
+
+
+ ))}
+
+
+
+
+ Let's discuss what works for you
+
+
+
+
+ );
+}
diff --git a/components/Sponsorship/SocialProofSection.tsx b/components/Sponsorship/SocialProofSection.tsx
new file mode 100644
index 00000000..22038883
--- /dev/null
+++ b/components/Sponsorship/SocialProofSection.tsx
@@ -0,0 +1,84 @@
+import Link from "next/link";
+import Image from "next/image";
+
+const sponsors = [
+ {
+ name: "Version 1",
+ logo: "/images/sponsors/version1.png",
+ href: "https://www.version1.com/",
+ },
+ {
+ name: "LearnUpon",
+ logo: "/images/sponsors/learnupon.png",
+ href: "https://www.learnupon.com/",
+ },
+ {
+ name: "OfferZen",
+ logo: "/images/sponsors/offerzen.png",
+ href: "https://www.offerzen.com/",
+ },
+ {
+ name: "WeWork",
+ logo: "/images/sponsors/wework.png",
+ href: "https://www.wework.com/",
+ },
+ {
+ name: "Harvey Nash",
+ logo: "/images/sponsors/harveynash.png",
+ href: "https://www.harveynash.com/",
+ },
+ {
+ name: "NineDots",
+ logo: "/images/sponsors/ninedots.png",
+ href: "/company/ninedots",
+ },
+];
+
+export function SocialProofSection() {
+ return (
+
+
+
+ Trusted by Leading Tech Companies
+
+
+ {/* Logo grid */}
+
+ {sponsors.map((sponsor) => (
+
+
+
+ ))}
+
+
+ {/* Testimonial */}
+
+
+ “Partnering with Codú gave us direct access to a thriving
+ developer community. The engagement is genuine and the
+ team is fantastic to work with.”
+
+
+ - Previous Sponsor Partner
+
+
+
+
+ );
+}
diff --git a/components/Sponsorship/index.ts b/components/Sponsorship/index.ts
new file mode 100644
index 00000000..bbfffd9d
--- /dev/null
+++ b/components/Sponsorship/index.ts
@@ -0,0 +1,6 @@
+export { HeroSection } from "./HeroSection";
+export { MetricsSection } from "./MetricsSection";
+export { OfferingsSection } from "./OfferingsSection";
+export { SocialProofSection } from "./SocialProofSection";
+export { ContactSection } from "./ContactSection";
+export { ContactForm } from "./ContactForm";
diff --git a/components/UnifiedContentCard/UnifiedContentCard.tsx b/components/UnifiedContentCard/UnifiedContentCard.tsx
new file mode 100644
index 00000000..ea5d581d
--- /dev/null
+++ b/components/UnifiedContentCard/UnifiedContentCard.tsx
@@ -0,0 +1,474 @@
+"use client";
+
+import { useState } from "react";
+import Link from "next/link";
+import * as Sentry from "@sentry/nextjs";
+import {
+ BookmarkIcon,
+ ArrowTopRightOnSquareIcon,
+ ChatBubbleLeftIcon,
+ 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";
+
+export type ContentType = "POST" | "LINK";
+
+type AuthorInfo = {
+ name: string;
+ username: string;
+ image?: string | null;
+};
+
+type SourceInfo = {
+ name: string;
+ slug: string | null;
+ logo?: string | null;
+ websiteUrl?: string | null;
+};
+
+export interface UnifiedContentCardProps {
+ type: ContentType;
+ id: string | number;
+ title: string;
+ excerpt?: string | null;
+ slug?: string | null;
+ imageUrl?: string | null;
+ externalUrl?: string | null;
+ publishedAt?: string | null;
+ readTimeMins?: number | null;
+ upvotes: number;
+ downvotes: number;
+ userVote?: "up" | "down" | null;
+ isBookmarked?: boolean;
+ discussionCount?: number;
+ author?: AuthorInfo | null;
+ source?: SourceInfo | null;
+ linkAuthor?: string | null;
+ tags?: 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 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" });
+};
+
+// 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;
+};
+
+// Get display hostname
+const getHostname = (urlString: string): string => {
+ try {
+ const url = new URL(urlString);
+ return url.hostname;
+ } catch {
+ return urlString;
+ }
+};
+
+// Get display URL (hostname + truncated path)
+const getDisplayUrl = (urlString: string): string => {
+ try {
+ const url = new URL(urlString);
+ const path = url.pathname.length > 20 ? url.pathname.slice(0, 20) + "..." : url.pathname;
+ return url.hostname + (path !== "/" ? path : "");
+ } catch {
+ return urlString.slice(0, 40) + "...";
+ }
+};
+
+const UnifiedContentCard = ({
+ type,
+ id,
+ title,
+ excerpt,
+ slug,
+ imageUrl: rawImageUrl,
+ externalUrl,
+ publishedAt,
+ readTimeMins,
+ upvotes,
+ downvotes,
+ userVote: initialUserVote,
+ isBookmarked: initialBookmarked = false,
+ discussionCount = 0,
+ author,
+ source,
+ linkAuthor,
+}: UnifiedContentCardProps) => {
+ const [imageError, setImageError] = useState(false);
+ const [userVote, setUserVote] = useState(initialUserVote);
+ const [votes, setVotes] = useState({ upvotes, downvotes });
+ const [isBookmarked, setIsBookmarked] = useState(initialBookmarked);
+
+ const { data: session } = useSession();
+ const utils = api.useUtils();
+
+ const imageUrl = ensureHttps(rawImageUrl);
+
+ // Determine the URL for the card
+ const cardUrl =
+ type === "POST"
+ ? `/${author?.username || ""}/${slug || ""}`
+ : source?.slug && slug
+ ? `/${source.slug}/${slug}`
+ : `/feed/${id}`;
+
+ // Unified content voting mutation
+ const { mutate: voteContent, status: voteStatus } = api.content.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, downvotes });
+ toast.error("Failed to update vote");
+ Sentry.captureException(error);
+ },
+ onSettled: () => {
+ utils.content.getFeed.invalidate();
+ },
+ });
+
+ // Unified content bookmark mutation
+ const { mutate: bookmarkContent, status: bookmarkStatus } = api.content.bookmark.useMutation({
+ onMutate: async ({ setBookmarked }) => {
+ setIsBookmarked(setBookmarked);
+ },
+ onError: (error) => {
+ setIsBookmarked(initialBookmarked);
+ toast.error("Failed to update bookmark");
+ Sentry.captureException(error);
+ },
+ onSettled: () => {
+ utils.content.mySavedContent.invalidate();
+ },
+ });
+
+ // Click tracking for external links
+ const { mutate: trackClick } = api.content.trackClick.useMutation();
+
+ const handleVote = (voteType: "up" | "down" | null) => {
+ if (!session) {
+ signIn();
+ return;
+ }
+ voteContent({ contentId: String(id), voteType });
+ };
+
+ const handleBookmark = () => {
+ if (!session) {
+ signIn();
+ return;
+ }
+ bookmarkContent({ contentId: String(id), setBookmarked: !isBookmarked });
+ };
+
+ const handleExternalClick = () => {
+ if (type === "LINK") {
+ trackClick({ contentId: String(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;
+
+ const faviconUrl = getFaviconUrl(source?.websiteUrl || externalUrl);
+ const showThumbnail = imageUrl && !imageError;
+ const score = votes.upvotes - votes.downvotes;
+ const hostname = externalUrl ? getHostname(externalUrl) : null;
+
+ return (
+
+ {/* Meta info row */}
+
+ {/* Author/Source info */}
+ {type === "POST" && author ? (
+
+ {author.image ? (
+
+ ) : (
+
+ {author.name?.charAt(0).toUpperCase() || "?"}
+
+ )}
+
{author.name}
+
+ ) : source ? (
+ source.slug ? (
+
+ {source.logo ? (
+
+ ) : faviconUrl ? (
+
+ ) : (
+
+ {source.name?.charAt(0).toUpperCase() || "?"}
+
+ )}
+
{source.name}
+
+ ) : (
+
+ {source.logo ? (
+
+ ) : faviconUrl ? (
+
+ ) : (
+
+ {source.name?.charAt(0).toUpperCase() || "?"}
+
+ )}
+ {source.name}
+
+ )
+ ) : null}
+
+ {/* Link author (if different from source) */}
+ {type === "LINK" && linkAuthor && linkAuthor.trim() &&
+ !["by", "by,", "by ,"].includes(linkAuthor.trim().toLowerCase()) && (
+ <>
+
·
+
+ {linkAuthor.replace(/^by\s+/i, "").trim()}
+
+ >
+ )}
+
+ {/* Time */}
+ {relativeTime && (
+ <>
+
·
+
+ {relativeTime}
+
+ >
+ )}
+
+ {/* Read time for all content types */}
+ {readTimeMins && (
+ <>
+
·
+
{readTimeMins} min
+ >
+ )}
+
+ {/* External link indicator */}
+ {type === "LINK" && hostname && (
+ <>
+
·
+
{hostname}
+ >
+ )}
+
+
+ {/* Main content area */}
+
+ {/* Text content */}
+
+
+
+ {title}
+
+
+ {/* External URL display for LINK types */}
+ {type === "LINK" && externalUrl && (
+
+ {getDisplayUrl(externalUrl)}
+
+
+ )}
+ {excerpt && (
+
+ {excerpt}
+
+ )}
+
+
+ {/* Thumbnail */}
+ {showThumbnail && (
+
+
setImageError(true)}
+ />
+ {type === "LINK" && (
+
+ )}
+
+ )}
+
+
+ {/* Action bar */}
+
+ {/* Vote buttons */}
+
+ handleVote(userVote === "up" ? null : "up")}
+ disabled={voteStatus === "pending"}
+ className={`rounded-l-full p-1 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
+ userVote === "up"
+ ? "text-green-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ aria-label="Upvote"
+ >
+
+
+ 0
+ ? "text-green-500"
+ : score < 0
+ ? "text-red-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ >
+ {score}
+
+ handleVote(userVote === "down" ? null : "down")}
+ disabled={voteStatus === "pending"}
+ className={`rounded-r-full p-1 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
+ userVote === "down"
+ ? "text-red-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ aria-label="Downvote"
+ >
+
+
+
+
+ {/* Comments */}
+
+
+
{discussionCount}
+
+
+ {/* Bookmark */}
+
+ {isBookmarked ? (
+
+ ) : (
+
+ )}
+
+
+ {/* External link button for LINKs */}
+ {type === "LINK" && externalUrl && (
+
+
+ Open
+
+ )}
+
+
+ );
+};
+
+export default UnifiedContentCard;
diff --git a/components/UnifiedContentCard/index.ts b/components/UnifiedContentCard/index.ts
new file mode 100644
index 00000000..705fefce
--- /dev/null
+++ b/components/UnifiedContentCard/index.ts
@@ -0,0 +1,2 @@
+export { default as UnifiedContentCard } from "./UnifiedContentCard";
+export type { UnifiedContentCardProps, ContentType } from "./UnifiedContentCard";
From d3fd206a77bf943d8ace5547cd0405f4d8bb56a6 Mon Sep 17 00:00:00 2001
From: NiallJoeMaher
Date: Tue, 6 Jan 2026 07:40:36 +0000
Subject: [PATCH 13/38] refactor(components): update components for unified
content system
Update existing components to support unified content table, add voting
UI to feed items, and update sidebar/trending posts for new data model.
---
.../ArticleActionBar/ArticleActionBar.tsx | 20 +-
components/ArticleMenu/ArticleMenu.tsx | 13 +-
components/ArticlePreview/ArticlePreview.tsx | 4 +-
components/Comments/CommentsArea.tsx | 62 +-
components/Content/ContentCard.tsx | 26 +-
components/Discussion/DiscussionArea.tsx | 603 +++++++++---------
components/Feed/FeedItemAggregated.tsx | 44 +-
components/Feed/VoteButtons.tsx | 14 +-
components/ReportModal/ReportModal.tsx | 461 ++++++++-----
.../SideBar/SideBarSavedArticlePreview.tsx | 2 +-
components/SideBar/SideBarSavedPosts.tsx | 69 +-
components/TrendingPosts/TrendingPosts.tsx | 80 ++-
.../TrendingPosts/TrendingPostsLoading.tsx | 4 +-
13 files changed, 804 insertions(+), 598 deletions(-)
diff --git a/components/ArticleActionBar/ArticleActionBar.tsx b/components/ArticleActionBar/ArticleActionBar.tsx
index a799e929..fe34eec5 100644
--- a/components/ArticleActionBar/ArticleActionBar.tsx
+++ b/components/ArticleActionBar/ArticleActionBar.tsx
@@ -30,7 +30,7 @@ interface ArticleActionBarProps {
postUsername: string;
initialUpvotes: number;
initialDownvotes: number;
- initialUserVote: "UP" | "DOWN" | null;
+ initialUserVote: "up" | "down" | null;
initialBookmarked: boolean;
discussionCount?: number;
}
@@ -64,11 +64,11 @@ const ArticleActionBar = ({
let newUpvotes = prev.upvotes;
let newDownvotes = prev.downvotes;
- if (oldVote === "UP") newUpvotes--;
- if (oldVote === "DOWN") newDownvotes--;
+ if (oldVote === "up") newUpvotes--;
+ if (oldVote === "down") newDownvotes--;
- if (voteType === "UP") newUpvotes++;
- if (voteType === "DOWN") newDownvotes++;
+ if (voteType === "up") newUpvotes++;
+ if (voteType === "down") newDownvotes++;
return { upvotes: newUpvotes, downvotes: newDownvotes };
});
@@ -99,7 +99,7 @@ const ArticleActionBar = ({
},
});
- const handleVote = (voteType: "UP" | "DOWN" | null) => {
+ const handleVote = (voteType: "up" | "down" | null) => {
if (!session) {
signIn();
return;
@@ -131,10 +131,10 @@ const ArticleActionBar = ({
{/* Vote buttons */}
handleVote(userVote === "UP" ? null : "UP")}
+ onClick={() => handleVote(userVote === "up" ? null : "up")}
disabled={voteStatus === "pending"}
className={`rounded-l-full p-2 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
- userVote === "UP"
+ userVote === "up"
? "text-green-500"
: "text-neutral-400 dark:text-neutral-500"
}`}
@@ -154,10 +154,10 @@ const ArticleActionBar = ({
{score}
handleVote(userVote === "DOWN" ? null : "DOWN")}
+ onClick={() => handleVote(userVote === "down" ? null : "down")}
disabled={voteStatus === "pending"}
className={`rounded-r-full p-2 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
- userVote === "DOWN"
+ userVote === "down"
? "text-red-500"
: "text-neutral-400 dark:text-neutral-500"
}`}
diff --git a/components/ArticleMenu/ArticleMenu.tsx b/components/ArticleMenu/ArticleMenu.tsx
index f8b1f4c8..a3012c23 100644
--- a/components/ArticleMenu/ArticleMenu.tsx
+++ b/components/ArticleMenu/ArticleMenu.tsx
@@ -56,7 +56,7 @@ const ArticleMenu = ({
return () => clearTimeout(to);
}, [copied]);
- const { mutate: like, status: likeStatus } = api.post.like.useMutation({
+ const { mutate: vote, status: voteStatus } = api.post.vote.useMutation({
onSettled() {
refetch();
},
@@ -69,10 +69,11 @@ const ArticleMenu = ({
},
});
+ // Like is now implemented as an upvote
const likePost = async (postId: string, setLiked = true) => {
- if (likeStatus === "pending") return;
+ if (voteStatus === "pending") return;
try {
- await like({ postId, setLiked });
+ await vote({ postId, voteType: setLiked ? "up" : null });
} catch (err) {
// @TODO handle error
console.error(err);
@@ -124,7 +125,7 @@ const ArticleMenu = ({
aria-label="like-trigger"
className="rounded-full p-1 hover:bg-neutral-300 dark:hover:bg-neutral-800"
onClick={() => {
- if (data?.currentUserLiked) return likePost(postId, false);
+ if (data?.userVote === "up") return likePost(postId, false);
likePost(postId);
if (!session) {
signIn();
@@ -133,13 +134,13 @@ const ArticleMenu = ({
>
- {data?.likes || 0}
+ {(data?.upvotes ?? 0) - (data?.downvotes ?? 0)}
= ({
{title}
@@ -148,7 +148,7 @@ const ArticlePreview: NextPage = ({
Read full article
diff --git a/components/Comments/CommentsArea.tsx b/components/Comments/CommentsArea.tsx
index 24db1d4c..27ec75a3 100644
--- a/components/Comments/CommentsArea.tsx
+++ b/components/Comments/CommentsArea.tsx
@@ -46,9 +46,9 @@ interface Props {
}
const CommentsArea = ({ postId, postOwnerId }: Props) => {
- const [showCommentBoxId, setShowCommentBoxId] = useState
(null);
- const [editCommentBoxId, setEditCommentBoxId] = useState(null);
- const [viewPreviewId, setViewPreviewId] = useState(null);
+ const [showCommentBoxId, setShowCommentBoxId] = useState(null);
+ const [editCommentBoxId, setEditCommentBoxId] = useState(null);
+ const [viewPreviewId, setViewPreviewId] = useState(null);
const [initiallyLoaded, setInitiallyLoaded] = useState(false);
const { data: session } = useSession();
@@ -78,17 +78,19 @@ const CommentsArea = ({ postId, postOwnerId }: Props) => {
},
});
- const { mutate: like, status: likeStatus } = api.comment.like.useMutation({
+ const { mutate: vote, status: voteStatus } = api.comment.vote.useMutation({
onSettled() {
refetch();
},
});
- const likeComment = async (commentId: number) => {
+ // Toggle upvote for a comment
+ const likeComment = async (commentId: string, currentVote: "up" | "down" | null) => {
if (!session) return signIn();
- if (likeStatus === "pending") return;
+ if (voteStatus === "pending") return;
try {
- await like({ commentId });
+ // If already upvoted, remove vote; otherwise upvote
+ await vote({ commentId, voteType: currentVote === "up" ? null : "up" });
} catch (err) {
toast.error("Something went wrong, try again.");
}
@@ -124,7 +126,7 @@ const CommentsArea = ({ postId, postOwnerId }: Props) => {
const onSubmit = async (
body: string,
- parentId: number | undefined,
+ parentId: string | undefined,
fieldName: FieldName,
) => {
// vaidate markdoc syntax
@@ -143,7 +145,7 @@ const CommentsArea = ({ postId, postOwnerId }: Props) => {
if (fieldName === "edit") {
try {
EditCommentSchema.parse({ body, id: editCommentBoxId });
- if (typeof editCommentBoxId !== "number")
+ if (typeof editCommentBoxId !== "string")
throw new Error("Invalid edit.");
await editComment({ body: body || "", id: editCommentBoxId });
resetField(fieldName);
@@ -177,17 +179,25 @@ const CommentsArea = ({ postId, postOwnerId }: Props) => {
) => {
if (!commentsArr) return null;
return commentsArr.map(
- ({
- body,
- createdAt,
- updatedAt,
- id,
- youLikedThis,
- likeCount,
- user: { name, image, username, id: userId },
- children,
- }) => {
- const ast = Markdoc.parse(body);
+ (comment) => {
+ const {
+ body,
+ createdAt,
+ updatedAt,
+ id,
+ userVote,
+ score,
+ author,
+ } = comment;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const children = (comment as any).children;
+ // Handle deleted comments (author is null)
+ const name = author?.name || "[deleted]";
+ const image = author?.image || "";
+ const username = author?.username || "";
+ const userId = author?.id || "";
+ const displayBody = body || "[Comment deleted]";
+ const ast = Markdoc.parse(displayBody);
const content = Markdoc.transform(ast, config);
const isCurrentUser = session?.user?.id === userId;
const isAuthor = userId === postOwnerId;
@@ -312,18 +322,18 @@ const CommentsArea = ({ postId, postOwnerId }: Props) => {
likeComment(id)}
+ onClick={() => likeComment(id, userVote)}
>
- {likeCount}
+ {score}
{depth < 6 && (
@@ -382,8 +392,8 @@ const CommentsArea = ({ postId, postOwnerId }: Props) => {
interface CommentAreaProps {
onCancel?: () => void;
- parentId?: number;
- id: number | null;
+ parentId?: string;
+ id: string | null;
name: FieldName;
editMode?: boolean;
loading?: boolean;
@@ -499,7 +509,7 @@ const CommentsArea = ({ postId, postOwnerId }: Props) => {
{session ? (
-
+
) : (
Hey! 👋
diff --git a/components/Content/ContentCard.tsx b/components/Content/ContentCard.tsx
index 003aec51..b06228e8 100644
--- a/components/Content/ContentCard.tsx
+++ b/components/Content/ContentCard.tsx
@@ -24,7 +24,7 @@ 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 ContentType = "POST" | "LINK" | "QUESTION" | "VIDEO" | "DISCUSSION";
type Props = {
id: string;
@@ -50,7 +50,7 @@ type Props = {
sourceWebsite?: string | null;
sourceAuthor?: string | null;
// User state
- userVote?: "UP" | "DOWN" | null;
+ userVote?: "up" | "down" | null;
isBookmarked?: boolean;
// Options
showBookmark?: boolean;
@@ -107,7 +107,7 @@ const ensureHttps = (url: string | null | undefined): string | null => {
// Content type badge colors
const typeColors: Record
= {
- ARTICLE: { bg: "bg-blue-100 dark:bg-blue-900", text: "text-blue-700 dark:text-blue-300" },
+ POST: { 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" },
@@ -115,7 +115,7 @@ const typeColors: Record = {
};
const typeLabels: Record = {
- ARTICLE: "Article",
+ POST: "Article",
LINK: "Link",
QUESTION: "Question",
VIDEO: "Video",
@@ -187,7 +187,7 @@ const ContentCard = ({
trackClick({ contentId: id });
};
- const handleVote = (voteType: "UP" | "DOWN" | null) => {
+ const handleVote = (voteType: "up" | "down" | null) => {
if (!session) {
signIn();
return;
@@ -266,7 +266,7 @@ const ContentCard = ({
)}
{/* Type badge - only show for non-articles */}
- {type !== "ARTICLE" && (
+ {type !== "POST" && (
<>
·
handleVote(userVote === "UP" ? null : "UP")}
+ onClick={() => handleVote(userVote === "up" ? null : "up")}
disabled={voteStatus === "pending"}
className={`rounded-l-full p-1 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
- userVote === "UP"
+ userVote === "up"
? "text-green-500"
: "text-neutral-400 dark:text-neutral-500"
}`}
@@ -349,10 +349,10 @@ const ContentCard = ({
{score}
handleVote(userVote === "DOWN" ? null : "DOWN")}
+ onClick={() => handleVote(userVote === "down" ? null : "down")}
disabled={voteStatus === "pending"}
className={`rounded-r-full p-1 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
- userVote === "DOWN"
+ userVote === "down"
? "text-red-500"
: "text-neutral-400 dark:text-neutral-500"
}`}
@@ -376,6 +376,7 @@ const ContentCard = ({
)}
-
+
{initialBookmarked ? "Saved" : "Save"}
@@ -396,10 +397,11 @@ const ContentCard = ({
{/* Share button */}
- Share
+ Share
{/* Triple-dot menu */}
diff --git a/components/Discussion/DiscussionArea.tsx b/components/Discussion/DiscussionArea.tsx
index f3d22375..9df3e1e3 100644
--- a/components/Discussion/DiscussionArea.tsx
+++ b/components/Discussion/DiscussionArea.tsx
@@ -1,6 +1,6 @@
"use client";
-import React, { useEffect } from "react";
+import React, { useEffect, useState } from "react";
import {
Menu,
MenuButton,
@@ -14,68 +14,46 @@ import {
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 { Fragment } 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 { 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;
-};
+import { useReportModal } from "@/components/ReportModal/ReportModal";
+import { DiscussionEditor } from "./DiscussionEditor";
interface Props {
- targetType: "POST" | "ARTICLE";
- postId?: string;
- articleId?: number;
+ contentId: string;
+ noWrapper?: boolean;
}
-const DiscussionArea = ({ targetType, postId, articleId }: Props) => {
- const [showCommentBoxId, setShowCommentBoxId] = useState(null);
- const [editCommentBoxId, setEditCommentBoxId] = useState(null);
- const [viewPreviewId, setViewPreviewId] = useState(null);
+type SortOrder = "top" | "new";
+
+const DiscussionArea = ({
+ contentId,
+ noWrapper = false,
+}: Props) => {
+ const [showCommentBoxId, setShowCommentBoxId] = useState(null);
+ const [editCommentBoxId, setEditCommentBoxId] = useState(null);
+ const [editContent, setEditContent] = useState("");
const [initiallyLoaded, setInitiallyLoaded] = useState(false);
+ const [sortOrder, setSortOrder] = useState("top");
const { data: session } = useSession();
-
- const { handleSubmit, register, getValues, resetField, setValue } =
- useForm({
- mode: "onSubmit",
- defaultValues: {
- comment: "",
- reply: "",
- edit: "",
- },
- });
+ const { openReport } = useReportModal();
const {
data: discussionsResponse,
refetch,
status: discussionStatus,
} = api.discussion.get.useQuery({
- targetType,
- postId,
- articleId,
+ contentId,
});
const { mutate, status: createDiscussionStatus } =
@@ -95,7 +73,10 @@ const DiscussionArea = ({ targetType, postId, articleId }: Props) => {
},
});
- const voteDiscussion = (discussionId: number, voteType: "UP" | "DOWN" | null) => {
+ const voteDiscussion = (
+ discussionId: string,
+ voteType: "up" | "down" | null,
+ ) => {
if (!session) return signIn();
if (voteStatus === "pending") return;
vote({ discussionId, voteType });
@@ -120,20 +101,28 @@ const DiscussionArea = ({ targetType, postId, articleId }: Props) => {
type Discussions = typeof discussions;
type Children = typeof firstChild;
- type FieldName = "comment" | "reply" | "edit";
+
+ // Sort discussions based on selected sort order
+ const sortDiscussions = (items: Discussions | Children | undefined): typeof items => {
+ if (!items) return items;
+ const sorted = [...items].sort((a, b) => {
+ if (sortOrder === "top") {
+ return b.score - a.score;
+ }
+ // "new" - sort by createdAt descending
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
+ });
+ return sorted as typeof items;
+ };
useEffect(() => {
if (initiallyLoaded) {
return;
}
setInitiallyLoaded(true);
- }, [discussionStatus]);
+ }, [discussionStatus, initiallyLoaded]);
- const onSubmit = async (
- body: string,
- parentId: number | undefined,
- fieldName: FieldName,
- ) => {
+ const handleCreateComment = async (body: string, parentId?: string) => {
// validate markdoc syntax
const ast = Markdoc.parse(body);
const errors = Markdoc.validate(ast, config).filter(
@@ -144,43 +133,47 @@ const DiscussionArea = ({ targetType, postId, articleId }: Props) => {
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.");
- }
+ throw new Error("Invalid markdown");
}
try {
- SaveSchema.parse({ body });
await mutate({
- body: body || "",
- targetType,
- postId,
- articleId,
+ body,
+ contentId,
parentId,
});
- resetField(fieldName);
- setViewPreviewId(null);
+ } catch (err) {
+ toast.error("Something went wrong saving your comment.");
+ throw err;
+ }
+ };
+
+ const handleEditComment = async (body: string, id: string) => {
+ // 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);
+ });
+ throw new Error("Invalid markdown");
+ }
+
+ try {
+ EditDiscussionSchema.parse({ body, id });
+ await editDiscussion({ body, id });
+ setEditCommentBoxId(null);
+ setEditContent("");
} catch (err) {
if (err instanceof ZodError) {
- return toast.error(err.issues[0].message);
+ toast.error(err.issues[0].message);
+ throw err;
}
- toast.error("Something went wrong saving your comment.");
+ toast.error("Something went wrong editing your comment.");
+ throw err;
}
};
@@ -189,28 +182,27 @@ const DiscussionArea = ({ targetType, postId, articleId }: Props) => {
depth = 0,
) => {
if (!discussionsArr) return null;
- return discussionsArr.map(
+ const sortedDiscussions = sortDiscussions(discussionsArr);
+ if (!sortedDiscussions) return null;
+
+ return sortedDiscussions.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;
+ id: string;
youLikedThis: boolean;
likeCount: number;
- userVote: "UP" | "DOWN" | null;
+ userVote: "up" | "down" | null;
score: number;
upvotes: number;
downvotes: number;
@@ -242,48 +234,54 @@ const DiscussionArea = ({ targetType, postId, articleId }: Props) => {
const discussionUpdated =
new Date(createdAt).toISOString() !==
new Date(updatedAt).toISOString();
+
+ const hasReplies = children && children.length > 0;
+
return (
-
+
{editCommentBoxId !== id ? (
- <>
-
-
-
-
-
-
- {name}
-
- {isCurrentUser && (
-
- YOU
-
- )}
-
·
-
{readableDate}
-
- {discussionUpdated ? (
- <>
-
·
-
Edited
- >
- ) : null}
-
- {isCurrentUser ? (
+
+ {/* Avatar column - no self-stretch, just contains avatar */}
+
+
+
+
+
+
+ {/* Content column */}
+
+ {/* Header row */}
+
+
+
+ {name}
+
+ {isCurrentUser && (
+
+ YOU
+
+ )}
+ ·
+ {readableDate}
+ {discussionUpdated && (
+ <>
+ ·
+ Edited
+ >
+ )}
+
-
-
- Open user menu
-
-
-
+
+ Comment options
+
+
{
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
-
- <>
+
+ {isCurrentUser ? (
+ <>
+
+ {
+ setEditContent(body);
+ setEditCommentBoxId(id);
+ setShowCommentBoxId(null);
+ }}
+ >
+ Edit comment
+
+
+
+ {
+ deleteDiscussion({ id });
+ }}
+ >
+ Delete comment
+
+
+ >
+ ) : (
{
- if (id !== editCommentBoxId) {
- setValue("edit", body);
+ if (!session) {
+ signIn();
+ return;
}
- setEditCommentBoxId(id);
- setShowCommentBoxId(null);
- }}
- >
- Edit comment
-
-
-
- {
- deleteDiscussion({ id });
+ openReport("discussion", id);
}}
>
- Delete comment
+ Report comment
- >
+ )}
- ) : null}
-
+
-
+ {/* Comment body */}
{Markdoc.renderers.react(content, React, {
components: markdocComponents,
})}
-
+ {/* Action bar */}
+
{/* Vote buttons */}
- voteDiscussion(id, userVote === "UP" ? null : "UP")
+ voteDiscussion(id, userVote === "up" ? null : "up")
}
disabled={voteStatus === "pending"}
className={`rounded-l-full p-1 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
- userVote === "UP"
+ userVote === "up"
? "text-green-500"
: "text-neutral-400 dark:text-neutral-500"
}`}
@@ -363,11 +376,11 @@ const DiscussionArea = ({ targetType, postId, articleId }: Props) => {
- voteDiscussion(id, userVote === "DOWN" ? null : "DOWN")
+ voteDiscussion(id, userVote === "down" ? null : "down")
}
disabled={voteStatus === "pending"}
className={`rounded-r-full p-1 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
- userVote === "DOWN"
+ userVote === "down"
? "text-red-500"
: "text-neutral-400 dark:text-neutral-500"
}`}
@@ -376,52 +389,106 @@ const DiscussionArea = ({ targetType, postId, articleId }: Props) => {
-
{depth < 6 && (
{
if (!session) return signIn();
- if (showCommentBoxId !== id) {
- resetField("reply");
- setShowCommentBoxId((currentId) =>
- currentId === id ? null : id,
- );
- }
+ setShowCommentBoxId((currentId) =>
+ currentId === id ? null : id,
+ );
}}
>
+
Reply
)}
- <>
- {showCommentBoxId === id && (
-
-
{
- resetField("reply");
- setShowCommentBoxId(null);
- }}
- loading={createDiscussionStatus === "pending"}
- />
+ {/* Reply editor */}
+ {showCommentBoxId === id && (
+
+ {
+ await handleCreateComment(markdown, id);
+ setShowCommentBoxId(null);
+ }}
+ onCancel={() => setShowCommentBoxId(null)}
+ autoExpand
+ placeholder="Write a reply..."
+ submitLabel="Reply"
+ disabled={createDiscussionStatus === "pending"}
+ />
+
+ )}
+
+ {/* Nested replies with curved connectors */}
+ {hasReplies && (
+
+
+ {(sortDiscussions(children) || []).map((child: typeof children[0], index: number, arr: typeof children) => {
+ const isLast = index === arr.length - 1;
+ const isFirst = index === 0;
+ return (
+
+ {/* Vertical line from parent avatar area down to this curved connector */}
+ {isFirst && (
+
+ )}
+ {/* Curved connector from thread line to this reply */}
+
+ {/* Vertical line continues to next reply (if not last) */}
+ {!isLast && (
+
+ )}
+ {generateDiscussions([child], depth + 1)}
+
+ );
+ })}
- )}
- >
- {!!children && generateDiscussions(children, depth + 1)}
+
+ )}
- >
+
) : (
-
setEditCommentBoxId(null)}
- />
+
+ {
+ await handleEditComment(markdown, id);
+ }}
+ onCancel={() => {
+ setEditCommentBoxId(null);
+ setEditContent("");
+ }}
+ initialContent={editContent}
+ autoExpand
+ submitLabel="Update"
+ disabled={editStatus === "pending"}
+ />
+
)}
);
@@ -429,129 +496,63 @@ const DiscussionArea = ({ targetType, postId, articleId }: Props) => {
);
};
- 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 (
-
- );
- };
-
- return (
-
+ const content = (
+ <>
{!initiallyLoaded && (
-
+
)}
-
-
- {initiallyLoaded
- ? `Discussion (${discussionsResponse?.count || 0})`
- : "Loading discussion..."}
-
-
+
+
+
+ {initiallyLoaded
+ ? `Discussion (${discussionsResponse?.count || 0})`
+ : "Loading discussion..."}
+
+ {initiallyLoaded && (discussionsResponse?.count ?? 0) > 1 && (
+
+ Sort:
+ setSortOrder("top")}
+ className={`rounded-full px-3 py-1 font-medium transition-colors ${
+ sortOrder === "top"
+ ? "bg-neutral-200 text-neutral-900 dark:bg-neutral-700 dark:text-white"
+ : "text-neutral-500 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:bg-neutral-800"
+ }`}
+ >
+ Top
+
+ setSortOrder("new")}
+ className={`rounded-full px-3 py-1 font-medium transition-colors ${
+ sortOrder === "new"
+ ? "bg-neutral-200 text-neutral-900 dark:bg-neutral-700 dark:text-white"
+ : "text-neutral-500 hover:bg-neutral-100 dark:text-neutral-400 dark:hover:bg-neutral-800"
+ }`}
+ >
+ New
+
+
+ )}
+
+
{session ? (
-
+
{
+ await handleCreateComment(markdown);
+ }}
+ placeholder="Join the conversation..."
+ submitLabel="Comment"
+ disabled={createDiscussionStatus === "pending"}
+ />
) : (
-
+
Hey! 👋
Got something to say?
@@ -574,6 +575,16 @@ const DiscussionArea = ({ targetType, postId, articleId }: Props) => {
)}
{generateDiscussions(discussions)}
+ >
+ );
+
+ if (noWrapper) {
+ return
;
+ }
+
+ return (
+
);
};
diff --git a/components/Feed/FeedItemAggregated.tsx b/components/Feed/FeedItemAggregated.tsx
index 2321fdde..08570e23 100644
--- a/components/Feed/FeedItemAggregated.tsx
+++ b/components/Feed/FeedItemAggregated.tsx
@@ -10,15 +10,17 @@ import {
ShareIcon,
ChevronUpIcon,
ChevronDownIcon,
+ FlagIcon,
} 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 { useReportModal } from "@/components/ReportModal/ReportModal";
type Props = {
- id: number;
+ id: string;
shortId: string | null;
title: string;
excerpt: string | null;
@@ -32,7 +34,7 @@ type Props = {
sourceLogo: string | null;
sourceWebsite?: string | null;
author: string | null;
- userVote: "UP" | "DOWN" | null;
+ userVote: "up" | "down" | null;
isBookmarked: boolean;
};
@@ -107,6 +109,7 @@ const FeedItemAggregated = ({
const [imageError, setImageError] = useState(false);
const { data: session } = useSession();
const utils = api.useUtils();
+ const { openReport } = useReportModal();
// Convert http to https for images
const imageUrl = ensureHttps(rawImageUrl);
@@ -139,7 +142,7 @@ const FeedItemAggregated = ({
trackClick({ articleId: id });
};
- const handleVote = (voteType: "UP" | "DOWN" | null) => {
+ const handleVote = (voteType: "up" | "down" | null) => {
if (!session) {
signIn();
return;
@@ -165,6 +168,14 @@ const FeedItemAggregated = ({
}
};
+ const handleReport = () => {
+ if (!session) {
+ signIn();
+ return;
+ }
+ openReport("article", id);
+ };
+
const dateTime = publishedAt
? Temporal.Instant.from(new Date(publishedAt).toISOString())
: null;
@@ -279,11 +290,11 @@ const FeedItemAggregated = ({
- handleVote(userVote === "UP" ? null : "UP")
+ handleVote(userVote === "up" ? null : "up")
}
disabled={voteStatus === "pending"}
className={`rounded-l-full p-1 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
- userVote === "UP"
+ userVote === "up"
? "text-green-500"
: "text-neutral-400 dark:text-neutral-500"
}`}
@@ -304,11 +315,11 @@ const FeedItemAggregated = ({
- handleVote(userVote === "DOWN" ? null : "DOWN")
+ handleVote(userVote === "down" ? null : "down")
}
disabled={voteStatus === "pending"}
className={`rounded-r-full p-1 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
- userVote === "DOWN"
+ userVote === "down"
? "text-red-500"
: "text-neutral-400 dark:text-neutral-500"
}`}
@@ -331,6 +342,7 @@ const FeedItemAggregated = ({
)}
-
+
{initialBookmarked ? "Saved" : "Save"}
@@ -350,10 +362,20 @@ const FeedItemAggregated = ({
{/* Share button */}
- Share
+ Share
+
+
+ {/* Report button */}
+
+
@@ -365,17 +387,19 @@ const FeedItemAggregated = ({
target="_blank"
rel="noopener noreferrer"
onClick={handleClick}
+ aria-label={`View article: ${title}`}
className="relative hidden w-[120px] flex-shrink-0 self-start overflow-hidden rounded-lg sm:block"
>
setImageError(true)}
loading="lazy"
/>
{/* External link icon overlay */}
-
+
diff --git a/components/Feed/VoteButtons.tsx b/components/Feed/VoteButtons.tsx
index 6be7ce70..20b5be35 100644
--- a/components/Feed/VoteButtons.tsx
+++ b/components/Feed/VoteButtons.tsx
@@ -6,8 +6,8 @@ import { signIn, useSession } from "next-auth/react";
type Props = {
upvotes: number;
downvotes: number;
- userVote: "UP" | "DOWN" | null;
- onVote: (voteType: "UP" | "DOWN" | null) => void;
+ userVote: "up" | "down" | null;
+ onVote: (voteType: "up" | "down" | null) => void;
isLoading?: boolean;
};
@@ -21,7 +21,7 @@ const VoteButtons = ({
const { data: session } = useSession();
const score = upvotes - downvotes;
- const handleVote = (voteType: "UP" | "DOWN") => {
+ const handleVote = (voteType: "up" | "down") => {
if (!session) {
signIn();
return;
@@ -33,10 +33,10 @@ const VoteButtons = ({
return (
handleVote("UP")}
+ onClick={() => handleVote("up")}
disabled={isLoading}
className={`rounded p-1 transition-colors hover:bg-neutral-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-700 ${
- userVote === "UP" ? "text-orange-500" : "text-neutral-400"
+ userVote === "up" ? "text-orange-500" : "text-neutral-400"
}`}
aria-label="Upvote"
>
@@ -54,10 +54,10 @@ const VoteButtons = ({
{score}
handleVote("DOWN")}
+ onClick={() => handleVote("down")}
disabled={isLoading}
className={`rounded p-1 transition-colors hover:bg-neutral-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-600 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-700 ${
- userVote === "DOWN" ? "text-blue-500" : "text-neutral-400"
+ userVote === "down" ? "text-blue-500" : "text-neutral-400"
}`}
aria-label="Downvote"
>
diff --git a/components/ReportModal/ReportModal.tsx b/components/ReportModal/ReportModal.tsx
index d771382e..1577e2c9 100644
--- a/components/ReportModal/ReportModal.tsx
+++ b/components/ReportModal/ReportModal.tsx
@@ -1,8 +1,11 @@
-import React, { useRef, useState } from "react";
+"use client";
+
+import React, { useRef, useState, useEffect, useCallback } from "react";
import { XMarkIcon, FlagIcon } from "@heroicons/react/20/solid";
import { toast } from "sonner";
import { signIn, useSession } from "next-auth/react";
import { api } from "@/server/trpc/react";
+import { useSearchParams, usePathname, useRouter } from "next/navigation";
import {
Description,
Dialog,
@@ -10,182 +13,340 @@ import {
DialogTitle,
} from "@headlessui/react";
-type Props = Post | Comment | Discussion;
+// Report types for different content
+export type ReportType = "post" | "comment" | "discussion" | "article";
-type Post = {
- type: "post";
- title: string;
- id: string;
+type ReportItem = {
+ type: ReportType;
+ id: string | number;
+ title?: string;
+ comment?: string;
};
-type Comment = {
- type: "comment";
- comment: string;
- id: number;
-};
+// URL-aware hook for report modal state
+export function useReportModal() {
+ const searchParams = useSearchParams();
+ const pathname = usePathname();
+ const router = useRouter();
-type Discussion = {
- type: "discussion";
- comment: string;
- id: number;
-};
+ const reportParam = searchParams?.get("report");
-export const ReportModal = (props: Props) => {
- const { mutate: sendEmail } = api.report.send.useMutation({
- onSuccess: () => {
- toast.success("Report sent");
+ // Parse report param format: type_id (e.g., "post_abc123", "article_42")
+ const parseReportParam = useCallback(
+ (param: string | null): { type: ReportType; id: string | number } | null => {
+ if (!param) return null;
+ const [type, ...idParts] = param.split("_");
+ const id = idParts.join("_");
+ if (!type || !id) return null;
+
+ const validTypes: ReportType[] = ["post", "comment", "discussion", "article"];
+ if (!validTypes.includes(type as ReportType)) return null;
+
+ // Articles use numeric IDs, posts use string IDs
+ const parsedId = type === "article" || type === "comment" || type === "discussion"
+ ? parseInt(id, 10)
+ : id;
+
+ if (typeof parsedId === "number" && isNaN(parsedId)) return null;
+
+ return { type: type as ReportType, id: parsedId };
},
- onError: () => {
- toast.error("Oops, something went wrong.");
+ [],
+ );
+
+ const reportData = parseReportParam(reportParam);
+
+ const openReport = useCallback(
+ (type: ReportType, id: string | number) => {
+ const params = new URLSearchParams(searchParams?.toString() || "");
+ params.set("report", `${type}_${id}`);
+ router.push(`${pathname}?${params.toString()}`, { scroll: false });
},
- });
+ [searchParams, pathname, router],
+ );
- const { data: session } = useSession();
+ const closeReport = useCallback(() => {
+ const params = new URLSearchParams(searchParams?.toString() || "");
+ params.delete("report");
+ const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname;
+ router.push(newUrl, { scroll: false });
+ }, [searchParams, pathname, router]);
- const [isModalOpen, setIsModalOpen] = useState(false);
- const [loading, setLoading] = useState(false);
+ return {
+ isOpen: !!reportData,
+ reportData,
+ openReport,
+ closeReport,
+ };
+}
+// Global modal component that reads from URL
+export function ReportModalProvider() {
+ const { data: session } = useSession();
+ const { isOpen, reportData, closeReport } = useReportModal();
const [reportBody, setReportBody] = useState("");
- const textAreaRef = useRef(null);
+ const [loading, setLoading] = useState(false);
+ const textAreaRef = useRef(null);
- const { type, id } = props;
+ const { mutate: sendReport } = api.report.send.useMutation({
+ onSuccess: () => {
+ toast.success("Report submitted successfully");
+ closeReport();
+ setReportBody("");
+ },
+ onError: () => {
+ toast.error("Failed to submit report. Please try again.");
+ },
+ onSettled: () => {
+ setLoading(false);
+ },
+ });
- 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;
+ // New mutation for storing reports in database (shows in admin dashboard)
+ const { mutate: createReport } = api.report.create.useMutation({
+ onSuccess: () => {
+ toast.success("Report submitted successfully");
+ closeReport();
+ setReportBody("");
+ },
+ onError: (error) => {
+ if (error.message === "You have already reported this item") {
+ toast.error("You have already reported this item");
+ } else {
+ toast.error("Failed to submit report. Please try again.");
+ }
+ },
+ onSettled: () => {
+ setLoading(false);
+ },
+ });
+
+ // Reset form when modal closes
+ useEffect(() => {
+ if (!isOpen) {
+ setReportBody("");
+ setLoading(false);
+ }
+ }, [isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
- if (loading) return;
+ if (loading || !reportData) return;
+
+ if (!session) {
+ signIn();
+ return;
+ }
+
setLoading(true);
- try {
- if (!session) return signIn();
+ const { type, id } = reportData;
- if (isCommentLike) {
- await sendEmail({
- type: "comment", // Treat discussions same as comments for reporting
- body: reportBody,
- id,
- });
- }
+ if (type === "post") {
+ sendReport({
+ type: "post",
+ body: reportBody,
+ id: id as string,
+ });
+ } else if (type === "discussion") {
+ // Use new create mutation for discussions - stores in DB and shows in admin dashboard
+ createReport({
+ discussionId: id as number,
+ reason: "OTHER",
+ details: reportBody || undefined,
+ });
+ } else if (type === "comment") {
+ // Legacy comments still use send (email)
+ sendReport({
+ type: "comment",
+ body: reportBody,
+ id: id as number,
+ });
+ } else if (type === "article") {
+ sendReport({
+ type: "article",
+ body: reportBody,
+ id: id as number,
+ });
+ }
+ };
- if (isPost) {
- await sendEmail({
- type,
- body: reportBody,
- id,
- });
- }
+ if (!isOpen || !reportData) return null;
- setIsModalOpen(false);
- setReportBody("");
- setLoading(false);
+ const { type } = reportData;
+ const contentLabel =
+ type === "post" ? "article" : type === "article" ? "feed article" : "comment";
- if (!isCommentLike && !isPost) {
- throw new Error("Invalid report");
- }
- } catch (error) {
- toast.error("Something went wrong. Please try submit report again.");
+ return (
+
+
+
+
+
+
+
+
+
+ Report {contentLabel}
+
+
+
+
+ Is something inappropriate? Help us keep the community safe by
+ reporting content that violates our guidelines.
+
+
+
+
+
+
+
+ );
+}
+
+// Button component to trigger report
+type ReportButtonProps = {
+ type: ReportType;
+ id: string | number;
+ variant?: "icon" | "text" | "menu";
+ className?: string;
+};
+
+export function ReportButton({
+ type,
+ id,
+ variant = "icon",
+ className = "",
+}: ReportButtonProps) {
+ const { data: session } = useSession();
+ const { openReport } = useReportModal();
+
+ const handleClick = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (!session) {
+ signIn();
+ return;
}
+ openReport(type, id);
};
+ if (variant === "icon") {
+ return (
+
+
+
+ );
+ }
+
+ if (variant === "menu") {
+ return (
+
+
+ Report
+
+ );
+ }
+
return (
- <>
- {isCommentLike && (
- {
- session ? setIsModalOpen(true) : signIn();
- }}
- className="mr-4 flex rounded-full p-1.5 hover:bg-neutral-300 dark:hover:bg-neutral-800"
- >
-
-
- )}
-
- {!isCommentLike && (
- (session ? setIsModalOpen(true) : signIn())}
- className="w-full rounded text-neutral-900 hover:bg-neutral-200 dark:text-neutral-700"
- >
- Report Article
-
- )}
-
- setIsModalOpen(false)}
- initialFocus={textAreaRef}
- className="relative z-50"
+
+
+ Report
+
+ );
+}
+
+// Legacy export for backwards compatibility
+export const ReportModal = ({
+ type,
+ id,
+ title,
+ comment,
+}: ReportItem & { title?: string; comment?: string }) => {
+ const { data: session } = useSession();
+ const { openReport } = useReportModal();
+
+ const isCommentLike = type === "comment" || type === "discussion";
+
+ const handleClick = () => {
+ if (!session) {
+ signIn();
+ return;
+ }
+ openReport(type, id);
+ };
+
+ if (isCommentLike) {
+ return (
+
-
-
-
-
-
- Submit a report
-
-
-
-
- Is {isPost ? "something in this article" : "this comment"}{" "}
- inappropriate?
-
-
- {isPost ? "Article : " : "Comment : "}
- {isCommentLike && (props as Comment | Discussion).comment}
- {isPost && (props as Post).title}
-
-
-
-
- Thank you for bringing it to our attention. We take reports very
- seriously and will thoroughly investigate the matter.
-
-
-
-
+
+
+ );
+ }
- setIsModalOpen(false)}
- aria-label="Close modal"
- className="absolute right-6 top-6 rounded-full p-1 hover:bg-neutral-800"
- >
-
-
-
-
-
- >
+ return (
+
+ Report Article
+
);
};
diff --git a/components/SideBar/SideBarSavedArticlePreview.tsx b/components/SideBar/SideBarSavedArticlePreview.tsx
index de4cb42a..470cee89 100644
--- a/components/SideBar/SideBarSavedArticlePreview.tsx
+++ b/components/SideBar/SideBarSavedArticlePreview.tsx
@@ -33,7 +33,7 @@ const SideBarSavedArticlePreview: NextPage = ({
{title}
diff --git a/components/SideBar/SideBarSavedPosts.tsx b/components/SideBar/SideBarSavedPosts.tsx
index fca498a2..0c558797 100644
--- a/components/SideBar/SideBarSavedPosts.tsx
+++ b/components/SideBar/SideBarSavedPosts.tsx
@@ -1,9 +1,14 @@
"use client";
import { api } from "@/server/trpc/react";
import React from "react";
-
-import SideBarSavedArticlePreview from "./SideBarSavedArticlePreview";
import Link from "next/link";
+import { SavedItemCard } from "@/components/SavedItemCard";
+
+// Map DB type to frontend type
+const toFrontendType = (dbType: string | null): "POST" | "LINK" => {
+ if (dbType === "article") return "POST";
+ return "LINK";
+};
export default React.memo(function SideBarSavedPosts() {
const howManySavedToShow = 3;
@@ -12,15 +17,15 @@ export default React.memo(function SideBarSavedPosts() {
limit: howManySavedToShow,
});
- const totalNumberSaved = bookmarksData?.totalCount || 0;
- const bookmarks = bookmarksData?.bookmarks || [];
+ const totalNumberSaved = bookmarksData?.items?.length || 0;
+ const bookmarks = bookmarksData?.items || [];
return (
Recent bookmarks
-
+
{bookmarkStatus === "pending" &&
Array.from({ length: howManySavedToShow }, (_, i) => (
@@ -33,28 +38,21 @@ export default React.memo(function SideBarSavedPosts() {
{bookmarks &&
bookmarkStatus === "success" &&
- bookmarks.map(
- ({
- id,
- slug,
- title,
- user: { name, username },
- published,
- readTimeMins,
- }) => {
- return (
-
- );
- },
- )}
+ bookmarks.map((item) => (
+
+ ))}
{bookmarkStatus === "success" && bookmarks?.length === 0 && (
Recently Saved posts will be displayed in this section for easy
@@ -63,8 +61,8 @@ export default React.memo(function SideBarSavedPosts() {
)}
{(totalNumberSaved && totalNumberSaved > howManySavedToShow && (
-
- View all saved posts →
+
+ View all saved posts
)) ||
""}
@@ -74,17 +72,6 @@ export default React.memo(function SideBarSavedPosts() {
function LoadingSkeleton() {
return (
-
+
);
}
diff --git a/components/TrendingPosts/TrendingPosts.tsx b/components/TrendingPosts/TrendingPosts.tsx
index 35f3385b..aacf8c81 100644
--- a/components/TrendingPosts/TrendingPosts.tsx
+++ b/components/TrendingPosts/TrendingPosts.tsx
@@ -1,9 +1,7 @@
-"use server";
-
import Link from "next/link";
-import ArticlePreview from "@/components/ArticlePreview/ArticlePreview";
-import { getTrending } from "@/server/lib/posts";
+import { getUnifiedTrending } from "@/server/lib/posts";
import { type Session } from "next-auth";
+import { UnifiedContentCard } from "@/components/UnifiedContentCard";
type TrendingPostsProps = {
session: Session | null;
@@ -12,51 +10,63 @@ type TrendingPostsProps = {
export default async function TrendingPosts({ session }: TrendingPostsProps) {
const userId = session?.user?.id ?? undefined;
- const trendingPosts = await getTrending({
+ const trendingItems = await getUnifiedTrending({
currentUserId: userId,
+ limit: 20,
});
- // Refactor with option to refresh
- if (!trendingPosts)
+ if (!trendingItems) {
return (
Something went wrong... Please refresh the page.
);
+ }
return (
- {trendingPosts.map(
- ({
- slug,
- title,
- excerpt,
- user,
- updatedAt,
- readTimeMins,
- id,
- currentUserBookmarkedPost,
- }) => (
- {
+ const isPost = item.type === "post";
+ return (
+
- ),
- )}
+ );
+ })}
-
-
- View more articles →
+
+
+ View Feed →
diff --git a/components/TrendingPosts/TrendingPostsLoading.tsx b/components/TrendingPosts/TrendingPostsLoading.tsx
index 27d91932..2cceaf67 100644
--- a/components/TrendingPosts/TrendingPostsLoading.tsx
+++ b/components/TrendingPosts/TrendingPostsLoading.tsx
@@ -1,10 +1,10 @@
-import ArticleLoading from "@/components/ArticlePreview/ArticleLoading";
+import { FeedItemLoading } from "@/components/Feed";
function LoadingTrendingPosts() {
return (
{Array.from({ length: 5 }, (_, i) => (
-
+
))}
);
From 2ce4be1917a74b40d4b80fbbb98fe86f682ca55a Mon Sep 17 00:00:00 2001
From: NiallJoeMaher
Date: Tue, 6 Jan 2026 07:41:33 +0000
Subject: [PATCH 14/38] feat(pages): update pages for unified content and
sponsorship
Add advertise page, content detail pages for links/articles, update feed
and article pages for unified content system, remove alpha sponsorship.
---
.../[username]/[slug]/_feedArticleContent.tsx | 431 +++++++++++
.../[username]/[slug]/_linkContentDetail.tsx | 380 ++++++++++
app/(app)/[username]/[slug]/page.tsx | 696 ++++++++++++++++++
app/(app)/[username]/_sourceProfileClient.tsx | 298 ++++++++
app/(app)/[username]/_usernameClient.tsx | 64 +-
app/(app)/[username]/page.tsx | 150 ++--
app/(app)/admin/sources/_client.tsx | 7 +-
app/(app)/advertise/_client.tsx | 21 +
app/(app)/advertise/page.tsx | 17 +
app/(app)/alpha/sponsorship/page.tsx | 178 -----
app/(app)/articles/[slug]/page.tsx | 203 +----
app/(app)/articles/_client.tsx | 46 +-
app/(app)/articles/page.tsx | 32 +-
.../feed/[sourceSlug]/[shortId]/_client.tsx | 388 +++-------
.../feed/[sourceSlug]/[shortId]/page.tsx | 44 +-
app/(app)/feed/[sourceSlug]/_client.tsx | 2 +-
app/(app)/feed/_client.tsx | 148 ++--
app/(app)/my-posts/_client.tsx | 42 +-
app/(app)/page.tsx | 8 +-
app/(app)/saved/_client.tsx | 72 +-
app/(app)/sponsorship/page.tsx | 136 +---
.../create/[[...paramsArr]]/_client.tsx | 92 ++-
22 files changed, 2375 insertions(+), 1080 deletions(-)
create mode 100644 app/(app)/[username]/[slug]/_feedArticleContent.tsx
create mode 100644 app/(app)/[username]/[slug]/_linkContentDetail.tsx
create mode 100644 app/(app)/[username]/[slug]/page.tsx
create mode 100644 app/(app)/[username]/_sourceProfileClient.tsx
create mode 100644 app/(app)/advertise/_client.tsx
create mode 100644 app/(app)/advertise/page.tsx
delete mode 100644 app/(app)/alpha/sponsorship/page.tsx
diff --git a/app/(app)/[username]/[slug]/_feedArticleContent.tsx b/app/(app)/[username]/[slug]/_feedArticleContent.tsx
new file mode 100644
index 00000000..5c49720c
--- /dev/null
+++ b/app/(app)/[username]/[slug]/_feedArticleContent.tsx
@@ -0,0 +1,431 @@
+"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;
+ articleSlug: 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
+const ensureHttps = (url: string | null | undefined): string | null => {
+ if (!url) return null;
+ if (url.startsWith("http://")) {
+ return url.replace("http://", "https://");
+ }
+ return url;
+};
+
+const FeedArticleContent = ({ sourceSlug, articleSlug }: Props) => {
+ const { data: session } = useSession();
+ const utils = api.useUtils();
+
+ const { data: article, status } = api.feed.getBySourceAndArticleSlug.useQuery(
+ {
+ sourceSlug,
+ articleSlug,
+ },
+ );
+
+ const { data: discussionCount } =
+ api.discussion.getContentDiscussionCount.useQuery(
+ { contentId: article?.id ?? "" },
+ { enabled: !!article?.id },
+ );
+
+ const { mutate: vote, status: voteStatus } = api.feed.vote.useMutation({
+ onSuccess: () => {
+ utils.feed.getBySourceAndArticleSlug.invalidate({
+ sourceSlug,
+ articleSlug,
+ });
+ 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.getBySourceAndArticleSlug.invalidate({
+ sourceSlug,
+ articleSlug,
+ });
+ 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}/${sourceSlug}/${articleSlug}`;
+ 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
+
+
+
+ Post Not Found
+
+
+ This post 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.externalUrl);
+ const hostname = article.externalUrl ? getHostname(article.externalUrl) : null;
+ const score = article.upvotes - article.downvotes;
+
+ return (
+
+ {/* Breadcrumb */}
+
+
+ Feed
+
+ /
+
+ {article.source?.name || sourceSlug}
+
+
+
+ {/* Article card */}
+
+ {/* Source info */}
+
+
+ {article.source?.logoUrl ? (
+
+ ) : faviconUrl ? (
+
+ ) : (
+
+ {article.source?.name?.charAt(0).toUpperCase() || "?"}
+
+ )}
+
+ {article.source?.name || "Unknown Source"}
+
+
+ {article.sourceAuthor &&
+ article.sourceAuthor.trim() &&
+ !["by", "by,", "by ,"].includes(
+ article.sourceAuthor.trim().toLowerCase(),
+ ) && (
+ <>
+
·
+
{article.sourceAuthor.replace(/^by\s+/i, "").trim()}
+ >
+ )}
+ {readableDate && (
+ <>
+
·
+
{readableDate}
+ >
+ )}
+
+
+ {/* Title */}
+
+ {article.title}
+
+
+ {/* Excerpt */}
+ {article.excerpt && (
+
+ {article.excerpt}
+
+ )}
+
+ {/* Thumbnail image */}
+ {ensureHttps(article.imageUrl) && article.externalUrl && (
+
+
+
+
+ )}
+
+ {/* Read article CTA */}
+ {article.externalUrl && (
+
+
+ Read Full Article at {hostname}
+
+ )}
+
+ {/* Action bar */}
+
+ {/* Vote buttons */}
+
+
+ handleVote(article.userVote === "up" ? null : "up")
+ }
+ disabled={voteStatus === "pending"}
+ className={`rounded-l-full p-2 transition-colors hover:bg-neutral-200 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-700 ${
+ article.userVote === "up"
+ ? "text-green-500"
+ : "text-neutral-500 dark:text-neutral-400"
+ }`}
+ aria-label="Upvote"
+ >
+
+
+ 0
+ ? "text-green-500"
+ : score < 0
+ ? "text-red-500"
+ : "text-neutral-500 dark:text-neutral-400"
+ }`}
+ >
+ {score}
+
+
+ handleVote(article.userVote === "down" ? null : "down")
+ }
+ disabled={voteStatus === "pending"}
+ className={`rounded-r-full p-2 transition-colors hover:bg-neutral-200 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-700 ${
+ article.userVote === "down"
+ ? "text-red-500"
+ : "text-neutral-500 dark:text-neutral-400"
+ }`}
+ aria-label="Downvote"
+ >
+
+
+
+
+ {/* Comments count */}
+
+
+ {discussionCount ?? 0} comments
+
+
+ {/* Save button */}
+
+ {article.isBookmarked ? (
+
+ ) : (
+
+ )}
+ {article.isBookmarked ? "Saved" : "Save"}
+
+
+ {/* Share button */}
+
+
+ Share
+
+
+
+ {/* Inline source info */}
+ {article.source && (
+
+
+ {article.source.logoUrl ? (
+
+ ) : faviconUrl ? (
+
+ ) : (
+
+ {article.source.name?.charAt(0).toUpperCase() || "?"}
+
+ )}
+
+
+
+ {article.source.name}
+
+ {article.source.description && (
+
+ {article.source.description}
+
+ )}
+
+
+ )}
+
+ {/* Discussion section - inside the card */}
+
+
+
+ );
+};
+
+export default FeedArticleContent;
diff --git a/app/(app)/[username]/[slug]/_linkContentDetail.tsx b/app/(app)/[username]/[slug]/_linkContentDetail.tsx
new file mode 100644
index 00000000..aecc0822
--- /dev/null
+++ b/app/(app)/[username]/[slug]/_linkContentDetail.tsx
@@ -0,0 +1,380 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import Link from "next/link";
+import {
+ ArrowTopRightOnSquareIcon,
+ ChatBubbleLeftIcon,
+ ChevronUpIcon,
+ ChevronDownIcon,
+ ShareIcon,
+} from "@heroicons/react/20/solid";
+import { api } from "@/server/trpc/react";
+import { toast } from "sonner";
+import { Temporal } from "@js-temporal/polyfill";
+import DiscussionArea from "@/components/Discussion/DiscussionArea";
+import { useSession, signIn } from "next-auth/react";
+
+type Props = {
+ sourceSlug: string;
+ contentSlug: 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
+const ensureHttps = (url: string | null | undefined): string | null => {
+ if (!url) return null;
+ if (url.startsWith("http://")) {
+ return url.replace("http://", "https://");
+ }
+ return url;
+};
+
+const LinkContentDetail = ({ sourceSlug, contentSlug }: Props) => {
+ const { data: session } = useSession();
+ const { data: linkContent, status } = api.feed.getLinkContentBySourceAndSlug.useQuery({
+ sourceSlug,
+ contentSlug,
+ });
+
+ const { data: discussionCount } =
+ api.discussion.getContentDiscussionCount.useQuery(
+ { contentId: linkContent?.id ?? "" },
+ { enabled: !!linkContent?.id },
+ );
+
+ // Vote state management
+ const [userVote, setUserVote] = useState<"up" | "down" | null>(null);
+ const [votes, setVotes] = useState({ upvotes: 0, downvotes: 0 });
+
+ // Initialize vote state when data loads
+ useEffect(() => {
+ if (linkContent) {
+ setUserVote(linkContent.userVote ?? null);
+ setVotes({ upvotes: linkContent.upvotes, downvotes: linkContent.downvotes });
+ }
+ }, [linkContent]);
+
+ const { mutate: vote, status: voteStatus } = api.content.vote.useMutation({
+ onMutate: async ({ voteType }) => {
+ // Optimistic update
+ 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: () => {
+ // Revert on error
+ setUserVote(linkContent?.userVote ?? null);
+ setVotes({ upvotes: linkContent?.upvotes ?? 0, downvotes: linkContent?.downvotes ?? 0 });
+ toast.error("Failed to update vote");
+ },
+ });
+
+ const handleVote = (voteType: "up" | "down" | null) => {
+ if (!session) {
+ signIn();
+ return;
+ }
+ if (!linkContent) return;
+ vote({ contentId: linkContent.id, voteType });
+ };
+
+ const handleShare = async () => {
+ const shareUrl = `${window.location.origin}/${sourceSlug}/${contentSlug}`;
+ try {
+ await navigator.clipboard.writeText(shareUrl);
+ toast.success("Link copied to clipboard");
+ } catch {
+ toast.error("Failed to copy link");
+ }
+ };
+
+ if (status === "pending") {
+ return (
+
+ );
+ }
+
+ if (status === "error" || !linkContent) {
+ return (
+
+
+ Back to Feed
+
+
+
+ Content Not Found
+
+
+ This link may have been removed or the URL is invalid.
+
+
+
+ );
+ }
+
+ const externalUrl = linkContent.externalUrl || "";
+ const dateTime = linkContent.publishedAt
+ ? Temporal.Instant.from(new Date(linkContent.publishedAt).toISOString())
+ : null;
+ const readableDate = dateTime
+ ? dateTime.toLocaleString(["en-IE"], {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })
+ : null;
+
+ const faviconUrl = getFaviconUrl(linkContent.source?.websiteUrl || externalUrl);
+ const hostname = externalUrl ? getHostname(externalUrl) : null;
+ const score = votes.upvotes - votes.downvotes;
+
+ return (
+
+ {/* Breadcrumb */}
+
+
+ Feed
+
+ /
+
+ {linkContent.source?.name || sourceSlug}
+
+
+
+ {/* Content card */}
+
+ {/* Source info */}
+
+
+ {linkContent.source?.logoUrl ? (
+
+ ) : faviconUrl ? (
+
+ ) : (
+
+ {linkContent.source?.name?.charAt(0).toUpperCase() || "?"}
+
+ )}
+
+ {linkContent.source?.name || "Unknown Source"}
+
+
+ {linkContent.sourceAuthor && linkContent.sourceAuthor.trim() && (
+ <>
+
·
+
{linkContent.sourceAuthor}
+ >
+ )}
+ {readableDate && (
+ <>
+
·
+
{readableDate}
+ >
+ )}
+
+
+ {/* Title */}
+
+ {linkContent.title}
+
+
+ {/* Excerpt */}
+ {linkContent.excerpt && (
+
+ {linkContent.excerpt}
+
+ )}
+
+ {/* Thumbnail image */}
+ {ensureHttps(linkContent.imageUrl) && externalUrl && (
+
+
+ {hostname && (
+
+ )}
+
+ )}
+
+ {/* Visit link CTA */}
+ {externalUrl && hostname && (
+
+
+ Visit Link at {hostname}
+
+ )}
+
+ {/* Action bar */}
+
+ {/* Vote buttons */}
+
+ handleVote(userVote === "up" ? null : "up")}
+ disabled={voteStatus === "pending"}
+ className={`rounded-l-full p-2 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
+ userVote === "up"
+ ? "text-green-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ aria-label="Upvote"
+ >
+
+
+ 0
+ ? "text-green-500"
+ : score < 0
+ ? "text-red-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ >
+ {score}
+
+ handleVote(userVote === "down" ? null : "down")}
+ disabled={voteStatus === "pending"}
+ className={`rounded-r-full p-2 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
+ userVote === "down"
+ ? "text-red-500"
+ : "text-neutral-400 dark:text-neutral-500"
+ }`}
+ aria-label="Downvote"
+ >
+
+
+
+
+ {/* Comments count */}
+
+
+ {discussionCount ?? 0} comments
+
+
+ {/* Share button */}
+
+
+ Share
+
+
+
+ {/* Inline source info */}
+ {linkContent.source && (
+
+
+ {linkContent.source.logoUrl ? (
+
+ ) : faviconUrl ? (
+
+ ) : (
+
+ {linkContent.source.name?.charAt(0).toUpperCase() || "?"}
+
+ )}
+
+
+
+ {linkContent.source.name}
+
+ {linkContent.source.description && (
+
+ {linkContent.source.description}
+
+ )}
+
+
+ )}
+
+ {/* Discussion section */}
+
+
+
+ );
+};
+
+export default LinkContentDetail;
diff --git a/app/(app)/[username]/[slug]/page.tsx b/app/(app)/[username]/[slug]/page.tsx
new file mode 100644
index 00000000..a320422d
--- /dev/null
+++ b/app/(app)/[username]/[slug]/page.tsx
@@ -0,0 +1,696 @@
+import React from "react";
+import type { RenderableTreeNode } from "@markdoc/markdoc";
+import Markdoc from "@markdoc/markdoc";
+import Link from "next/link";
+import { markdocComponents } from "@/markdoc/components";
+import { config } from "@/markdoc/config";
+import DiscussionArea from "@/components/Discussion/DiscussionArea";
+import { ArticleActionBarWrapper } from "@/components/ArticleActionBar";
+import { InlineAuthorBio } from "@/components/ContentDetail";
+import { headers } from "next/headers";
+import { notFound } from "next/navigation";
+import { getServerAuthSession } from "@/server/auth";
+import ArticleAdminPanel from "@/components/ArticleAdminPanel/ArticleAdminPanel";
+import { type Metadata } from "next";
+import { getCamelCaseFromLower } from "@/utils/utils";
+import { generateHTML } from "@tiptap/core";
+import { RenderExtensions } from "@/components/editor/editor/extensions/render-extensions";
+import sanitizeHtml from "sanitize-html";
+import type { JSONContent } from "@tiptap/core";
+import NotFound from "@/components/NotFound/NotFound";
+import { db } from "@/server/db";
+import {
+ posts,
+ user,
+ feed_sources,
+ post_tags,
+ tag,
+} from "@/server/db/schema";
+import { eq, and, lte } from "drizzle-orm";
+import FeedArticleContent from "./_feedArticleContent";
+import LinkContentDetail from "./_linkContentDetail";
+
+type Props = { params: Promise<{ username: string; slug: string }> };
+
+// Helper to fetch user article by username and slug (uses new posts table)
+async function getUserPost(username: string, postSlug: string) {
+ // First find user
+ const userRecord = await db.query.user.findFirst({
+ columns: { id: true },
+ where: eq(user.username, username),
+ });
+
+ if (!userRecord) return null;
+
+ // Then find published article by slug that belongs to this user - using explicit JOIN
+ const postResults = await db
+ .select({
+ id: posts.id,
+ title: posts.title,
+ body: posts.body,
+ status: posts.status,
+ publishedAt: posts.publishedAt,
+ updatedAt: posts.updatedAt,
+ readingTime: posts.readingTime,
+ slug: posts.slug,
+ excerpt: posts.excerpt,
+ canonicalUrl: posts.canonicalUrl,
+ showComments: posts.showComments,
+ upvotesCount: posts.upvotesCount,
+ downvotesCount: posts.downvotesCount,
+ type: posts.type,
+ // Author info via JOIN
+ authorId: user.id,
+ authorName: user.name,
+ authorImage: user.image,
+ authorUsername: user.username,
+ authorBio: user.bio,
+ })
+ .from(posts)
+ .leftJoin(user, eq(posts.authorId, user.id))
+ .where(
+ and(
+ eq(posts.slug, postSlug),
+ eq(posts.authorId, userRecord.id),
+ eq(posts.status, "published"),
+ eq(posts.type, "article"),
+ lte(posts.publishedAt, new Date().toISOString()),
+ ),
+ )
+ .limit(1);
+
+ if (postResults.length === 0) return null;
+
+ const postRecord = postResults[0];
+
+ // Fetch tags separately using explicit JOIN
+ const tagsResult = await db
+ .select({ title: tag.title })
+ .from(post_tags)
+ .innerJoin(tag, eq(post_tags.tagId, tag.id))
+ .where(eq(post_tags.postId, postRecord.id));
+
+ // Map to expected shape for backwards compatibility
+ return {
+ ...postRecord,
+ published: postRecord.publishedAt,
+ readTimeMins: postRecord.readingTime,
+ upvotes: postRecord.upvotesCount,
+ downvotes: postRecord.downvotesCount,
+ tags: tagsResult.map((t) => ({ tag: { title: t.title } })),
+ user: {
+ id: postRecord.authorId,
+ name: postRecord.authorName,
+ image: postRecord.authorImage,
+ username: postRecord.authorUsername,
+ bio: postRecord.authorBio,
+ },
+ };
+}
+
+// Helper to fetch link post by source slug and article slug (uses new posts table)
+async function getFeedArticle(
+ sourceSlug: string,
+ articleSlugOrShortId: string,
+) {
+ // First find feed source by slug
+ const source = await db.query.feed_sources.findFirst({
+ where: eq(feed_sources.slug, sourceSlug),
+ });
+
+ if (!source) return null;
+
+ // Find link post by slug that belongs to this source - using explicit JOIN
+ const linkPostResults = await db
+ .select({
+ id: posts.id,
+ title: posts.title,
+ body: posts.body,
+ excerpt: posts.excerpt,
+ slug: posts.slug,
+ externalUrl: posts.externalUrl,
+ coverImage: posts.coverImage,
+ upvotesCount: posts.upvotesCount,
+ downvotesCount: posts.downvotesCount,
+ publishedAt: posts.publishedAt,
+ createdAt: posts.createdAt,
+ updatedAt: posts.updatedAt,
+ showComments: posts.showComments,
+ // Source info
+ sourceName: feed_sources.name,
+ sourceSlug: feed_sources.slug,
+ sourceLogo: feed_sources.logoUrl,
+ sourceWebsite: feed_sources.websiteUrl,
+ })
+ .from(posts)
+ .leftJoin(feed_sources, eq(posts.sourceId, feed_sources.id))
+ .where(
+ and(
+ eq(posts.slug, articleSlugOrShortId),
+ eq(posts.sourceId, source.id),
+ eq(posts.type, "link"),
+ eq(posts.status, "published"),
+ )
+ )
+ .limit(1);
+
+ if (linkPostResults.length === 0) return null;
+
+ const linkPost = linkPostResults[0];
+
+ // Map to expected shape for backwards compatibility
+ return {
+ ...linkPost,
+ shortId: linkPost.slug.split("-").pop() || "",
+ imageUrl: linkPost.coverImage,
+ ogImageUrl: linkPost.coverImage,
+ upvotes: linkPost.upvotesCount,
+ downvotes: linkPost.downvotesCount,
+ source: {
+ name: linkPost.sourceName,
+ slug: linkPost.sourceSlug,
+ logoUrl: linkPost.sourceLogo,
+ websiteUrl: linkPost.sourceWebsite,
+ },
+ };
+}
+
+// Helper to fetch link content (uses new posts table - same as getFeedArticle)
+async function getLinkContent(sourceSlug: string, contentSlug: string) {
+ // Delegate to getFeedArticle since they query the same table now
+ return getFeedArticle(sourceSlug, contentSlug);
+}
+
+// Helper to fetch user article content (uses new posts table - same as getUserPost)
+async function getUserArticleContent(username: string, contentSlug: string) {
+ // Delegate to getUserPost since they query the same table now
+ return getUserPost(username, contentSlug);
+}
+
+export async function generateMetadata(props: Props): Promise {
+ const params = await props.params;
+ const { username, slug } = params;
+
+ // First try user post (legacy Post table)
+ const userPost = await getUserPost(username, slug);
+ if (userPost) {
+ const tags = userPost.tags.map((tag) => tag.tag.title);
+ const host = (await headers()).get("host") || "";
+ const authorName = userPost.user.name || "Unknown";
+
+ return {
+ title: `${userPost.title} | by ${authorName} | Codú`,
+ authors: {
+ name: authorName,
+ url: `https://www.${host}/${userPost.user.username}`,
+ },
+ keywords: tags,
+ description: userPost.excerpt ?? undefined,
+ openGraph: {
+ description: userPost.excerpt ?? undefined,
+ type: "article",
+ images: [
+ `/og?title=${encodeURIComponent(
+ userPost.title,
+ )}&readTime=${userPost.readTimeMins}&author=${encodeURIComponent(
+ authorName,
+ )}&date=${userPost.updatedAt}`,
+ ],
+ siteName: "Codú",
+ },
+ twitter: {
+ description: userPost.excerpt ?? undefined,
+ images: [`/og?title=${encodeURIComponent(userPost.title)}`],
+ },
+ alternates: {
+ canonical: userPost.canonicalUrl,
+ },
+ };
+ }
+
+ // Then try user ARTICLE content (new unified Content table)
+ const userArticle = await getUserArticleContent(username, slug);
+ if (userArticle && userArticle.user) {
+ const tags = userArticle.tags?.map((t) => t.tag.title) || [];
+ const host = (await headers()).get("host") || "";
+ const articleAuthorName = userArticle.user.name || "Unknown";
+
+ return {
+ title: `${userArticle.title} | by ${articleAuthorName} | Codú`,
+ authors: {
+ name: articleAuthorName,
+ url: `https://www.${host}/${userArticle.user.username}`,
+ },
+ keywords: tags,
+ description: userArticle.excerpt,
+ openGraph: {
+ description: userArticle.excerpt || "",
+ type: "article",
+ images: [
+ `/og?title=${encodeURIComponent(
+ userArticle.title,
+ )}&readTime=${userArticle.readTimeMins || 5}&author=${encodeURIComponent(
+ userArticle.user.name || "",
+ )}&date=${userArticle.updatedAt}`,
+ ],
+ siteName: "Codú",
+ },
+ twitter: {
+ description: userArticle.excerpt || "",
+ images: [`/og?title=${encodeURIComponent(userArticle.title)}`],
+ },
+ alternates: {
+ canonical: userArticle.canonicalUrl,
+ },
+ };
+ }
+
+ // Then try feed article (legacy aggregated_article table)
+ const feedArticle = await getFeedArticle(username, slug);
+ if (feedArticle) {
+ return {
+ title: `${feedArticle.title} | Codú Feed`,
+ description:
+ feedArticle.excerpt || `Discussion about ${feedArticle.title}`,
+ openGraph: {
+ title: feedArticle.title,
+ description:
+ feedArticle.excerpt || `Discussion about ${feedArticle.title}`,
+ images:
+ feedArticle.ogImageUrl || feedArticle.imageUrl
+ ? [feedArticle.ogImageUrl || feedArticle.imageUrl!]
+ : undefined,
+ },
+ };
+ }
+
+ // Try unified content table (new LINK type items)
+ const linkContent = await getLinkContent(username, slug);
+ if (linkContent) {
+ return {
+ title: `${linkContent.title} | Codú Feed`,
+ description:
+ linkContent.excerpt || `Discussion about ${linkContent.title}`,
+ openGraph: {
+ title: linkContent.title,
+ description:
+ linkContent.excerpt || `Discussion about ${linkContent.title}`,
+ images:
+ linkContent.ogImageUrl || linkContent.imageUrl
+ ? [linkContent.ogImageUrl || linkContent.imageUrl!]
+ : undefined,
+ },
+ };
+ }
+
+ return { title: "Content Not Found" };
+}
+
+const parseJSON = (str: string): JSONContent | null => {
+ try {
+ return JSON.parse(str);
+ } catch (e) {
+ return null;
+ }
+};
+
+const renderSanitizedTiptapContent = (jsonContent: JSONContent) => {
+ const rawHtml = generateHTML(jsonContent, [...RenderExtensions]);
+ return sanitizeHtml(rawHtml, {
+ allowedTags: sanitizeHtml.defaults.allowedTags.concat([
+ "img",
+ "iframe",
+ "h1",
+ "h2",
+ ]),
+ allowedAttributes: {
+ ...sanitizeHtml.defaults.allowedAttributes,
+ img: ["src", "alt", "title", "width", "height", "class"],
+ iframe: ["src", "width", "height", "frameborder", "allowfullscreen"],
+ "*": ["class", "id", "style"],
+ },
+ allowedIframeHostnames: [
+ "www.youtube.com",
+ "youtube.com",
+ "www.youtube-nocookie.com",
+ ],
+ });
+};
+
+const UnifiedPostPage = async (props: Props) => {
+ const params = await props.params;
+ const session = await getServerAuthSession();
+ const { username, slug } = params;
+
+ const host = (await headers()).get("host") || "";
+
+ // First try user post
+ const userPost = await getUserPost(username, slug);
+
+ if (userPost) {
+ // Render user article
+ const bodyContent = userPost.body ?? "";
+ const parsedBody = parseJSON(bodyContent);
+ const isTiptapContent = parsedBody?.type === "doc";
+
+ let renderedContent: string | RenderableTreeNode;
+
+ if (isTiptapContent && parsedBody) {
+ const jsonContent = parsedBody;
+ renderedContent = renderSanitizedTiptapContent(jsonContent);
+ } else {
+ const ast = Markdoc.parse(bodyContent);
+ const transformedContent = Markdoc.transform(ast, config);
+ renderedContent = Markdoc.renderers.react(transformedContent, React, {
+ components: markdocComponents,
+ }) as unknown as string;
+ }
+
+ return (
+ <>
+
+ {/* Breadcrumb navigation */}
+
+
+ Feed
+
+ /
+
+ {userPost.user.name}
+
+
+
+ {/* Article card - contains everything in one cohesive unit */}
+
+ {/* Author info */}
+
+
+ {userPost.user.image ? (
+
+ ) : (
+
+ {userPost.user.name?.charAt(0).toUpperCase() || "?"}
+
+ )}
+
{userPost.user.name}
+
+ {userPost.published && (
+ <>
+
·
+
+ {new Date(userPost.published).toLocaleDateString("en-IE", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })}
+
+ >
+ )}
+ {userPost.readTimeMins && (
+ <>
+
·
+
{userPost.readTimeMins} min read
+ >
+ )}
+
+
+ {/* Article content */}
+
+ {!isTiptapContent &&
{userPost.title} }
+
+ {isTiptapContent ? (
+
,
+ }}
+ className="tiptap-content"
+ />
+ ) : (
+
+ {Markdoc.renderers.react(renderedContent, React, {
+ components: markdocComponents,
+ })}
+
+ )}
+
+
+ {/* Tags */}
+ {userPost.tags.length > 0 && (
+
+ {userPost.tags.map(({ tag }) => (
+
+ {getCamelCaseFromLower(tag.title)}
+
+ ))}
+
+ )}
+
+ {/* Action bar */}
+
+
+ {/* Compact inline author bio */}
+
+
+
+
+ {/* Discussion section - inside the card */}
+
+ {userPost.showComments ? (
+
+ ) : (
+
+
+ Comments are disabled for this post
+
+
+ )}
+
+
+
+
+ {session && session?.user?.role === "ADMIN" && (
+
+ )}
+ >
+ );
+ }
+
+ // Then try user ARTICLE content (new unified Content table)
+ const userArticle = await getUserArticleContent(username, slug);
+
+ if (userArticle && userArticle.user && userArticle.body) {
+ // Render user article from Content table
+ const parsedBody = parseJSON(userArticle.body);
+ const isTiptapContent = parsedBody?.type === "doc";
+
+ let renderedContent: string | RenderableTreeNode;
+
+ if (isTiptapContent && parsedBody) {
+ const jsonContent = parsedBody;
+ renderedContent = renderSanitizedTiptapContent(jsonContent);
+ } else {
+ const ast = Markdoc.parse(userArticle.body);
+ const transformedContent = Markdoc.transform(ast, config);
+ renderedContent = Markdoc.renderers.react(transformedContent, React, {
+ components: markdocComponents,
+ }) as unknown as string;
+ }
+
+ return (
+ <>
+
+ {/* Breadcrumb navigation */}
+
+
+ Feed
+
+ /
+
+ {userArticle.user.name}
+
+
+
+ {/* Article card - contains everything in one cohesive unit */}
+
+ {/* Author info */}
+
+
+ {userArticle.user.image ? (
+
+ ) : (
+
+ {userArticle.user.name?.charAt(0).toUpperCase() || "?"}
+
+ )}
+
{userArticle.user.name}
+
+ {userArticle.publishedAt && (
+ <>
+
·
+
+ {new Date(userArticle.publishedAt).toLocaleDateString("en-IE", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ })}
+
+ >
+ )}
+ {userArticle.readTimeMins && (
+ <>
+
·
+
{userArticle.readTimeMins} min read
+ >
+ )}
+
+
+ {/* Article content */}
+
+ {!isTiptapContent &&
{userArticle.title} }
+
+ {isTiptapContent ? (
+
,
+ }}
+ className="tiptap-content"
+ />
+ ) : (
+
+ {Markdoc.renderers.react(renderedContent, React, {
+ components: markdocComponents,
+ })}
+
+ )}
+
+
+ {/* Tags */}
+ {userArticle.tags && userArticle.tags.length > 0 && (
+
+ {userArticle.tags.map(({ tag }) => (
+
+ {getCamelCaseFromLower(tag.title)}
+
+ ))}
+
+ )}
+
+ {/* Action bar - using Content voting */}
+
+
+ {/* Compact inline author bio */}
+
+
+
+
+ {/* Discussion section - inside the card */}
+
+ {userArticle.showComments ? (
+
+ ) : (
+
+
+ Comments are disabled for this article
+
+
+ )}
+
+
+
+
+ {session && session?.user?.role === "ADMIN" && (
+
+ )}
+ >
+ );
+ }
+
+ // Then try feed article (legacy aggregated_article table)
+ const feedArticle = await getFeedArticle(username, slug);
+
+ if (feedArticle) {
+ // Render feed article
+ return ;
+ }
+
+ // Try unified content table (new LINK type items)
+ const linkContent = await getLinkContent(username, slug);
+
+ if (linkContent) {
+ // Render link content
+ return ;
+ }
+
+ // Nothing found
+ return notFound();
+};
+
+export default UnifiedPostPage;
diff --git a/app/(app)/[username]/_sourceProfileClient.tsx b/app/(app)/[username]/_sourceProfileClient.tsx
new file mode 100644
index 00000000..2bf65fd8
--- /dev/null
+++ b/app/(app)/[username]/_sourceProfileClient.tsx
@@ -0,0 +1,298 @@
+"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 SourceProfileContent = ({ 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: 25 },
+ {
+ 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 ? (
+
+ ) : faviconUrl ? (
+
+ ) : (
+
+ {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) => (
+ setSort(sortOption)}
+ className={`rounded-full px-3 py-1 text-xs font-medium capitalize transition-colors ${
+ sort === sortOption
+ ? "bg-orange-500 text-white"
+ : "bg-neutral-100 text-neutral-600 hover:bg-neutral-200 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:bg-neutral-700"
+ }`}
+ >
+ {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;
+
+ // Use slug for SEO-friendly URLs, fallback to shortId for legacy articles
+ const articlePath = article.slug
+ ? `/${sourceSlug}/${article.slug}`
+ : `/${sourceSlug}/${article.shortId}`;
+
+ 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 SourceProfileContent;
diff --git a/app/(app)/[username]/_usernameClient.tsx b/app/(app)/[username]/_usernameClient.tsx
index 4aa03b51..1b417c76 100644
--- a/app/(app)/[username]/_usernameClient.tsx
+++ b/app/(app)/[username]/_usernameClient.tsx
@@ -3,7 +3,7 @@
import * as Sentry from "@sentry/nextjs";
import React from "react";
import Link from "next/link";
-import ArticlePreview from "@/components/ArticlePreview/ArticlePreview";
+import { UnifiedContentCard } from "@/components/UnifiedContentCard";
import { LinkIcon } from "@heroicons/react/20/solid";
import { api } from "@/server/trpc/react";
import { useRouter, useSearchParams } from "next/navigation";
@@ -16,11 +16,11 @@ type Props = {
isOwner: boolean;
profile: {
posts: {
- published: string | null;
+ publishedAt: string | null;
title: string;
- excerpt: string;
+ excerpt: string | null;
slug: string;
- readTimeMins: number;
+ readingTime: number | null;
id: string;
}[];
accountLocked: boolean;
@@ -127,36 +127,38 @@ const Profile = ({ profile, isOwner, session }: Props) => {
slug,
title,
excerpt,
- readTimeMins,
- published,
+ readingTime,
+ publishedAt,
id,
}) => {
- if (!published) return;
+ if (!publishedAt) return null;
return (
-
+
+
+ {isOwner && (
+
+ Edit
+
+ )}
+
);
},
)
diff --git a/app/(app)/[username]/page.tsx b/app/(app)/[username]/page.tsx
index c744b1e8..e8aeb4ac 100644
--- a/app/(app)/[username]/page.tsx
+++ b/app/(app)/[username]/page.tsx
@@ -1,9 +1,12 @@
import React from "react";
import { notFound } from "next/navigation";
import Content from "./_usernameClient";
+import SourceProfileContent from "./_sourceProfileClient";
import { getServerAuthSession } from "@/server/auth";
import { type Metadata } from "next";
import { db } from "@/server/db";
+import { feed_sources } from "@/server/db/schema";
+import { eq } from "drizzle-orm";
type Props = { params: Promise<{ username: string }> };
@@ -11,6 +14,7 @@ export async function generateMetadata(props: Props): Promise {
const params = await props.params;
const username = params.username;
+ // First check if it's a user
const profile = await db.query.user.findFirst({
columns: {
bio: true,
@@ -19,38 +23,58 @@ export async function generateMetadata(props: Props): Promise {
where: (users, { eq }) => eq(users.username, username),
});
- if (!profile) {
- notFound();
- }
-
- const { bio, name } = profile;
- const title = `${name || username} - Codú Profile | Codú - The Web Developer Community`;
- const description = `${name || username}'s profile on Codú. ${bio ? `Bio: ${bio}` : "View their posts and contributions."}`;
+ if (profile) {
+ const { bio, name } = profile;
+ const title = `${name || username} - Codú Profile | Codú - The Web Developer Community`;
+ const description = `${name || username}'s profile on Codú. ${bio ? `Bio: ${bio}` : "View their posts and contributions."}`;
- return {
- title,
- description,
- openGraph: {
- title,
- description,
- type: "profile",
- images: [
- {
- url: "/images/og/home-og.png",
- width: 1200,
- height: 630,
- alt: `${name || username}'s profile on Codú`,
- },
- ],
- siteName: "Codú",
- },
- twitter: {
- card: "summary_large_image",
+ return {
title,
description,
- images: ["/images/og/home-og.png"],
- },
- };
+ openGraph: {
+ title,
+ description,
+ type: "profile",
+ images: [
+ {
+ url: "/images/og/home-og.png",
+ width: 1200,
+ height: 630,
+ alt: `${name || username}'s profile on Codú`,
+ },
+ ],
+ siteName: "Codú",
+ },
+ twitter: {
+ card: "summary_large_image",
+ title,
+ description,
+ images: ["/images/og/home-og.png"],
+ },
+ };
+ }
+
+ // Check if it's a feed source
+ const source = await db.query.feed_sources.findFirst({
+ where: eq(feed_sources.slug, username),
+ });
+
+ if (source) {
+ 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,
+ },
+ };
+ }
+
+ // Neither user nor source found
+ return { title: "Profile Not Found" };
}
export default async function Page(props: {
@@ -63,6 +87,7 @@ export default async function Page(props: {
notFound();
}
+ // First check if it's a user
const profile = await db.query.user.findFirst({
columns: {
bio: true,
@@ -78,48 +103,53 @@ export default async function Page(props: {
title: true,
excerpt: true,
slug: true,
- readTimeMins: true,
- published: true,
+ readingTime: true,
+ publishedAt: true,
id: true,
},
- where: (posts, { isNotNull, and, lte }) =>
+ where: (posts, { eq, and, lte }) =>
and(
- isNotNull(posts.published),
- lte(posts.published, new Date().toISOString()),
+ eq(posts.status, "published"),
+ lte(posts.publishedAt, new Date().toISOString()),
),
- orderBy: (posts, { desc }) => [desc(posts.published)],
+ orderBy: (posts, { desc }) => [desc(posts.publishedAt)],
},
},
where: (users, { eq }) => eq(users.username, username),
});
- if (!profile) {
- notFound();
+ if (profile) {
+ const bannedUser = await db.query.banned_users.findFirst({
+ where: (bannedUsers, { eq }) => eq(bannedUsers.userId, profile.id),
+ });
+
+ const accountLocked = !!bannedUser;
+ const session = await getServerAuthSession();
+ const isOwner = session?.user?.id === profile.id;
+
+ const shapedProfile = {
+ ...profile,
+ posts: accountLocked ? [] : profile.posts,
+ accountLocked,
+ };
+
+ return (
+ <>
+ {`${shapedProfile.name || shapedProfile.username}'s Coding Profile`}
+
+ >
+ );
}
- const bannedUser = await db.query.banned_users.findFirst({
- where: (bannedUsers, { eq }) => eq(bannedUsers.userId, profile.id),
+ // Check if it's a feed source
+ const source = await db.query.feed_sources.findFirst({
+ where: eq(feed_sources.slug, username),
});
- const accountLocked = !!bannedUser;
- const session = await getServerAuthSession();
- const isOwner = session?.user?.id === profile.id;
-
- const shapedProfile = {
- ...profile,
- posts: accountLocked
- ? []
- : profile.posts.map((post) => ({
- ...post,
- published: post.published,
- })),
- accountLocked,
- };
-
- return (
- <>
- {`${shapedProfile.name || shapedProfile.username}'s Coding Profile`}
-
- >
- );
+ if (source) {
+ return ;
+ }
+
+ // Neither user nor source found
+ notFound();
}
diff --git a/app/(app)/admin/sources/_client.tsx b/app/(app)/admin/sources/_client.tsx
index 3b462b89..77b964aa 100644
--- a/app/(app)/admin/sources/_client.tsx
+++ b/app/(app)/admin/sources/_client.tsx
@@ -141,7 +141,8 @@ const AdminSourcesPage = () => {
};
const handleStatusToggle = (id: number, currentStatus: string) => {
- const newStatus = currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE";
+ // Status is now lowercase in the new schema, but UpdateFeedSourceSchema still expects uppercase
+ const newStatus = currentStatus === "active" ? "PAUSED" : "ACTIVE";
updateSource.mutate({ id, status: newStatus as "ACTIVE" | "PAUSED" | "ERROR" });
};
@@ -359,9 +360,9 @@ const AdminSourcesPage = () => {
handleStatusToggle(source.sourceId, source.status)}
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 dark:hover:bg-neutral-800 dark:hover:text-neutral-300"
- title={source.status === "ACTIVE" ? "Pause" : "Activate"}
+ title={source.status === "active" ? "Pause" : "Activate"}
>
- {source.status === "ACTIVE" ? (
+ {source.status === "active" ? (
) : (
diff --git a/app/(app)/advertise/_client.tsx b/app/(app)/advertise/_client.tsx
new file mode 100644
index 00000000..e9b83b28
--- /dev/null
+++ b/app/(app)/advertise/_client.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import {
+ HeroSection,
+ MetricsSection,
+ OfferingsSection,
+ SocialProofSection,
+ ContactSection,
+} from "@/components/Sponsorship";
+
+export function AdvertiseClient() {
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/(app)/advertise/page.tsx b/app/(app)/advertise/page.tsx
new file mode 100644
index 00000000..6e3fd36a
--- /dev/null
+++ b/app/(app)/advertise/page.tsx
@@ -0,0 +1,17 @@
+import type { Metadata } from "next";
+import { AdvertiseClient } from "./_client";
+
+export const metadata: Metadata = {
+ title: "Advertise with Codú - Reach Ireland's Developer Community",
+ description:
+ "Partner with Codú to reach 100,000+ monthly developer visits. Job postings, newsletter ads, event branding, and more. Connect with Ireland's largest web developer community.",
+ openGraph: {
+ title: "Advertise with Codú",
+ description:
+ "Connect your brand with Ireland's most engaged developer community. Sponsorship packages for job postings, newsletter advertising, and event branding.",
+ },
+};
+
+export default function AdvertisePage() {
+ return ;
+}
diff --git a/app/(app)/alpha/sponsorship/page.tsx b/app/(app)/alpha/sponsorship/page.tsx
deleted file mode 100644
index 82e4d0f3..00000000
--- a/app/(app)/alpha/sponsorship/page.tsx
+++ /dev/null
@@ -1,178 +0,0 @@
-"use client";
-
-import type { StaticImageData } from "next/image";
-import Image from "next/image";
-import Link from "next/link";
-import { useEffect } from "react";
-
-import pic1 from "@/public/images/sponsors/pic1.png";
-import pic2 from "@/public/images/sponsors/pic2.png";
-import pic3 from "@/public/images/sponsors/pic3.png";
-import pic4 from "@/public/images/sponsors/pic4.png";
-import pic5 from "@/public/images/sponsors/pic5.png";
-
-interface Image {
- rotate: number;
- src: StaticImageData;
- alt: string;
-}
-
-const images: Image[] = [
- {
- src: pic1,
- alt: "Audience watching a presentation",
- rotate: 6.56,
- },
- {
- src: pic2,
- alt: "Audience watching a presentation with the name 'Codú' on the screen",
- rotate: -3.57,
- },
- {
- src: pic3,
- alt: "Six people from Codú smiling at the camera",
- rotate: 4.58,
- },
- {
- src: pic4,
- alt: "Audience watching a presentation",
- rotate: -4.35,
- },
- {
- src: pic5,
- alt: "Audience smiling at the camera",
- rotate: 6.56,
- },
-];
-
-const Sponsorship = () => {
- useEffect(() => {
- function handleScroll() {
- document.body.style.setProperty("--scroll", String(window.scrollY));
- }
- window.addEventListener("scroll", handleScroll);
-
- return () => {
- window.removeEventListener("scroll", handleScroll);
- document.body.style.removeProperty("--scroll");
- };
- }, []);
-
- return (
- <>
-
-
-
- Become a{" "}
-
- Sponsor
-
-
-
- Reach thousands of developers every month!
-
-
-
- {images.map((image) => (
-
-
-
- ))}
-
-
-
-
- Trusted by brands both large and small
-
-
-
- Codú aims to create one of the largest coding communities
- globally. Your funds go directly towards building the community
- and a flourishing ecosystem.
-
-
- We offer opportunities to sponsor hackathons , monthly{" "}
- events , giveaways and online ad space .
-
-
-
- Contact us
- {" "}
- today to find out more.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Let us help you amplify your brand.
-
-
- Find out more
-
-
-
-
- >
- );
-};
-
-export default Sponsorship;
diff --git a/app/(app)/articles/[slug]/page.tsx b/app/(app)/articles/[slug]/page.tsx
index 87c0bc50..58a52372 100644
--- a/app/(app)/articles/[slug]/page.tsx
+++ b/app/(app)/articles/[slug]/page.tsx
@@ -1,191 +1,32 @@
-import React from "react";
-import type { RenderableTreeNode } from "@markdoc/markdoc";
-import Markdoc from "@markdoc/markdoc";
-import Link from "next/link";
-import BioBar from "@/components/BioBar/BioBar";
-import { markdocComponents } from "@/markdoc/components";
-import { config } from "@/markdoc/config";
-import CommentsArea from "@/components/Comments/CommentsArea";
-import { ArticleActionBarWrapper } from "@/components/ArticleActionBar";
-import { headers } from "next/headers";
-import { notFound } from "next/navigation";
-import { getServerAuthSession } from "@/server/auth";
-import ArticleAdminPanel from "@/components/ArticleAdminPanel/ArticleAdminPanel";
-import { type Metadata } from "next";
-import { getPost } from "@/server/lib/posts";
-import { getCamelCaseFromLower } from "@/utils/utils";
-import { generateHTML } from "@tiptap/core";
-import { RenderExtensions } from "@/components/editor/editor/extensions/render-extensions";
-import sanitizeHtml from "sanitize-html";
-import type { JSONContent } from "@tiptap/core";
-import NotFound from "@/components/NotFound/NotFound";
+import { permanentRedirect, notFound } from "next/navigation";
+import { db } from "@/server/db";
+import { post, user } from "@/server/db/schema";
+import { eq } from "drizzle-orm";
type Props = { params: Promise<{ slug: string }> };
-export async function generateMetadata(props: Props): Promise {
+// This page exists only to redirect from legacy /articles/[slug] URLs to the new /[username]/[slug] pattern
+export default async function ArticlePage(props: Props) {
const params = await props.params;
- const slug = params.slug;
-
- const post = await getPost({ slug });
-
- // @TODO revisit to give more defaults
- // @TODO can we parse article and give recommended tags?
- const tags = post?.tags.map((tag) => tag.tag.title);
-
- if (!post) return {};
- const host = (await headers()).get("host") || "";
- return {
- title: `${post.title} | by ${post.user.name} | Codú`,
- authors: {
- name: post.user.name,
- url: `https://www.${host}/${post.user.username}`,
- },
- keywords: tags,
- description: post.excerpt,
- openGraph: {
- description: post.excerpt,
- type: "article",
- images: [
- `/og?title=${encodeURIComponent(
- post.title,
- )}&readTime=${post.readTimeMins}&author=${encodeURIComponent(
- post.user.name,
- )}&date=${post.updatedAt}`,
- ],
- siteName: "Codú",
- },
- twitter: {
- description: post.excerpt,
- images: [`/og?title=${encodeURIComponent(post.title)}`],
- },
- alternates: {
- canonical: post.canonicalUrl,
- },
- };
-}
-
-const parseJSON = (str: string): JSONContent | null => {
- try {
- return JSON.parse(str);
- } catch (e) {
- return null;
- }
-};
-
-const renderSanitizedTiptapContent = (jsonContent: JSONContent) => {
- const rawHtml = generateHTML(jsonContent, [...RenderExtensions]);
- // Sanitize the HTML using sanitize-html (server-safe, no jsdom dependency)
- return sanitizeHtml(rawHtml, {
- allowedTags: sanitizeHtml.defaults.allowedTags.concat([
- "img",
- "iframe",
- "h1",
- "h2",
- ]),
- allowedAttributes: {
- ...sanitizeHtml.defaults.allowedAttributes,
- img: ["src", "alt", "title", "width", "height", "class"],
- iframe: ["src", "width", "height", "frameborder", "allowfullscreen"],
- "*": ["class", "id", "style"],
- },
- allowedIframeHostnames: [
- "www.youtube.com",
- "youtube.com",
- "www.youtube-nocookie.com",
- ],
- });
-};
-
-const ArticlePage = async (props: Props) => {
- const params = await props.params;
- const session = await getServerAuthSession();
const { slug } = params;
- const host = (await headers()).get("host") || "";
-
- const post = await getPost({ slug });
-
- if (!post) {
+ // Look up the post and its author
+ const postRecord = await db
+ .select({
+ slug: post.slug,
+ username: user.username,
+ })
+ .from(post)
+ .leftJoin(user, eq(post.userId, user.id))
+ .where(eq(post.slug, slug))
+ .limit(1);
+
+ if (!postRecord.length || !postRecord[0].username) {
return notFound();
}
- const parsedBody = parseJSON(post.body);
- const isTiptapContent = parsedBody?.type === "doc";
-
- let renderedContent: string | RenderableTreeNode;
-
- if (isTiptapContent && parsedBody) {
- const jsonContent = parsedBody;
- renderedContent = renderSanitizedTiptapContent(jsonContent);
- } else {
- const ast = Markdoc.parse(post.body);
- const transformedContent = Markdoc.transform(ast, config);
- renderedContent = Markdoc.renderers.react(transformedContent, React, {
- components: markdocComponents,
- }) as unknown as string;
- }
-
- return (
- <>
-
-
- {!isTiptapContent && {post.title} }
+ const { username } = postRecord[0];
- {isTiptapContent ? (
-
,
- }}
- className="tiptap-content"
- />
- ) : (
-
- {Markdoc.renderers.react(renderedContent, React, {
- components: markdocComponents,
- })}
-
- )}
-
- {post.tags.length > 0 && (
-
- {post.tags.map(({ tag }) => (
-
- {getCamelCaseFromLower(tag.title)}
-
- ))}
-
- )}
-
-
-
-
-
-
- {session && session?.user?.role === "ADMIN" && (
-
- )}
- >
- );
-};
-
-export default ArticlePage;
+ // Permanent redirect (308) to the new URL pattern
+ permanentRedirect(`/${username}/${slug}`);
+}
diff --git a/app/(app)/articles/_client.tsx b/app/(app)/articles/_client.tsx
index fd101bdb..ef9b685b 100644
--- a/app/(app)/articles/_client.tsx
+++ b/app/(app)/articles/_client.tsx
@@ -30,6 +30,7 @@ import CoduChallenge from "@/components/CoduChallenge/CoduChallenge";
import { toast } from "sonner";
import * as Sentry from "@sentry/nextjs";
import { FeedFilters } from "@/components/Feed";
+import { useReportModal } from "@/components/ReportModal/ReportModal";
// Get relative time string
const getRelativeTime = (dateStr: string): string => {
@@ -60,7 +61,7 @@ type ArticleCardProps = {
readTime: number;
upvotes: number;
downvotes: number;
- userVote: "UP" | "DOWN" | null;
+ userVote: "up" | "down" | null;
isBookmarked: boolean;
discussionCount?: number;
};
@@ -86,6 +87,7 @@ const ArticleCard = ({
const [userVote, setUserVote] = useState(initialUserVote);
const [votes, setVotes] = useState({ upvotes, downvotes });
const [isBookmarked, setIsBookmarked] = useState(initialBookmarked);
+ const { openReport } = useReportModal();
const { mutate: vote, status: voteStatus } = api.post.vote.useMutation({
onMutate: async ({ voteType }) => {
@@ -98,12 +100,12 @@ const ArticleCard = ({
let newDownvotes = prev.downvotes;
// Remove old vote
- if (oldVote === "UP") newUpvotes--;
- if (oldVote === "DOWN") newDownvotes--;
+ if (oldVote === "up") newUpvotes--;
+ if (oldVote === "down") newDownvotes--;
// Add new vote
- if (voteType === "UP") newUpvotes++;
- if (voteType === "DOWN") newDownvotes++;
+ if (voteType === "up") newUpvotes++;
+ if (voteType === "down") newDownvotes++;
return { upvotes: newUpvotes, downvotes: newDownvotes };
});
@@ -135,7 +137,7 @@ const ArticleCard = ({
},
});
- const handleVote = (voteType: "UP" | "DOWN" | null) => {
+ const handleVote = (voteType: "up" | "down" | null) => {
if (!session) {
signIn();
return;
@@ -152,7 +154,7 @@ const ArticleCard = ({
};
const handleShare = async () => {
- const shareUrl = `${window.location.origin}/articles/${slug}`;
+ const shareUrl = `${window.location.origin}/${username}/${slug}`;
try {
await navigator.clipboard.writeText(shareUrl);
toast.success("Link copied to clipboard");
@@ -161,6 +163,14 @@ const ArticleCard = ({
}
};
+ const handleReport = () => {
+ if (!session) {
+ signIn();
+ return;
+ }
+ openReport("post", id);
+ };
+
const relativeTime = getRelativeTime(date);
const score = votes.upvotes - votes.downvotes;
@@ -192,7 +202,7 @@ const ArticleCard = ({
{/* Title */}
{title}
@@ -211,10 +221,10 @@ const ArticleCard = ({
{/* Vote buttons */}
handleVote(userVote === "UP" ? null : "UP")}
+ onClick={() => handleVote(userVote === "up" ? null : "up")}
disabled={voteStatus === "pending"}
className={`rounded-l-full p-1 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
- userVote === "UP"
+ userVote === "up"
? "text-green-500"
: "text-neutral-400 dark:text-neutral-500"
}`}
@@ -234,10 +244,10 @@ const ArticleCard = ({
{score}
handleVote(userVote === "DOWN" ? null : "DOWN")}
+ onClick={() => handleVote(userVote === "down" ? null : "down")}
disabled={voteStatus === "pending"}
className={`rounded-r-full p-1 transition-colors hover:bg-neutral-100 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-800 ${
- userVote === "DOWN"
+ userVote === "down"
? "text-red-500"
: "text-neutral-400 dark:text-neutral-500"
}`}
@@ -249,7 +259,7 @@ const ArticleCard = ({
{/* Comments button */}
@@ -309,6 +319,14 @@ const ArticleCard = ({
Copy link
+
+
+ Report
+
+
@@ -480,7 +498,7 @@ const ArticlesPage = () => {
username={user?.username || ""}
image={user?.image || ""}
date={published}
- readTime={readTimeMins}
+ readTime={readTimeMins ?? 0}
upvotes={upvotes ?? 0}
downvotes={downvotes ?? 0}
userVote={userVote ?? null}
diff --git a/app/(app)/articles/page.tsx b/app/(app)/articles/page.tsx
index d106775c..d26d6e34 100644
--- a/app/(app)/articles/page.tsx
+++ b/app/(app)/articles/page.tsx
@@ -1,29 +1,7 @@
-import Content from "./_client";
+import { redirect } from "next/navigation";
-// @TODO - Add custom image for this page
-export const metadata = {
- title: "Codú - Read Our Web Developer Articles",
- description:
- "Codú is an open-source web developer community and blogging platform where readers can learn, and where writers can teach.",
- keywords: [
- "programming",
- "frontend",
- "community",
- "learn",
- "programmer",
- "article",
- "Python",
- "JavaScript",
- "AWS",
- "HTML",
- "CSS",
- "Tailwind",
- "React",
- "blog",
- "backend",
- ],
-};
-
-export default async function Page() {
- return ;
+// Redirect /articles to /feed?type=article
+// The unified feed now handles all content types with filtering
+export default function Page() {
+ redirect("/feed?type=article");
}
diff --git a/app/(app)/feed/[sourceSlug]/[shortId]/_client.tsx b/app/(app)/feed/[sourceSlug]/[shortId]/_client.tsx
index 2cdaa671..32cc645e 100644
--- a/app/(app)/feed/[sourceSlug]/[shortId]/_client.tsx
+++ b/app/(app)/feed/[sourceSlug]/[shortId]/_client.tsx
@@ -1,40 +1,22 @@
"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 { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid";
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";
+import {
+ ContentDetailLayout,
+ ContentTypeBadge,
+ ContentMetaHeader,
+ UnifiedActionBar,
+ SourceInfoCard,
+} from "@/components/ContentDetail";
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 {
@@ -55,76 +37,19 @@ const ensureHttps = (url: string | null | undefined): string | null => {
};
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 },
+ api.discussion.getContentDiscussionCount.useQuery(
+ { contentId: article?.id ?? "" },
{ 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 });
@@ -157,220 +82,125 @@ const FeedArticlePage = ({ sourceSlug, shortId }: Props) => {
- Article Not Found
+ Post Not Found
- This article may have been removed or the link is invalid.
+ This post 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;
+ const hostname = article.externalUrl ? getHostname(article.externalUrl) : null;
+ const shareUrl = typeof window !== "undefined"
+ ? `${window.location.origin}/feed/${sourceSlug}/${shortId}`
+ : `/feed/${sourceSlug}/${shortId}`;
return (
-
- {/* Breadcrumb */}
-
-
- Feed
-
- /
-
- {article.source?.name || sourceSlug}
-
-
-
- {/* 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 && (
- <>
-
·
-
{readableDate}
- >
- )}
-
-
- {/* Title */}
-
- {article.title}
-
-
- {/* Excerpt */}
- {article.excerpt && (
-
- {article.excerpt}
-
- )}
+
+ }
+ sideInfo={
+ article.source && (
+
+ )
+ }
+ discussion={
+
+ }
+ >
+ {/* Content type badge */}
+
+
+
- {/* Thumbnail image */}
- {(ensureHttps(article.ogImageUrl) || ensureHttps(article.imageUrl)) && (
-
-
-
-
- )}
+ {/* Source/author info */}
+
+
+ {/* Title */}
+
+ {article.title}
+
+
+ {/* Excerpt */}
+ {article.excerpt && (
+
+ {article.excerpt}
+
+ )}
+
+ {/* Thumbnail image */}
+ {ensureHttps(article.imageUrl) && (
+
+
+
+
+ )}
- {/* Read article CTA */}
+ {/* Read article CTA */}
+ {article.externalUrl && (
Read Full Article at {hostname}
-
- {/* Action bar */}
-
- {/* Vote buttons */}
-
-
- handleVote(article.userVote === "UP" ? null : "UP")
- }
- disabled={voteStatus === "pending"}
- className={`rounded-l-full p-2 transition-colors hover:bg-neutral-200 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-700 ${
- article.userVote === "UP"
- ? "text-green-500"
- : "text-neutral-500 dark:text-neutral-400"
- }`}
- aria-label="Upvote"
- >
-
-
- 0
- ? "text-green-500"
- : score < 0
- ? "text-red-500"
- : "text-neutral-500 dark:text-neutral-400"
- }`}
- >
- {score}
-
-
- handleVote(article.userVote === "DOWN" ? null : "DOWN")
- }
- disabled={voteStatus === "pending"}
- className={`rounded-r-full p-2 transition-colors hover:bg-neutral-200 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-neutral-700 ${
- article.userVote === "DOWN"
- ? "text-red-500"
- : "text-neutral-500 dark:text-neutral-400"
- }`}
- aria-label="Downvote"
- >
-
-
-
-
- {/* Comments count */}
-
-
- {discussionCount ?? 0} comments
-
-
- {/* Save button */}
-
- {article.isBookmarked ? (
-
- ) : (
-
- )}
- {article.isBookmarked ? "Saved" : "Save"}
-
-
- {/* Share button */}
-
-
- Share
-
-
-
-
- {/* Discussion section */}
-
-
+ )}
+
);
};
diff --git a/app/(app)/feed/[sourceSlug]/[shortId]/page.tsx b/app/(app)/feed/[sourceSlug]/[shortId]/page.tsx
index a50ca338..6fa1fbc1 100644
--- a/app/(app)/feed/[sourceSlug]/[shortId]/page.tsx
+++ b/app/(app)/feed/[sourceSlug]/[shortId]/page.tsx
@@ -1,6 +1,6 @@
import { notFound } from "next/navigation";
import { db } from "@/server/db";
-import { aggregated_article, feed_source } from "@/server/db/schema";
+import { posts, feed_sources } from "@/server/db/schema";
import { eq, and } from "drizzle-orm";
import type { Metadata } from "next";
import FeedArticlePage from "./_client";
@@ -13,36 +13,36 @@ 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),
+ const source = await db.query.feed_sources.findFirst({
+ where: eq(feed_sources.slug, sourceSlug),
});
if (!source) {
- return { title: "Article Not Found" };
+ return { title: "Post Not Found" };
}
- // Find the article by shortId and sourceId
- const article = await db.query.aggregated_article.findFirst({
+ // Find the post by slug (shortId is part of the slug) and sourceId
+ const post = await db.query.posts.findFirst({
where: and(
- eq(aggregated_article.shortId, shortId),
- eq(aggregated_article.sourceId, source.id),
+ eq(posts.sourceId, source.id),
+ eq(posts.type, "link"),
),
with: {
source: true,
},
});
- if (!article) {
- return { title: "Article Not Found" };
+ if (!post) {
+ return { title: "Post Not Found" };
}
return {
- title: `${article.title} | Codú Feed`,
- description: article.excerpt || `Discussion about ${article.title}`,
+ title: `${post.title} | Codú Feed`,
+ description: post.excerpt || `Discussion about ${post.title}`,
openGraph: {
- title: article.title,
- description: article.excerpt || `Discussion about ${article.title}`,
- images: article.ogImageUrl || article.imageUrl ? [article.ogImageUrl || article.imageUrl!] : undefined,
+ title: post.title,
+ description: post.excerpt || `Discussion about ${post.title}`,
+ images: post.coverImage ? [post.coverImage] : undefined,
},
};
}
@@ -51,23 +51,23 @@ 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),
+ const source = await db.query.feed_sources.findFirst({
+ where: eq(feed_sources.slug, sourceSlug),
});
if (!source) {
notFound();
}
- // Verify article exists
- const article = await db.query.aggregated_article.findFirst({
+ // Verify post exists - the shortId is part of the slug
+ const post = await db.query.posts.findFirst({
where: and(
- eq(aggregated_article.shortId, shortId),
- eq(aggregated_article.sourceId, source.id),
+ eq(posts.sourceId, source.id),
+ eq(posts.type, "link"),
),
});
- if (!article) {
+ if (!post) {
notFound();
}
diff --git a/app/(app)/feed/[sourceSlug]/_client.tsx b/app/(app)/feed/[sourceSlug]/_client.tsx
index 5f5e74f7..abe562cf 100644
--- a/app/(app)/feed/[sourceSlug]/_client.tsx
+++ b/app/(app)/feed/[sourceSlug]/_client.tsx
@@ -47,7 +47,7 @@ const SourceProfilePage = ({ sourceSlug }: Props) => {
hasNextPage,
isFetchingNextPage,
} = api.feed.getArticlesBySource.useInfiniteQuery(
- { sourceSlug, sort, limit: 20 },
+ { sourceSlug, sort, limit: 25 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
},
diff --git a/app/(app)/feed/_client.tsx b/app/(app)/feed/_client.tsx
index d485e1d9..316eeab1 100644
--- a/app/(app)/feed/_client.tsx
+++ b/app/(app)/feed/_client.tsx
@@ -5,14 +5,16 @@ 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";
+import { FeedItemLoading, FeedFilters } from "@/components/Feed";
+import { UnifiedContentCard } from "@/components/UnifiedContentCard";
+import { SavedItemCard } from "@/components/SavedItemCard";
type SortOption = "recent" | "trending" | "popular";
+type ContentType = "ARTICLE" | "LINK" | "QUESTION" | "VIDEO" | "DISCUSSION" | null;
+
const validSorts: SortOption[] = ["recent", "trending", "popular"];
+// Lowercase type values for URL params (converted to uppercase for API)
+const validTypesLower: string[] = ["article", "link", "question", "video", "discussion"];
const FeedPage = () => {
const searchParams = useSearchParams();
@@ -22,6 +24,7 @@ const FeedPage = () => {
// Get filter params from URL
const sortParam = searchParams?.get("sort");
const categoryParam = searchParams?.get("category");
+ const typeParam = searchParams?.get("type")?.toLowerCase();
// Validate sort param
const sort: SortOption = validSorts.includes(sortParam as SortOption)
@@ -30,14 +33,19 @@ const FeedPage = () => {
const category = typeof categoryParam === "string" ? categoryParam : null;
- // Fetch feed data with infinite scroll
+ // Validate type param (URL uses lowercase, API uses uppercase)
+ const type: ContentType = validTypesLower.includes(typeParam || "")
+ ? (typeParam?.toUpperCase() as ContentType)
+ : null;
+
+ // Fetch feed data with infinite scroll using the unified content API
const { status, data, isFetchingNextPage, fetchNextPage, hasNextPage } =
- api.feed.getFeed.useInfiniteQuery(
+ api.content.getFeed.useInfiniteQuery(
{
- limit: 20,
+ limit: 25,
sort,
+ type,
category,
- includeCommunity: false,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
@@ -45,7 +53,7 @@ const FeedPage = () => {
);
// Fetch categories for filter dropdown
- const { data: categoriesData } = api.feed.getCategories.useQuery();
+ const { data: categoriesData } = api.content.getCategories.useQuery();
// Intersection observer for infinite scroll
const { ref, inView } = useInView();
@@ -69,12 +77,23 @@ const FeedPage = () => {
const params = new URLSearchParams();
if (sort !== "recent") params.set("sort", sort);
if (newCategory) params.set("category", newCategory);
+ if (type) params.set("type", type);
+ const queryString = params.toString();
+ router.push(`/feed${queryString ? `?${queryString}` : ""}`);
+ };
+
+ const handleTypeChange = (newType: ContentType) => {
+ const params = new URLSearchParams();
+ if (sort !== "recent") params.set("sort", sort);
+ if (category) params.set("category", category);
+ // Use lowercase in URL params for cleaner URLs
+ if (newType) params.set("type", newType.toLowerCase());
const queryString = params.toString();
router.push(`/feed${queryString ? `?${queryString}` : ""}`);
};
return (
-
+
{/* Header */}
@@ -82,10 +101,13 @@ const FeedPage = () => {
@@ -106,34 +128,51 @@ const FeedPage = () => {
{status === "success" &&
data.pages.map((page, pageIndex) => (
- {page.articles.map((article) => (
- (
+
))}
))}
- {status === "success" && !data.pages[0].articles.length && (
+ {status === "success" && !data.pages[0].items.length && (
- No articles yet
+ No content yet
Check back soon for curated developer content.
@@ -198,13 +237,13 @@ const FeedPage = () => {
)}
-
+
);
};
// Component to show saved articles preview in sidebar
const SavedArticlesPreview = () => {
- const { data, status } = api.feed.mySavedArticles.useQuery();
+ const { data, status } = api.post.myBookmarks.useQuery({ limit: 5 });
if (status === "pending") {
return (
@@ -219,7 +258,7 @@ const SavedArticlesPreview = () => {
);
}
- if (status === "error" || !data?.length) {
+ if (status === "error" || !data?.items?.length) {
return (
No saved articles yet. Save articles to read them later!
@@ -227,30 +266,35 @@ const SavedArticlesPreview = () => {
);
}
+ // Map DB type to frontend type
+ const toFrontendType = (dbType: string | null): "POST" | "LINK" => {
+ if (dbType === "article") return "POST";
+ return "LINK";
+ };
+
return (
diff --git a/app/(app)/my-posts/_client.tsx b/app/(app)/my-posts/_client.tsx
index f70e979c..4bb61123 100644
--- a/app/(app)/my-posts/_client.tsx
+++ b/app/(app)/my-posts/_client.tsx
@@ -42,15 +42,15 @@ const MyPosts = () => {
const [selectedArticleToDelete, setSelectedArticleToDelete] =
useState();
- const drafts = api.post.myDrafts.useQuery();
- const scheduled = api.post.myScheduled.useQuery();
- const published = api.post.myPublished.useQuery();
+ const drafts = api.content.myDrafts.useQuery({});
+ const scheduled = api.content.myScheduled.useQuery({});
+ const publishedContent = api.content.myPublished.useQuery({});
- const { mutate, status: deleteStatus } = api.post.delete.useMutation({
+ const { mutate, status: deleteStatus } = api.content.delete.useMutation({
onSuccess() {
setSelectedArticleToDelete(undefined);
drafts.refetch();
- published.refetch();
+ publishedContent.refetch();
},
});
@@ -83,8 +83,8 @@ const MyPosts = () => {
name: "Published",
href: `?tab=${PUBLISHED}`,
value: PUBLISHED,
- data: published.data,
- status: published.status,
+ data: publishedContent.data,
+ status: publishedContent.status,
current: selectedTab === PUBLISHED,
},
];
@@ -128,13 +128,12 @@ const MyPosts = () => {
id,
title,
excerpt,
- readTimeMins,
slug,
- published,
+ publishedAt,
updatedAt,
}) => {
- const postStatus = published
- ? getPostStatus(new Date(published))
+ const postStatus = publishedAt
+ ? getPostStatus(new Date(publishedAt))
: status.DRAFT;
return (
{
{excerpt || "No excerpt yet... Write more to see one."}
-
- Read time so far: {readTimeMins} mins
-
- {published && postStatus === status.SCHEDULED ? (
+ {publishedAt && postStatus === status.SCHEDULED ? (
<>
- {renderDate("Scheduled to publish on ", published)}
+ {renderDate("Scheduled to publish on ", publishedAt)}
>
- ) : published && postStatus === status.PUBLISHED ? (
+ ) : publishedAt && postStatus === status.PUBLISHED ? (
<>
- {/*If updatedAt is greater than published by more than on minutes show updated at else show published
- as on updating published updatedAt is automatically updated and is greater than published*/}
- {new Date(updatedAt).getTime() -
- new Date(published).getTime() >=
+ {/*If updatedAt is greater than publishedAt by more than one minute show updated at else show publishedAt
+ as on updating publishedAt updatedAt is automatically updated and is greater than publishedAt*/}
+ {updatedAt && new Date(updatedAt).getTime() -
+ new Date(publishedAt).getTime() >=
60000 ? (
<>{renderDate("Last updated on ", updatedAt)}>
) : (
- <>{renderDate("Published on ", published)}>
+ <>{renderDate("Published on ", publishedAt)}>
)}
>
) : postStatus === status.DRAFT ? (
- <>{renderDate("Last updated on ", updatedAt)}>
+ <>{updatedAt && renderDate("Last updated on ", updatedAt)}>
) : null}
diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx
index c539a68e..848dfca6 100644
--- a/app/(app)/page.tsx
+++ b/app/(app)/page.tsx
@@ -13,7 +13,7 @@ const Home = async () => {
const session = await getServerAuthSession();
return (
- <>
+
{session == null && (
@@ -29,10 +29,10 @@ const Home = async () => {
Get started
- Learn more →
+ Browse feed →
@@ -68,7 +68,7 @@ const Home = async () => {
- >
+
);
};
diff --git a/app/(app)/saved/_client.tsx b/app/(app)/saved/_client.tsx
index 7a5a68c0..0d63cea4 100644
--- a/app/(app)/saved/_client.tsx
+++ b/app/(app)/saved/_client.tsx
@@ -1,17 +1,22 @@
"use client";
-import ArticlePreview from "@/components/ArticlePreview/ArticlePreview";
import { api } from "@/server/trpc/react";
import PageHeading from "@/components/PageHeading/PageHeading";
-import ArticleLoading from "@/components/ArticlePreview/ArticleLoading";
+import { SavedItemCard } from "@/components/SavedItemCard";
+
+// Map DB type to frontend type
+const toFrontendType = (dbType: string | null): "POST" | "LINK" => {
+ if (dbType === "article") return "POST";
+ return "LINK";
+};
const SavedPosts = () => {
const {
data: bookmarksData,
refetch,
status: bookmarkStatus,
- } = api.post.myBookmarks.useQuery({});
- const bookmarks = bookmarksData?.bookmarks || [];
+ } = api.post.myBookmarks.useQuery({ limit: 100 });
+ const bookmarks = bookmarksData?.items || [];
const { mutate: bookmark } = api.post.bookmark.useMutation({
onSettled() {
@@ -30,9 +35,14 @@ const SavedPosts = () => {
return (
Saved items
-
+
{bookmarkStatus === "pending" &&
- Array.from({ length: 7 }, (_, i) =>
)}
+ Array.from({ length: 7 }, (_, i) => (
+
+ ))}
{bookmarkStatus === "error" && (
Something went wrong fetching your saved posts... Refresh the page.
@@ -40,40 +50,22 @@ const SavedPosts = () => {
)}
{bookmarkStatus === "success" &&
- bookmarks.map(
- ({
- id,
- slug,
- title,
- excerpt,
- user: { name, image, username },
- updatedAt,
- readTimeMins,
- }) => {
- return (
- removeSavedItem(id),
- },
- ]}
- />
- );
- },
- )}
+ bookmarks.map((item) => (
+ removeSavedItem(item.id)}
+ />
+ ))}
{bookmarkStatus === "success" && bookmarks?.length === 0 && (
diff --git a/app/(app)/sponsorship/page.tsx b/app/(app)/sponsorship/page.tsx
index ecdc46b2..20a4a126 100644
--- a/app/(app)/sponsorship/page.tsx
+++ b/app/(app)/sponsorship/page.tsx
@@ -1,133 +1,5 @@
-import Link from "next/link";
+import { redirect } from "next/navigation";
-export const metadata = {
- title: "Sponsor Codú - And Reach More Developers!",
- description:
- "The largest JavaScript and web developer community in Ireland! Reach thousands of developers in Ireland and beyond.",
-};
-
-const Sponsorship = () => {
- return (
- <>
-
-
-
-
-
- Support us
-
-
- Sponsor Codú
-
-
- Our work and events would not be possible without the support of
- our partners.
-
-
- {`Codú is the perfect place to show your company's support of open source software, find new developers and fund the next generation of avid learners.`}
-
-
- {`Sponsors can post jobs to our network of thousands of developers (and growing), brand at our events, and advertise in our newsletter.`}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Previous sponsors include
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- );
-};
-
-export default Sponsorship;
+export default function SponsorshipRedirect() {
+ redirect("/advertise");
+}
diff --git a/app/(editor)/create/[[...paramsArr]]/_client.tsx b/app/(editor)/create/[[...paramsArr]]/_client.tsx
index 24adf097..d0df41f4 100644
--- a/app/(editor)/create/[[...paramsArr]]/_client.tsx
+++ b/app/(editor)/create/[[...paramsArr]]/_client.tsx
@@ -12,8 +12,8 @@ import {
Transition,
} from "@headlessui/react";
import { ChevronUpIcon } from "@heroicons/react/20/solid";
-import type { SavePostInput } from "@/schema/post";
-import { ConfirmPostSchema } from "@/schema/post";
+import type { UpdateContentInput } from "@/schema/content";
+import { ConfirmContentSchema } from "@/schema/content";
import { api } from "@/server/trpc/react";
import { removeMarkdown } from "@/utils/removeMarkdown";
import { useDebounce } from "@/hooks/useDebounce";
@@ -122,6 +122,17 @@ const Create = ({ session }: { session: Session | null }) => {
useMarkdownHotkeys(textareaRef);
useMarkdownShortcuts(textareaRef);
+ // Form input type for the editor
+ type EditorFormInput = {
+ id?: string;
+ title: string;
+ body: string;
+ excerpt?: string;
+ canonicalUrl?: string;
+ published?: string;
+ tags?: string[];
+ };
+
const {
handleSubmit,
register,
@@ -131,7 +142,7 @@ const Create = ({ session }: { session: Session | null }) => {
formState: { isDirty, errors },
setError,
clearErrors,
- } = useForm({
+ } = useForm({
mode: "onSubmit",
defaultValues: {
title: "",
@@ -147,14 +158,14 @@ const Create = ({ session }: { session: Session | null }) => {
mutate: publish,
status: publishStatus,
data: publishData,
- } = api.post.publish.useMutation({
+ } = api.content.publish.useMutation({
onError(error) {
toast.error("Error saving settings.");
Sentry.captureException(error);
},
});
- const { mutate: save, status: saveStatus } = api.post.update.useMutation({
+ const { mutate: save, status: saveStatus } = api.content.update.useMutation({
onError(error) {
// TODO: Add error messages from field validations
toast.error("Error auto-saving");
@@ -166,15 +177,14 @@ const Create = ({ session }: { session: Session | null }) => {
data: createData,
isError,
isSuccess,
- } = api.post.create.useMutation();
+ } = api.content.create.useMutation();
- // TODO get rid of this for standard get post
- // Should be allowed get draft post through regular mechanism if you own it
+ // Fetch user's own content for editing
const {
data,
status: dataStatus,
isError: draftFetchError,
- } = api.post.editDraft.useQuery(
+ } = api.content.editDraft.useQuery(
{ id: postId },
{
enabled: !!postId && shouldRefetch,
@@ -223,12 +233,28 @@ const Create = ({ session }: { session: Session | null }) => {
const savePost = async () => {
const formData = getFormData();
- // Don't include published time when saving post, handle separately in onSubmit
- delete formData.published;
+ // Don't include published time when saving content, handle separately in onSubmit
+ const { published: _published, ...saveData } = formData;
if (!formData.id) {
- await create({ ...formData });
+ // Create new content as ARTICLE type
+ await create({
+ type: "POST",
+ title: saveData.title,
+ body: saveData.body,
+ excerpt: saveData.excerpt,
+ canonicalUrl: saveData.canonicalUrl,
+ tags: saveData.tags,
+ published: false,
+ });
} else {
- await save({ ...formData, id: postId });
+ await save({
+ id: postId,
+ title: saveData.title,
+ body: saveData.body,
+ excerpt: saveData.excerpt,
+ canonicalUrl: saveData.canonicalUrl,
+ tags: saveData.tags,
+ });
setSavedTime(
new Date().toLocaleString(undefined, {
dateStyle: "medium",
@@ -244,11 +270,11 @@ const Create = ({ session }: { session: Session | null }) => {
saveStatus === "pending" ||
dataStatus === "pending";
- const currentPostStatus = data?.published
- ? getPostStatus(new Date(data.published))
+ const currentPostStatus = data?.publishedAt
+ ? getPostStatus(new Date(data.publishedAt))
: status.DRAFT;
- const onSubmit = async (inputData: SavePostInput) => {
+ const onSubmit = async (inputData: EditorFormInput) => {
// validate markdoc syntax
const ast = Markdoc.parse(inputData.body);
const errors = Markdoc.validate(ast, config).filter(
@@ -266,15 +292,15 @@ const Create = ({ session }: { session: Session | null }) => {
await savePost();
if (currentPostStatus === status.PUBLISHED) {
- if (data) {
- router.push(`/articles/${data.slug}`);
+ if (data && session?.user?.username) {
+ router.push(`/${session.user.username}/${data.slug}`);
}
return;
}
try {
const formData = getFormData();
- ConfirmPostSchema.parse(formData);
+ ConfirmContentSchema.parse(formData);
await publish({
id: postId,
published: true,
@@ -332,20 +358,20 @@ const Create = ({ session }: { session: Session | null }) => {
useEffect(() => {
if (!data) return;
- const { body, excerpt, title, id, tags, published } = data;
- setTags(tags.map(({ tag }) => tag.title));
+ const { body, excerpt, title, id, tags, publishedAt } = data;
+ setTags(tags.map(({ tag }) => tag.title.toUpperCase()));
reset({
- body,
- excerpt,
- title,
+ body: body || "",
+ excerpt: excerpt || "",
+ title: title || "",
id,
- published: published ? published : undefined,
+ published: publishedAt ? publishedAt : undefined,
});
- setIsPostScheduled(published ? new Date(published) > new Date() : false);
+ setIsPostScheduled(publishedAt ? new Date(publishedAt) > new Date() : false);
setPostStatus(
- published ? getPostStatus(new Date(published)) : status.DRAFT,
+ publishedAt ? getPostStatus(new Date(publishedAt)) : status.DRAFT,
);
- }, [data]);
+ }, [data, reset]);
useEffect(() => {
if ((title + body).length < 5) {
@@ -377,14 +403,14 @@ const Create = ({ session }: { session: Session | null }) => {
}, [title, body]);
useEffect(() => {
- if (publishStatus === "success" && publishData?.slug) {
+ if (publishStatus === "success" && publishData?.slug && session?.user?.username) {
if (isPostScheduled) {
router.push("/my-posts?tab=scheduled");
} else {
- router.push(`/articles/${publishData.slug}`);
+ router.push(`/${session.user.username}/${publishData.slug}`);
}
}
- }, [publishStatus, publishData, isPostScheduled, router]);
+ }, [publishStatus, publishData, isPostScheduled, router, session?.user?.username]);
const handlePublish = () => {
if (isDisabled) return;
@@ -490,8 +516,8 @@ const Create = ({ session }: { session: Session | null }) => {
{data &&
- (data.published === null ||
- new Date(data.published) > new Date()) && (
+ (data.publishedAt === null ||
+ new Date(data.publishedAt) > new Date()) && (
Date: Tue, 6 Jan 2026 07:42:04 +0000
Subject: [PATCH 15/38] feat(utils): add content sync script and sponsor email
template
Add sync-content-table script for migrating data to unified content
table, useCreateContent hook, and sponsor inquiry email template.
---
hooks/useCreateContent.ts | 340 +++++++++++++++++++++
hooks/useCreatePage.ts | 14 +-
scripts/fetch-rss.ts | 178 +++++++++--
scripts/sync-content-table.ts | 329 ++++++++++++++++++++
utils/createSponsorInquiryEmailTemplate.ts | 106 +++++++
5 files changed, 932 insertions(+), 35 deletions(-)
create mode 100644 hooks/useCreateContent.ts
create mode 100644 scripts/sync-content-table.ts
create mode 100644 utils/createSponsorInquiryEmailTemplate.ts
diff --git a/hooks/useCreateContent.ts b/hooks/useCreateContent.ts
new file mode 100644
index 00000000..70580718
--- /dev/null
+++ b/hooks/useCreateContent.ts
@@ -0,0 +1,340 @@
+"use client";
+
+import { ZodError } from "zod";
+import { useState, useEffect } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+
+import { ConfirmContentSchema } from "../schema/content";
+import type { UpdateContentInput } from "../schema/content";
+
+import { api } from "@/server/trpc/react";
+import { useDebounce } from "./useDebounce";
+import { redirect, useRouter } from "next/navigation";
+import { useSession } from "next-auth/react";
+
+export type SaveContentInput = {
+ id?: string;
+ title: string;
+ body: string;
+ excerpt?: string;
+ canonicalUrl?: string;
+};
+
+export type useCreateContentReturnType = {
+ tags: string[];
+ setTags: React.Dispatch>;
+ tagValue: string;
+ setTagValue: React.Dispatch>;
+ savedTime: string;
+ setSavedTime: React.Dispatch>;
+ open: boolean;
+ setOpen: React.Dispatch>;
+ hasUnsavedChanges: boolean;
+ setHasUnsavedChanges: React.Dispatch>;
+ allowUpdate: boolean;
+ handleSubmit: ReturnType>["handleSubmit"];
+ register: ReturnType>["register"];
+ watch: ReturnType>["watch"];
+ reset: ReturnType>["reset"];
+ getValues: ReturnType>["getValues"];
+ control: ReturnType>["control"];
+ trigger: ReturnType>["trigger"];
+ isDirty: boolean;
+ saveContent: () => Promise;
+ debouncedValue: string;
+ hasContent: boolean;
+ isDisabled: boolean;
+ onSubmit: (data: SaveContentInput) => Promise;
+ onChange: (e: React.ChangeEvent) => void;
+ onDelete: (tag: string) => void;
+ onKeyDown: (e: React.KeyboardEvent) => void;
+ handleOpenDialog: (res: string) => void;
+ data: ReturnType["data"];
+ hasLoadingState: boolean;
+ dataStatus: "pending" | "error" | "success";
+ title: string;
+ body: string;
+ saveStatus: string;
+};
+
+type useCreateContentPropTypes = {
+ contentId: string;
+};
+
+function useCreateContent({
+ contentId,
+}: useCreateContentPropTypes): useCreateContentReturnType {
+ const { data: session } = useSession();
+ const [tags, setTags] = useState([]);
+ const [tagValue, setTagValue] = useState("");
+ const [savedTime, setSavedTime] = useState("");
+ const [open, setOpen] = useState(false);
+ const [shouldRefetch, setShouldRefetch] = useState(true);
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
+ const [delayDebounce, setDelayDebounce] = useState(false);
+ const allowUpdate = hasUnsavedChanges && !delayDebounce;
+ const router = useRouter();
+
+ const {
+ handleSubmit,
+ register,
+ watch,
+ reset,
+ getValues,
+ control,
+ trigger,
+ formState: { isDirty },
+ } = useForm({
+ mode: "onSubmit",
+ defaultValues: {
+ title: "",
+ body: "",
+ },
+ });
+
+ const { title, body } = watch();
+ const debouncedValue = useDebounce(title + body, 1500);
+
+ const {
+ mutate: publish,
+ status: publishStatus,
+ data: publishData,
+ } = api.content.publish.useMutation();
+
+ const { mutate: save, status: saveStatus } = api.content.update.useMutation({
+ onError() {
+ return toast.error("Something went wrong auto-saving");
+ },
+ onSuccess() {
+ console.log("saved");
+ setSavedTime(
+ new Date().toLocaleString(undefined, {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }),
+ );
+ },
+ });
+
+ const { mutate: create, data: createData } = api.content.create.useMutation({
+ onError() {
+ toast.error("Something went wrong creating draft");
+ },
+ onSuccess() {
+ console.log("saved");
+ },
+ });
+
+ const {
+ data,
+ status: dataStatus,
+ error: dataError,
+ } = api.content.editDraft.useQuery(
+ { id: contentId },
+ {
+ enabled: !!contentId && shouldRefetch,
+ },
+ );
+
+ // Handle query error with useEffect
+ useEffect(() => {
+ if (dataError) {
+ toast.error(
+ "Something went wrong fetching your draft, refresh your page or you may lose data",
+ {
+ duration: 5000,
+ },
+ );
+ }
+ }, [dataError]);
+
+ useEffect(() => {
+ if (shouldRefetch) {
+ setShouldRefetch(!(dataStatus === "success"));
+ }
+ }, [dataStatus, shouldRefetch]);
+
+ const getFormData = () => {
+ const data = getValues();
+ const formData = {
+ ...data,
+ tags,
+ canonicalUrl: data.canonicalUrl || undefined,
+ excerpt: data.excerpt || "",
+ };
+ return formData;
+ };
+
+ const saveContent = async () => {
+ const formData = getFormData();
+ const json = JSON.parse(formData.body);
+ const titleText = json?.content?.[0]?.content?.[0]?.text || "My article";
+ const updatedFormData = { ...formData, title: titleText };
+
+ if (!formData.id) {
+ // Create new content as ARTICLE type
+ create({
+ type: "POST",
+ title: updatedFormData.title,
+ body: updatedFormData.body,
+ excerpt: updatedFormData.excerpt,
+ canonicalUrl: updatedFormData.canonicalUrl,
+ tags: updatedFormData.tags,
+ published: false,
+ });
+ } else {
+ // Update existing content
+ save({
+ id: contentId,
+ title: updatedFormData.title,
+ body: updatedFormData.body,
+ excerpt: updatedFormData.excerpt,
+ canonicalUrl: updatedFormData.canonicalUrl,
+ tags: updatedFormData.tags,
+ });
+ }
+ setHasUnsavedChanges(false);
+ };
+
+ const hasLoadingState =
+ publishStatus === "pending" ||
+ saveStatus === "pending" ||
+ dataStatus === "pending";
+
+ const published = !!data?.published || false;
+
+ const onSubmit = async (formInput: SaveContentInput) => {
+ if (!published) {
+ try {
+ const formData = getFormData();
+ ConfirmContentSchema.parse(formData);
+ return publish({ id: contentId, published: true });
+ } catch (err) {
+ if (err instanceof ZodError) {
+ return toast.error(err.issues[0].message);
+ } else {
+ return toast.error("Something went wrong when trying to publish.");
+ }
+ }
+ }
+ await saveContent();
+ };
+
+ if (publishStatus === "success" && publishData?.slug && session?.user?.username) {
+ redirect(`/${session.user.username}/${publishData.slug}`);
+ }
+
+ const onChange = (e: React.ChangeEvent) => {
+ e.preventDefault();
+ const { value } = e.target;
+ setTagValue(value);
+ };
+
+ const onDelete = (tag: string) => {
+ setTags((t) => t.filter((t) => t !== tag));
+ };
+
+ const onKeyDown = (e: React.KeyboardEvent) => {
+ const { key } = e;
+ const trimmedInput = tagValue
+ .trim()
+ .toUpperCase()
+ .replace(/[^\w\s]/gi, "");
+ if (
+ (key === "," || key === "." || key === "Enter") &&
+ trimmedInput.length &&
+ !tags.includes(trimmedInput)
+ ) {
+ e.preventDefault();
+ setTags((prevState) => [...prevState, trimmedInput]);
+ setTagValue("");
+ }
+ };
+
+ useEffect(() => {
+ if (!data) return;
+ const { body, excerpt, title, id, tags: contentTags } = data;
+ setTags(contentTags.map(({ tag }) => tag.title.toUpperCase()));
+ reset({ body: body || "", excerpt: excerpt || "", title: title || "", id });
+ }, [data, reset]);
+
+ useEffect(() => {
+ if (published) return;
+ if ((title + body).length < 5) return;
+ if (debouncedValue === (data?.title || "") + (data?.body || "")) return;
+ if (allowUpdate) saveContent();
+ }, [debouncedValue]);
+
+ useEffect(() => {
+ if (!createData?.id) return;
+ router.push(`/create/${createData.id}`);
+ }, [createData, router]);
+
+ const hasContent = title.length >= 5 && body.length >= 10;
+
+ const isDisabled = hasLoadingState || !hasContent;
+
+ useEffect(() => {
+ if ((title + body).length < 5) return;
+ if (isDirty) setHasUnsavedChanges(true);
+ }, [title, body, isDirty]);
+
+ const handleOpenDialog = (res: string) => {
+ switch (res) {
+ case "initial":
+ setDelayDebounce(true);
+ break;
+ case "confirm":
+ setHasUnsavedChanges(false);
+ setDelayDebounce(false);
+ break;
+ case "cancel":
+ setDelayDebounce(false);
+ !published && saveContent();
+ break;
+ default:
+ setDelayDebounce(false);
+ setHasUnsavedChanges(true);
+ }
+ };
+
+ return {
+ tags,
+ setTags,
+ tagValue,
+ setTagValue,
+ savedTime,
+ setSavedTime,
+ open,
+ setOpen,
+ hasUnsavedChanges,
+ setHasUnsavedChanges,
+ allowUpdate,
+ handleSubmit,
+ register,
+ watch,
+ reset,
+ getValues,
+ control,
+ trigger,
+ isDirty,
+ saveContent,
+ debouncedValue,
+ hasContent,
+ isDisabled,
+ onSubmit,
+ onChange,
+ onDelete,
+ onKeyDown,
+ handleOpenDialog,
+ data,
+ hasLoadingState,
+ dataStatus,
+ title,
+ body,
+ saveStatus,
+ };
+}
+
+export default useCreateContent;
diff --git a/hooks/useCreatePage.ts b/hooks/useCreatePage.ts
index e53e24d6..5f8faadd 100644
--- a/hooks/useCreatePage.ts
+++ b/hooks/useCreatePage.ts
@@ -13,6 +13,7 @@ import { ConfirmPostSchema } from "../schema/post";
import { api } from "@/server/trpc/react";
import { useDebounce } from "./useDebounce";
import { redirect, useRouter } from "next/navigation";
+import { useSession } from "next-auth/react";
export type useCreatePageReturnType = {
tags: string[];
@@ -58,6 +59,7 @@ type useCreatepagePropTypes = {
function useCreatePage({
postId,
}: useCreatepagePropTypes): useCreatePageReturnType {
+ const { data: session } = useSession();
const [tags, setTags] = useState([]);
const [tagValue, setTagValue] = useState("");
const [savedTime, setSavedTime] = useState("");
@@ -170,7 +172,8 @@ function useCreatePage({
const updatedFormData = { ...formData, title: titleText };
if (!formData.id) {
- create({ ...updatedFormData });
+ // New posts default to article type
+ create({ ...updatedFormData, type: "article" as const });
} else {
save({ ...updatedFormData, id: postId });
}
@@ -182,7 +185,7 @@ function useCreatePage({
saveStatus === "pending" ||
dataStatus === "pending";
- const published = !!data?.published || false;
+ const published = !!data?.publishedAt || false;
const onSubmit = async (data: SavePostInput) => {
if (!published) {
@@ -201,8 +204,8 @@ function useCreatePage({
await savePost();
};
- if (publishStatus === "success" && publishData?.slug) {
- redirect(`/articles/${publishData.slug}`);
+ if (publishStatus === "success" && publishData?.slug && session?.user?.username) {
+ redirect(`/${session.user.username}/${publishData.slug}`);
}
const onChange = (e: React.ChangeEvent) => {
@@ -236,7 +239,8 @@ function useCreatePage({
if (!data) return;
const { body, excerpt, title, id, tags } = data;
setTags(tags.map(({ tag }) => tag.title));
- reset({ body, excerpt, title, id });
+ // Convert null to undefined for form compatibility
+ reset({ body: body ?? undefined, excerpt: excerpt ?? undefined, title, id });
}, [data]);
useEffect(() => {
diff --git a/scripts/fetch-rss.ts b/scripts/fetch-rss.ts
index 9508502c..878f8d9a 100644
--- a/scripts/fetch-rss.ts
+++ b/scripts/fetch-rss.ts
@@ -1,12 +1,12 @@
/**
- * Local script to fetch RSS feeds and populate the aggregated_article table.
+ * Local script to fetch RSS feeds and populate the Content table directly.
* 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 { feed_source, content } from "../server/db/schema";
import { eq, and } from "drizzle-orm";
import { nanoid } from "nanoid";
import Parser from "rss-parser";
@@ -18,6 +18,19 @@ const parser = new Parser({
},
});
+// Generate SEO-friendly slug from title + shortId
+function generateSlug(title: string, shortId: string): string {
+ const slugifiedTitle = title
+ .toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, "") // Remove special characters
+ .replace(/\s+/g, "-") // Replace spaces with hyphens
+ .replace(/-+/g, "-") // Replace multiple hyphens with single
+ .substring(0, 280) // Limit length
+ .replace(/^-|-$/g, ""); // Remove leading/trailing hyphens
+
+ return `${slugifiedTitle}-${shortId}`;
+}
+
// Simple excerpt extraction
function extractExcerpt(content: string, maxLength = 200): string {
// Remove HTML tags
@@ -26,6 +39,28 @@ function extractExcerpt(content: string, maxLength = 200): string {
return text.substring(0, maxLength).trim() + "...";
}
+// Calculate read time from word count
+function calculateReadTime(wordCount: number): number {
+ // Reading speed: ~225 words per minute
+ const readTimeMinutes = Math.ceil(wordCount / 225);
+ // Clamp between 1 and 30 minutes
+ return Math.max(1, Math.min(30, readTimeMinutes));
+}
+
+// Extract text content from HTML and count words
+function extractTextAndWordCount(html: string): { text: string; wordCount: number } {
+ // Remove scripts and styles
+ const cleaned = html
+ .replace(/
+ Comments are disabled for this post +
+ )} +