-
-
Notifications
You must be signed in to change notification settings - Fork 173
Feat/aggregator #1322
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat/aggregator #1322
Conversation
- 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
- 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
- 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
- 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
- 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)
- 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
- Update package dependencies - Add local fetch-rss.ts script for testing RSS feeds without Lambda
Consolidate migrations into unified content table supporting posts and aggregated links. Add sponsor inquiry schema for advertiser contact form.
Add sponsor inquiry router, update content/feed routers for unified content table, and add Reddit-style voting endpoints.
…t components Add ContentDetail components for article/link display, DiscussionEditor for rich text discussions, SavedItemCard, Sponsorship page sections, and UnifiedContentCard for the feed.
Update existing components to support unified content table, add voting UI to feed items, and update sidebar/trending posts for new data model.
Add advertise page, content detail pages for links/articles, update feed and article pages for unified content system, remove alpha sponsorship.
Add sync-content-table script for migrating data to unified content table, useCreateContent hook, and sponsor inquiry email template.
Update Algolia indexer and RSS fetcher lambdas to support unified content table structure.
Rename verbose component files where folder context already provides clarity: - ContentDetail/: Layout, TypeBadge, MetaHeader, ActionBar - DiscussionEditor/: Editor, Toolbar, useEditor - Feed/: AggregatedItem, ItemLoading, Filters
…e info and author bios
Database triggers (tr_post_vote_counts, tr_comment_vote_counts) now handle all vote count updates. Removing duplicate manual updates eliminates double-counting risk.
Components were using api.feed.vote which lacked proper count updates. Migrated to api.content.vote for consistent behavior.
Adds safety net script to verify and fix vote count discrepancies. Run with: npm run votes:reconcile (or votes:reconcile:dry for dry run)
Scheduled Lambda runs at 5 AM UTC daily to reconcile vote counts, ensuring DB triggers are working correctly. Runs before Algolia indexing to ensure accurate counts before re-indexing.
- Update articles.spec.ts for new feed URLs and voting UI - Create feed.spec.ts with 15 tests for filters, voting, bookmarks - Create saved.spec.ts with 6 tests for bookmark management - Create admin.spec.ts with 13 tests for admin dashboard - Add data-testid attributes to VoteButtons, Filters, ContentCard, DiscussionArea - Update e2e setup with admin user and new posts table schema - Add loggedInAsAdmin utility for admin test authentication Total: 63 passing e2e tests
|
@NiallJoeMaher is attempting to deploy a commit to the Codú Team on Vercel. A member of the Team first needs to authorize it. |
|
Warning Rate limit exceeded@NiallJoeMaher has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 16 minutes and 53 seconds before requesting another review. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. 📒 Files selected for processing (2)
WalkthroughAdds a unified content system: feed listing and filters, per-source profiles, per-article feed pages, unified user-scoped post routing (/username/slug), content/detail layout and action bars, discussion editor/area, admin UIs, RSS sync lambdas, DB migrations, and many new UI components and E2E tests. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant Browser
participant Server as Next.js Page
participant TRPC as TRPC API
participant DB as PostgreSQL
User->>Browser: Request /{username}/{slug}
Browser->>Server: SSR/route handler (generateMetadata/Page)
Server->>TRPC: query unified content by username+slug
TRPC->>DB: lookup user post OR feed article OR link content
DB-->>TRPC: return resolved content + source/author
TRPC-->>Server: return content + discussionId
Server-->>Browser: render page shell with client component props
Browser->>TRPC: fetch discussion/comments by contentId (client)
TRPC->>DB: query comments (ltree), votes, counts
DB-->>TRPC: return nested comments + counts
TRPC-->>Browser: discussion data
Browser->>TRPC: user actions (vote/bookmark/trackClick)
TRPC->>DB: perform mutations, update denormalized counters
DB-->>TRPC: mutation result
TRPC-->>Browser: updated state (optimistic/settled)
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 9
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (6)
components/Comments/CommentsArea.tsx (2)
88-100:await vote()won't catch errors — usemutateAsyncoronErrorcallback.The
mutatefunction fromuseMutationdoes not return a promise, soawait vote(...)resolves immediately without waiting for the mutation, and thecatchblock will never execute on mutation failure.🔎 Proposed fix using onError callback
- const { mutate: vote, status: voteStatus } = api.comment.vote.useMutation({ - onSettled() { - refetch(); - }, - }); + const { mutate: vote, status: voteStatus } = api.comment.vote.useMutation({ + onSettled() { + refetch(); + }, + onError() { + toast.error("Something went wrong, try again."); + }, + }); // Toggle upvote for a comment - const likeComment = async ( + const likeComment = ( commentId: string, currentVote: "up" | "down" | null, ) => { if (!session) return signIn(); if (voteStatus === "pending") return; - try { - // If already upvoted, remove vote; otherwise upvote - await vote({ commentId, voteType: currentVote === "up" ? null : "up" }); - } catch (err) { - toast.error("Something went wrong, try again."); - } + // If already upvoted, remove vote; otherwise upvote + vote({ commentId, voteType: currentVote === "up" ? null : "up" }); };
166-176: Sameawait mutate()issue — mutation errors won't be caught.Similar to the vote function,
await mutate(...)on line 168 won't wait for the mutation to complete. The catch block will only handle synchronous errors fromSaveSchema.parse(), not mutation failures.The
api.comment.create.useMutationshould handle errors viaonErrorcallback, or usemutateAsyncif you need promise-based error handling here.cdk/lib/cron-stack.ts (2)
20-29: IAM policy grants overly broad SSM access.Using
resources: ["*"]grants read access to all SSM parameters in the account. Consider restricting to specific parameter paths used by these Lambdas (e.g.,/env/*).🔎 Proposed fix to scope IAM policy
const policy = new iam.PolicyStatement({ actions: [ "ssm:GetParameter*", "ssm:DescribeParameters", "ssm:GetParameters", "ssm:GetParametersByPath", ], - resources: ["*"], + resources: [ + `arn:aws:ssm:${this.region}:${this.account}:parameter/env/*`, + ], });
33-45: Add CloudWatch Logs permissions to the Lambda role.The custom
lambdaRoledefined at the start of the stack (lines 16-18) only includes an SSM parameter policy (lines 21-31). It lacks the necessary CloudWatch Logs permissions (logs:CreateLogGroup,logs:CreateLogStream,logs:PutLogEvents) required for Lambda to write logs. Add theAWSLambdaBasicExecutionRolemanaged policy or an equivalent inline policy to the role to enable logging for this Lambda function and the other two Lambda functions that share the same role.components/ArticleMenu/ArticleMenu.tsx (1)
127-133: Auth check should occur before the vote action.The current flow triggers the vote mutation before checking authentication. Unauthenticated users will hit the vote endpoint (which will likely fail server-side) before being redirected to sign in.
🔎 Suggested fix
onClick={() => { + if (!session) { + signIn(); + return; + } if (data?.userVote === "up") return likePost(postId, false); likePost(postId); - if (!session) { - signIn(); - } }}drizzle/seed.ts (1)
832-842: Fix contradictory log messages.Lines 837 and 838 both execute for every table, logging both "Delete" and "Skipping" for the same table. Line 838 appears to be debug code that was left in by mistake.
🔎 Proposed fix
try { const query = await sql`DELETE FROM "${sql.raw(table.table_name as string)}" CASCADE;`; await db.execute(query); console.log("Delete", table.table_name); - console.log(`Skipping ${table.table_name}`); } catch (error) { console.log(`Error deleting ${table.table_name}: ${error}`); }
🤖 Fix all issues with AI Agents
In @app/(app)/[username]/[slug]/_linkContentDetail.tsx:
- Around line 69-82: The useEffect is synchronously calling setUserVote and
setVotes when linkContent changes which triggers the linter; instead derive
initial vote state from linkContent (initialize userVote and votes via useState
lazy initializer or useMemo based on linkContent) or use a ref (e.g.,
mountedRef) to avoid immediate setState in useEffect, and then update your
mutation handlers to use an optimistic setter like setOptimisticVote and
clear/reset it on success/error; specifically replace the effect that calls
setUserVote/setVotes with a derived initial state for userVote and votes and
adjust all handlers that previously relied on setUserVote/setVotes to consult
and update setOptimisticVote and reconcile on mutation results.
In @app/(app)/[username]/[slug]/page.tsx:
- Around line 301-307: The catch binding in parseJSON is unused; remove the
unused variable by changing the catch clause from "catch (e) {" to either an
empty catch "catch {" or use an underscore-prefixed name like "catch (_e) {" so
the ESLint warning goes away while preserving the current behavior of returning
null on parse failure.
In @app/(app)/admin/_client.tsx:
- Around line 19-72: StatCard is currently defined inside AdminDashboard which
recreates it on every render; move the StatCard function component definition
out of AdminDashboard (e.g., declare it above or below the AdminDashboard
component) and add an explicit isLoading: boolean prop to its props signature;
update all places inside AdminDashboard that render <StatCard ...> to pass the
current isLoading value, ensure the prop types include isLoading and that
React/Link imports used inside StatCard remain in scope after moving it.
- Line 11: The import list in admin/_client.tsx includes an unused symbol
LinkIcon; remove LinkIcon from the import statement (where LinkIcon is currently
imported alongside other icons) so the file no longer imports unused symbols and
re-run lint/format to ensure imports are ordered correctly.
In @app/(app)/admin/moderation/_client.tsx:
- Around line 5-11: Remove the unused CheckCircleIcon import from the import
list — update the import statement that currently imports FlagIcon,
CheckCircleIcon, XCircleIcon, ExclamationTriangleIcon, and ArrowLeftIcon to omit
CheckCircleIcon so only the actually used icons (FlagIcon, XCircleIcon,
ExclamationTriangleIcon, ArrowLeftIcon) are imported.
In @app/(app)/admin/sources/_client.tsx:
- Around line 343-366: statusIcons/statusColors lookups use uppercase keys while
source.status is lowercase, causing StatusIcon to be undefined and styles to be
missing; normalize source.status before lookups by mapping it to the matching
key form (e.g., call .toUpperCase() on source.status) when computing StatusIcon
and when indexing statusColors so both StatusIcon =
statusIcons[source.status.toUpperCase() as keyof typeof statusIcons] and the
class uses statusColors[source.status.toUpperCase() as keyof typeof
statusColors]; ensure you guard against null/undefined (fallback icon/color) if
status is unexpected.
- Line 41: The unused variable `utils` (from api.useUtils()) should be removed
to silence the ESLint warning or used instead of direct refetches; either delete
the line `const utils = api.useUtils();` or replace calls that use `refetch()`
after mutations with cache invalidation via
`utils.feed.getSourceStats.invalidate()` (use the `utils` instance returned by
`api.useUtils()` in the mutation callbacks where you currently call
`refetch()`).
In @app/(app)/advertise/page.tsx:
- Around line 4-13: The page metadata's visitor metric ("100,000+ monthly
developer visits" in export const metadata) conflicts with the copy in
HeroSection and MetricsSection ("20,000+..."); pick the correct canonical metric
and make all three sources consistent by extracting a single shared constant
(e.g., DEV_VISITOR_STAT or VISITOR_COUNT_LABEL) and using it in export const
metadata, the HeroSection component, and the MetricsSection component so the SEO
metadata (metadata.openGraph.description and metadata.description) and on-page
copy match exactly.
In @app/(app)/articles/_client.tsx:
- Around line 35-49: Extract the duplicated getRelativeTime implementation into
a single shared utility (e.g., export getRelativeTime from a new
utils/dateUtils.ts) and replace the inline copies in _client.tsx,
AggregatedItem.tsx, and UnifiedContentCard.tsx with an import from that utility;
ensure the exported function signature matches string => string and that each
consumer updates its import and removes the local implementation so behavior
stays consistent across components.
In @app/(app)/feed/_client.tsx:
- Around line 80-86: The handleSortChange function drops the current type filter
when building URLSearchParams; update it to read the existing type (e.g., the
component state/prop named type) and, if present, call params.set("type", type)
before building queryString so router.push preserves the type filter along with
category and sort; ensure the logic mirrors
handleCategoryChange/handleTypeChange and only omits defaults (e.g., skip
setting sort when "recent") to avoid changing behavior.
- Around line 305-310: Replace the hardcoded anchor element that points to
"/saved" with Next.js client-side navigation by importing Link from 'next/link'
and using the Link component for the element that currently reads `<a
href="/saved" className="...">View all saved</a>`; ensure you add `import Link
from 'next/link'` at the top of the file and replace the anchor with `<Link
href="/saved" className="...">View all saved</Link>` so the internal navigation
uses Next.js Link for client-side routing.
In @app/(app)/feed/[sourceSlug]/_client.tsx:
- Around line 189-191: The feed is passing a hardcoded discussionCount=0 because
getArticlesBySource isn't selecting posts.commentsCount; update the
getArticlesBySource query (function getArticlesBySource) to include
commentsCount: posts.commentsCount in the select clause and then add
commentsCount to the transformed article object returned by that function (the
mapping that builds each article) so the client _client.tsx can use
article.commentsCount instead of a hardcoded value; also update any related
types/interfaces (e.g., the Article/ArticleRow shape) if present.
In @app/(app)/feed/[sourceSlug]/[shortId]/_client.tsx:
- Around line 181-186: The img currently uses an empty alt attribute which harms
accessibility; update the <img> in the component (the element rendering
ensureHttps(article.imageUrl)) to provide a meaningful alt string derived from
the article (e.g., article.title or a short description like `${article.title}
thumbnail`), ensuring the alt conveys the image’s content and intent for screen
readers while keeping decorative fallback empty only when appropriate.
In @app/(app)/feed/[sourceSlug]/[shortId]/page.tsx:
- Around line 24-30: The query in db.query.posts.findFirst (inside the Page
function) ignores the shortId param, so include the shortId filter: add
eq(posts.shortId, shortId) to the where clause alongside eq(posts.sourceId,
source.id) and eq(posts.type, "link") so the lookup returns the specific post;
update both occurrences of db.query.posts.findFirst (the one at the top and the
one at Lines 60-62 referenced in the comment) to include this shortId predicate
using the shortId extracted from params.
In @app/(editor)/create/[[...paramsArr]]/_client.tsx:
- Around line 238-248: The comment says "ARTICLE type" but the create call sets
type: "POST", causing ambiguity; update the create invocation (the object passed
to create(...) when formData.id is falsy) to use type: "ARTICLE" to match the
comment and frontend schema naming (or alternatively change the comment to "POST
type" if you prefer keeping the code); ensure you only change the value of the
type field in that create({...}) call so other properties (title, body, excerpt,
canonicalUrl, tags, published) remain unchanged.
In @app/api/admin/sync-feeds/route.ts:
- Line 169: parser.parseURL currently performs an external request with no
timeout; wrap the fetch in an AbortController to avoid hanging: create an
AbortController, set a timer (e.g., 5s) that calls controller.abort(), pass
controller.signal into the parser call (or perform a fetch(url, { signal }) and
feed the response text to parser.parseString if parser.parseURL doesn't accept a
signal), await the parse, clear the timer on success, and catch AbortError to
handle timeouts; update the usage at the parser.parseURL call site to use this
pattern so unresponsive feeds are aborted.
In @app/feed.xml/route.ts:
- Line 30: The RSS URL construction uses post.user.username without handling
nullability; update the logic that maps posts from getAllPosts() to either
skip/filter posts where post.user?.username is missing or add a guard that
validates post.user?.username before building the URL (the template
`https://www.codu.co/${post.user.username}/${post.slug}`), ensuring only posts
with a defined username are included in the feed or producing a safe
fallback/validation error.
In @cdk/lambdas/algoliaIndex/index.ts:
- Around line 52-61: The SELECT that builds posts mixes legacy and new schema;
update the first client.query in index.ts to query the new posts table and
columns: select p.title, p.excerpt, p.slug, u.username from posts p inner join
"user" u on p.author_id = u.id where p.status = 'published' and p.published_at
IS NOT NULL so it uses posts, author_id, status and published_at instead of
"Post" and p."userId"/p."published"; keep the users query as needed but ensure
any joins or filters reference these new column names (symbols: client.query,
posts p, "user" u, author_id, status, published_at).
In @cdk/lambdas/rssFetcher/index.ts:
- Around line 319-335: The SQL update incorrectly checks error_count before the
increment so a source with error_count = 4 won't flip to 'error' on the 5th
failure; update the CASE expression in the client.query inside the catch block
(the feed_sources UPDATE executed in index.ts) to evaluate (error_count + 1) >=
5 (or otherwise compute the post-increment value) when deciding to set status =
'error', and keep the rest of the SET clause (last_fetched_at, error_count =
error_count + 1, last_error, updated_at) the same so the status transition
happens atomically with the increment.
In @cdk/lambdas/voteReconcile/index.ts:
- Around line 56-188: Database client is connected but not guaranteed to be
closed on query failure; wrap the block that runs the SQL queries in a
try/finally so client.end() always runs. Specifically, after calling
client.connect() (the Client constructor and client.connect()), immediately
enter try { /* all queries and stats assignments using client.query(...) */ }
finally { await client.end(); } to ensure the connection is closed even if any
query throws; if client may be null/undefined ensure you check before calling
client.end().
In @components/ArticleActionBar/ArticleActionBar.tsx:
- Around line 58-85: The rollback currently uses
initialUserVote/initialUpvotes/initialDownvotes which only represent page-load
state; change the mutation to capture the pre-mutation state in onMutate and use
that context in onError to restore it. Specifically, in
api.post.vote.useMutation's onMutate, compute and return an object like {
previousUserVote: oldVote, previousVotes: { upvotes: prev.upvotes, downvotes:
prev.downvotes } } (capture prev before calling setVotes), and then update
onError to accept the context parameter and call
setUserVote(context.previousUserVote) and setVotes(context.previousVotes) (and
keep toast.error and Sentry.captureException). Ensure onSettled still
invalidates via utils.post.sidebarData.invalidate().
In @components/Comments/CommentsArea.tsx:
- Around line 319-325: The HeartIcon's className template literal concatenates
without a separating space, producing classes like "w-6 h-6fill-red-400"; update
the className construction in the HeartIcon usage so there is a space before the
conditional class (either add the space inside the template before ${...} or
include a leading space in both conditional strings) so that userVote === "up"
correctly yields "w-6 h-6 fill-red-400" and the else branch yields "w-6 h-6
fill-neutral-400 dark:fill-neutral-600".
- Around line 224-230: The avatar img in CommentsArea renders a broken image
when author is null because image is an empty string; update the rendering for
Link/ img by either supplying a fallback placeholder URL (e.g.,
placeholderAvatar) when image is falsy or conditionally render a placeholder
element (e.g., a rounded div with initials or icon) instead of the <img>; ensure
you update the image variable usage and alt text accordingly so deleted users
show the placeholder rather than a broken image.
In @components/ContentDetail/ActionBar.tsx:
- Around line 293-310: The share links build query strings without encoding;
update the hrefs in ActionBar's share anchors (where shareUrl, shareTitle, and
shareUsername are used) to wrap URL/query values with encodeURIComponent so
parameters are safe: use encodeURIComponent(shareUrl) for LinkedIn and
encodeURIComponent(shareUrl), encodeURIComponent(shareTitle) and
encodeURIComponent(shareUsername) for the Twitter intent URL, keeping the rest
of the string formatting and target/rel attributes unchanged.
In @components/ContentDetail/SourceInfoCard.tsx:
- Around line 64-73: The component SourceInfoCard currently uses new
URL(websiteUrl) directly in the render, which can throw for malformed strings;
change this by extracting the hostname safely before rendering: in the component
(SourceInfoCard) compute a safeHostname (e.g., try constructing new
URL(websiteUrl) in a try/catch or use a helper parseHost function that returns
null on error) and then update the JSX to render the anchor only when
safeHostname is non-null (use safeHostname instead of new
URL(websiteUrl).hostname) or show a fallback (e.g., the raw string or nothing).
In @components/ContentDetail/TypeBadge.tsx:
- Line 1: There are multiple inconsistent ContentType definitions (e.g.,
ContentType in TypeBadge.tsx, UnifiedContentCard.tsx, ContentCard.tsx) causing
fragmentation; create or re-export a single canonical union type (use the
existing schema/content.ts export or add types/content.ts and export the
schema's type) and update all references (TypeBadge.tsx, UnifiedContentCard.tsx,
ContentCard.tsx, Feed/Filters.tsx, feed/_client.tsx) to import that canonical
ContentType instead of local literals, then adjust any place that used
lowercase/uppercase variants (map or normalize values if needed) so all
components use the same exported union.
In @components/Discussion/DiscussionArea.tsx:
- Around line 138-148: The try/catch is awaiting mutate(...) which returns void
for tRPC mutations, so replace usage with the promise-returning mutateAsync:
destructure mutateAsync from the useMutation hook (the same place you currently
get mutate) and change await mutate({...}) to await mutateAsync({...}) inside
the function (the one that sends body, contentId, parentId) so the try/catch
will correctly await the network call and catch errors.
- Around line 164-177: The edit handler is awaiting editDiscussion which is a
mutation object (mutate) and not a Promise; change the call in the handler
(where EditDiscussionSchema.parse({ body, id }) is used) to use
editDiscussion.mutateAsync({ body, id }) so the await works (or remove await and
call editDiscussion.mutate({ body, id }) if you prefer the callback-style); keep
the existing ZodError handling and state updates (setEditCommentBoxId,
setEditContent) after the awaited mutateAsync resolves.
- Around line 117-122: The useEffect currently sets initiallyLoaded on first run
regardless of data; change it so it only sets setInitiallyLoaded(true) when the
discussion data is actually loaded by checking discussionStatus (or the actual
data object) before setting. Update the effect to depend on discussionStatus
(remove initiallyLoaded from deps) and inside the effect do something like: if
(initiallyLoaded) return; if (discussionStatus !== 'loading' && discussionStatus
!== 'idle') setInitiallyLoaded(true) — or check for a concrete loaded value
(e.g., discussion !== undefined) so initiallyLoaded becomes true only after real
data arrival.
In @components/Discussion/DiscussionEditor/Editor.tsx:
- Around line 36-41: The editor is ignoring the incoming placeholder prop
because useDiscussionEditor is called with a hardcoded string; update the call
to useDiscussionEditor to pass the component's placeholder prop (e.g.,
placeholder) instead of "What are your thoughts?", ensuring the same placeholder
value is used for both the collapsed button and the editor; confirm the
component accepts a placeholder prop and forward it to useDiscussionEditor
(function name: useDiscussionEditor) so the prop takes effect.
In @components/Discussion/DiscussionEditor/MarkdownHelpModal.tsx:
- Line 37: In MarkdownHelpModal (the entry that defines the example with syntax:
"```\\ncode\\n```"), replace the escaped newline sequence with actual newlines
so the example renders as a real multiline code block (e.g., use
"```\ncode\n```" or a template literal) — update the object with key syntax in
the examples array inside MarkdownHelpModal.tsx so the UI shows line breaks
instead of literal "\n" characters.
In @components/Feed/AggregatedItem.tsx:
- Around line 174-180: handleReport passes a string UUID as id into
openReport("article", id) but ReportModal.parseReportParam expects a numeric
article ID; convert and validate the id to a number before calling openReport in
AggregatedItem.handleReport (e.g. parseInt/Number on id when type is "article"),
and if conversion yields NaN, handle it (e.g. show error/toast or skip opening)
so openReport always receives a valid numeric article ID.
- Around line 41-88: The helper functions getFaviconUrl, getRelativeTime,
getDisplayUrl, and ensureHttps are duplicated; extract them into a single shared
utility module (e.g., feedUtils or feedHelpers) and replace the local
implementations by importing those functions. Specifically, create a module
exporting getFaviconUrl, getRelativeTime, getDisplayUrl, and ensureHttps, remove
the duplicate implementations from AggregatedItem.tsx, UnifiedContentCard.tsx,
and _client.tsx, and update those files to import the functions by name
(preserving existing function signatures and behavior). Ensure all call sites
still reference the same function names so type checks and usages remain
unchanged.
- Around line 120-140: The vote mutation currently uses api.content.vote while
the bookmark mutation uses api.feed.bookmark, causing inconsistent API
namespaces; change the bookmark mutation to use api.content.bookmark (matching
UnifiedContentCard) and update related invalidations to the content namespace
(e.g., replace utils.feed.getFeed and utils.feed.mySavedArticles invalidations
with the appropriate utils.content.* invalidation calls) so both vote and
bookmark use api.content and the corresponding utils.content invalidations.
- Around line 338-345: The comments button in AggregatedItem.tsx currently
renders a hard-coded "0" in the span; add a discussionCount prop to the
component props type (e.g., Props or AggregatedItemProps) and use that value in
the Link's span (e.g., {discussionCount ?? 0}) so it displays the real count
with a safe fallback; update any call sites to pass discussionCount or rely on
the fallback.
In @components/Feed/ItemLoading.tsx:
- Around line 1-46: FeedItemLoading duplicates ContentCardLoading; extract the
shared JSX into a new reusable component SkeletonContentCard that both
FeedItemLoading and ContentCardLoading render; create SkeletonContentCard(props:
{ variant?: 'default' | 'bordered-actions' }) which contains the source/header,
title, url, excerpt, action bar and responsive thumbnail skeletons and use the
variant prop to toggle the smaller outlined action buttons (as used in
FeedItemLoading) vs the default actions (as used in ContentCardLoading), then
replace the body of FeedItemLoading and ContentCardLoading with a simple return
of <SkeletonContentCard variant="..."/> so styling and structure live in one
place.
In @components/ReportModal/ReportModal.tsx:
- Around line 155-181: Summary: Different content types use mixed submission
paths (sendReport vs createReport), so only discussions reach the DB/admin. Fix:
update ReportModal's submit branch to call the DB-backed createReport for post,
comment, article (not just discussion); replace sendReport usages (in branches
for type === "post", "comment", "article") with createReport and map the fields
expected by createReport (pass the numeric id as the appropriate id field or a
generic contentId, include reason and details/reportBody), preserving the
existing discussion branch; ensure createReport is invoked with the correct
payload shape that the mutation expects and remove/stop using sendReport for
these content types so all reports go through the same DB-backed workflow.
In @components/SavedItemCard/SavedItemCard.tsx:
- Around line 62-68: The cardUrl construction in SavedItemCard uses
authorUsername when type === "POST" which yields an invalid URL like "//{slug}"
if authorUsername is empty; update the logic in the cardUrl calculation to
explicitly check for a truthy authorUsername and only build
`/${authorUsername}/${slug}` when present, otherwise fall back to a safe route
such as `/post/${slug}` (or `/feed/${id}`) so that type, authorUsername, slug,
sourceSlug, and id are all considered and no empty path segments are produced.
In @components/Sponsorship/ContactForm.tsx:
- Around line 410-414: The errors access for the "interests" field in
ContactForm is overly complex and stems from inconsistent error shapes; switch
the form to use a zod schema with react-hook-form's zodResolver (useForm({
resolver: zodResolver(yourSchema) })) so validation errors are normalized, then
simplify the error prop to a single access like errors.interests?.message
(remove the casted root check). Update ContactForm's useForm initialization and
the component JSX that renders the error to use the normalized errors shape.
- Around line 338-348: toggleInterest uses unsafe casts (as string[]) because
the form types for interests don't match SponsorInquirySchema; remove these
assertions by making the form generic over the Zod schema type so
selectedInterests is typed as SponsorInquirySchema["interests"]. Reinitialize
useForm with zodResolver(SponsorInquirySchema) and the inferred type (e.g.,
useForm<z.infer<typeof SponsorInquirySchema>>(...)) so selectedInterests,
setValue("interests", ...) and clearErrors("interests") all use the correct
enum-array type and the casts in toggleInterest can be removed.
- Around line 304-327: The form currently runs manual Zod validation inside the
submit handler; update ContactForm to integrate Zod with react-hook-form by
importing zodResolver from @hookform/resolvers/zod and passing resolver:
zodResolver(yourZodSchema) into the useForm(...) call (where
useForm<SponsorInquiryInput> is initialized), remove the manual validation block
in the submit handler (the code that validates with the Zod schema and sets
errors) so handleSubmit relies on RHF's built-in validation and
formState.errors, and ensure the Zod schema exported/used matches
SponsorInquiryInput for proper typing.
- Around line 354-368: The navigation currently lets currentStep go from 2 to 3
without validating step-2 fields; explicitly validate the required budgetRange
from the form (or use the form's validate/resolvers) before calling
setCurrentStep(3). In the ContactForm component, when currentStep === 2, check
the form value for budgetRange (or call trigger("budgetRange") /
trigger([...step2FieldNames])) and if missing call setError("budgetRange", {
type: "manual", message: "Please select a budget range" }) and return; only call
clearErrors("budgetRange") and setCurrentStep(3) when validation passes. Ensure
this matches SponsorInquirySchema expectations so schema defaults and form
defaults are aligned.
In @components/Sponsorship/SocialProofSection.tsx:
- Around line 4-35: The sponsors array in SocialProofSection.tsx references six
logo assets (version1.png, learnupon.png, offerzen.png, wework.png,
harveynash.png, ninedots.png) under /images/sponsors/ that do not exist; either
add the missing files into the public/images/sponsors/ directory with the exact
filenames and proper PNGs, or remove or comment out the corresponding entries in
the sponsors array (in components/Sponsorship/SocialProofSection.tsx) until the
assets are available so the component stops referencing non-existent images.
In @components/UnifiedContentCard/UnifiedContentCard.tsx:
- Around line 145-151: The cardUrl logic incorrectly builds LINK URLs using
`/${source.slug}/${slug}`; update the `cardUrl` construction in
`UnifiedContentCard` so that when `type === "LINK"` and `source?.slug` and
`shortId` (or equivalent short identifier used elsewhere) exist it returns
`/feed/${source.slug}/${shortId}` (matching other feed components), otherwise
fall back to the existing `/feed/${id}`; keep the `POST` branch unchanged and
ensure you reference `type`, `source.slug`, `slug`, `shortId`, and `id` when
implementing the conditional.
In @drizzle/0011_unified_content_system.sql:
- Around line 223-239: The migration's reports table is missing support for
discussion reports used by ReportModal and the application's content_report
schema; add a discussion_id UUID REFERENCES discussion(id) ON DELETE CASCADE
column to the reports table (or rename to match content_report if you intend to
follow schema.ts), and update the CONSTRAINT reports_target_check to allow
exactly one of post_id, comment_id, or discussion_id to be non-null (i.e.,
(post_id IS NOT NULL AND comment_id IS NULL AND discussion_id IS NULL) OR
(comment_id IS NOT NULL AND post_id IS NULL AND discussion_id IS NULL) OR
(discussion_id IS NOT NULL AND post_id IS NULL AND comment_id IS NULL)); ensure
any references to reports vs content_report are consistent with schema.ts and
application code.
- Around line 121-131: Replace the global unique index posts_slug_idx on
posts(slug) with a per-owner scoped uniqueness: create a unique composite index
posts_slug_author_idx ON posts(slug, author_id) to allow identical slugs across
different authors, and also add a unique composite index posts_slug_source_idx
ON posts(slug, source_id) WHERE source_id IS NOT NULL to ensure feed articles
are scoped by source; remove the original CREATE UNIQUE INDEX IF NOT EXISTS
posts_slug_idx ON posts(slug) and add these two composite unique indexes
instead.
In @drizzle/0012_sponsor_inquiry.sql:
- Line 26: The budgetRange column (type "SponsorBudgetRange") is defined with a
DEFAULT of 'EXPLORING' but is nullable; make its nullability consistent with
status by adding a NOT NULL constraint to the budgetRange definition in the
0012_sponsor_inquiry.sql migration so the column cannot be NULL (unless you
intentionally need NULL as semantically distinct from 'EXPLORING'); update the
CREATE/ALTER statement for "budgetRange" accordingly and run/validate the
migration.
In @e2e/saved.spec.ts:
- Around line 24-31: The test "Should show empty state when no saved items" only
asserts pageContent is truthy; replace that with an explicit check for the
empty-state UI by asserting a known text or element is visible (e.g., use
page.getByText(/no saved items/i) or a specific empty-state selector) via
Playwright's expect(...).toBeVisible(), and remove the weak
expect(pageContent).toBeTruthy() assertion so the test actually validates the
empty state.
🟠 Major comments (17)
app/api/admin/sync-feeds/route.ts-169-169 (1)
169-169: Add timeout for RSS feed fetching.
parser.parseURLmakes an external HTTP request without a timeout. If a feed server is unresponsive, this could hang the request indefinitely, potentially exhausting server resources or hitting serverless function timeouts unexpectedly.🔎 Suggested fix using AbortController
+const RSS_FETCH_TIMEOUT_MS = 30000; // 30 seconds + for (const source of sources) { try { - const feed = await parser.parseURL(source.url); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), RSS_FETCH_TIMEOUT_MS); + + const response = await fetch(source.url, { + signal: controller.signal, + headers: { "User-Agent": "Mozilla/5.0 (compatible; CoduBot/1.0)" }, + }); + clearTimeout(timeoutId); + + const feedText = await response.text(); + const feed = await parser.parseString(feedText);Committable suggestion skipped: line range outside the PR's diff.
components/Sponsorship/ContactForm.tsx-410-414 (1)
410-414: Simplify error message access pattern.The error access pattern for interests is overly complex and non-standard:
errors.interests?.message || (errors.interests?.root as { message?: string } | undefined)?.messageThis suggests uncertainty about the error structure. Using
zodResolverwould standardize error handling and eliminate this complexity.components/Sponsorship/ContactForm.tsx-338-348 (1)
338-348: Fix type safety issues with interests field.The repeated type assertions
as string[]on lines 339, 343, and 408 indicate a type mismatch. Theinterestsfield should be properly typed to avoid these unsafe casts.The issue stems from the form's type definition. Since
SponsorInquirySchemadefines interests as an array of specific enum values, ensure the form state properly reflects this type. UsingzodResolver(as suggested in the previous comment) would automatically provide correct typing and eliminate the need for these assertions.components/Sponsorship/ContactForm.tsx-304-327 (1)
304-327: Consider using Zod resolver for react-hook-form integration.The form currently performs manual Zod validation in the submit handler (lines 377-386). React Hook Form has built-in support for Zod validation through
@hookform/resolvers/zod, which would provide real-time field validation and better type safety.🔎 Proposed refactor to use zodResolver
+import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; const { register, handleSubmit, formState: { errors, isSubmitting }, reset, watch, setValue, clearErrors, - setError, } = useForm<SponsorInquiryInput>({ + resolver: zodResolver(SponsorInquirySchema), defaultValues: { interests: [], budgetRange: "EXPLORING", name: "", email: "", company: "", phone: "", goals: "", }, });Then simplify the submit handler:
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); + await submitMutation.mutateAsync(data); };cdk/lambdas/rssFetcher/index.ts-189-341 (1)
189-341: Database connection not closed in error paths within the source loop.If an error occurs during the main
tryblock afterclient.connect()but beforeclient.end(), the connection remains open. Thefinallyblock should ensure the connection is always closed.🔎 Proposed fix using try/finally
try { const DATABASE_URL = await getSsmValue("/env/db/dbUrl"); const client = new Client({ connectionString: DATABASE_URL, }); await client.connect(); console.log("Connected to database"); + try { // Get active feed sources with linked user profiles // ... rest of processing logic ... - await client.end(); + } finally { + await client.end(); + } console.log("RSS Fetcher completed:", stats); // ... } catch (error) { // ... }cdk/lambdas/algoliaIndex/index.ts-52-61 (1)
52-61: Update query to use new unified schema tables.The query mixes legacy and new table naming—it selects from
"Post"(legacy) but joins with"user"(new). Since migration 0015 migrated post data to the newpoststable and the current schema definespostsanduser(lowercase), update the query to:SELECT p.title, p.excerpt, p.slug, u.username FROM posts p INNER JOIN "user" u ON p.author_id = u.id WHERE p.status = 'published' AND p.published_at IS NOT NULL;Note: The new schema uses
author_id(notuserId),statusenum (notpublishedtimestamp), andpublished_atfield. Adjust the WHERE clause logic accordingly for your indexing requirements.cdk/lambdas/voteReconcile/index.ts-56-188 (1)
56-188: Database connection should use try/finally for cleanup.Similar to the RSS fetcher, if any query fails after
client.connect(), the connection won't be closed before the error is thrown. Usetry/finallyto ensure cleanup.🔎 Proposed fix
try { const DATABASE_URL = await getSsmValue("/env/db/dbUrl"); const client = new Client({ connectionString: DATABASE_URL, }); await client.connect(); console.log("Connected to database"); + try { // 1. Fix post upvote counts... // ... all queries ... - await client.end(); + } finally { + await client.end(); + } // Calculate total fixes... } catch (error) { // ... }components/Feed/ItemLoading.tsx-1-46 (1)
1-46: Significant duplication with ContentCardLoading.FeedItemLoading and ContentCardLoading (components/Content/ContentCardLoading.tsx) share nearly identical structure and purpose:
- Both render source/author header skeletons
- Both display title, excerpt, and action bar skeletons in the same layout
- Both include responsive thumbnails
- Both use similar styling and spacing
Consider extracting a shared
SkeletonContentCardcomponent to eliminate duplication and ensure consistency across loading states.💡 Suggested approach
Create a unified skeleton component:
// components/ui/SkeletonContentCard.tsx interface SkeletonContentCardProps { variant?: 'default' | 'bordered-actions'; } const SkeletonContentCard = ({ variant = 'default' }: SkeletonContentCardProps) => { // Shared skeleton structure // Use variant prop to handle minor differences like action button styles };Then reuse in both contexts:
// FeedItemLoading -const FeedItemLoading = () => { ... } +import { SkeletonContentCard } from '@/components/ui/SkeletonContentCard'; +const FeedItemLoading = () => <SkeletonContentCard variant="bordered-actions" />;Committable suggestion skipped: line range outside the PR's diff.
components/ContentDetail/SourceInfoCard.tsx-64-73 (1)
64-73: Unguarded URL parsing may throw on malformed URLs.The
websiteUrlcheck on line 64 only verifies truthiness. IfwebsiteUrlis a malformed string (e.g.,"invalid-url"),new URL(websiteUrl)on line 71 will throw, crashing the component.🔎 Suggested fix: Extract hostname safely
+// Get hostname from a URL safely +const getHostname = (url: string | null | undefined): string | null => { + if (!url) return null; + try { + return new URL(url).hostname; + } catch { + return null; + } +}; + const SourceInfoCard = ({ name, slug, description, logo, websiteUrl, }: SourceInfoCardProps) => { const faviconUrl = getFaviconUrl(websiteUrl); const sourceLink = slug ? `/feed/${slug}` : "#"; + const hostname = getHostname(websiteUrl);Then update the render:
- {websiteUrl && ( + {hostname && ( <a href={websiteUrl} target="_blank" rel="noopener noreferrer" className="mt-1 inline-block text-xs text-blue-600 hover:underline dark:text-blue-400" > - {new URL(websiteUrl).hostname} + {hostname} </a> )}Committable suggestion skipped: line range outside the PR's diff.
components/ArticleActionBar/ArticleActionBar.tsx-58-85 (1)
58-85: Fix error rollback to use previous state, not initial state.The error handler on lines 76-81 reverts to
initialUserVoteand the initial vote counts. This is incorrect if the user has voted multiple times during the session. When a vote mutation fails, it should roll back to the state immediately before that mutation, not to the page load state.🔎 Recommended fix using mutation context
const { mutate: vote, status: voteStatus } = api.post.vote.useMutation({ onMutate: async ({ voteType }) => { const oldVote = userVote; + const oldVotes = votes; 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 }; }); + + return { oldVote, oldVotes }; }, - onError: (error) => { + onError: (error, variables, context) => { - setUserVote(initialUserVote); - setVotes({ upvotes: initialUpvotes, downvotes: initialDownvotes }); + if (context) { + setUserVote(context.oldVote); + setVotes(context.oldVotes); + } toast.error("Failed to update vote"); Sentry.captureException(error); }, onSettled: () => { utils.post.sidebarData.invalidate(); }, });drizzle/0011_unified_content_system.sql-121-131 (1)
121-131: Slug uniqueness constraint prevents multiple authors from using the same slug.The unique index on
slugalone at line 123 prevents different authors from creating posts with identical slugs (e.g., both writing "introduction-to-react"). The URL structure (/{username}/{slug}for user posts,/{sourceSlug}/{slug}for feed articles) and query patterns throughout the codebase indicate slugs should be scoped per author or per source, not globally unique. This is further evidenced by slug collision handling in the migration logic (0015) and sync scripts.Make the index composite to scope uniqueness to the author:
Suggested fix
- CREATE UNIQUE INDEX IF NOT EXISTS posts_slug_idx ON posts(slug); + CREATE UNIQUE INDEX IF NOT EXISTS posts_author_slug_idx ON posts(author_id, slug);components/SideBar/SideBarSavedPosts.tsx-63-71 (1)
63-71: "View all saved posts" link will never display.The condition
totalNumberSaved > howManySavedToShowcan never be true becausetotalNumberSavedis derived fromitems.length, which is limited tohowManySavedToShow(3) by the query. Even if the user has 100 bookmarks, only 3 are returned.To fix this, either:
- Fetch
limit + 1and only display the first 3, using the extra item as a "has more" indicator- Have the API return a separate
totalCountfield- Always show the link if there are any saved items
🔎 Option 1: Fetch one extra item to detect "has more"
const howManySavedToShow = 3; const { data: bookmarksData, status: bookmarkStatus } = api.post.myBookmarks.useQuery({ - limit: howManySavedToShow, + limit: howManySavedToShow + 1, }); -const totalNumberSaved = bookmarksData?.items?.length || 0; -const bookmarks = bookmarksData?.items || []; +const allItems = bookmarksData?.items || []; +const hasMore = allItems.length > howManySavedToShow; +const bookmarks = allItems.slice(0, howManySavedToShow); ... - {(totalNumberSaved && totalNumberSaved > howManySavedToShow && ( + {(hasMore && ( <LinkCommittable suggestion skipped: line range outside the PR's diff.
app/(app)/admin/sources/_client.tsx-343-366 (1)
343-366: Status case mismatch causes missing icon and styling.The
statusColorsandstatusIconsobjects use uppercase keys (ACTIVE,PAUSED,ERROR), but per the comment on line 151,source.statusis now lowercase. This causesStatusIconto beundefinedandstatusColors[source.status]to returnundefined.🔎 Proposed fix - normalize status to uppercase for lookups
{sources?.map((source) => { + const normalizedStatus = source.status.toUpperCase() as keyof typeof statusIcons; const StatusIcon = - statusIcons[source.status as keyof typeof statusIcons]; + statusIcons[normalizedStatus]; return ( <tr key={source.sourceId}> {/* ... */} <td className="whitespace-nowrap px-6 py-4"> <span className={`inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ${ - statusColors[ - source.status as keyof typeof statusColors - ] + statusColors[normalizedStatus] }`} > - <StatusIcon className="h-4 w-4" /> + {StatusIcon && <StatusIcon className="h-4 w-4" />} {source.status} </span> </td>components/Feed/AggregatedItem.tsx-41-88 (1)
41-88: Helper functions duplicated across feed components.
getFaviconUrl,getRelativeTime,getDisplayUrl, andensureHttpsare duplicated inUnifiedContentCard.tsxand_client.tsx. Extract these to a shared utility module.components/UnifiedContentCard/UnifiedContentCard.tsx-145-151 (1)
145-151: URL pattern for LINK content may be incorrect.For
type === "LINK", the URL is constructed as/${source.slug}/${slug}but other feed components use/feed/${source.slug}/${shortId}. This inconsistency could lead to broken links.🔎 Proposed fix
const cardUrl = type === "POST" ? `/${author?.username || ""}/${slug || ""}` : source?.slug && slug - ? `/${source.slug}/${slug}` + ? `/feed/${source.slug}/${slug}` : `/feed/${id}`;app/(app)/articles/_client.tsx-35-49 (1)
35-49:getRelativeTimeis duplicated across multiple files.This utility function appears in
AggregatedItem.tsx,UnifiedContentCard.tsx, and here with slight variations. Extract to a shared utility module to ensure consistent behavior and reduce maintenance burden.🔎 Suggested approach
Create a shared utility file:
// utils/dateUtils.ts export 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" }); };Then import from each consumer file.
components/Feed/AggregatedItem.tsx-120-140 (1)
120-140: Use consistent API namespaces for voting and bookmarking in AggregatedItem.Currently, voting uses
api.content.votewhile bookmarking usesapi.feed.bookmark. This is inconsistent withUnifiedContentCard, which usesapi.contentfor both operations. Consider aligning these to use the same namespace (likelyapi.contentfor both) to maintain consistency across components.
| 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.", | ||
| }, | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# First, locate the files mentioned in the review
fd -e tsx "advertise" | head -20Repository: codu-code/codu
Length of output: 40
🏁 Script executed:
# Search for HeroSection and MetricsSection files
fd -e tsx "HeroSection|MetricsSection"Repository: codu-code/codu
Length of output: 138
🏁 Script executed:
# Check the advertise page structure
git ls-files | grep -E "(advertise|HeroSection|MetricsSection)" | head -20Repository: codu-code/codu
Length of output: 199
🏁 Script executed:
# Read the advertise page.tsx file
cat -n app/(app)/advertise/page.tsxRepository: codu-code/codu
Length of output: 180
🏁 Script executed:
# Read HeroSection component
cat -n components/Sponsorship/HeroSection.tsxRepository: codu-code/codu
Length of output: 2093
🏁 Script executed:
# Read MetricsSection component
cat -n components/Sponsorship/MetricsSection.tsxRepository: codu-code/codu
Length of output: 1772
🏁 Script executed:
# Read the advertise page.tsx file with proper escaping
cat -n "app/(app)/advertise/page.tsx"Repository: codu-code/codu
Length of output: 871
Critical inconsistency in visitor metrics.
The metadata claims "100,000+ monthly developer visits" (line 7), but HeroSection displays "20,000+ Developers Every Month" (HeroSection.tsx line 20) and MetricsSection shows "20,000+ Monthly Website Visits" (MetricsSection.tsx line 16). This 5x discrepancy between SEO metadata and page content undermines credibility—when users arrive from search results expecting 100,000+, they encounter 20,000+ instead. Align these metrics across all three locations.
🤖 Prompt for AI Agents
In @app/(app)/advertise/page.tsx around lines 4 - 13, The page metadata's
visitor metric ("100,000+ monthly developer visits" in export const metadata)
conflicts with the copy in HeroSection and MetricsSection ("20,000+..."); pick
the correct canonical metric and make all three sources consistent by extracting
a single shared constant (e.g., DEV_VISITOR_STAT or VISITOR_COUNT_LABEL) and
using it in export const metadata, the HeroSection component, and the
MetricsSection component so the SEO metadata (metadata.openGraph.description and
metadata.description) and on-page copy match exactly.
| try { | ||
| await mutate({ | ||
| body, | ||
| contentId, | ||
| parentId, | ||
| }); | ||
| } catch (err) { | ||
| toast.error("Something went wrong saving your comment."); | ||
| throw err; | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mutate does not return a Promise; use mutateAsync instead.
The await mutate(...) call on line 139 won't actually wait for the mutation to complete because tRPC's useMutation hook's mutate function returns void. This means errors won't be caught by the try/catch block, and the function may throw "Invalid markdown" before the mutation completes.
🔎 Proposed fix
const handleCreateComment = async (body: string, parentId?: 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 {
- await mutate({
+ await mutateAsync({
body,
contentId,
parentId,
});
} catch (err) {
toast.error("Something went wrong saving your comment.");
throw err;
}
};You'll also need to destructure mutateAsync from the mutation hook:
- const { mutate, status: createDiscussionStatus } =
+ const { mutate, mutateAsync, status: createDiscussionStatus } =
api.discussion.create.useMutation({Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In @components/Discussion/DiscussionArea.tsx around lines 138 - 148, The
try/catch is awaiting mutate(...) which returns void for tRPC mutations, so
replace usage with the promise-returning mutateAsync: destructure mutateAsync
from the useMutation hook (the same place you currently get mutate) and change
await mutate({...}) to await mutateAsync({...}) inside the function (the one
that sends body, contentId, parentId) so the try/catch will correctly await the
network call and catch errors.
| try { | ||
| EditDiscussionSchema.parse({ body, id }); | ||
| await editDiscussion({ body, id }); | ||
| setEditCommentBoxId(null); | ||
| setEditContent(""); | ||
| } catch (err) { | ||
| if (err instanceof ZodError) { | ||
| toast.error(err.issues[0].message); | ||
| throw err; | ||
| } | ||
| toast.error("Something went wrong editing your comment."); | ||
| throw err; | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same mutate vs mutateAsync issue in edit handler.
Similar to handleCreateComment, this handler uses await editDiscussion(...) but editDiscussion is mutate, which doesn't return a Promise.
🔎 Proposed fix
- const { mutate: editDiscussion, status: editStatus } =
+ const { mutateAsync: editDiscussionAsync, mutate: editDiscussion, status: editStatus } =
api.discussion.edit.useMutation({
onSuccess: () => {
refetch();
},
});Then in handleEditComment:
try {
EditDiscussionSchema.parse({ body, id });
- await editDiscussion({ body, id });
+ await editDiscussionAsync({ body, id });
setEditCommentBoxId(null);
setEditContent("");
} catch (err) {Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In @components/Discussion/DiscussionArea.tsx around lines 164 - 177, The edit
handler is awaiting editDiscussion which is a mutation object (mutate) and not a
Promise; change the call in the handler (where EditDiscussionSchema.parse({
body, id }) is used) to use editDiscussion.mutateAsync({ body, id }) so the
await works (or remove await and call editDiscussion.mutate({ body, id }) if you
prefer the callback-style); keep the existing ZodError handling and state
updates (setEditCommentBoxId, setEditContent) after the awaited mutateAsync
resolves.
| 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", | ||
| }, | ||
| ]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check if sponsor logo images exist in public directory
echo "Checking for sponsor logo images..."
cd "$(git rev-parse --show-toplevel)" 2>/dev/null || cd .
# Check the public directory structure first
if [ -d "public" ]; then
echo "Public directory found. Checking for images/sponsors subdirectory..."
if [ -d "public/images/sponsors" ]; then
echo "Sponsors directory found. Files present:"
ls -la public/images/sponsors/ 2>/dev/null || echo "Cannot list files"
else
echo "public/images/sponsors directory does not exist"
fi
else
echo "public directory not found"
fi
echo ""
echo "Searching for each sponsor image file:"
for logo in version1.png learnupon.png offerzen.png wework.png harveynash.png ninedots.png; do
echo "Searching for: $logo"
fd -t f "$logo" public 2>/dev/null || echo " Not found with fd"
doneRepository: codu-code/codu
Length of output: 419
🏁 Script executed:
#!/bin/bash
# Check public directory structure and search for sponsor images
echo "=== Checking public directory structure ==="
if [ -d "public" ]; then
echo "Contents of public directory:"
ls -la public/ | head -20
echo ""
echo "=== Checking for images subdirectory ==="
if [ -d "public/images" ]; then
echo "Contents of public/images:"
ls -la public/images/
else
echo "No images subdirectory in public"
fi
fi
echo ""
echo "=== Searching for sponsor image references in codebase ==="
rg "sponsors/(version1|learnupon|offerzen|wework|harveynash|ninedots)" --type ts --type tsx
echo ""
echo "=== Searching for any references to sponsor images ==="
rg "sponsor.*\.png" --type ts --type tsx
echo ""
echo "=== Checking for images in alternative locations ==="
fd -t d "sponsors" . 2>/dev/null || echo "No sponsors directory found"
fd -t d "images" . 2>/dev/null | head -20Repository: codu-code/codu
Length of output: 680
🏁 Script executed:
#!/bin/bash
# Read the SocialProofSection.tsx file
echo "=== Reading SocialProofSection.tsx ==="
cat -n components/Sponsorship/SocialProofSection.tsx 2>/dev/null || echo "File not found"
echo ""
echo "=== Searching for 'Version 1' or other sponsor names ==="
rg "Version 1|LearnUpon|OfferZen|WeWork|Harvey Nash|NineDots"
echo ""
echo "=== Checking sponsorship directory contents ==="
find ./components/Sponsorship -type f
find ./app -path "*sponsorship*" -type f 2>/dev/nullRepository: codu-code/codu
Length of output: 4037
Add missing sponsor logo image assets.
The component references six sponsor logo images that do not exist in the public directory. The directory structure public/images/sponsors/ is missing entirely, and none of these files are present:
- version1.png
- learnupon.png
- offerzen.png
- wework.png
- harveynash.png
- ninedots.png
Create the directory structure and add all six sponsor logo PNG files, or remove these sponsor entries if they are not ready to be displayed.
🤖 Prompt for AI Agents
In @components/Sponsorship/SocialProofSection.tsx around lines 4 - 35, The
sponsors array in SocialProofSection.tsx references six logo assets
(version1.png, learnupon.png, offerzen.png, wework.png, harveynash.png,
ninedots.png) under /images/sponsors/ that do not exist; either add the missing
files into the public/images/sponsors/ directory with the exact filenames and
proper PNGs, or remove or comment out the corresponding entries in the sponsors
array (in components/Sponsorship/SocialProofSection.tsx) until the assets are
available so the component stops referencing non-existent images.
| 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) | ||
| ) | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
# Check schema for discussions and report definitions
find . -name "schema.ts" -o -name "*.sql" | head -20
rg -n "discussion" --type sql | head -30
rg -n "type.*discussion\|discussion.*type" server/ | head -20Repository: codu-code/codu
Length of output: 887
🏁 Script executed:
# Find ReportModal component
fd -name "*ReportModal*" -o -name "*Report*Modal*"
rg -n "ReportModal\|type.*==.*discussion" | head -30Repository: codu-code/codu
Length of output: 288
🏁 Script executed:
# Check how reports are handled in routers
rg -n "report.*router\|createReport\|discussion" server/api/routers/ | head -30Repository: codu-code/codu
Length of output: 117
🏁 Script executed:
# Find the correct directory structure
find . -type d -name "routers" | head -5
ls -la server/api/ 2>/dev/null || ls -la server/ 2>/dev/null | head -20Repository: codu-code/codu
Length of output: 281
🏁 Script executed:
# Search for ReportModal component
find . -type f \( -name "*.tsx" -o -name "*.ts" -o -name "*.jsx" -o -name "*.js" \) | xargs rg -l "ReportModal" | head -10Repository: codu-code/codu
Length of output: 400
🏁 Script executed:
# Search for discussion report handling and post_type usage
rg "discussion.*report|report.*discussion" -i | head -20
rg "post_type.*discussion|type.*discussion" | head -20Repository: codu-code/codu
Length of output: 1388
🏁 Script executed:
# Check all migrations after 0011 for discussionId
rg "discussionId|discussion_id" drizzle/Repository: codu-code/codu
Length of output: 40
🏁 Script executed:
# Read the schema.ts to see the actual content_report/reports definition
cat -n server/db/schema.ts | grep -A 30 "reports\|content_report" | head -60Repository: codu-code/codu
Length of output: 2334
🏁 Script executed:
# Check the full ReportModal implementation
cat -n components/ReportModal/ReportModal.tsx | head -100Repository: codu-code/codu
Length of output: 3718
🏁 Script executed:
# Get the full reports table definition from schema.ts
rg -A 50 "export const reports = pgTable" server/db/schema.tsRepository: codu-code/codu
Length of output: 1709
🏁 Script executed:
# Check if there's a separate discussions table
rg -n "discussions|discussion.*table" server/db/schema.ts | head -20Repository: codu-code/codu
Length of output: 201
🏁 Script executed:
# Check all migrations to understand discussions storage
rg -B 5 -A 5 "post_type.*discussion|discussion.*enum" drizzle/0011_unified_content_system.sqlRepository: codu-code/codu
Length of output: 342
🏁 Script executed:
# Get the discussions table definition
rg -A 30 "export const discussion = pgTable" server/db/schema.tsRepository: codu-code/codu
Length of output: 962
🏁 Script executed:
# Check all migrations including those after 0011 for any schema changes to reports
for file in drizzle/*.sql; do echo "=== $file ==="; grep -E "reports|discussion" "$file" | head -10; done | tail -100Repository: codu-code/codu
Length of output: 1289
🏁 Script executed:
# Check the schema/report.ts Zod schema to see expected structure
cat -n schema/report.ts | head -50Repository: codu-code/codu
Length of output: 1615
🏁 Script executed:
# Check the report router to see how it actually handles discussion reports
find . -type f -path "*/router*" -name "*.ts" | head -20Repository: codu-code/codu
Length of output: 479
🏁 Script executed:
# Look for the report router implementation
rg -B 5 -A 20 "createReport\|submitReport" --type ts | head -80Repository: codu-code/codu
Length of output: 40
🏁 Script executed:
# Get the report router implementation
cat -n server/api/router/report.ts | head -150Repository: codu-code/codu
Length of output: 6190
🏁 Script executed:
# Also check how ReportModal actually submits the report
rg -A 30 "const.*mutation\|useCreateReport\|api\.report\." components/ReportModal/ReportModal.tsx | head -80Repository: codu-code/codu
Length of output: 40
🏁 Script executed:
# Search for content_report table to see if it's different from reports
rg -B 5 -A 40 "export const content_report = pgTable" server/db/schema.tsRepository: codu-code/codu
Length of output: 1407
🏁 Script executed:
# Check the rest of report.ts to see how create/submit report is implemented
cat -n server/api/router/report.ts | tail -200 | head -100Repository: codu-code/codu
Length of output: 3342
The reports table lacks support for discussion reports that the application requires.
The ReportModal component supports type === "discussion" with numeric IDs, and the application's actual schema (schema.ts) defines a content_report table with a discussionId column that references the separate discussion table. However, the SQL migration in lines 223–239 creates a reports table with only post_id and comment_id columns, omitting the discussion_id column needed for discussion reports.
Either add discussion_id UUID REFERENCES discussion(id) ON DELETE CASCADE to the reports table, or ensure the migration aligns with how the application actually stores reports (the codebase currently uses the content_report table defined in schema.ts, which has the discussionId field).
🤖 Prompt for AI Agents
In @drizzle/0011_unified_content_system.sql around lines 223 - 239, The
migration's reports table is missing support for discussion reports used by
ReportModal and the application's content_report schema; add a discussion_id
UUID REFERENCES discussion(id) ON DELETE CASCADE column to the reports table (or
rename to match content_report if you intend to follow schema.ts), and update
the CONSTRAINT reports_target_check to allow exactly one of post_id, comment_id,
or discussion_id to be non-null (i.e., (post_id IS NOT NULL AND comment_id IS
NULL AND discussion_id IS NULL) OR (comment_id IS NOT NULL AND post_id IS NULL
AND discussion_id IS NULL) OR (discussion_id IS NOT NULL AND post_id IS NULL AND
comment_id IS NULL)); ensure any references to reports vs content_report are
consistent with schema.ts and application code.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 18
🤖 Fix all issues with AI Agents
In @app/(app)/[username]/[slug]/_linkContentDetail.tsx:
- Around line 69-100: The code updates state during render (serverVoteKey !==
lastSyncedKey block); move that logic into a useEffect: import useEffect, then
create an effect that depends on serverVoteKey and linkContent and inside call
setUserVote(currentUserVote), setVotes({upvotes: currentUpvotes, downvotes:
currentDownvotes}) and setLastSyncedKey(serverVoteKey); keep the
currentUserVote/currentUpvotes/currentDownvotes and serverVoteKey calculations,
but remove the direct state updates from the render path.
In @app/(app)/[username]/[slug]/page.tsx:
- Around line 182-299: generateMetadata duplicates nearly identical metadata
assembly for userPost and userArticle; extract a helper (e.g.,
generateArticleMetadata) that accepts an article-like object (fields: title,
excerpt, readTimeMins, updatedAt, canonicalUrl, user{name,username}, tags) and
returns the Metadata, computing tags, host (from headers()), authorName
defaulting to "Unknown", readTime defaulting to 5, OG and twitter images and
alternates.canonical, then replace the userPost and userArticle branches in
generateMetadata to call generateArticleMetadata(userPost) and
generateArticleMetadata(userArticle) respectively, preserving the same
openGraph/twitter/image formatting and null/undefined fallbacks.
- Around line 342-498: There is substantial duplicated rendering logic between
the userPost and userArticle branches; extract the shared UI into a reusable
component (e.g., UnifiedArticleRenderer) and a normalizer (e.g.,
normalizeArticle) that maps both shapes to a common article prop (fields like
id, title, body, slug, publishedAt/published, readTimeMins/readingTime,
showComments, upvotes, downvotes, tags, user). Move the Markdoc/Tiptap parsing
and rendering (the parsedBody/isTiptapContent logic and
renderSanitizedTiptapContent/Markdoc.renderers.react calls), breadcrumb, author
block, tags loop, InlineAuthorBio, ArticleActionBarWrapper and DiscussionArea
usage into UnifiedArticleRenderer and replace both if (userPost) and if
(userArticle?.user?.body) branches with a single return that calls
<UnifiedArticleRenderer article={normalizeArticle(userPost|userArticle)}
session={session} host={host}>, ensuring Admin panel rendering
(ArticleAdminPanel) remains conditional on session?.user?.role.
In @app/(app)/feed/_client.tsx:
- Around line 81-87: The handleSortChange function currently omits the current
"type" query parameter so switching sort clears the content type filter; update
handleSortChange to preserve type by adding if (type) params.set("type", type)
(similar to handleCategoryChange/handleTypeChange) before building queryString
and calling router.push, ensuring the router.push URL includes the existing type
when present.
- Around line 144-148: The prop passing to UnifiedContentCard is unsafe because
item.type is being force-cast to "POST" | "LINK"; update the code to map backend
item.type to the frontend union explicitly (similar to toFrontendType used in
SavedArticlesPreview) before passing it to UnifiedContentCard. Implement a small
mapping function or switch that converts allowed backend types (e.g., ARTICLE,
QUESTION, VIDEO, DISCUSSION, LINK, POST) into the frontend values "POST" or
"LINK" (and provide a safe default or skip rendering for unsupported types),
then pass the mapped value to UnifiedContentCard instead of casting item.type
directly.
- Around line 13-20: The local ContentType definition conflicts with
UnifiedContentCard’s expected type; replace the ad-hoc ContentType with the
canonical type used by the UI (import ContentType from
"@/components/UnifiedContentCard" or from "schema/content.ts"), remove the
unsafe cast currently applied when rendering UnifiedContentCard, and add an
explicit mapper function (e.g., mapApiContentTypeToUiType) that converts API
types like "ARTICLE" | "QUESTION" | "VIDEO" | "DISCUSSION" into the UI-safe
values "POST" | "LINK" (or returns null/handles unknowns) before passing to
UnifiedContentCard so mismatches are surfaced and handled deterministically.
- Around line 282-286: The toFrontendType function is comparing dbType to
lowercase "article" which doesn't match the API ContentType values (e.g.,
"ARTICLE"), causing everything to default to "LINK"; update the check in
toFrontendType (or normalize dbType) to compare against "ARTICLE" (or
dbType?.toUpperCase() === "ARTICLE") so that ARTICLE maps to "POST" and all
other values map to "LINK".
In @app/(app)/sponsorship/page.tsx:
- Line 4: Replace the remaining hardcoded "/sponsorship" references: update the
anchor href value currently set to "href=\"/sponsorship\"" (in the company page
component) to "href=\"/advertise\"" and update the Algolia index entry that
stores the page URL (the property currently containing "/sponsorship" in the
algolia indexing code) to use "/advertise" so the sitemap, internal links, and
search index all point to the new URL without requiring the redirect.
In @app/sitemap.ts:
- Around line 17-39: The query building the `articles` sitemap can yield URLs
with `null` usernames because `user.username` isn't enforced non-null; add a
non-null check for `user.username` (e.g., include isNotNull(user.username) in
the where() clause alongside the existing predicates) so the result set only
returns rows with valid `username` values before mapping to
`${BASE_URL}/${username}/${slug}`; alternatively, filter out entries with a
falsy `username` after the query to avoid producing `null` in the URL.
- Around line 61-83: The feedArticles query can produce URLs with a null
sourceSlug because the join only filters feed_sources.status; update the where
clause for the feedArticles query to also assert that feed_sources.slug is not
null (e.g., add an isNotNull(feed_sources.slug) condition or equivalent) so only
rows with a non-null feed_sources.slug are selected before mapping into URLs.
- Around line 50-59: The sitemap generation uses feed_sources where slug can be
null, producing URLs like `${BASE_URL}/${slug}`; update the logic around
db.query.feed_sources.findMany / the sources mapping to exclude records with
null/empty slugs (either by adding a where condition filtering feed_sources.slug
!= null and != '' or by filtering the result before mapping), and ensure you
only map entries that have a valid slug when creating the URL, lastModified and
priority.
- Around line 41-48: The sitemap generation maps db.query.user.findMany()
results into URLs but doesn't exclude records with null username, producing
invalid URLs; update the query or post-filter before mapping (e.g., add a where
clause to db.query.user.findMany({ where: { username: { not: null } } }) or
filter the array returned by db.query.user.findMany()) so the users variable
only includes entries with a non-null username before creating url:
`${BASE_URL}/${username}`; keep the mapping logic (lastModified, priority) the
same.
In @components/Discussion/DiscussionArea.tsx:
- Around line 145-172: The handler handleEditComment currently does await
editDiscussion(...), but editDiscussion is the synchronous mutate function (not
promise-returning), so destructure and use the async mutation instead: update
the hook where editDiscussion is defined to pull mutateAsync (e.g., const {
mutate, mutateAsync } = useYourEditDiscussionHook(...) or const { mutateAsync:
editDiscussionAsync } = editDiscussionHook), then replace await editDiscussion({
body, id }) with await mutateAsync({ body, id }) (or await editDiscussionAsync({
body, id })) so errors can be caught and state updates (setEditCommentBoxId,
setEditContent) run only after the mutation completes.
- Around line 119-143: The handler handleCreateComment is awaiting mutate which
returns void from the tRPC useMutation hook, so errors and completion aren't
observable; update the mutation usage to destructure and use mutateAsync instead
of mutate (e.g., get mutateAsync from the same hook where mutate is currently
obtained) and call await mutateAsync({ body, contentId, parentId }) inside
handleCreateComment so the try/catch correctly catches mutation errors and the
function truly waits for the mutation to finish.
- Around line 183-205: The inline TypeScript annotation on the destructured
callback parameters (the object containing body, createdAt, updatedAt, id,
userVote, score, user: { ... }, children) is redundant and inconsistent (it
includes unused props like youLikedThis and likeCount); remove the entire
explicit ": { ... }" type annotation from that anonymous arrow function
parameter and rely on TypeScript inference (or replace with a single named
interface/type used where the data originates if explicit typing is required),
and also delete any references to the stale properties youLikedThis and
likeCount inside this scope so the parameter list matches the actual API shape.
In @components/ReportModal/ReportModal.tsx:
- Around line 127-132: The onError block in ReportModal.tsx is fragile because
it matches the full error.message string; update onError to prefer structured
error checks (e.g., check error.code, error.data?.code or error.shape?.code from
the tRPC/HTTP response) and only fall back to a flexible string test like
includes("already report") if no code exists, then show the specific toast when
the code/substring indicates "already reported" and the generic toast otherwise;
apply this change inside the same onError handler so the mutation keeps
consistent user-facing messages.
🧹 Nitpick comments (4)
app/(app)/sponsorship/page.tsx (1)
3-3: Consider making the page component async for consistency.While not strictly necessary here, Next.js App Router page components are typically defined as
asyncfunctions. The relevant code snippets show this pattern in other pages (get-started/page.tsx,notifications/page.tsx).🔎 Optional refactor for consistency
-export default function SponsorshipRedirect() { +export default async function SponsorshipRedirect() { permanentRedirect("/advertise"); }app/(app)/[username]/[slug]/_linkContentDetail.tsx (2)
23-53: Refactor: Extract duplicate utility functions to shared module.The helper functions
getFaviconUrl,getHostname, andensureHttpsare duplicated across multiple feed-related components. Based on the PR context, similar utilities exist inapp/(app)/feed/[sourceSlug]/[shortId]/_client.tsxandapp/(app)/feed/[sourceSlug]/page.tsx.🔎 Suggested approach
Create a shared utility file (e.g.,
utils/feed.tsorutils/url-helpers.ts) and import these functions from there:// utils/url-helpers.ts export 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; } }; export const getHostname = (urlString: string): string => { try { const url = new URL(urlString); return url.hostname; } catch { return urlString; } }; export const ensureHttps = (url: string | null | undefined): string | null => { if (!url) return null; if (url.startsWith("http://")) { return url.replace("http://", "https://"); } return url; };Then update all files that use these utilities to import from the shared module.
126-143: Consider memoizing event handlers with useCallback.The
handleVoteandhandleSharefunctions are recreated on every render. While this doesn't cause functional issues, wrapping them withuseCallbackwould prevent unnecessary re-creations and potential re-renders of child components.🔎 Suggested optimization
+import { useState, useMemo, useCallback } from "react"; -const handleVote = (voteType: "up" | "down" | null) => { +const handleVote = useCallback((voteType: "up" | "down" | null) => { if (!session) { signIn(); return; } if (!linkContent) return; vote({ contentId: linkContent.id, voteType }); -}; +}, [session, linkContent, vote]); -const handleShare = async () => { +const handleShare = useCallback(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"); } -}; +}, [sourceSlug, contentSlug]);components/ReportModal/ReportModal.tsx (1)
184-189: Consider clarifying content type terminology.The mapping from internal types to user-facing labels creates some inconsistency:
"post"→"article""article"→"feed article""comment"→"comment"While this may align with your app's domain language, it could potentially confuse users about what content they're reporting.
💡 Optional improvement
Consider adding a comment explaining the terminology mapping or using a more explicit mapping object:
- const contentLabel = - type === "post" - ? "article" - : type === "article" - ? "feed article" - : "comment"; + // Map internal types to user-facing labels + const CONTENT_LABELS: Record<ReportType, string> = { + post: "article", // User-created posts are called "articles" in the UI + article: "feed article", // RSS articles are called "feed articles" + discussion: "discussion", + comment: "comment" + }; + const contentLabel = CONTENT_LABELS[type] || "content";
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (21)
app/(app)/[username]/[slug]/_linkContentDetail.tsxapp/(app)/[username]/[slug]/page.tsxapp/(app)/admin/_client.tsxapp/(app)/feed/_client.tsxapp/(app)/sponsorship/page.tsxapp/sitemap.tscomponents/Discussion/DiscussionArea.tsxcomponents/Discussion/DiscussionEditor/Editor.tsxcomponents/ReportModal/ReportModal.tsxserver/api/router/content.tsserver/api/router/discussion.tsserver/api/router/feed.tsserver/api/router/notification.tsserver/api/router/post.tsserver/api/router/report.tsserver/api/router/tag.tsserver/api/router/utils.tsserver/lib/posts.tsserver/lib/tags.tsutils/editor.tsutils/utils.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- components/Discussion/DiscussionEditor/Editor.tsx
- app/(app)/admin/_client.tsx
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2024-10-22T08:43:13.236Z
Learnt from: dineshsutihar
Repo: codu-code/codu PR: 1153
File: app/(app)/articles/[slug]/page.tsx:91-104
Timestamp: 2024-10-22T08:43:13.236Z
Learning: In `app/(app)/articles/[slug]/page.tsx`, when rendering content, the `renderedContent` needs to be treated as a string using a type assertion because it's used with `dangerouslySetInnerHTML`, and removing the type assertion could lead to runtime issues.
Applied to files:
app/(app)/[username]/[slug]/page.tsxapp/(app)/[username]/[slug]/_linkContentDetail.tsx
📚 Learning: 2024-10-18T04:18:28.906Z
Learnt from: RangerCreaky
Repo: codu-code/codu PR: 1146
File: server/api/router/series.ts:109-135
Timestamp: 2024-10-18T04:18:28.906Z
Learning: In the TypeScript file `server/api/router/series.ts`, when comments are included to aid reviewers, avoid suggesting their removal.
Applied to files:
components/Discussion/DiscussionArea.tsx
🧬 Code graph analysis (6)
app/(app)/feed/_client.tsx (3)
components/UnifiedContentCard/index.ts (1)
ContentType(4-4)components/UnifiedContentCard/UnifiedContentCard.tsx (1)
ContentType(19-19)schema/content.ts (1)
ContentType(14-14)
app/(app)/[username]/[slug]/page.tsx (2)
server/db/index.ts (1)
db(30-33)server/db/schema.ts (1)
user(114-170)
app/(app)/sponsorship/page.tsx (2)
app/(app)/get-started/page.tsx (1)
Page(10-17)app/(app)/notifications/page.tsx (1)
Page(13-19)
app/(app)/[username]/[slug]/_linkContentDetail.tsx (7)
auth.ts (2)
session(65-71)signIn(72-84)server/db/schema.ts (2)
session(71-77)voteType(41-41)utils/post.ts (1)
status(7-11)components/ui-components/link.tsx (1)
Link(12-22)app/(app)/notifications/_client.tsx (1)
api(32-209)app/(app)/articles/_client.tsx (1)
Link(165-173)app/(app)/[username]/_usernameClient.tsx (1)
Props(36-211)
components/Discussion/DiscussionArea.tsx (3)
schema/discussion.ts (1)
EditDiscussionSchema(11-14)markdoc/components.ts (1)
markdocComponents(7-13)components/Discussion/DiscussionEditor/Editor.tsx (1)
DiscussionEditor(12-178)
app/sitemap.ts (2)
server/db/index.ts (1)
db(30-33)server/db/schema.ts (5)
post(929-977)user(114-170)feed_sources(212-259)feed_sources(1664-1664)posts(273-356)
🪛 ast-grep (0.40.3)
app/(app)/[username]/[slug]/page.tsx
[warning] 427-427: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
[warning] 591-591: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
🪛 Biome (2.1.2)
app/(app)/[username]/[slug]/page.tsx
[error] 428-429: Avoid passing content using the dangerouslySetInnerHTML prop.
Setting content using code can expose users to cross-site scripting (XSS) attacks
(lint/security/noDangerouslySetInnerHtml)
[error] 592-593: Avoid passing content using the dangerouslySetInnerHTML prop.
Setting content using code can expose users to cross-site scripting (XSS) attacks
(lint/security/noDangerouslySetInnerHtml)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: e2e
🔇 Additional comments (14)
app/(app)/sponsorship/page.tsx (1)
1-5: LGTM! Clean redirect implementation.The
permanentRedirectusage is correct and appropriate for permanently moving the sponsorship page to/advertise. The 308 status code signals to search engines and browsers that this is a permanent move, which aligns well with the restructuring described in the PR objectives.app/(app)/feed/_client.tsx (2)
71-78: LGTM!The intersection observer setup and infinite scroll implementation follow standard patterns with proper dependency management.
132-194: LGTM!Error, loading, and empty states are handled appropriately with clear user feedback.
app/sitemap.ts (2)
85-103: LGTM!The priority hierarchy and route composition are well-structured. The OG filter and Sentry logging provide good observability.
5-14: LGTM!Imports and route additions align with the PR's expanded content system.
components/Discussion/DiscussionArea.tsx (1)
505-608: Main rendering logic looks good.The component properly handles:
- Loading states with an overlay spinner
- Conditional sort UI (only shown when multiple comments exist)
- User-friendly sign-in prompts for unauthenticated users
- Flexible rendering with the
noWrapperprop- Test identifiers for E2E testing
app/(app)/[username]/[slug]/_linkContentDetail.tsx (1)
145-410: LGTM - Content rendering and UI structure.The component provides a comprehensive user experience with:
- Proper loading states with skeleton UI
- Clear error handling and "Content Not Found" messaging
- Well-structured content layout with breadcrumbs, metadata, and actions
- Integration of voting, comments, and sharing features
- Proper use of semantic HTML and accessibility attributes
app/(app)/[username]/[slug]/page.tsx (3)
29-180: LGTM - Data fetching helpers.The helper functions properly fetch content from the unified
poststable with appropriate JOINs and WHERE clauses. The delegation pattern forgetLinkContentandgetUserArticleContentprovides a clean abstraction layer.
301-330: LGTM - Proper HTML sanitization.The
renderSanitizedTiptapContentfunction correctly sanitizes generated HTML usingsanitize-htmlwith explicit allow-lists for tags, attributes, and iframe hostnames. This mitigates XSS risks when usingdangerouslySetInnerHTML.Based on learnings, this approach is appropriate for Tiptap content rendering.
665-682: LGTM - Feed article and link content routing.The fallback routing for feed articles and link content properly delegates to specialized components (
FeedArticleContentandLinkContentDetail) and returnsnotFound()when no content is found. This provides a clean separation of concerns.components/ReportModal/ReportModal.tsx (4)
1-24: LGTM! Clean type definitions.The client directive, imports, and exported types are well-structured for a URL-driven modal flow.
26-91: Well-implemented URL-driven modal state.The parsing logic correctly handles IDs containing underscores by rejoining them, and the type-based ID conversion (numeric for articles/comments/discussions, string for posts) aligns with the data model. The
{ scroll: false }navigation option provides good UX.
261-321: Well-structured ReportButton with multiple variants.The component properly handles event propagation, authentication gating, and provides three clean variants (icon, text, menu) for different UI contexts.
323-363: Good backward compatibility with legacy export.The legacy
ReportModalexport bridges to the new URL-driven flow while maintaining the previous API surface. This allows for gradual migration of existing code.
| // Vote state management - derive initial values from query data | ||
| const initialVoteState = useMemo( | ||
| () => ({ | ||
| userVote: linkContent?.userVote ?? null, | ||
| upvotes: linkContent?.upvotes ?? 0, | ||
| downvotes: linkContent?.downvotes ?? 0, | ||
| }), | ||
| [linkContent?.userVote, linkContent?.upvotes, linkContent?.downvotes], | ||
| ); | ||
|
|
||
| const [userVote, setUserVote] = useState<"up" | "down" | null>( | ||
| initialVoteState.userVote, | ||
| ); | ||
| const [votes, setVotes] = useState({ | ||
| upvotes: initialVoteState.upvotes, | ||
| downvotes: initialVoteState.downvotes, | ||
| }); | ||
|
|
||
| // Sync state when server data changes (e.g., after mutation invalidation) | ||
| const currentUserVote = linkContent?.userVote ?? null; | ||
| const currentUpvotes = linkContent?.upvotes ?? 0; | ||
| const currentDownvotes = linkContent?.downvotes ?? 0; | ||
|
|
||
| // Use refs to track if we need to sync | ||
| const serverVoteKey = `${currentUserVote}-${currentUpvotes}-${currentDownvotes}`; | ||
| const [lastSyncedKey, setLastSyncedKey] = useState(serverVoteKey); | ||
|
|
||
| if (serverVoteKey !== lastSyncedKey && linkContent) { | ||
| setUserVote(currentUserVote); | ||
| setVotes({ upvotes: currentUpvotes, downvotes: currentDownvotes }); | ||
| setLastSyncedKey(serverVoteKey); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix: State updates during render violate React principles.
Lines 96-100 perform state updates directly during the render phase, which is a React anti-pattern. This can lead to unnecessary re-renders and unpredictable behavior.
🔎 Recommended fix using useEffect
// Vote state management - derive initial values from query data
const initialVoteState = useMemo(
() => ({
userVote: linkContent?.userVote ?? null,
upvotes: linkContent?.upvotes ?? 0,
downvotes: linkContent?.downvotes ?? 0,
}),
[linkContent?.userVote, linkContent?.upvotes, linkContent?.downvotes],
);
const [userVote, setUserVote] = useState<"up" | "down" | null>(
initialVoteState.userVote,
);
const [votes, setVotes] = useState({
upvotes: initialVoteState.upvotes,
downvotes: initialVoteState.downvotes,
});
- // Sync state when server data changes (e.g., after mutation invalidation)
- const currentUserVote = linkContent?.userVote ?? null;
- const currentUpvotes = linkContent?.upvotes ?? 0;
- const currentDownvotes = linkContent?.downvotes ?? 0;
-
- // Use refs to track if we need to sync
- const serverVoteKey = `${currentUserVote}-${currentUpvotes}-${currentDownvotes}`;
- const [lastSyncedKey, setLastSyncedKey] = useState(serverVoteKey);
-
- if (serverVoteKey !== lastSyncedKey && linkContent) {
- setUserVote(currentUserVote);
- setVotes({ upvotes: currentUpvotes, downvotes: currentDownvotes });
- setLastSyncedKey(serverVoteKey);
- }
+
+ // Sync state when server data changes (e.g., after mutation invalidation)
+ useEffect(() => {
+ if (linkContent) {
+ setUserVote(linkContent.userVote ?? null);
+ setVotes({
+ upvotes: linkContent.upvotes ?? 0,
+ downvotes: linkContent.downvotes ?? 0,
+ });
+ }
+ }, [linkContent?.userVote, linkContent?.upvotes, linkContent?.downvotes]);Add the useEffect import at the top:
-import { useState, useMemo } from "react";
+import { useState, useMemo, useEffect } from "react";📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Vote state management - derive initial values from query data | |
| const initialVoteState = useMemo( | |
| () => ({ | |
| userVote: linkContent?.userVote ?? null, | |
| upvotes: linkContent?.upvotes ?? 0, | |
| downvotes: linkContent?.downvotes ?? 0, | |
| }), | |
| [linkContent?.userVote, linkContent?.upvotes, linkContent?.downvotes], | |
| ); | |
| const [userVote, setUserVote] = useState<"up" | "down" | null>( | |
| initialVoteState.userVote, | |
| ); | |
| const [votes, setVotes] = useState({ | |
| upvotes: initialVoteState.upvotes, | |
| downvotes: initialVoteState.downvotes, | |
| }); | |
| // Sync state when server data changes (e.g., after mutation invalidation) | |
| const currentUserVote = linkContent?.userVote ?? null; | |
| const currentUpvotes = linkContent?.upvotes ?? 0; | |
| const currentDownvotes = linkContent?.downvotes ?? 0; | |
| // Use refs to track if we need to sync | |
| const serverVoteKey = `${currentUserVote}-${currentUpvotes}-${currentDownvotes}`; | |
| const [lastSyncedKey, setLastSyncedKey] = useState(serverVoteKey); | |
| if (serverVoteKey !== lastSyncedKey && linkContent) { | |
| setUserVote(currentUserVote); | |
| setVotes({ upvotes: currentUpvotes, downvotes: currentDownvotes }); | |
| setLastSyncedKey(serverVoteKey); | |
| } | |
| // Vote state management - derive initial values from query data | |
| const initialVoteState = useMemo( | |
| () => ({ | |
| userVote: linkContent?.userVote ?? null, | |
| upvotes: linkContent?.upvotes ?? 0, | |
| downvotes: linkContent?.downvotes ?? 0, | |
| }), | |
| [linkContent?.userVote, linkContent?.upvotes, linkContent?.downvotes], | |
| ); | |
| const [userVote, setUserVote] = useState<"up" | "down" | null>( | |
| initialVoteState.userVote, | |
| ); | |
| const [votes, setVotes] = useState({ | |
| upvotes: initialVoteState.upvotes, | |
| downvotes: initialVoteState.downvotes, | |
| }); | |
| // Sync state when server data changes (e.g., after mutation invalidation) | |
| useEffect(() => { | |
| if (linkContent) { | |
| setUserVote(linkContent.userVote ?? null); | |
| setVotes({ | |
| upvotes: linkContent.upvotes ?? 0, | |
| downvotes: linkContent.downvotes ?? 0, | |
| }); | |
| } | |
| }, [linkContent?.userVote, linkContent?.upvotes, linkContent?.downvotes]); |
🤖 Prompt for AI Agents
In @app/(app)/[username]/[slug]/_linkContentDetail.tsx around lines 69 - 100,
The code updates state during render (serverVoteKey !== lastSyncedKey block);
move that logic into a useEffect: import useEffect, then create an effect that
depends on serverVoteKey and linkContent and inside call
setUserVote(currentUserVote), setVotes({upvotes: currentUpvotes, downvotes:
currentDownvotes}) and setLastSyncedKey(serverVoteKey); keep the
currentUserVote/currentUpvotes/currentDownvotes and serverVoteKey calculations,
but remove the direct state updates from the render path.
| export async function generateMetadata(props: Props): Promise<Metadata> { | ||
| 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" }; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Refactor: Eliminate metadata generation duplication.
The metadata generation for userPost (lines 188-221) and userArticle (lines 224-258) contains nearly identical logic. This duplication makes the code harder to maintain and increases the risk of inconsistencies.
🔎 Suggested refactor
Extract a helper function to generate article metadata:
const generateArticleMetadata = async (
article: {
title: string;
excerpt: string | null | undefined;
readTimeMins?: number | null;
updatedAt: string;
canonicalUrl?: string | null;
user: {
name: string | null;
username: string | null;
};
tags?: Array<{ tag: { title: string } }>;
}
): Promise<Metadata> => {
const tags = article.tags?.map((t) => t.tag.title) || [];
const host = (await headers()).get("host") || "";
const authorName = article.user.name || "Unknown";
const readTime = article.readTimeMins || 5;
return {
title: `${article.title} | by ${authorName} | Codú`,
authors: {
name: authorName,
url: `https://www.${host}/${article.user.username}`,
},
keywords: tags,
description: article.excerpt ?? undefined,
openGraph: {
description: article.excerpt || "",
type: "article",
images: [
`/og?title=${encodeURIComponent(article.title)}&readTime=${readTime}&author=${encodeURIComponent(authorName)}&date=${article.updatedAt}`,
],
siteName: "Codú",
},
twitter: {
description: article.excerpt || "",
images: [`/og?title=${encodeURIComponent(article.title)}`],
},
alternates: {
canonical: article.canonicalUrl,
},
};
};
export async function generateMetadata(props: Props): Promise<Metadata> {
const params = await props.params;
const { username, slug } = params;
// Try user post
const userPost = await getUserPost(username, slug);
if (userPost) {
return generateArticleMetadata(userPost);
}
// Try user article
const userArticle = await getUserArticleContent(username, slug);
if (userArticle?.user) {
return generateArticleMetadata(userArticle);
}
// Feed article and link content logic remains the same...
// ...
}🤖 Prompt for AI Agents
In @app/(app)/[username]/[slug]/page.tsx around lines 182 - 299,
generateMetadata duplicates nearly identical metadata assembly for userPost and
userArticle; extract a helper (e.g., generateArticleMetadata) that accepts an
article-like object (fields: title, excerpt, readTimeMins, updatedAt,
canonicalUrl, user{name,username}, tags) and returns the Metadata, computing
tags, host (from headers()), authorName defaulting to "Unknown", readTime
defaulting to 5, OG and twitter images and alternates.canonical, then replace
the userPost and userArticle branches in generateMetadata to call
generateArticleMetadata(userPost) and generateArticleMetadata(userArticle)
respectively, preserving the same openGraph/twitter/image formatting and
null/undefined fallbacks.
| 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 ( | ||
| <> | ||
| <div className="mx-auto max-w-3xl px-4 py-8"> | ||
| {/* Breadcrumb navigation */} | ||
| <nav className="mb-6 flex items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400"> | ||
| <Link | ||
| href="/feed" | ||
| className="hover:text-neutral-700 dark:hover:text-neutral-200" | ||
| > | ||
| Feed | ||
| </Link> | ||
| <span aria-hidden="true">/</span> | ||
| <Link | ||
| href={`/${userPost.user.username}`} | ||
| className="hover:text-neutral-700 dark:hover:text-neutral-200" | ||
| > | ||
| {userPost.user.name} | ||
| </Link> | ||
| </nav> | ||
|
|
||
| {/* Article card - contains everything in one cohesive unit */} | ||
| <article className="rounded-lg border border-neutral-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-900"> | ||
| {/* Author info */} | ||
| <div className="mb-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-neutral-500 dark:text-neutral-400"> | ||
| <Link | ||
| href={`/${userPost.user.username}`} | ||
| className="flex items-center gap-2 hover:text-neutral-700 dark:hover:text-neutral-200" | ||
| > | ||
| {userPost.user.image ? ( | ||
| <img | ||
| src={userPost.user.image} | ||
| alt="" | ||
| className="h-5 w-5 rounded-full object-cover" | ||
| /> | ||
| ) : ( | ||
| <div className="flex h-5 w-5 items-center justify-center rounded-full bg-orange-100 text-xs font-bold text-orange-600 dark:bg-orange-900 dark:text-orange-300"> | ||
| {userPost.user.name?.charAt(0).toUpperCase() || "?"} | ||
| </div> | ||
| )} | ||
| <span className="font-medium">{userPost.user.name}</span> | ||
| </Link> | ||
| {userPost.published && ( | ||
| <> | ||
| <span aria-hidden="true">·</span> | ||
| <time> | ||
| {new Date(userPost.published).toLocaleDateString("en-IE", { | ||
| year: "numeric", | ||
| month: "long", | ||
| day: "numeric", | ||
| })} | ||
| </time> | ||
| </> | ||
| )} | ||
| {userPost.readTimeMins && ( | ||
| <> | ||
| <span aria-hidden="true">·</span> | ||
| <span>{userPost.readTimeMins} min read</span> | ||
| </> | ||
| )} | ||
| </div> | ||
|
|
||
| {/* Article content */} | ||
| <div className="prose mx-auto max-w-none dark:prose-invert lg:prose-lg"> | ||
| {!isTiptapContent && <h1>{userPost.title}</h1>} | ||
|
|
||
| {isTiptapContent ? ( | ||
| <div | ||
| dangerouslySetInnerHTML={{ | ||
| __html: renderedContent ?? <NotFound />, | ||
| }} | ||
| className="tiptap-content" | ||
| /> | ||
| ) : ( | ||
| <div> | ||
| {Markdoc.renderers.react(renderedContent, React, { | ||
| components: markdocComponents, | ||
| })} | ||
| </div> | ||
| )} | ||
| </div> | ||
|
|
||
| {/* Tags */} | ||
| {userPost.tags.length > 0 && ( | ||
| <section className="mt-6 flex flex-wrap gap-3"> | ||
| {userPost.tags.map(({ tag }) => ( | ||
| <Link | ||
| href={`/feed?tag=${tag.title.toLowerCase()}`} | ||
| key={tag.title} | ||
| className="rounded-full bg-gradient-to-r from-orange-400 to-pink-600 px-3 py-1 text-xs font-bold text-white hover:bg-pink-700" | ||
| > | ||
| {getCamelCaseFromLower(tag.title)} | ||
| </Link> | ||
| ))} | ||
| </section> | ||
| )} | ||
|
|
||
| {/* Compact inline author bio */} | ||
| <div className="mt-8"> | ||
| <InlineAuthorBio | ||
| name={userPost.user.name || "Unknown"} | ||
| username={userPost.user.username || ""} | ||
| image={userPost.user.image} | ||
| bio={userPost.user.bio} | ||
| /> | ||
| </div> | ||
|
|
||
| {/* Action bar - just above discussion */} | ||
| <div className="mt-8"> | ||
| <ArticleActionBarWrapper | ||
| postId={userPost.id} | ||
| postTitle={userPost.title} | ||
| postUrl={`https://${host}/${userPost.user.username}/${userPost.slug}`} | ||
| postUsername={userPost.user.username || ""} | ||
| initialUpvotes={userPost.upvotes ?? 0} | ||
| initialDownvotes={userPost.downvotes ?? 0} | ||
| /> | ||
| </div> | ||
|
|
||
| {/* Discussion section - inside the card */} | ||
| <section id="discussion" className="mt-8"> | ||
| {userPost.showComments ? ( | ||
| <DiscussionArea contentId={userPost.id} noWrapper /> | ||
| ) : ( | ||
| <div className="py-4"> | ||
| <p className="italic text-neutral-500 dark:text-neutral-400"> | ||
| Comments are disabled for this post | ||
| </p> | ||
| </div> | ||
| )} | ||
| </section> | ||
| </article> | ||
| </div> | ||
|
|
||
| {session && session?.user?.role === "ADMIN" && ( | ||
| <ArticleAdminPanel session={session} postId={userPost.id} /> | ||
| )} | ||
| </> | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Refactor: Extract duplicate article rendering logic.
The rendering logic for userPost (lines 342-498) and userArticle (lines 504-662) is nearly identical, with only minor differences in field names (published vs publishedAt, readTimeMins availability). This ~320 lines of duplication should be extracted into a reusable component or helper function.
🔎 Suggested approach
Create a shared article renderer component:
// components/UnifiedArticleRenderer.tsx
type UnifiedArticleProps = {
article: {
id: string;
title: string;
body: string;
slug: string;
publishedAt: string | null;
updatedAt: string;
readingTime: number | null;
showComments: boolean;
canonicalUrl?: string | null;
upvotes: number;
downvotes: number;
tags?: Array<{ tag: { title: string } }>;
user: {
id: string;
name: string | null;
username: string | null;
image: string | null;
bio: string | null;
};
};
session: Session | null;
host: string;
};
export const UnifiedArticleRenderer = ({ article, session, host }: UnifiedArticleProps) => {
// Extract the common rendering logic here
// Handle both Tiptap and Markdoc content
// Render breadcrumb, article card, author bio, action bar, discussion
return (
<>
<div className="mx-auto max-w-3xl px-4 py-8">
{/* Common rendering logic */}
</div>
{session?.user?.role === "ADMIN" && (
<ArticleAdminPanel session={session} postId={article.id} />
)}
</>
);
};Then use it in the main component:
if (userPost) {
return <UnifiedArticleRenderer article={normalizeArticle(userPost)} session={session} host={host} />;
}
if (userArticle?.user?.body) {
return <UnifiedArticleRenderer article={normalizeArticle(userArticle)} session={session} host={host} />;
}Create a normalizeArticle helper to ensure both data shapes conform to the expected interface.
Also applies to: 504-662
🧰 Tools
🪛 ast-grep (0.40.3)
[warning] 427-427: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
🪛 Biome (2.1.2)
[error] 428-429: Avoid passing content using the dangerouslySetInnerHTML prop.
Setting content using code can expose users to cross-site scripting (XSS) attacks
(lint/security/noDangerouslySetInnerHtml)
🤖 Prompt for AI Agents
In @app/(app)/[username]/[slug]/page.tsx around lines 342 - 498, There is
substantial duplicated rendering logic between the userPost and userArticle
branches; extract the shared UI into a reusable component (e.g.,
UnifiedArticleRenderer) and a normalizer (e.g., normalizeArticle) that maps both
shapes to a common article prop (fields like id, title, body, slug,
publishedAt/published, readTimeMins/readingTime, showComments, upvotes,
downvotes, tags, user). Move the Markdoc/Tiptap parsing and rendering (the
parsedBody/isTiptapContent logic and
renderSanitizedTiptapContent/Markdoc.renderers.react calls), breadcrumb, author
block, tags loop, InlineAuthorBio, ArticleActionBarWrapper and DiscussionArea
usage into UnifiedArticleRenderer and replace both if (userPost) and if
(userArticle?.user?.body) branches with a single return that calls
<UnifiedArticleRenderer article={normalizeArticle(userPost|userArticle)}
session={session} host={host}>, ensuring Admin panel rendering
(ArticleAdminPanel) remains conditional on session?.user?.role.
| <div | ||
| dangerouslySetInnerHTML={{ | ||
| __html: renderedContent ?? <NotFound />, | ||
| }} | ||
| className="tiptap-content" | ||
| /> | ||
| ) : ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix: Invalid fallback in dangerouslySetInnerHTML.
Lines 429 and 593 use renderedContent ?? <NotFound /> as the value for __html, but dangerouslySetInnerHTML requires a string, not JSX. The <NotFound /> component cannot be used as a fallback here and will cause a runtime type error.
🔎 Proposed fix
Option 1: Use conditional rendering instead of a fallback:
-{isTiptapContent ? (
- <div
- dangerouslySetInnerHTML={{
- __html: renderedContent ?? <NotFound />,
- }}
- className="tiptap-content"
- />
-) : (
+{isTiptapContent ? (
+ renderedContent ? (
+ <div
+ dangerouslySetInnerHTML={{
+ __html: renderedContent as string,
+ }}
+ className="tiptap-content"
+ />
+ ) : (
+ <NotFound />
+ )
+) : (
<div>
{Markdoc.renderers.react(renderedContent, React, {
components: markdocComponents,
})}
</div>
)}Option 2: Ensure renderedContent is never null by providing an empty string fallback:
<div
dangerouslySetInnerHTML={{
- __html: renderedContent ?? <NotFound />,
+ __html: (renderedContent as string) || "",
}}
className="tiptap-content"
/>Option 1 is preferred as it provides better error handling.
Also applies to: 590-597
🧰 Tools
🪛 ast-grep (0.40.3)
[warning] 427-427: Usage of dangerouslySetInnerHTML detected. This bypasses React's built-in XSS protection. Always sanitize HTML content using libraries like DOMPurify before injecting it into the DOM to prevent XSS attacks.
Context: dangerouslySetInnerHTML
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://reactjs.org/docs/dom-elements.html#dangerouslysetinnerhtml
- https://cwe.mitre.org/data/definitions/79.html
(react-unsafe-html-injection)
🪛 Biome (2.1.2)
[error] 428-429: Avoid passing content using the dangerouslySetInnerHTML prop.
Setting content using code can expose users to cross-site scripting (XSS) attacks
(lint/security/noDangerouslySetInnerHtml)
| type SortOption = "recent" | "trending" | "popular"; | ||
| type ContentType = | ||
| | "ARTICLE" | ||
| | "LINK" | ||
| | "QUESTION" | ||
| | "VIDEO" | ||
| | "DISCUSSION" | ||
| | null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Local ContentType conflicts with the component's expected type.
The local ContentType includes "ARTICLE" | "LINK" | "QUESTION" | "VIDEO" | "DISCUSSION", but UnifiedContentCard (per the relevant snippets) expects only "POST" | "LINK". This mismatch causes the type cast on line 147 to mask potential runtime issues when content types like "ARTICLE" or "QUESTION" are passed.
Consider importing ContentType from @/components/UnifiedContentCard or schema/content.ts and mapping API types to UI types explicitly.
🤖 Prompt for AI Agents
In @app/(app)/feed/_client.tsx around lines 13 - 20, The local ContentType
definition conflicts with UnifiedContentCard’s expected type; replace the ad-hoc
ContentType with the canonical type used by the UI (import ContentType from
"@/components/UnifiedContentCard" or from "schema/content.ts"), remove the
unsafe cast currently applied when rendering UnifiedContentCard, and add an
explicit mapper function (e.g., mapApiContentTypeToUiType) that converts API
types like "ARTICLE" | "QUESTION" | "VIDEO" | "DISCUSSION" into the UI-safe
values "POST" | "LINK" (or returns null/handles unknowns) before passing to
UnifiedContentCard so mismatches are surfaced and handled deterministically.
| const handleCreateComment = async (body: string, parentId?: 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 { | ||
| await mutate({ | ||
| body, | ||
| contentId, | ||
| parentId, | ||
| }); | ||
| } catch (err) { | ||
| toast.error("Something went wrong saving your comment."); | ||
| throw err; | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical issue from previous review remains unfixed: mutate does not return a Promise.
This issue was previously flagged but has not been addressed. The await mutate(...) on line 134 will not actually wait for the mutation to complete because tRPC's useMutation hook's mutate function returns void, not a Promise. This means:
- The
try/catchblock cannot catch mutation errors - The function may throw "Invalid markdown" before the mutation completes
- Error handling via the toast on line 140 will never execute
You must use mutateAsync instead of mutate for async/await patterns.
🔎 Proposed fix
Destructure mutateAsync from the mutation hook on line 55:
- const { mutate, status: createDiscussionStatus } =
+ const { mutate, mutateAsync, status: createDiscussionStatus } =
api.discussion.create.useMutation({Then use mutateAsync in the handler:
try {
- await mutate({
+ await mutateAsync({
body,
contentId,
parentId,
});Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In @components/Discussion/DiscussionArea.tsx around lines 119 - 143, The handler
handleCreateComment is awaiting mutate which returns void from the tRPC
useMutation hook, so errors and completion aren't observable; update the
mutation usage to destructure and use mutateAsync instead of mutate (e.g., get
mutateAsync from the same hook where mutate is currently obtained) and call
await mutateAsync({ body, contentId, parentId }) inside handleCreateComment so
the try/catch correctly catches mutation errors and the function truly waits for
the mutation to finish.
| 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) { | ||
| toast.error(err.issues[0].message); | ||
| throw err; | ||
| } | ||
| toast.error("Something went wrong editing your comment."); | ||
| throw err; | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical issue from previous review remains unfixed: editDiscussion is mutate, not mutateAsync.
This issue was previously flagged but has not been addressed. The await editDiscussion(...) on line 161 will not wait for the mutation because editDiscussion is assigned to mutate (line 83), which returns void. This means:
- The
try/catchcannot catch mutation errors - State updates on lines 162-163 may execute before the mutation completes
- Error handling will not work as intended
You must use mutateAsync for async/await patterns.
🔎 Proposed fix
Destructure both mutate and mutateAsync on line 83:
- const { mutate: editDiscussion, status: editStatus } =
+ const { mutate: editDiscussion, mutateAsync: editDiscussionAsync, status: editStatus } =
api.discussion.edit.useMutation({Then use the async version in the handler:
try {
EditDiscussionSchema.parse({ body, id });
- await editDiscussion({ body, id });
+ await editDiscussionAsync({ body, id });
setEditCommentBoxId(null);
setEditContent("");Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In @components/Discussion/DiscussionArea.tsx around lines 145 - 172, The handler
handleEditComment currently does await editDiscussion(...), but editDiscussion
is the synchronous mutate function (not promise-returning), so destructure and
use the async mutation instead: update the hook where editDiscussion is defined
to pull mutateAsync (e.g., const { mutate, mutateAsync } =
useYourEditDiscussionHook(...) or const { mutateAsync: editDiscussionAsync } =
editDiscussionHook), then replace await editDiscussion({ body, id }) with await
mutateAsync({ body, id }) (or await editDiscussionAsync({ body, id })) so errors
can be caught and state updates (setEditCommentBoxId, setEditContent) run only
after the mutation completes.
| ({ | ||
| body, | ||
| createdAt, | ||
| updatedAt, | ||
| id, | ||
| userVote, | ||
| score, | ||
| user: { name, image, username, id: odiserId }, | ||
| children, | ||
| }: { | ||
| body: string; | ||
| createdAt: string; | ||
| updatedAt: string; | ||
| id: string; | ||
| 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; | ||
| }) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Remove redundant and inconsistent inline type annotation.
The inline TypeScript type annotation for the destructured parameters is unnecessary and contains inconsistencies:
- TypeScript can infer the types from the actual data structure
- The annotation includes unused properties (
youLikedThis,likeCount) that don't match the actual API response - This creates maintenance burden and potential confusion
🔎 Proposed fix
Remove the explicit type annotation and rely on TypeScript's inference:
return sortedDiscussions.map(
({
body,
createdAt,
updatedAt,
id,
userVote,
score,
user: { name, image, username, id: odiserId },
children,
- }: {
- body: string;
- createdAt: string;
- updatedAt: string;
- id: string;
- 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;
- }) => {
+ }) => {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ({ | |
| body, | |
| createdAt, | |
| updatedAt, | |
| id, | |
| userVote, | |
| score, | |
| user: { name, image, username, id: odiserId }, | |
| children, | |
| }: { | |
| body: string; | |
| createdAt: string; | |
| updatedAt: string; | |
| id: string; | |
| 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; | |
| }) => { | |
| return sortedDiscussions.map( | |
| ({ | |
| body, | |
| createdAt, | |
| updatedAt, | |
| id, | |
| userVote, | |
| score, | |
| user: { name, image, username, id: odiserId }, | |
| children, | |
| }) => { |
🤖 Prompt for AI Agents
In @components/Discussion/DiscussionArea.tsx around lines 183 - 205, The inline
TypeScript annotation on the destructured callback parameters (the object
containing body, createdAt, updatedAt, id, userVote, score, user: { ... },
children) is redundant and inconsistent (it includes unused props like
youLikedThis and likeCount); remove the entire explicit ": { ... }" type
annotation from that anonymous arrow function parameter and rely on TypeScript
inference (or replace with a single named interface/type used where the data
originates if explicit typing is required), and also delete any references to
the stale properties youLikedThis and likeCount inside this scope so the
parameter list matches the actual API shape.
| 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."); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fragile error handling with exact string matching.
The error handling relies on matching the exact error message text "You have already reported this item". If the backend changes this message, the specific error handling will fail and users will see the generic error message instead.
🔎 Recommended fix using error codes
Consider having the backend return structured error codes or using tRPC error codes instead of string matching:
const { mutate: createReport } = api.report.create.useMutation({
onSuccess: () => {
toast.success("Report submitted successfully");
closeReport();
},
onError: (error) => {
- if (error.message === "You have already reported this item") {
+ if (error.data?.code === "ALREADY_REPORTED") {
toast.error("You have already reported this item");
} else {
toast.error("Failed to submit report. Please try again.");
}
},Alternatively, if error codes aren't available, use a more flexible check:
- if (error.message === "You have already reported this item") {
+ if (error.message?.includes("already reported")) {
toast.error("You have already reported this item");📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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."); | |
| } | |
| onError: (error) => { | |
| if (error.message?.includes("already reported")) { | |
| toast.error("You have already reported this item"); | |
| } else { | |
| toast.error("Failed to submit report. Please try again."); | |
| } |
🤖 Prompt for AI Agents
In @components/ReportModal/ReportModal.tsx around lines 127 - 132, The onError
block in ReportModal.tsx is fragile because it matches the full error.message
string; update onError to prefer structured error checks (e.g., check
error.code, error.data?.code or error.shape?.code from the tRPC/HTTP response)
and only fall back to a flexible string test like includes("already report") if
no code exists, then show the specific toast when the code/substring indicates
"already reported" and the generic toast otherwise; apply this change inside the
same onError handler so the mutation keeps consistent user-facing messages.
| } 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, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify report reason enum, backend schema, and reporting patterns
# 1. Find ReportReason enum/type definition
echo "=== Searching for ReportReason definition ==="
rg -n "enum.*ReportReason|type.*ReportReason|ReportReason\s*=" --type ts --type tsx -C 3
echo ""
echo "=== Searching for prisma schema report definitions ==="
fd -e prisma | xargs cat 2>/dev/null | grep -A 20 "model.*Report"
echo ""
echo "=== Checking reporting patterns across content types ==="
rg -n "createReport|sendReport" --type ts --type tsx -B 2 -A 2
echo ""
echo "=== Checking ReportModal component for reason handling ==="
fd -n "ReportModal.tsx" | head -1 | xargs wc -lRepository: codu-code/codu
Length of output: 583
🏁 Script executed:
#!/bin/bash
# Get more details on ReportModal.tsx
fd -n "ReportModal.tsx" | head -1 | xargs cat -n | head -200Repository: codu-code/codu
Length of output: 288
🏁 Script executed:
#!/bin/bash
# Search for migration or reporting strategy documentation
find . -type f \( -name "*.md" -o -name "*.txt" -o -name "MIGRATION*" \) | xargs rg -l "report.*migration|reporting.*strategy|discussion.*report" -i 2>/dev/nullRepository: codu-code/codu
Length of output: 42
🏁 Script executed:
#!/bin/bash
# Better search for ReportReason definition
echo "=== Searching for ReportReason enum/type ==="
rg "enum ReportReason|type ReportReason|ReportReason\s*=" -C 3
echo ""
echo "=== Checking for schema.prisma ==="
fd "schema.prisma" -exec cat {} \;Repository: codu-code/codu
Length of output: 1376
🏁 Script executed:
#!/bin/bash
# Search for reporting patterns
echo "=== Checking reporting patterns ==="
rg "createReport|sendReport" -B 2 -A 2
echo ""
echo "=== Find ReportModal.tsx file ==="
fd "ReportModal.tsx"Repository: codu-code/codu
Length of output: 2321
🏁 Script executed:
#!/bin/bash
# Get ReportModal content
echo "=== ReportModal.tsx content ==="
fd "ReportModal.tsx" -exec wc -l {} \;
fd "ReportModal.tsx" -exec cat -n {} \; | head -250Repository: codu-code/codu
Length of output: 524
🏁 Script executed:
#!/bin/bash
# Look for migration docs
echo "=== Migration/strategy docs ==="
fd ".*md$|.*txt$" | xargs grep -l "report.*migration\|reporting.*strategy\|discussion.*report" -i 2>/dev/null || echo "No migration docs found"Repository: codu-code/codu
Length of output: 113
🏁 Script executed:
#!/bin/bash
cat components/ReportModal/ReportModal.tsxRepository: codu-code/codu
Length of output: 10831
🏁 Script executed:
#!/bin/bash
# Check the schema/report.ts to see full ReportReason definition
echo "=== Full ReportReason definition ==="
cat schema/report.ts | head -100Repository: codu-code/codu
Length of output: 1974
🏁 Script executed:
#!/bin/bash
# Check the report API endpoints
echo "=== Report API endpoints ==="
rg "report\.(create|send)" -A 15 --type tsRepository: codu-code/codu
Length of output: 4356
Hardcoded "OTHER" reason prevents effective moderation triage for discussion reports.
The discussion report submission uses a hardcoded reason: "OTHER" despite the backend supporting multiple report reasons (SPAM, HARASSMENT, HATE_SPEECH, MISINFORMATION, COPYRIGHT, NSFW, OFF_TOPIC). This prevents moderators from categorizing reports by issue type, making triage and prioritization ineffective.
Additionally, discussions are the only content type using the new database-backed reporting (createReport), while posts, articles, and comments continue to use email-based reporting (sendReport). This inconsistency should be addressed as part of a unified reporting strategy.
The form currently only provides a free-text details field with no reason selector. Add a dropdown to allow users to specify the actual issue category:
Suggested implementation
+ const [reportReason, setReportReason] = useState<ReportReason>("OTHER");
+
// In the form, add after the description:
+ <label htmlFor="report-reason" className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
+ What's the issue?
+ </label>
+ <select
+ id="report-reason"
+ value={reportReason}
+ onChange={(e) => setReportReason(e.target.value as ReportReason)}
+ className="mt-2 block w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-neutral-900 focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-100"
+ >
+ <option value="SPAM">Spam</option>
+ <option value="HARASSMENT">Harassment</option>
+ <option value="HATE_SPEECH">Hate Speech</option>
+ <option value="MISINFORMATION">Misinformation</option>
+ <option value="NSFW">Inappropriate Content</option>
+ <option value="OFF_TOPIC">Off-Topic</option>
+ <option value="COPYRIGHT">Copyright Violation</option>
+ <option value="OTHER">Other</option>
+ </select>
createReport({
discussionId: id as number,
- reason: "OTHER",
+ reason: reportReason,
details: reportBody || undefined,
});Committable suggestion skipped: line range outside the PR's diff.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
components/Comments/CommentsArea.tsx (1)
1-1: Fix code formatting before merge.The pipeline detected code style issues. Run the Prettier formatter to resolve them:
prettier --write components/Comments/CommentsArea.tsx
🤖 Fix all issues with AI Agents
In @components/Comments/CommentsArea.tsx:
- Around line 151-152: The current type guard using typeof editCommentBoxId !==
"string" is too strict if IDs can be numeric; instead ensure editCommentBoxId is
not null/undefined and accept both string and number forms: replace the typeof
check with a nullish check (e.g., editCommentBoxId == null) and when you need to
compare or pass the id to functions, normalize it (use String(editCommentBoxId)
or Number(editCommentBoxId) consistently) so both numeric and string IDs work;
update any downstream comparisons in CommentsArea that assume a string id to use
the same normalized form.
- Around line 184-194: The map is using an unsafe cast for children; define a
CommentWithChildren interface (including id, body, createdAt, updatedAt,
userVote, score, author which can be null, and optional children?:
CommentWithChildren[]) and use it in the CommentsArea component so commentsArr
is typed as CommentWithChildren[]; then remove the (comment as any).children
cast and access comment.children directly (and update the function
signature/props where commentsArr is declared to use CommentWithChildren[]).
In @components/Feed/AggregatedItem.tsx:
- Around line 338-345: The comments button currently renders a hardcoded "0";
update the AggregatedItem component to use the real comment count (e.g.,
props.commentCount or article.commentCount) in the <span> next to
ChatBubbleLeftIcon, and if no count is provided/available render nothing (omit
the <span>) or a placeholder like "-" instead of "0"; ensure the prop is
accepted by the component (add commentCount to the component props or read it
from the article object) and use it in the Link rendering block.
- Around line 174-180: The handleReport function is passing the wrong content
type; update handleReport to call openReport with the type "article" instead of
"post" and ensure the id passed to openReport is a numeric ID (e.g., convert the
existing id prop to a number before passing). Keep the existing session check
and signIn() flow unchanged; only change the argument values in handleReport
(function name: handleReport, call site: openReport).
- Around line 120-123: The vote mutation is still using the legacy
api.content.vote and invalidating utils.content.getFeed, causing cache misses;
change the mutation to use api.feed.vote (so it aligns with api.feed.bookmark)
and update the onSuccess invalidation to utils.feed.getFeed; ensure you keep the
same destructured names (mutate: vote, status: voteStatus) and confirm the vote
call signature matches the new api.feed.vote usage.
🧹 Nitpick comments (5)
components/Feed/AggregatedItem.tsx (5)
19-19: Consider whether Temporal polyfill is necessary here.The
@js-temporal/polyfillis imported but only used fortoLocaleStringformatting (lines 182-191), which nativeDatealready supports. This polyfill adds significant bundle size. If Temporal-specific features aren't needed elsewhere, consider using nativeDate.toLocaleDateString()instead.
54-67: Handle edge case for future dates.If
publishedAtis in the future (e.g., scheduled content or clock skew),diffMsbecomes negative, resulting in strings like-5m ago. Consider adding a guard:🔎 Proposed fix
const getRelativeTime = (dateStr: string): string => { const now = new Date(); const date = new Date(dateStr); const diffMs = now.getTime() - date.getTime(); + if (diffMs < 0) return "Just now"; const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000);
106-107: Consider optimistic UI for bookmark toggle.The bookmark state relies on
initialBookmarkedprop and waits for cache invalidation. This can cause a noticeable delay between click and visual feedback. Consider local state with optimistic updates:🔎 Proposed optimistic bookmark pattern
+ const [isBookmarked, setIsBookmarked] = useState(initialBookmarked); + const { mutate: bookmark, status: bookmarkStatus } = api.feed.bookmark.useMutation({ onSuccess: () => { utils.feed.getFeed.invalidate(); utils.feed.mySavedArticles.invalidate(); }, - onError: (error) => { + onError: (error) => { + setIsBookmarked(initialBookmarked); // Revert on error toast.error("Failed to update bookmark"); Sentry.captureException(error); }, }); const handleBookmark = () => { if (!session) { signIn(); return; } - bookmark({ articleId: id, setBookmarked: !initialBookmarked }); + setIsBookmarked(!isBookmarked); + bookmark({ articleId: id, setBookmarked: !isBookmarked }); };Also applies to: 156-162
208-239: Extract duplicated source icon rendering logic.The logo/favicon/initials rendering is duplicated between the linked (lines 208-220) and non-linked (lines 225-237) variants. Extract to a helper component for maintainability:
🔎 Proposed refactor
const SourceIcon = ({ sourceLogo, faviconUrl, sourceName }: { sourceLogo: string | null; faviconUrl: string | null; sourceName: string | null; }) => { if (sourceLogo) { return <img src={sourceLogo} alt="" className="h-4 w-4 rounded object-cover" />; } if (faviconUrl) { return <img src={faviconUrl} alt="" className="h-4 w-4 rounded" />; } return ( <div className="flex h-4 w-4 items-center justify-center rounded bg-orange-100 text-[10px] font-bold text-orange-600 dark:bg-orange-900 dark:text-orange-300"> {sourceName?.charAt(0).toUpperCase() || "?"} </div> ); };
209-215: Consider using Next.js Image component for optimization.Native
<img>elements are used for source logos and thumbnails. The Next.jsImagecomponent provides automatic optimization (lazy loading, responsive sizing, format conversion). For external URLs, configureremotePatternsinnext.config.js.Also applies to: 226-232, 403-410
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
app/(app)/feed/[sourceSlug]/[shortId]/page.tsxcomponents/Comments/CommentsArea.tsxcomponents/Feed/AggregatedItem.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
- app/(app)/feed/[sourceSlug]/[shortId]/page.tsx
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2024-10-22T08:43:13.236Z
Learnt from: dineshsutihar
Repo: codu-code/codu PR: 1153
File: app/(app)/articles/[slug]/page.tsx:91-104
Timestamp: 2024-10-22T08:43:13.236Z
Learning: In `app/(app)/articles/[slug]/page.tsx`, when rendering content, the `renderedContent` needs to be treated as a string using a type assertion because it's used with `dangerouslySetInnerHTML`, and removing the type assertion could lead to runtime issues.
Applied to files:
components/Feed/AggregatedItem.tsx
🧬 Code graph analysis (2)
components/Comments/CommentsArea.tsx (1)
server/db/schema.ts (2)
session(71-77)comment(1049-1089)
components/Feed/AggregatedItem.tsx (5)
auth.ts (2)
session(65-71)signIn(72-84)components/ReportModal/ReportModal.tsx (1)
useReportModal(27-91)components/ui-components/link.tsx (1)
Link(12-22)app/(app)/notifications/_client.tsx (2)
api(32-209)page(102-200)app/(app)/saved/_client.tsx (1)
data(8-86)
🪛 GitHub Actions: Code Quality Checks
components/Comments/CommentsArea.tsx
[warning] 1-1: Code style issues found in the file. Run 'prettier --write' to fix.
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: e2e
🔇 Additional comments (4)
components/Comments/CommentsArea.tsx (2)
384-391: Verify CommentAreaProps type alignment.The
parentIdandidprops are typed asstring | undefinedandstring | nullrespectively, but this must align with the backend API types and database schema (which defines these as numeric).Ensure consistency across:
- Database schema (currently numeric)
- Backend API responses
- Frontend prop types
If the backend serializes IDs to strings for transmission, this is acceptable. Otherwise, these should be numeric types.
81-100: No changes needed — vote mutation correctly handles string commentId.The backend schema explicitly defines
commentIdas a string inVoteCommentSchema, and the mutation implementation uses it directly without type conversion. The frontend code is compatible with the backend.components/Feed/AggregatedItem.tsx (2)
241-250: Good edge case handling for author field.The filtering of malformed author values like
"by","by,"and stripping the"by "prefix shows good attention to RSS data quality issues.
279-288: External link handling looks correct.Proper use of
target="_blank"withrel="noopener noreferrer"for security, click tracking integration, and accessible aria-labels on the thumbnail link.Also applies to: 395-419
| if (typeof editCommentBoxId !== "string") | ||
| throw new Error("Invalid edit."); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Type guard may fail if IDs are numeric at runtime.
The check typeof editCommentBoxId !== "string" assumes string IDs, but the database schema indicates numeric IDs. If the backend returns numbers, this guard will always throw, breaking edit functionality.
Verify the actual runtime type of editCommentBoxId matches the database schema and adjust the type guard accordingly:
🔎 Proposed fix if IDs remain numeric
- if (typeof editCommentBoxId !== "string")
+ if (typeof editCommentBoxId !== "number")
throw new Error("Invalid edit.");📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (typeof editCommentBoxId !== "string") | |
| throw new Error("Invalid edit."); | |
| if (typeof editCommentBoxId !== "number") | |
| throw new Error("Invalid edit."); |
🤖 Prompt for AI Agents
In @components/Comments/CommentsArea.tsx around lines 151 - 152, The current
type guard using typeof editCommentBoxId !== "string" is too strict if IDs can
be numeric; instead ensure editCommentBoxId is not null/undefined and accept
both string and number forms: replace the typeof check with a nullish check
(e.g., editCommentBoxId == null) and when you need to compare or pass the id to
functions, normalize it (use String(editCommentBoxId) or
Number(editCommentBoxId) consistently) so both numeric and string IDs work;
update any downstream comparisons in CommentsArea that assume a string id to use
the same normalized form.
| return commentsArr.map((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]"; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Replace any type cast with proper typing.
Using (comment as any).children bypasses TypeScript's type safety. Define a proper interface for comments with the children property to maintain type safety.
🔎 Proposed fix
Add a type definition at the top of the file or import from a shared types file:
interface CommentWithChildren {
id: string; // or number, depending on resolution of ID type issue
body: string;
createdAt: string;
updatedAt: string;
userVote: "up" | "down" | null;
score: number;
author: {
id: string;
name: string;
image: string;
username: string;
} | null;
children?: CommentWithChildren[];
}Then update line 188:
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const children = (comment as any).children;
+ const children = comment.children;And update the function signature:
const generateComments = (
- commentsArr: Comments | Children | undefined,
+ commentsArr: CommentWithChildren[] | undefined,
depth = 0,
) => {Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In @components/Comments/CommentsArea.tsx around lines 184 - 194, The map is
using an unsafe cast for children; define a CommentWithChildren interface
(including id, body, createdAt, updatedAt, userVote, score, author which can be
null, and optional children?: CommentWithChildren[]) and use it in the
CommentsArea component so commentsArr is typed as CommentWithChildren[]; then
remove the (comment as any).children cast and access comment.children directly
(and update the function signature/props where commentsArr is declared to use
CommentWithChildren[]).
| const { mutate: vote, status: voteStatus } = api.content.vote.useMutation({ | ||
| onSuccess: () => { | ||
| utils.content.getFeed.invalidate(); | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
cd /tmp && find . -name "AggregatedItem.tsx" -type f 2>/dev/null | head -5Repository: codu-code/codu
Length of output: 40
🏁 Script executed:
git ls-files | grep -i "aggregateditem\|server" | head -20Repository: codu-code/codu
Length of output: 599
🏁 Script executed:
rg -n "getFeed:" --type=ts -A5Repository: codu-code/codu
Length of output: 1314
🏁 Script executed:
wc -l components/Feed/AggregatedItem.tsxRepository: codu-code/codu
Length of output: 96
🏁 Script executed:
sed -n '115,140p' components/Feed/AggregatedItem.tsxRepository: codu-code/codu
Length of output: 807
🏁 Script executed:
cat -n server/api/router/index.tsRepository: codu-code/codu
Length of output: 1418
🏁 Script executed:
cat -n server/api/router/content.ts | head -100Repository: codu-code/codu
Length of output: 3582
🏁 Script executed:
cat -n server/api/router/feed.ts | head -100Repository: codu-code/codu
Length of output: 3425
🏁 Script executed:
rg -n "vote:" server/api/router/feed.ts -A5Repository: codu-code/codu
Length of output: 268
🏁 Script executed:
rg -n "bookmark:" server/api/router/content.ts -A5Repository: codu-code/codu
Length of output: 281
🏁 Script executed:
sed -n '1,50p' components/Feed/AggregatedItem.tsxRepository: codu-code/codu
Length of output: 1409
🏁 Script executed:
rg -n "vote:" server/api/router/content.ts -A5Repository: codu-code/codu
Length of output: 268
🏁 Script executed:
rg -n "bookmark:" server/api/router/feed.ts -A5Repository: codu-code/codu
Length of output: 281
Consolidate to primary feed router to avoid cache invalidation misses.
The vote mutation uses the legacy api.content.vote endpoint while bookmark uses the primary api.feed.bookmark endpoint. They invalidate different cache keys (utils.content.getFeed vs utils.feed.getFeed). During the migration from content to feed routers, migrate vote to also use api.feed.vote and invalidate utils.feed.getFeed consistently.
🤖 Prompt for AI Agents
In @components/Feed/AggregatedItem.tsx around lines 120 - 123, The vote mutation
is still using the legacy api.content.vote and invalidating
utils.content.getFeed, causing cache misses; change the mutation to use
api.feed.vote (so it aligns with api.feed.bookmark) and update the onSuccess
invalidation to utils.feed.getFeed; ensure you keep the same destructured names
(mutate: vote, status: voteStatus) and confirm the vote call signature matches
the new api.feed.vote usage.
| const handleReport = () => { | ||
| if (!session) { | ||
| signIn(); | ||
| return; | ||
| } | ||
| openReport("post", id); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Report type should match the content type.
This component renders aggregated feed articles, but handleReport passes "post" as the type. Based on ReportModal.tsx, articles should use the "article" type with a numeric ID. This is the same issue flagged in a previous review.
🔎 Proposed fix
const handleReport = () => {
if (!session) {
signIn();
return;
}
- openReport("post", id);
+ // Note: If articles require numeric IDs, ensure the backend
+ // provides a numeric articleId or use a different report type
+ openReport("article", id);
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleReport = () => { | |
| if (!session) { | |
| signIn(); | |
| return; | |
| } | |
| openReport("post", id); | |
| }; | |
| const handleReport = () => { | |
| if (!session) { | |
| signIn(); | |
| return; | |
| } | |
| // Note: If articles require numeric IDs, ensure the backend | |
| // provides a numeric articleId or use a different report type | |
| openReport("article", id); | |
| }; |
🤖 Prompt for AI Agents
In @components/Feed/AggregatedItem.tsx around lines 174 - 180, The handleReport
function is passing the wrong content type; update handleReport to call
openReport with the type "article" instead of "post" and ensure the id passed to
openReport is a numeric ID (e.g., convert the existing id prop to a number
before passing). Keep the existing session check and signIn() flow unchanged;
only change the argument values in handleReport (function name: handleReport,
call site: openReport).
| {/* Comments button */} | ||
| <Link | ||
| href={articleUrl} | ||
| className="flex items-center gap-1 rounded-full border border-neutral-200 px-2 py-1 text-xs font-medium text-neutral-500 transition-colors hover:bg-neutral-100 dark:border-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-800" | ||
| > | ||
| <ChatBubbleLeftIcon className="h-3.5 w-3.5" /> | ||
| <span>0</span> | ||
| </Link> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Comments count is hardcoded to 0.
The comments button always displays 0 regardless of actual comment count. If comment counts are available, pass them as a prop; otherwise, consider hiding the count or showing a placeholder.
🔎 Proposed fix if comment count is available
type Props = {
// ... existing props
+ commentCount?: number;
};
// In the JSX:
- <span>0</span>
+ <span>{commentCount ?? 0}</span>Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In @components/Feed/AggregatedItem.tsx around lines 338 - 345, The comments
button currently renders a hardcoded "0"; update the AggregatedItem component to
use the real comment count (e.g., props.commentCount or article.commentCount) in
the <span> next to ChatBubbleLeftIcon, and if no count is provided/available
render nothing (omit the <span>) or a placeholder like "-" instead of "0";
ensure the prop is accepted by the component (add commentCount to the component
props or read it from the article object) and use it in the Link rendering
block.
…in sitemap generation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🤖 Fix all issues with AI Agents
In @app/sitemap.ts:
- Around line 67-89: The feedArticles query can generate invalid URLs if
posts.slug or feed_sources.slug are null; update the db query that builds
feedArticles to filter out null slugs by adding conditions that both posts.slug
IS NOT NULL and feed_sources.slug IS NOT NULL (in the same where() along with
the existing eq(posts.type, "link"), eq(posts.status, "published"),
eq(feed_sources.status, "active")), so only rows with non-null
articleSlug/sourceSlug are mapped into URLs.
🧹 Nitpick comments (1)
app/sitemap.ts (1)
90-93: Consider logging swallowed errors for observability.The empty catch block could hide legitimate database errors beyond missing tables (e.g., connection timeouts, query syntax issues). Consider logging the error at debug level to aid troubleshooting while still allowing graceful degradation.
🔎 Proposed improvement
- } catch { + } catch (error) { // Tables may not exist yet if migrations haven't been run // Continue with empty arrays for sources and feedArticles + Sentry.captureException(error, { level: "debug" }); }
📜 Review details
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
app/sitemap.ts
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2024-10-22T08:43:13.236Z
Learnt from: dineshsutihar
Repo: codu-code/codu PR: 1153
File: app/(app)/articles/[slug]/page.tsx:91-104
Timestamp: 2024-10-22T08:43:13.236Z
Learning: In `app/(app)/articles/[slug]/page.tsx`, when rendering content, the `renderedContent` needs to be treated as a string using a type assertion because it's used with `dangerouslySetInnerHTML`, and removing the type assertion could lead to runtime issues.
Applied to files:
app/sitemap.ts
📚 Learning: 2024-10-18T04:13:45.950Z
Learnt from: RangerCreaky
Repo: codu-code/codu PR: 1146
File: server/api/router/series.ts:0-0
Timestamp: 2024-10-18T04:13:45.950Z
Learning: In the `seriesRouter` in `server/api/router/series.ts`, when deleting a series using the `seriesId` from `currentPost`, additional `userId` checks may not be necessary because unauthorized access to `currentPost` is already prevented.
Applied to files:
app/sitemap.ts
🧬 Code graph analysis (1)
app/sitemap.ts (2)
server/db/index.ts (1)
db(30-33)server/db/schema.ts (5)
post(929-977)user(114-170)feed_sources(212-259)feed_sources(1664-1664)posts(273-356)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: e2e
🔇 Additional comments (3)
app/sitemap.ts (3)
5-6: LGTM!Imports are correctly added for the new feed sources and posts queries.
9-14: LGTM!Route additions align with the new aggregator pages.
95-117: LGTM!Route composition with priority ordering is sensible, and the expanded Sentry logging provides good visibility into sitemap generation metrics.
| // Feed articles from posts table (type=link): /[sourceSlug]/[slug] | ||
| feedArticles = ( | ||
| await db | ||
| .select({ | ||
| articleSlug: posts.slug, | ||
| sourceSlug: feed_sources.slug, | ||
| publishedAt: posts.publishedAt, | ||
| updatedAt: posts.updatedAt, | ||
| }) | ||
| .from(posts) | ||
| .innerJoin(feed_sources, eq(posts.sourceId, feed_sources.id)) | ||
| .where( | ||
| and( | ||
| eq(posts.type, "link"), | ||
| eq(posts.status, "published"), | ||
| eq(feed_sources.status, "active"), | ||
| ), | ||
| ) | ||
| ).map(({ articleSlug, sourceSlug, publishedAt, updatedAt }) => ({ | ||
| url: `${BASE_URL}/${sourceSlug}/${articleSlug}`, | ||
| lastModified: new Date(updatedAt || publishedAt || new Date()), | ||
| priority: 0.5, | ||
| })); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Feed articles query may produce invalid URLs due to null slugs.
The query doesn't filter out null values for feed_sources.slug (as previously flagged) or posts.slug. Either field being null would produce invalid URLs like https://www.codu.co/null/article or https://www.codu.co/source/null.
Add null checks for both slug fields:
🔎 Proposed fix
.where(
and(
eq(posts.type, "link"),
eq(posts.status, "published"),
eq(feed_sources.status, "active"),
+ isNotNull(feed_sources.slug),
+ isNotNull(posts.slug),
),
)📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // Feed articles from posts table (type=link): /[sourceSlug]/[slug] | |
| feedArticles = ( | |
| await db | |
| .select({ | |
| articleSlug: posts.slug, | |
| sourceSlug: feed_sources.slug, | |
| publishedAt: posts.publishedAt, | |
| updatedAt: posts.updatedAt, | |
| }) | |
| .from(posts) | |
| .innerJoin(feed_sources, eq(posts.sourceId, feed_sources.id)) | |
| .where( | |
| and( | |
| eq(posts.type, "link"), | |
| eq(posts.status, "published"), | |
| eq(feed_sources.status, "active"), | |
| ), | |
| ) | |
| ).map(({ articleSlug, sourceSlug, publishedAt, updatedAt }) => ({ | |
| url: `${BASE_URL}/${sourceSlug}/${articleSlug}`, | |
| lastModified: new Date(updatedAt || publishedAt || new Date()), | |
| priority: 0.5, | |
| })); | |
| // Feed articles from posts table (type=link): /[sourceSlug]/[slug] | |
| feedArticles = ( | |
| await db | |
| .select({ | |
| articleSlug: posts.slug, | |
| sourceSlug: feed_sources.slug, | |
| publishedAt: posts.publishedAt, | |
| updatedAt: posts.updatedAt, | |
| }) | |
| .from(posts) | |
| .innerJoin(feed_sources, eq(posts.sourceId, feed_sources.id)) | |
| .where( | |
| and( | |
| eq(posts.type, "link"), | |
| eq(posts.status, "published"), | |
| eq(feed_sources.status, "active"), | |
| isNotNull(feed_sources.slug), | |
| isNotNull(posts.slug), | |
| ), | |
| ) | |
| ).map(({ articleSlug, sourceSlug, publishedAt, updatedAt }) => ({ | |
| url: `${BASE_URL}/${sourceSlug}/${articleSlug}`, | |
| lastModified: new Date(updatedAt || publishedAt || new Date()), | |
| priority: 0.5, | |
| })); |
🤖 Prompt for AI Agents
In @app/sitemap.ts around lines 67 - 89, The feedArticles query can generate
invalid URLs if posts.slug or feed_sources.slug are null; update the db query
that builds feedArticles to filter out null slugs by adding conditions that both
posts.slug IS NOT NULL and feed_sources.slug IS NOT NULL (in the same where()
along with the existing eq(posts.type, "link"), eq(posts.status, "published"),
eq(feed_sources.status, "active")), so only rows with non-null
articleSlug/sourceSlug are mapped into URLs.
🚀 The Great Content Unification
AKA: "We Built Reddit... But For Developers... And It's Actually Good"
🎯 What's This All About?
Remember when we said "what if Codú had a feed of curated dev content from around the web?" Well, we did that. And then we got a little carried away. In the best way possible.
✨ The Highlights
🗳️ Reddit-Style Voting
📡 RSS Content Aggregation
🎛️ Admin Dashboard
🔄 Unified Content System
💰 Sponsorship System
🧪 E2E Tests
📊 The Numbers
140 files changed
27,017 insertions(+)
2,692 deletions(-)
Yes, you read that right. 27k lines added. We've been busy.
🗄️ New Migrations
🧑💻 Test Plan