Skip to content

Conversation

@ahmetskilinc
Copy link
Collaborator

@ahmetskilinc ahmetskilinc commented Aug 25, 2025

Summary by CodeRabbit

  • New Features

    • Add user endorsements: modal form to endorse users with three types (General, Project, Work), validation, conditional fields, and success/error toasts.
    • Profile activity now includes endorsements given and received with distinct icon and color.
  • UI

    • New Endorsements tab and endorsement cards showing endorser info, project/work details, timestamps, loading skeletons, empty state, and a “Load more” button.
    • “Endorse” action enabled only for logged-in visitors; otherwise shown disabled.

@vercel
Copy link

vercel bot commented Aug 25, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Comments Updated (UTC)
ossdotnow Error Error Aug 25, 2025 11:19pm

@coderabbitai
Copy link

coderabbitai bot commented Aug 25, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Adds an endorsements feature: DB migration and Drizzle schema, a new endorsements TRPC router (CRUD + shared-projects), profile activity integration, and web UI components (EndorsementDialog, EndorsementList) plus profile tab/session wiring.

Changes

Cohort / File(s) Summary
DB migration & meta
packages/db/drizzle/0026_brainy_havok.sql, packages/db/drizzle/meta/0026_snapshot.json, packages/db/drizzle/meta/_journal.json
Introduces endorsement_type enum and endorsement table with FKs; adds migration snapshot and updates drizzle journal/meta.
DB ORM schema & exports
packages/db/src/schema/endorsements.ts, packages/db/src/schema/index.ts
Adds Drizzle enum endorsementTypeEnum, endorsement table schema (workDetails JSONB, timestamps, relations) and re-exports it from schema index.
API router & root
packages/api/src/routers/endorsements.ts, packages/api/src/root.ts
New endorsementsRouter (create, getEndorsements, getUserSharedProjects, update, delete); wired into appRouter as endorsements.
API: profile integration
packages/api/src/routers/profile.ts
Includes endorsements in recent activities, expands ActivityItem union (endorsement_given/endorsement_received) and related fields; adjusts procedure visibility and queries.
Web UI: Endorsement components
apps/web/components/user/endorsement-dialog.tsx, apps/web/components/user/endorsement-list.tsx
Adds EndorsementDialog (client-side modal, react-hook-form + Zod, fetches shared projects, submits endorsement) and EndorsementList (fetches and renders endorsements, loading/empty states).
Web UI: profile wiring
apps/web/components/user/profile-tabs.tsx, apps/web/components/user/profile.tsx
Adds Endorsements tab wired to EndorsementList and conditionally renders EndorsementDialog based on session; minor layout and import tweaks.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor U as User
  participant D as EndorsementDialog (Web)
  participant TR as TRPC Client
  participant ER as endorsementsRouter.create
  participant DB as Database

  U->>D: Open dialog
  D->>TR: getUserSharedProjects (enabled when open)
  TR->>DB: Query shared projects
  DB-->>TR: Projects
  TR-->>D: Projects list

  U->>D: Submit endorsement
  D->>TR: create({ endorsedUserId, type, ... })
  TR->>ER: call create
  ER->>DB: Validate & insert endorsement
  DB-->>ER: Created record
  ER-->>TR: Result
  TR-->>D: Success
  D-->>U: Toast, Close, Reset
Loading
sequenceDiagram
  autonumber
  actor V as Viewer
  participant L as EndorsementList (Web)
  participant TR as TRPC Client
  participant GE as endorsementsRouter.getEndorsements
  participant DB as Database

  V->>L: Open Endorsements tab
  L->>TR: getEndorsements({ userId, limit })
  TR->>GE: call
  GE->>DB: Query endorsements + count
  DB-->>GE: Rows + total
  GE-->>TR: Data
  TR-->>L: Endorsements
  L-->>V: Render cards / Empty state / Load more
Loading
sequenceDiagram
  autonumber
  actor C as Client
  participant PR as profileRouter.getRecentActivities
  participant DB as Database

  C->>PR: Fetch recent activities
  PR->>DB: Query projects/comments/votes/claims/launches
  PR->>DB: Query endorsements
  DB-->>PR: Results
  PR-->>C: Merged, sorted activities incl. endorsements
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~70 minutes

Possibly related PRs

Poem

A rabbit taps keys with delighted cheer,
Praise hops near and far, sincere.
Tables, routers, dialogs bloom—
Cards arrive inside the room.
Toasts pop bright; endorsements cheer! 🐇✨

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch endorsements-feature

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.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

cursor[bot]

This comment was marked as outdated.


{Number(data.total) > data.endorsements.length && (
<div className="text-center">
<Button variant="outline" className="rounded-none">
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Load More" button doesn't have any click handler, making it non-functional.

View Details
📝 Patch Details
diff --git a/apps/web/components/user/endorsement-list.tsx b/apps/web/components/user/endorsement-list.tsx
index 149d284..1be5698 100644
--- a/apps/web/components/user/endorsement-list.tsx
+++ b/apps/web/components/user/endorsement-list.tsx
@@ -8,6 +8,7 @@ import { Button } from '@workspace/ui/components/button';
 import { useQuery } from '@tanstack/react-query';
 import { formatDistanceToNow } from 'date-fns';
 import { useTRPC } from '@/hooks/use-trpc';
+import { useState, useEffect } from 'react';
 import Link from 'next/link';
 
 interface EndorsementListProps {
@@ -16,14 +17,42 @@ interface EndorsementListProps {
 
 export function EndorsementList({ userId }: EndorsementListProps) {
   const trpc = useTRPC();
+  const [offset, setOffset] = useState(0);
+  const [allEndorsements, setAllEndorsements] = useState<typeof data?.endorsements>([]);
+  const [hasMore, setHasMore] = useState(true);
+  const [isLoadingMore, setIsLoadingMore] = useState(false);
 
   const { data, isLoading } = useQuery(
     trpc.endorsements.getEndorsements.queryOptions({
       userId,
       limit: 20,
+      offset,
     }),
   );
 
+  // Handle data updates with useEffect
+  useEffect(() => {
+    if (data) {
+      if (offset === 0) {
+        // First load
+        setAllEndorsements(data.endorsements);
+      } else {
+        // Load more
+        setAllEndorsements(prev => [...prev, ...data.endorsements]);
+        setIsLoadingMore(false);
+      }
+      setHasMore(data.endorsements.length === 20 && Number(data.total) > offset + data.endorsements.length);
+    }
+  }, [data, offset, setAllEndorsements, setHasMore, setIsLoadingMore]);
+
+  const handleLoadMore = () => {
+    setIsLoadingMore(true);
+    setOffset(prev => prev + 20);
+  };
+
+  // Use allEndorsements for rendering when we have loaded more data
+  const endorsementsToShow = offset === 0 ? data?.endorsements || [] : allEndorsements;
+
   if (isLoading) {
     return (
       <div className="space-y-4">
@@ -57,7 +86,7 @@ export function EndorsementList({ userId }: EndorsementListProps) {
 
   return (
     <div className="space-y-4">
-      {data.endorsements.map((endorsement) => (
+      {endorsementsToShow.map((endorsement) => (
         <Card key={endorsement.id} className="rounded-none border-neutral-800 bg-neutral-900">
           <CardContent className="px-6">
             <div className="flex gap-4">
@@ -138,10 +167,15 @@ export function EndorsementList({ userId }: EndorsementListProps) {
         </Card>
       ))}
 
-      {Number(data.total) > data.endorsements.length && (
+      {hasMore && (
         <div className="text-center">
-          <Button variant="outline" className="rounded-none">
-            Load More
+          <Button 
+            variant="outline" 
+            className="rounded-none"
+            onClick={handleLoadMore}
+            disabled={isLoadingMore}
+          >
+            {isLoadingMore ? 'Loading...' : 'Load More'}
           </Button>
         </div>
       )}

Analysis

The "Load More" button is rendered when there are more endorsements available (Number(data.total) > data.endorsements.length) but it has no onClick handler or functionality. This creates a broken user interface where users see a button they can click but nothing happens when they do.

The button appears to be a placeholder that was never connected to pagination logic. Users will expect to be able to load additional endorsements when clicking this button, but currently it's just a static element.


Recommendation

Either implement the pagination functionality with proper state management and API integration, or remove the "Load More" button entirely if pagination isn't intended to be implemented yet:

// Option 1: Remove the button
{/* Remove the entire Load More section until pagination is implemented */}

// Option 2: Implement pagination (requires state management)
const [offset, setOffset] = useState(0);
// Update the query to use offset
// Add onClick handler: onClick={() => setOffset(prev => prev + 20)}

Comment on lines +20 to +25
const { data, isLoading } = useQuery(
trpc.endorsements.getEndorsements.queryOptions({
userId,
limit: 20,
}),
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The EndorsementList component doesn't handle query errors, potentially showing "No endorsements yet" when there's actually a network failure.

View Details
📝 Patch Details
diff --git a/apps/web/components/user/endorsement-list.tsx b/apps/web/components/user/endorsement-list.tsx
index 149d284..6f8e422 100644
--- a/apps/web/components/user/endorsement-list.tsx
+++ b/apps/web/components/user/endorsement-list.tsx
@@ -17,7 +17,7 @@ interface EndorsementListProps {
 export function EndorsementList({ userId }: EndorsementListProps) {
   const trpc = useTRPC();
 
-  const { data, isLoading } = useQuery(
+  const { data, isLoading, isError } = useQuery(
     trpc.endorsements.getEndorsements.queryOptions({
       userId,
       limit: 20,
@@ -45,6 +45,16 @@ export function EndorsementList({ userId }: EndorsementListProps) {
     );
   }
 
+  if (isError) {
+    return (
+      <Card className="rounded-none border-neutral-800 bg-neutral-900">
+        <CardContent className="p-8 text-center">
+          <p className="text-red-400">Failed to load endorsements</p>
+        </CardContent>
+      </Card>
+    );
+  }
+
   if (!data?.endorsements.length) {
     return (
       <Card className="rounded-none border-neutral-800 bg-neutral-900">

Analysis

The EndorsementList component only destructures data and isLoading from the useQuery hook but ignores the isError state. This means that if the API call fails due to network issues or server errors, the component will fall through to showing the "No endorsements yet" empty state instead of displaying an error message.

This conflates error states with legitimate empty data, which is problematic for user experience - users won't know if their endorsements failed to load or if they genuinely have no endorsements. The pattern used throughout this codebase (as seen in projects-page.tsx) is to handle error states explicitly by showing an error message.


Recommendation

Add isError to the useQuery destructuring and handle it before checking for empty data:

const { data, isLoading, isError } = useQuery(
  trpc.endorsements.getEndorsements.queryOptions({
    userId,
    limit: 20,
  }),
);

if (isLoading) {
  // ... existing loading state
}

if (isError) {
  return (
    <Card className="rounded-none border-neutral-800 bg-neutral-900">
      <CardContent className="p-8 text-center">
        <p className="text-red-400">Failed to load endorsements</p>
      </CardContent>
    </Card>
  );
}

if (!data?.endorsements.length) {
  // ... existing empty state
}

Comment on lines +99 to +103
onSuccess: () => {
toast.success('Endorsement added successfully!');
setOpen(false);
form.reset();
},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing query invalidation after creating endorsements means the endorsement list won't update automatically.

View Details

Analysis

After successfully creating an endorsement, the EndorsementDialog component doesn't invalidate the relevant queries to refresh the endorsement list. This means users won't see their new endorsement appear in the EndorsementList component until they manually refresh the page or navigate away and back.

This pattern is inconsistent with other mutation operations in the codebase (like comments, categories, etc.) which properly invalidate related queries to keep the UI in sync. The user experience is broken because the action appears to succeed (toast message shows) but the UI doesn't reflect the change.


Recommendation

Add query invalidation in the onSuccess callback and import the necessary dependencies:

import { useQueryClient } from '@tanstack/react-query';

// Inside the component:
const queryClient = useQueryClient();

const { mutate: createEndorsement, isPending } = useMutation(
  trpc.endorsements.create.mutationOptions({
    onSuccess: () => {
      toast.success('Endorsement added successfully!');
      setOpen(false);
      form.reset();
      
      // Invalidate endorsements query to refresh the list
      queryClient.invalidateQueries({
        queryKey: trpc.endorsements.getEndorsements.queryKey({ userId }),
      });
    },
    onError: (error) => {
      toast.error(error.message || 'Failed to add endorsement');
    },
  }),
);

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

🧹 Nitpick comments (21)
packages/db/drizzle/0026_brainy_havok.sql (3)

9-13: Add read-path indexes to support the primary query (public endorsements by user ordered by created_at).

getEndorsements filters by endorsed_user_id and is_public and orders by created_at desc. A composite index significantly reduces IO on larger tables.

+-- Accelerate public endorsements listing by user
+CREATE INDEX "endorsement_endorsed_public_created_at_idx"
+  ON "endorsement" ("endorsed_user_id","is_public","created_at" DESC);
+
+-- Optional helpers for other lookups
+CREATE INDEX "endorsement_endorser_idx" ON "endorsement" ("endorser_id");
+CREATE INDEX "endorsement_project_idx" ON "endorsement" ("project_id");

6-11: Optional constraints to keep “project” variants consistent.

To mirror UI/validation rules and prevent inconsistent rows:

  • prevent both project_id and project_name being set simultaneously,
  • require one of them for type = 'project',
  • enforce content length at the DB level as a defense-in-depth measure.
+ALTER TABLE "endorsement"
+  ADD CONSTRAINT "endorsement_project_mutual_exclusion"
+  CHECK (NOT ("project_id" IS NOT NULL AND "project_name" IS NOT NULL));
+
+ALTER TABLE "endorsement"
+  ADD CONSTRAINT "endorsement_project_required_when_type_project"
+  CHECK (("type" != 'project') OR ("project_id" IS NOT NULL OR "project_name" IS NOT NULL));
+
+ALTER TABLE "endorsement"
+  ADD CONSTRAINT "endorsement_content_len"
+  CHECK (char_length("content") BETWEEN 10 AND 1000);

3-4: Minor: gen_random_uuid() requires pgcrypto.

If not already enabled in an earlier migration, add create extension for pgcrypto. If it’s already present, ignore this.

+-- Enable pgcrypto if not already enabled
+CREATE EXTENSION IF NOT EXISTS pgcrypto;
packages/db/src/schema/index.ts (1)

11-15: Nit: duplicate export for project-claims nearby.

Unrelated to this PR, but you have two exports for './project-claims' (Lines 6 and 13). Consider removing one in a housekeeping pass.

apps/web/components/user/profile.tsx (3)

28-30: Avoid any; type the session and drop unused state name underscore.

Using any hides shape mismatches (e.g., session.data.user.id), and _sessionLoading is never read. Type the session from the auth client return type and either remove the loading flag or use it to gate UI to prevent flicker.

-  const [session, setSession] = useState<any>(null);
-  const [_sessionLoading, setSessionLoading] = useState(true);
+  type SessionResult = Awaited<ReturnType<typeof authClient.getSession>>;
+  const [session, setSession] = useState<SessionResult | null>(null);
+  const [isSessionLoading, setSessionLoading] = useState(true);

40-52: Guard setState on unmount and handle errors consistently.

Minor robustness: prevent setState after unmount during slow getSession, and keep the dependency list stable.

-  useEffect(() => {
-    authClient
-      .getSession()
-      .then((sessionData) => {
-        setSession(sessionData);
-      })
-      .catch((error) => {
-        console.error('Session fetch failed:', error);
-      })
-      .finally(() => {
-        setSessionLoading(false);
-      });
-  }, []);
+  useEffect(() => {
+    let isMounted = true;
+    authClient
+      .getSession()
+      .then((sessionData) => {
+        if (isMounted) setSession(sessionData);
+      })
+      .catch((error) => {
+        // Consider routing through a central logger/toast
+        console.error('Session fetch failed:', error);
+      })
+      .finally(() => {
+        if (isMounted) setSessionLoading(false);
+      });
+    return () => {
+      isMounted = false;
+    };
+  }, []);

264-278: Endorsement CTA: avoid UI flicker and handle missing name.

  • While isSessionLoading, you briefly render a disabled button that flips to the dialog; consider gating on isSessionLoading to avoid flicker.
  • profile.name can be null; pass a fallback to EndorsementDialog.
-                        {session?.data?.user?.id &&
-                        profile &&
-                        session.data.user.id !== profile.id ? (
-                          <EndorsementDialog userId={profile.id} userName={profile.name} />
-                        ) : (
-                          <Button
-                            variant="outline"
-                            size="sm"
-                            className="flex-1 rounded-none border-neutral-800 bg-neutral-900/50 text-neutral-400 hover:border-neutral-700 hover:bg-neutral-800 hover:text-neutral-200"
-                            disabled
-                          >
-                            <Award className="mr-2 h-4 w-4" />
-                            Endorse
-                          </Button>
-                        )}
+                        {!isSessionLoading && session?.data?.user?.id &&
+                        profile &&
+                        session.data.user.id !== profile.id ? (
+                          <EndorsementDialog
+                            userId={profile.id}
+                            userName={profile.name ?? profile.username ?? 'this user'}
+                          />
+                        ) : (
+                          <Button
+                            variant="outline"
+                            size="sm"
+                            className="flex-1 rounded-none border-neutral-800 bg-neutral-900/50 text-neutral-400 hover:border-neutral-700 hover:bg-neutral-800 hover:text-neutral-200"
+                            disabled
+                            aria-disabled="true"
+                          >
+                            <Award className="mr-2 h-4 w-4" />
+                            Endorse
+                          </Button>
+                        )}

Optional UX follow-up: for unauthenticated users, consider a “Sign in to endorse” button instead of a disabled one.

apps/web/components/user/endorsement-list.tsx (3)

11-11: Import useState for local pagination state.

You add local state below; pull in useState from React.

-import Link from 'next/link';
+import Link from 'next/link';
+import { useState } from 'react';

27-46: Add error state to surface API failures.

Render a lightweight error card if the query fails.

   if (isLoading) {
     return (
       <div className="space-y-4">
         {[...Array(3)].map((_, i) => (
           <Card key={i} className="rounded-none border-neutral-800 bg-neutral-900">
             <CardContent className="p-6">
               <div className="flex gap-4">
                 <Skeleton className="h-12 w-12 rounded-full" />
                 <div className="flex-1 space-y-2">
                   <Skeleton className="h-4 w-32" />
                   <Skeleton className="h-4 w-full" />
                   <Skeleton className="h-4 w-3/4" />
                 </div>
               </div>
             </CardContent>
           </Card>
         ))}
       </div>
     );
   }
+
+  if (error) {
+    return (
+      <Card className="rounded-none border-neutral-800 bg-neutral-900">
+        <CardContent className="p-8 text-center">
+          <p className="text-neutral-400">Failed to load endorsements</p>
+          <p className="mt-1 text-xs text-neutral-600">{(error as Error).message}</p>
+        </CardContent>
+      </Card>
+    );
+  }

48-56: Guard the empty-state against transient loading.

Only show “No endorsements yet” when we have a valid response and zero items.

-  if (!data?.endorsements.length) {
+  if (data && data.endorsements.length === 0) {
     return (
       <Card className="rounded-none border-neutral-800 bg-neutral-900">
         <CardContent className="p-8 text-center">
           <p className="text-neutral-400">No endorsements yet</p>
         </CardContent>
       </Card>
     );
   }
packages/api/src/routers/endorsements.ts (2)

34-46: Duplicate check likely too strict; consider scoping by type or content.

Current check prevents any second endorsement to the same user, even of a different type. If product intent is “one endorsement per type” (or per project), include type (and optionally projectId) in the uniqueness predicate and enforce at DB level to avoid race conditions.

-      const existingEndorsement = await ctx.db.query.endorsement.findFirst({
-        where: and(
-          eq(endorsement.endorserId, ctx.user.id),
-          eq(endorsement.endorsedUserId, input.endorsedUserId),
-        ),
-      });
+      const existingEndorsement = await ctx.db.query.endorsement.findFirst({
+        where: and(
+          eq(endorsement.endorserId, ctx.user.id),
+          eq(endorsement.endorsedUserId, input.endorsedUserId),
+          eq(endorsement.type, input.type),
+        ),
+      });

If you confirm “single ever” is intended, add a unique index on (endorser_id, endorsed_user_id) to harden against concurrent writes.


64-106: Optional: prefer cursor pagination to offset for large tables.

Offset pagination degrades with growth; a createdAt/id cursor is typically more efficient and avoids duplicates on concurrent inserts. Not blocking for this PR.

packages/db/drizzle/meta/0026_snapshot.json (1)

2060-2182: Add supporting indexes and (optionally) a uniqueness constraint for endorsements.

To back the API query patterns and prevent duplicates under race, add:

  • Index for list view: (endorsed_user_id, is_public, created_at DESC).
  • If you enforce “one per user per type” or “one per pair overall”, add a unique index.

Proposed migration SQL (new file; don’t hand-edit the snapshot):

-- Speeds getEndorsements
CREATE INDEX IF NOT EXISTS endorsement_endorsed_user_public_created_idx
  ON public.endorsement (endorsed_user_id, is_public, created_at DESC);

-- If you want one endorsement per (endorser, endorsed, type)
CREATE UNIQUE INDEX IF NOT EXISTS endorsement_unique_pair_type_idx
  ON public.endorsement (endorser_id, endorsed_user_id, type);
-- Or, if the rule is single ever:
-- CREATE UNIQUE INDEX IF NOT EXISTS endorsement_unique_pair_idx
--   ON public.endorsement (endorser_id, endorsed_user_id);

Similarly, consider an index to support getUserSharedProjects:

CREATE INDEX IF NOT EXISTS project_owner_id_idx ON public.project (owner_id);
CREATE INDEX IF NOT EXISTS project_owner_public_idx ON public.project (owner_id, is_public);
apps/web/components/user/endorsement-dialog.tsx (2)

81-96: Simplify reactivity with useWatch instead of bridging watch to local state.

react-hook-form already provides reactive values; avoid extra state + subscription noise. Not blocking.

-  // Watch form values with state to force re-renders
-  const [formValues, setFormValues] = useState({
-    type: form.getValues('type'),
-    projectId: form.getValues('projectId'),
-  });
-
-  useEffect(() => {
-    const subscription = form.watch((value) => {
-      setFormValues({
-        type: value.type || 'general',
-        projectId: value.projectId,
-      });
-    });
-    return () => subscription.unsubscribe();
-  }, [form]);
+  const formValues = {
+    type: form.watch('type') ?? 'general',
+    projectId: form.watch('projectId'),
+  };

196-204: Prefer Next/Image and add alt text for accessibility.

If feasible in this component, switch to next/image and supply alt text; at minimum, add alt to .

-                                      {project.logoUrl && (
-                                        <img
-                                          src={project.logoUrl}
-                                          alt={project.name}
-                                          className="h-4 w-4 rounded"
-                                        />
-                                      )}
+                                      {project.logoUrl && (
+                                        <img
+                                          src={project.logoUrl}
+                                          alt={`${project.name} logo`}
+                                          className="h-4 w-4 rounded"
+                                          loading="lazy"
+                                          decoding="async"
+                                        />
+                                      )}
apps/web/components/user/profile-tabs.tsx (2)

289-291: Avoid using array index as key in lists.

Use a stable identifier to prevent rendering glitches during filters/pagination.

-                {filteredUnSubmitted.map((project, id) => (
-                  <UnsubmittedRepoCard isOwnProfile={isOwnProfile} key={id} repo={project} />
+                {filteredUnSubmitted.map((project) => (
+                  <UnsubmittedRepoCard
+                    isOwnProfile={isOwnProfile}
+                    key={project.repoUrl}
+                    repo={project}
+                  />
                 ))}

128-129: Optional: make Tabs controlled for consistency.

If tab is a controlled state, prefer value={tab} over defaultValue to keep it in sync across navigations.

-      <Tabs defaultValue={tab} onValueChange={setTab} className="w-full">
+      <Tabs value={tab} onValueChange={setTab} className="w-full">
packages/api/src/routers/profile.ts (3)

19-41: Tighten ActivityItem typing for endorsements and non-project activities

  • Narrow endorsementType to the enum literal union to avoid drift from DB values.
  • Endorsements of type "work" or "general" don’t have a project; using empty strings for projectId/projectName is leaky. Consider making them nullable/optional in the union, or scoped via a discriminated union on type.
   type ActivityItem = {
     id: string;
-    type:
+    type:
       | 'project_created'
       | 'comment'
       | 'upvote'
       | 'project_launch'
       | 'project_claim'
       | 'endorsement_given'
       | 'endorsement_received';
     timestamp: Date;
     title: string;
     description: string | null;
-    projectName: string;
-    projectId: string;
+    // For non-project items, consider allowing nulls:
+    projectName?: string | null;
+    projectId?: string | null;
     projectLogoUrl?: string | null;
     commentContent?: string;
     tagline?: string;
     claimSuccess?: boolean;
     verificationMethod?: string;
     endorserName?: string;
     endorsedUserName?: string;
-    endorsementType?: string;
+    endorsementType?: 'project' | 'work' | 'general';
     data: unknown;
   };

If changing the shape is too broad for this PR, keep current fields but avoid empty-string sentinels when mapping (see Lines 240-260).


240-260: Avoid empty-string sentinel IDs and simplify mapping; keep payload lean

  • Creating a synthetic { name, id: '' } object makes the type lie. Prefer nulls and use a direct fallback from e.projectName.
  • After narrowing columns in the query (see above), data: e is safer, but consider omitting raw data entirely on public feeds and projecting only UI fields.
-        ...userEndorsements.map((e) => {
-          const isEndorser = e.endorserId === userId;
-          const projectInfo = e.project || (e.projectName ? { name: e.projectName, id: '' } : null);
-
-          return {
+        ...userEndorsements.map((e) => {
+          const isEndorser = e.endorserId === userId;
+          const projectName = e.project?.name ?? e.projectName ?? '';
+          const projectId = e.project?.id ?? null;
+          return {
             id: e.id,
             type: isEndorser ? ('endorsement_given' as const) : ('endorsement_received' as const),
             timestamp: e.createdAt,
             title: isEndorser
               ? `Endorsed ${e.endorsedUser.name || 'a user'}`
               : `Received endorsement from ${e.endorser.name || 'a user'}`,
             description: e.content.length > 100 ? e.content.substring(0, 100) + '...' : e.content,
-            projectName: projectInfo?.name || '',
-            projectId: projectInfo?.id || '',
+            projectName,
+            projectId,
             projectLogoUrl: e.project?.logoUrl || null,
             endorserName: e.endorser.name,
             endorsedUserName: e.endorsedUser.name,
             endorsementType: e.type,
-            data: e,
+            // Optional: drop raw data on public feeds to minimize PII surface
+            // data: e,
           };
         }),

If you keep projectId as a required string, change the initialization to const projectId = e.project?.id ?? '' and leave the type as-is.


105-109: Result size and sort are fine; minor note on headroom

The fan-in (≤100+ items) then sort and slice to 50 is okay. If we ever raise per-source limits, consider doing DB-side limits per source relative to timestamps to curb memory copies.

Also applies to: 263-266

packages/db/src/schema/endorsements.ts (1)

21-27: Optional: enforce data integrity with CHECKs

Examples:

  • If type = 'project', require one of (project_id not null OR project_name not null).
  • If type = 'work', require work_details not null and work_details->>'company' present.

If you want DB-level enforcement, we can add check() constraints with sql expressions.

Would you like me to propose the exact check() constraints with sql for Postgres?

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 8063553 and c24a79e.

📒 Files selected for processing (12)
  • apps/web/components/user/endorsement-dialog.tsx (1 hunks)
  • apps/web/components/user/endorsement-list.tsx (1 hunks)
  • apps/web/components/user/profile-tabs.tsx (7 hunks)
  • apps/web/components/user/profile.tsx (4 hunks)
  • packages/api/src/root.ts (2 hunks)
  • packages/api/src/routers/endorsements.ts (1 hunks)
  • packages/api/src/routers/profile.ts (4 hunks)
  • packages/db/drizzle/0026_brainy_havok.sql (1 hunks)
  • packages/db/drizzle/meta/0026_snapshot.json (1 hunks)
  • packages/db/drizzle/meta/_journal.json (1 hunks)
  • packages/db/src/schema/endorsements.ts (1 hunks)
  • packages/db/src/schema/index.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (8)
packages/api/src/routers/endorsements.ts (3)
packages/api/src/trpc.ts (3)
  • createTRPCRouter (36-36)
  • protectedProcedure (40-52)
  • publicProcedure (38-38)
packages/db/src/schema/endorsements.ts (1)
  • endorsement (8-35)
packages/db/src/schema/projects.ts (1)
  • project (16-71)
apps/web/components/user/endorsement-list.tsx (1)
packages/db/src/schema/endorsements.ts (1)
  • endorsement (8-35)
apps/web/components/user/endorsement-dialog.tsx (6)
packages/ui/src/components/dialog.tsx (6)
  • Dialog (133-133)
  • DialogTrigger (142-142)
  • DialogContent (135-135)
  • DialogHeader (138-138)
  • DialogTitle (141-141)
  • DialogDescription (136-136)
packages/ui/src/components/button.tsx (1)
  • Button (56-56)
packages/ui/src/components/form.tsx (7)
  • Form (145-145)
  • FormField (151-151)
  • FormItem (146-146)
  • FormLabel (147-147)
  • FormControl (148-148)
  • FormDescription (149-149)
  • FormMessage (150-150)
packages/ui/src/components/select.tsx (5)
  • Select (178-178)
  • SelectTrigger (186-186)
  • SelectValue (187-187)
  • SelectContent (179-179)
  • SelectItem (181-181)
packages/ui/src/components/input.tsx (1)
  • Input (21-21)
packages/ui/src/components/textarea.tsx (1)
  • Textarea (18-18)
packages/db/src/schema/endorsements.ts (2)
packages/db/src/schema/auth.ts (1)
  • user (4-23)
packages/db/src/schema/projects.ts (1)
  • project (16-71)
packages/api/src/root.ts (1)
packages/api/src/routers/endorsements.ts (1)
  • endorsementsRouter (7-196)
apps/web/components/user/profile.tsx (2)
packages/auth/src/client.ts (1)
  • authClient (3-3)
apps/web/components/user/endorsement-dialog.tsx (1)
  • EndorsementDialog (64-345)
packages/api/src/routers/profile.ts (1)
packages/db/src/schema/endorsements.ts (1)
  • endorsement (8-35)
apps/web/components/user/profile-tabs.tsx (3)
packages/api/src/driver/types.ts (1)
  • UnSubmittedRepo (116-128)
apps/web/components/user/unsubmitted-project-card.tsx (1)
  • UnsubmittedRepoCard (7-88)
apps/web/components/user/endorsement-list.tsx (1)
  • EndorsementList (17-150)
⏰ 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). (2)
  • GitHub Check: Vade Review
  • GitHub Check: Cursor Bugbot
🔇 Additional comments (13)
packages/db/drizzle/meta/_journal.json (1)

186-193: Integrity of _journal.json verified—changes approved

All entries in packages/db/drizzle/meta/_journal.json have been checked and are in good order:

  • Contiguous idx values from 0 through 26 with no gaps or duplicates.
  • Corresponding migration file 0026_brainy_havok.sql and snapshot packages/db/drizzle/meta/0026_snapshot.json are present.
  • All tag values are unique.

No further action is required for this PR. You may optionally integrate a CI check using the previously supplied script to guard against future drift.

packages/db/drizzle/0026_brainy_havok.sql (1)

12-13: All updatedAt columns use Drizzle’s $onUpdate; no trigger or manual update needed

Verification shows that every schema in packages/db/src/schema/** configures the updatedAt field with .$onUpdate(() => new Date()), ensuring updated_at is set on UPDATE at the ORM layer. You can safely leave the SQL DDL as-is.

• packages/db/src/schema/endorsements.ts
• packages/db/src/schema/project-launches.ts
• packages/db/src/schema/project-comments.ts
• packages/db/src/schema/categories.ts
• packages/db/src/schema/projects.ts
• packages/db/src/schema/competitors.ts

packages/db/src/schema/index.ts (1)

15-15: Re-export looks good; schema is now discoverable by consumers.

packages/api/src/root.ts (2)

5-5: Importing endorsementsRouter here is correct; no cycles detected.


31-31: Verify TRPC Client Regeneration and App/Web Compile

The new endorsements: endorsementsRouter entry in packages/api/src/root.ts is correctly wired. However, adding this endpoint changes the AppRouter type consumed by the web app—any TRPC-typed clients must be re-generated and the web package re-compiled against the updated router.

• Location to check:

  • packages/api/src/root.ts, around line 31:
    export const appRouter = createTRPCRouter({,
      endorsements: endorsementsRouter,
    });

• Action items:

  1. Regenerate your TRPC client types (e.g. via trpc-codegen or your existing codegen setup).

  2. From the monorepo root, install dependencies and re-run the build for both API and web:

    # Install/update workspace deps (using Bun, as declared in package.json)
    bun install
    
    # Run the build, filtering to api and web
    bun run build -- --filter=@workspace/api --filter=web
  3. Confirm the web package compiles without type errors and that you can call trpc.endorsements.* as expected.

apps/web/components/user/profile.tsx (1)

11-13: Good integration point: importing EndorsementDialog and authClient.

This is the right layer to gate the UI by session and show the dialog.

apps/web/components/user/endorsement-list.tsx (1)

60-139: LGTM: clean, defensive UI for project/work variants.

  • Safe text rendering (no HTML injection).
  • Proper fallbacks for missing avatar/name/username.
  • Concise conditional blocks for project vs work endorsements.
apps/web/components/user/profile-tabs.tsx (2)

129-147: LGTM: tabs layout updated to include Endorsements.

The 6-column grid and added trigger look consistent with the rest of the UI.


268-295: Render Unsubmitted only when data exists; add filtering UI—nice.

Good conditional block and simple filter state. No issues spotted.

packages/api/src/routers/profile.ts (2)

44-80: Public profile read still looks solid

Profile + git details flow remains consistent with the driver pattern; no concerns here.


151-160: Session is available on publicProcedure—use ctx.session.userId for privacy gating

I confirmed that createTRPCContext always attaches the session object (and user data) to ctx for both publicProcedure and protectedProcedure (see packages/api/src/trpc.ts). You can therefore implement your conditional privacy gate inside this publicProcedure by checking:

const currentUserId = ctx.session?.userId;
if (currentUserId === input.userId) {
  // show both in/out endorsements
} else {
  // restrict to endorsements the target user has made
}

No change to a protected procedure is required unless you want to enforce authentication for all callers.

packages/db/src/schema/endorsements.ts (2)

6-6: Enum choice and values look good

Clear, future-proofed enum for endorsement type.


37-52: Relations naming is clear and disambiguates the two user FKs

This matches usage in the API (with: { endorser, endorsedUser, project }). Good.

Comment on lines +39 to +55
const endorsementSchema = z.object({
type: z.enum(['project', 'work', 'general']),
content: z
.string()
.min(10, 'Endorsement must be at least 10 characters')
.max(1000, 'Endorsement must be less than 1000 characters'),
projectId: z.string().optional(),
projectName: z.string().optional(),
workDetails: z
.object({
company: z.string().optional(),
role: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
})
.optional(),
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Strengthen form schema: trim, conditional fields, and date order.

Align client validation with server rules and improve UX.

-const endorsementSchema = z.object({
-  type: z.enum(['project', 'work', 'general']),
-  content: z
-    .string()
-    .min(10, 'Endorsement must be at least 10 characters')
-    .max(1000, 'Endorsement must be less than 1000 characters'),
-  projectId: z.string().optional(),
-  projectName: z.string().optional(),
-  workDetails: z
-    .object({
-      company: z.string().optional(),
-      role: z.string().optional(),
-      startDate: z.string().optional(),
-      endDate: z.string().optional(),
-    })
-    .optional(),
-});
+const endorsementSchema = z
+  .object({
+    type: z.enum(['project', 'work', 'general']),
+    content: z
+      .string()
+      .trim()
+      .min(10, 'Endorsement must be at least 10 characters')
+      .max(1000, 'Endorsement must be less than 1000 characters'),
+    projectId: z.string().optional(),
+    projectName: z.string().trim().optional(),
+    workDetails: z
+      .object({
+        company: z.string().trim().optional(),
+        role: z.string().trim().optional(),
+        startDate: z.string().optional(),
+        endDate: z.string().optional(),
+      })
+      .optional(),
+  })
+  .superRefine((val, ctx) => {
+    if (val.type === 'project') {
+      if (!val.projectId && !val.projectName) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          path: ['projectId'],
+          message: "Select a project or choose 'Other' and provide a name",
+        });
+      }
+    } else if (val.projectId || val.projectName) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        path: ['projectId'],
+        message: 'Project fields are only allowed for Project endorsements',
+      });
+    }
+
+    if (val.type === 'work') {
+      const hasBasics = Boolean(val.workDetails?.company || val.workDetails?.role);
+      if (!hasBasics) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          path: ['workDetails'],
+          message: 'Provide company or role for Work endorsements',
+        });
+      }
+      const s = val.workDetails?.startDate;
+      const e = val.workDetails?.endDate;
+      if (s && e && new Date(s) > new Date(e)) {
+        ctx.addIssue({
+          code: z.ZodIssueCode.custom,
+          path: ['workDetails', 'endDate'],
+          message: 'End date must be after start date',
+        });
+      }
+    } else if (val.workDetails) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        path: ['workDetails'],
+        message: 'Work details are only allowed for Work endorsements',
+      });
+    }
+  });
📝 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.

Suggested change
const endorsementSchema = z.object({
type: z.enum(['project', 'work', 'general']),
content: z
.string()
.min(10, 'Endorsement must be at least 10 characters')
.max(1000, 'Endorsement must be less than 1000 characters'),
projectId: z.string().optional(),
projectName: z.string().optional(),
workDetails: z
.object({
company: z.string().optional(),
role: z.string().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
})
.optional(),
});
const endorsementSchema = z
.object({
type: z.enum(['project', 'work', 'general']),
content: z
.string()
.trim()
.min(10, 'Endorsement must be at least 10 characters')
.max(1000, 'Endorsement must be less than 1000 characters'),
projectId: z.string().optional(),
projectName: z.string().trim().optional(),
workDetails: z
.object({
company: z.string().trim().optional(),
role: z.string().trim().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
})
.optional(),
})
.superRefine((val, ctx) => {
if (val.type === 'project') {
if (!val.projectId && !val.projectName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['projectId'],
message: "Select a project or choose 'Other' and provide a name",
});
}
} else if (val.projectId || val.projectName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['projectId'],
message: 'Project fields are only allowed for Project endorsements',
});
}
if (val.type === 'work') {
const hasBasics = Boolean(val.workDetails?.company || val.workDetails?.role);
if (!hasBasics) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['workDetails'],
message: 'Provide company or role for Work endorsements',
});
}
const s = val.workDetails?.startDate;
const e = val.workDetails?.endDate;
if (s && e && new Date(s) > new Date(e)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['workDetails', 'endDate'],
message: 'End date must be after start date',
});
}
} else if (val.workDetails) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['workDetails'],
message: 'Work details are only allowed for Work endorsements',
});
}
});
🤖 Prompt for AI Agents
In apps/web/components/user/endorsement-dialog.tsx around lines 39 to 55, the
zod schema should be hardened: trim string fields (content, projectId,
projectName, and workDetails.company/role) and validate content length after
trimming; make projectId/projectName required when type === 'project'; require
workDetails (with company/role/date strings trimmed) when type === 'work';
validate startDate/endDate formats (e.g., ISO) and add a refinement ensuring
startDate <= endDate when both present; implement these conditional requirements
and date-order checks via zod .refine/.superRefine so client validation matches
server rules and improves UX.

Comment on lines +97 to +108
const { mutate: createEndorsement, isPending } = useMutation(
trpc.endorsements.create.mutationOptions({
onSuccess: () => {
toast.success('Endorsement added successfully!');
setOpen(false);
form.reset();
},
onError: (error) => {
toast.error(error.message || 'Failed to add endorsement');
},
}),
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Invalidate the endorsements list on success so the new item shows immediately.

Without invalidation, users won’t see their endorsement until a manual refresh.

-import { useQuery, useMutation } from '@tanstack/react-query';
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
...
 export function EndorsementDialog({ userId, userName }: EndorsementDialogProps) {
   const [open, setOpen] = useState(false);
   const trpc = useTRPC();
+  const queryClient = useQueryClient();
@@
   const { mutate: createEndorsement, isPending } = useMutation(
     trpc.endorsements.create.mutationOptions({
       onSuccess: () => {
         toast.success('Endorsement added successfully!');
         setOpen(false);
         form.reset();
+        // Invalidate all cached pages of endorsements for this user
+        queryClient.invalidateQueries({
+          queryKey: trpc.endorsements.getEndorsements.getQueryKey({ userId }),
+          exact: false,
+        });
       },
       onError: (error) => {
         toast.error(error.message || 'Failed to add endorsement');
       },
     }),
   );
📝 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.

Suggested change
const { mutate: createEndorsement, isPending } = useMutation(
trpc.endorsements.create.mutationOptions({
onSuccess: () => {
toast.success('Endorsement added successfully!');
setOpen(false);
form.reset();
},
onError: (error) => {
toast.error(error.message || 'Failed to add endorsement');
},
}),
);
// apps/web/components/user/endorsement-dialog.tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
...
export function EndorsementDialog({ userId, userName }: EndorsementDialogProps) {
const [open, setOpen] = useState(false);
const trpc = useTRPC();
const queryClient = useQueryClient();
const { mutate: createEndorsement, isPending } = useMutation(
trpc.endorsements.create.mutationOptions({
onSuccess: () => {
toast.success('Endorsement added successfully!');
setOpen(false);
form.reset();
// Invalidate all cached pages of endorsements for this user
queryClient.invalidateQueries({
queryKey: trpc.endorsements.getEndorsements.getQueryKey({ userId }),
exact: false,
});
},
onError: (error) => {
toast.error(error.message || 'Failed to add endorsement');
},
}),
);
...
}
🤖 Prompt for AI Agents
In apps/web/components/user/endorsement-dialog.tsx around lines 97 to 108, the
mutation's onSuccess currently only shows a toast and resets UI but does not
invalidate the endorsements list, so the new endorsement won't appear
immediately; fix by acquiring the tRPC/react-query context (e.g., const utils =
trpc.useContext()) and call the appropriate invalidate on success (e.g.,
utils.endorsements.list.invalidate() or the exact endorsements query name used
in the app) inside onSuccess before closing/resetting the form so the
endorsements list is refetched and updates immediately.

Comment on lines +20 to +26
const { data, isLoading } = useQuery(
trpc.endorsements.getEndorsements.queryOptions({
userId,
limit: 20,
}),
);

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid fetching with empty userId and enable incremental “Load More” via dynamic limit.

  • Prevents an unnecessary request when profile.id isn’t ready.
  • Switches to a variable limit so “Load More” can simply increase the limit without managing offsets or merging pages.
-  const { data, isLoading } = useQuery(
-    trpc.endorsements.getEndorsements.queryOptions({
-      userId,
-      limit: 20,
-    }),
-  );
+  const [limit, setLimit] = useState(20);
+  const {
+    data,
+    isLoading,
+    isFetching,
+    error,
+  } = useQuery({
+    ...trpc.endorsements.getEndorsements.queryOptions({
+      userId,
+      limit,
+    }),
+    enabled: Boolean(userId),
+  });
📝 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.

Suggested change
const { data, isLoading } = useQuery(
trpc.endorsements.getEndorsements.queryOptions({
userId,
limit: 20,
}),
);
const [limit, setLimit] = useState(20);
const {
data,
isLoading,
isFetching,
error,
} = useQuery({
...trpc.endorsements.getEndorsements.queryOptions({
userId,
limit,
}),
enabled: Boolean(userId),
});
🤖 Prompt for AI Agents
In apps/web/components/user/endorsement-list.tsx around lines 20 to 26, the
query currently fires even when userId may be empty and uses a hardcoded limit;
change the query to only run when userId is present (use the query's enabled
option or equivalent: enabled: Boolean(userId)) and replace the fixed limit with
a component state variable (e.g., const [limit, setLimit] = useState(20)) so the
query uses the dynamic limit value; expose a "Load More" handler that increments
setLimit(prev => prev + X) to fetch more items without managing offsets or
merging pages.

Comment on lines +141 to +147
{Number(data.total) > data.endorsements.length && (
<div className="text-center">
<Button variant="outline" className="rounded-none">
Load More
</Button>
</div>
)}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Wire up “Load More” to request additional items; disable while fetching.

This pairs with the dynamic limit change above and avoids managing page merges.

-      {Number(data.total) > data.endorsements.length && (
+      {data && Number(data.total) > data.endorsements.length && (
         <div className="text-center">
-          <Button variant="outline" className="rounded-none">
+          <Button
+            variant="outline"
+            className="rounded-none"
+            onClick={() => setLimit((l) => l + 20)}
+            disabled={isFetching}
+          >
             Load More
           </Button>
         </div>
       )}
📝 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.

Suggested change
{Number(data.total) > data.endorsements.length && (
<div className="text-center">
<Button variant="outline" className="rounded-none">
Load More
</Button>
</div>
)}
{data && Number(data.total) > data.endorsements.length && (
<div className="text-center">
<Button
variant="outline"
className="rounded-none"
onClick={() => setLimit((l) => l + 20)}
disabled={isFetching}
>
Load More
</Button>
</div>
)}
🤖 Prompt for AI Agents
In apps/web/components/user/endorsement-list.tsx around lines 141 to 147, the
"Load More" button is currently static; wire it to request additional items and
disable it while new items are being fetched. Add an onClick handler that
triggers the existing pagination/limit change mechanism (e.g. call the parent
prop to increase limit or invoke fetchMore/fetchNextPage from your data fetching
hook) instead of trying to merge pages locally, and pass a loading state to the
Button so it is disabled (and optionally shows a spinner) while the request is
in flight.

Comment on lines 297 to 316
<TabsContent value="endorsements" className="mt-2">
<EndorsementList userId={profile?.id || ''} />
</TabsContent>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Avoid firing endorsements query with empty userId.

Gate rendering until profile.id is available or pass an enabled flag down.

-        <TabsContent value="endorsements" className="mt-2">
-          <EndorsementList userId={profile?.id || ''} />
-        </TabsContent>
+        <TabsContent value="endorsements" className="mt-2">
+          {profile?.id ? (
+            <EndorsementList userId={profile.id} />
+          ) : (
+            <div className="rounded-none border border-neutral-800 bg-neutral-900/50 p-6 text-center text-neutral-400">
+              Loading endorsements...
+            </div>
+          )}
+        </TabsContent>
📝 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.

Suggested change
<TabsContent value="endorsements" className="mt-2">
<EndorsementList userId={profile?.id || ''} />
</TabsContent>
<TabsContent value="endorsements" className="mt-2">
{profile?.id ? (
<EndorsementList userId={profile.id} />
) : (
<div className="rounded-none border border-neutral-800 bg-neutral-900/50 p-6 text-center text-neutral-400">
Loading endorsements...
</div>
)}
</TabsContent>
🤖 Prompt for AI Agents
In apps/web/components/user/profile-tabs.tsx around lines 297 to 299, the
EndorsementList is rendered with userId={profile?.id || ''} which causes it to
fire its endorsements query with an empty id; gate rendering until profile.id
exists or pass an explicit enabled flag so the query won't run prematurely. Fix
by only rendering <EndorsementList> when profile?.id is truthy (e.g.,
{profile?.id && <EndorsementList userId={profile.id} />}) or modify
EndorsementList props to accept an enabled boolean and pass
enabled={Boolean(profile?.id)} while supplying userId={profile?.id} to prevent
the query from executing with an empty id.

Comment on lines +115 to +131
const userProjects = await ctx.db.query.project.findMany({
where: eq(project.ownerId, ctx.user.id),
columns: {
id: true,
name: true,
logoUrl: true,
},
});

const targetUserProjects = await ctx.db.query.project.findMany({
where: eq(project.ownerId, input.targetUserId),
columns: {
id: true,
name: true,
logoUrl: true,
},
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Privacy: target user’s private projects leak in shared list.

getUserSharedProjects returns all projects owned by the target user, including non-public ones. Unless both parties are authorized collaborators, limit target user projects to public ones. Keep the current user’s own projects unfiltered.

-      const targetUserProjects = await ctx.db.query.project.findMany({
-        where: eq(project.ownerId, input.targetUserId),
+      const targetUserProjects = await ctx.db.query.project.findMany({
+        where: and(
+          eq(project.ownerId, input.targetUserId),
+          eq(project.isPublic, true),
+        ),
         columns: {
           id: true,
           name: true,
           logoUrl: true,
         },
       });
📝 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.

Suggested change
const userProjects = await ctx.db.query.project.findMany({
where: eq(project.ownerId, ctx.user.id),
columns: {
id: true,
name: true,
logoUrl: true,
},
});
const targetUserProjects = await ctx.db.query.project.findMany({
where: eq(project.ownerId, input.targetUserId),
columns: {
id: true,
name: true,
logoUrl: true,
},
});
const userProjects = await ctx.db.query.project.findMany({
where: eq(project.ownerId, ctx.user.id),
columns: {
id: true,
name: true,
logoUrl: true,
},
});
const targetUserProjects = await ctx.db.query.project.findMany({
where: and(
eq(project.ownerId, input.targetUserId),
eq(project.isPublic, true),
),
columns: {
id: true,
name: true,
logoUrl: true,
},
});
🤖 Prompt for AI Agents
In packages/api/src/routers/endorsements.ts around lines 115–131, the code
fetches targetUserProjects without filtering private projects; change the
targetUserProjects query to only return projects that are public or that the
current user is explicitly authorized on (keep the current user's own projects
unfiltered). Concretely, add an additional where clause that requires
project.isPublic = true OR an existence/join check that the current user is a
collaborator/member on the project (e.g., exists row in projectCollaborators
with projectId = project.id and userId = ctx.user.id); keep the original ownerId
= input.targetUserId condition. Ensure the query uses the DB's exists/join
mechanism rather than client-side filtering so private projects are never
returned to unauthorized users.

Comment on lines +12 to +13
import { createTRPCRouter, publicProcedure } from '../trpc';
import { desc, eq, or } from 'drizzle-orm';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Prep import for a privacy-safe endorsements filter

We’ll need the logical AND operator to apply an isPublic gate (see endorsement query comment). Import and from drizzle now.

-import { createTRPCRouter, publicProcedure } from '../trpc';
-import { desc, eq, or } from 'drizzle-orm';
+import { createTRPCRouter, publicProcedure } from '../trpc';
+import { and, desc, eq, or } from 'drizzle-orm';
📝 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.

Suggested change
import { createTRPCRouter, publicProcedure } from '../trpc';
import { desc, eq, or } from 'drizzle-orm';
import { createTRPCRouter, publicProcedure } from '../trpc';
import { and, desc, eq, or } from 'drizzle-orm';
🤖 Prompt for AI Agents
In packages/api/src/routers/profile.ts around lines 12 to 13, the file currently
imports desc, eq, or from drizzle-orm but is missing the logical AND operator
needed for the privacy-safe endorsements filter; update the import list to also
include and from 'drizzle-orm' so downstream endorsement queries can apply an
isPublic gate using and(eq(...), eq(...)) as required.

Comment on lines +4 to +6
"endorser_id" text NOT NULL,
"endorsed_user_id" text NOT NULL,
"project_id" uuid,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add a DB-level uniqueness guard to prevent duplicate endorsements (race-safe).

API-side you check for an existing endorsement before insert, but two concurrent requests can slip through and create duplicates. Add a unique constraint on (endorser_id, endorsed_user_id) to enforce integrity at the database layer.

Apply in this migration (or a follow-up migration if already applied):

 CREATE TABLE "endorsement" (
   "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
   "endorser_id" text NOT NULL,
   "endorsed_user_id" text NOT NULL,
   "project_id" uuid,
   "project_name" text,
   "type" "endorsement_type" NOT NULL,
   "content" text NOT NULL,
   "work_details" jsonb,
   "is_public" boolean DEFAULT true NOT NULL,
   "created_at" timestamp with time zone DEFAULT now() NOT NULL,
   "updated_at" timestamp with time zone DEFAULT now() NOT NULL
 );
+ALTER TABLE "endorsement"
+  ADD CONSTRAINT "endorsement_unique_pair"
+  UNIQUE ("endorser_id","endorsed_user_id");

Also consider handling the unique violation (SQLSTATE 23505) in the create endpoint to return a stable TRPC error if it occurs despite the pre-check. If helpful, I can provide a small snippet for that.

📝 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.

Suggested change
"endorser_id" text NOT NULL,
"endorsed_user_id" text NOT NULL,
"project_id" uuid,
CREATE TABLE "endorsement" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"endorser_id" text NOT NULL,
"endorsed_user_id" text NOT NULL,
"project_id" uuid,
"project_name" text,
"type" "endorsement_type" NOT NULL,
"content" text NOT NULL,
"work_details" jsonb,
"is_public" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "endorsement"
ADD CONSTRAINT "endorsement_unique_pair"
UNIQUE ("endorser_id","endorsed_user_id");
🤖 Prompt for AI Agents
In packages/db/drizzle/0026_brainy_havok.sql around lines 4 to 6, the
endorsements table lacks a DB-level uniqueness constraint allowing duplicate
(endorser_id, endorsed_user_id) rows under concurrent requests; add a unique
constraint on the pair (endorser_id, endorsed_user_id) in this migration (or a
new follow-up migration) by creating a named UNIQUE constraint (and index if
needed) so the DB enforces uniqueness; after applying the migration, update the
create endorsement endpoint to catch SQLSTATE 23505 unique-violation errors and
translate them into a stable TRPC error response.

Comment on lines +8 to +35
export const endorsement = pgTable('endorsement', {
id: uuid('id').primaryKey().defaultRandom(),
endorserId: text('endorser_id')
.references(() => user.id, { onDelete: 'cascade' })
.notNull(),
endorsedUserId: text('endorsed_user_id')
.references(() => user.id, { onDelete: 'cascade' })
.notNull(),
projectId: uuid('project_id').references(() => project.id, { onDelete: 'set null' }),
projectName: text('project_name'),

type: endorsementTypeEnum('type').notNull(),
content: text('content').notNull(),
workDetails: jsonb('work_details').$type<{
company?: string;
role?: string;
startDate?: string;
endDate?: string;
}>(),

isPublic: boolean('is_public').notNull().default(true),

createdAt: timestamp('created_at', { mode: 'date', withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'date', withTimezone: true })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add indexes aligned with query patterns (endorserId/endorsedUserId + createdAt, isPublic)

getRecentActivities filters by endorserId OR endorsedUserId and sorts by createdAt desc. Adding composite indexes will materially improve performance and avoid full sorts as data grows. An is_public index helps the public-view gate.

-import { pgTable, text, timestamp, uuid, boolean, jsonb, pgEnum } from 'drizzle-orm/pg-core';
+import { pgTable, text, timestamp, uuid, boolean, jsonb, pgEnum, index } from 'drizzle-orm/pg-core';

-export const endorsement = pgTable('endorsement', {
-  id: uuid('id').primaryKey().defaultRandom(),
-  endorserId: text('endorser_id')
-    .references(() => user.id, { onDelete: 'cascade' })
-    .notNull(),
-  endorsedUserId: text('endorsed_user_id')
-    .references(() => user.id, { onDelete: 'cascade' })
-    .notNull(),
-  projectId: uuid('project_id').references(() => project.id, { onDelete: 'set null' }),
-  projectName: text('project_name'),
-
-  type: endorsementTypeEnum('type').notNull(),
-  content: text('content').notNull(),
-  workDetails: jsonb('work_details').$type<{
-    company?: string;
-    role?: string;
-    startDate?: string;
-    endDate?: string;
-  }>(),
-
-  isPublic: boolean('is_public').notNull().default(true),
-
-  createdAt: timestamp('created_at', { mode: 'date', withTimezone: true }).defaultNow().notNull(),
-  updatedAt: timestamp('updated_at', { mode: 'date', withTimezone: true })
-    .defaultNow()
-    .$onUpdate(() => new Date())
-    .notNull(),
-});
+export const endorsement = pgTable(
+  'endorsement',
+  {
+    id: uuid('id').primaryKey().defaultRandom(),
+    endorserId: text('endorser_id')
+      .references(() => user.id, { onDelete: 'cascade' })
+      .notNull(),
+    endorsedUserId: text('endorsed_user_id')
+      .references(() => user.id, { onDelete: 'cascade' })
+      .notNull(),
+    projectId: uuid('project_id').references(() => project.id, { onDelete: 'set null' }),
+    projectName: text('project_name'),
+
+    type: endorsementTypeEnum('type').notNull(),
+    content: text('content').notNull(),
+    workDetails: jsonb('work_details').$type<{
+      company?: string;
+      role?: string;
+      startDate?: string;
+      endDate?: string;
+    }>(),
+
+    isPublic: boolean('is_public').notNull().default(true),
+
+    createdAt: timestamp('created_at', { mode: 'date', withTimezone: true }).defaultNow().notNull(),
+    updatedAt: timestamp('updated_at', { mode: 'date', withTimezone: true })
+      .defaultNow()
+      .$onUpdate(() => new Date())
+      .notNull(),
+  },
+  (table) => [
+    index('endorsement_endorser_id_created_at_idx').on(table.endorserId, table.createdAt.desc()),
+    index('endorsement_endorsed_user_id_created_at_idx').on(table.endorsedUserId, table.createdAt.desc()),
+    index('endorsement_is_public_idx').on(table.isPublic),
+  ],
+);
📝 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.

Suggested change
export const endorsement = pgTable('endorsement', {
id: uuid('id').primaryKey().defaultRandom(),
endorserId: text('endorser_id')
.references(() => user.id, { onDelete: 'cascade' })
.notNull(),
endorsedUserId: text('endorsed_user_id')
.references(() => user.id, { onDelete: 'cascade' })
.notNull(),
projectId: uuid('project_id').references(() => project.id, { onDelete: 'set null' }),
projectName: text('project_name'),
type: endorsementTypeEnum('type').notNull(),
content: text('content').notNull(),
workDetails: jsonb('work_details').$type<{
company?: string;
role?: string;
startDate?: string;
endDate?: string;
}>(),
isPublic: boolean('is_public').notNull().default(true),
createdAt: timestamp('created_at', { mode: 'date', withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'date', withTimezone: true })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});
import { pgTable, text, timestamp, uuid, boolean, jsonb, pgEnum, index } from 'drizzle-orm/pg-core';
export const endorsement = pgTable(
'endorsement',
{
id: uuid('id').primaryKey().defaultRandom(),
endorserId: text('endorser_id')
.references(() => user.id, { onDelete: 'cascade' })
.notNull(),
endorsedUserId: text('endorsed_user_id')
.references(() => user.id, { onDelete: 'cascade' })
.notNull(),
projectId: uuid('project_id').references(() => project.id, { onDelete: 'set null' }),
projectName: text('project_name'),
type: endorsementTypeEnum('type').notNull(),
content: text('content').notNull(),
workDetails: jsonb('work_details').$type<{
company?: string;
role?: string;
startDate?: string;
endDate?: string;
}>(),
isPublic: boolean('is_public').notNull().default(true),
createdAt: timestamp('created_at', { mode: 'date', withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { mode: 'date', withTimezone: true })
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [
index('endorsement_endorser_id_created_at_idx').on(table.endorserId, table.createdAt.desc()),
index('endorsement_endorsed_user_id_created_at_idx').on(table.endorsedUserId, table.createdAt.desc()),
index('endorsement_is_public_idx').on(table.isPublic),
],
);
🤖 Prompt for AI Agents
packages/db/src/schema/endorsements.ts around lines 8 to 35: the endorsements
table lacks indexes needed for the getRecentActivities query patterns; add
composite indexes on (endorser_id, created_at) and (endorsed_user_id,
created_at) to support the OR + sort by createdAt desc pattern, and add an index
on is_public (or a composite on is_public, created_at if public queries are also
sorted) to speed public-view filtering; implement these by adding the
appropriate index declarations in the schema (or a migration) so the database
creates these indexes without changing existing column definitions.

cursor[bot]

This comment was marked as outdated.

Copy link

@coderabbitai coderabbitai bot left a 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

♻️ Duplicate comments (3)
apps/web/components/user/profile-tabs.tsx (1)

314-316: Gate EndorsementList until profile.id is available (prevents empty userId calls)

As flagged earlier, userId={profile?.id || ''} can trigger a request with an empty string and cause noisy API calls. Render the list only when profile.id is present (or pass an enabled flag down).

-        <TabsContent value="endorsements" className="mt-2">
-          <EndorsementList userId={profile?.id || ''} />
-        </TabsContent>
+        <TabsContent value="endorsements" className="mt-2">
+          {profile?.id ? (
+            <EndorsementList userId={profile.id} />
+          ) : (
+            <div className="rounded-none border border-neutral-800 bg-neutral-900/50 p-6 text-center text-neutral-400">
+              Loading endorsements...
+            </div>
+          )}
+        </TabsContent>
packages/api/src/routers/profile.ts (2)

12-13: Prepare for privacy-safe filtering with drizzle.and

To apply an isPublic gate, you’ll need and from drizzle-orm.

-import { createTRPCRouter, publicProcedure } from '../trpc';
-import { desc, eq, or } from 'drizzle-orm';
+import { createTRPCRouter, publicProcedure } from '../trpc';
+import { and, desc, eq, or } from 'drizzle-orm';

110-161: Privacy leak: endorsements in a public feed expose private items and full user rows

Issues:

  • No isPublic gate → private endorsements can be enumerated by any caller of this public route.
  • with: { endorser: true, endorsedUser: true, project: true } fetches full related rows; combined with data: e this can leak sensitive fields.

Minimal, safe fix for a public feed:

  • Always filter endorsements by isPublic = true.
  • Restrict columns on both the root endorsement and relations.

Apply this diff:

-          ctx.db.query.endorsement.findMany({
-            where: or(eq(endorsement.endorserId, userId), eq(endorsement.endorsedUserId, userId)),
-            orderBy: [desc(endorsement.createdAt)],
-            limit: 20,
-            with: {
-              endorser: true,
-              endorsedUser: true,
-              project: true,
-            },
-          }),
+          ctx.db.query.endorsement.findMany({
+            where: and(
+              or(eq(endorsement.endorserId, userId), eq(endorsement.endorsedUserId, userId)),
+              eq(endorsement.isPublic, true),
+            ),
+            orderBy: [desc(endorsement.createdAt)],
+            limit: 20,
+            columns: {
+              id: true,
+              endorserId: true,
+              endorsedUserId: true,
+              projectId: true,
+              projectName: true,
+              type: true,
+              content: true,
+              workDetails: true,
+              isPublic: true,
+              createdAt: true,
+            },
+            with: {
+              endorser: { columns: { id: true, name: true, username: true, image: true } },
+              endorsedUser: { columns: { id: true, name: true, username: true, image: true } },
+              project: { columns: { id: true, name: true, logoUrl: true } },
+            },
+          }),

Follow-up (optional but recommended):

  • For “self view” including private endorsements, expose a separate protected route (or upgrade this procedure to protectedProcedure) and branch on ctx.session.user.id === userId.
🧹 Nitpick comments (2)
apps/web/components/user/profile-tabs.tsx (1)

271-271: Avoid mixing gap and space-y on a grid container

Using both gap-4 and space-y-4 on a grid duplicates vertical spacing and can yield inconsistent layout; prefer just gap utilities on grids.

Apply this minimal change:

-            <div className="grid grid-cols-1 gap-4 space-y-4">
+            <div className="grid grid-cols-1 gap-4">
packages/api/src/routers/profile.ts (1)

121-149: Consider reducing over-fetching in profile activity queries

Currently, several findMany calls in packages/api/src/routers/profile.ts pull entire related rows via

with: {
  project: true,
  user:    true,
}

This returns all columns for each project and user record—even fields you may not need—and risks exposing PII and bloating response payloads. As an optional improvement, you can tighten each relation by explicitly selecting only the necessary fields (e.g., IDs, names, timestamps), similar to how endorsements are scoped elsewhere.

Key locations to update:

  • lines 116–117: initial query with owner: true
  • lines 125–126: projectComment.findMany
  • lines 135–136: projectVote.findMany
  • lines 145–146: projectClaim.findMany

Suggested pattern:

-with: { project: true, user: true }
+with: {
+  project: { select: { id: true, slug: true, title: true } },
+  user:    { select: { id: true, displayName: true } }
+}

By tightening your with clauses, you’ll keep payloads lean, improve performance, and reduce the risk of leaking sensitive data.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between c24a79e and 49dca12.

📒 Files selected for processing (12)
  • apps/web/components/user/endorsement-dialog.tsx (1 hunks)
  • apps/web/components/user/endorsement-list.tsx (1 hunks)
  • apps/web/components/user/profile-tabs.tsx (5 hunks)
  • apps/web/components/user/profile.tsx (4 hunks)
  • packages/api/src/root.ts (2 hunks)
  • packages/api/src/routers/endorsements.ts (1 hunks)
  • packages/api/src/routers/profile.ts (4 hunks)
  • packages/db/drizzle/0026_brainy_havok.sql (1 hunks)
  • packages/db/drizzle/meta/0026_snapshot.json (1 hunks)
  • packages/db/drizzle/meta/_journal.json (1 hunks)
  • packages/db/src/schema/endorsements.ts (1 hunks)
  • packages/db/src/schema/index.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (10)
  • packages/db/src/schema/index.ts
  • packages/api/src/routers/endorsements.ts
  • packages/db/drizzle/0026_brainy_havok.sql
  • packages/db/src/schema/endorsements.ts
  • apps/web/components/user/endorsement-list.tsx
  • packages/db/drizzle/meta/0026_snapshot.json
  • packages/api/src/root.ts
  • apps/web/components/user/endorsement-dialog.tsx
  • packages/db/drizzle/meta/_journal.json
  • apps/web/components/user/profile.tsx
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/components/user/profile-tabs.tsx (1)
apps/web/components/user/endorsement-list.tsx (1)
  • EndorsementList (17-150)
packages/api/src/routers/profile.ts (1)
packages/db/src/schema/endorsements.ts (1)
  • endorsement (8-35)
⏰ 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). (2)
  • GitHub Check: Cursor Bugbot
  • GitHub Check: Vade Review
🔇 Additional comments (4)
apps/web/components/user/profile-tabs.tsx (2)

20-20: Endorsements import looks good

Importing EndorsementList here is correct and localizes the feature to Profile Tabs.


126-141: Tabs header update is consistent

grid-cols-5 matches the 5 triggers (About, Projects, Contributions, Collections, Endorsements). Trigger wiring looks correct.

packages/api/src/routers/profile.ts (2)

9-10: Schema import is correct

Pulling endorsement from @workspace/db/schema is expected for composing activity items.


19-27: Consider avoiding empty-string sentinel for missing projectId; use null instead

Our scan found that projectId is still declared strictly as string (without | null) in many downstream locations, for example:

  • packages/apps/web/hooks/use-launch-updates.ts (interface UseLaunchUpdatesOptions)
  • packages/apps/web/components/user/recent-activity.tsx (props for RecentActivity)
  • packages/apps/web/components/user/endorsement-dialog.tsx (form values and mapping)
  • packages/apps/web/components/admin/project-edit-form.tsx (props for ProjectEditForm)
  • packages/apps/web/components/project/launch-project-dialog.tsx (props and TRPC calls)
  • packages/apps/web/components/project/debug-permissions.tsx
  • packages/apps/web/components/project/project-report.tsx
  • packages/apps/web/components/project/claim-project-dialog.tsx

Changing the type to string | null (or making it optional) will be a breaking change that needs coordinated refactoring across these files. If you can’t update the type now, at minimum derive the ID as:

const resolvedProjectId = project?.id ?? projectIdFromSource ?? null;

so you never propagate ''.

Please review all affected declarations and usages to ensure they handle null properly before adopting this change.

Comment on lines +240 to +260
...userEndorsements.map((e) => {
const isEndorser = e.endorserId === userId;
const projectInfo = e.project || (e.projectName ? { name: e.projectName, id: '' } : null);

return {
id: e.id,
type: isEndorser ? ('endorsement_given' as const) : ('endorsement_received' as const),
timestamp: e.createdAt,
title: isEndorser
? `Endorsed ${e.endorsedUser.name || 'a user'}`
: `Received endorsement from ${e.endorser.name || 'a user'}`,
description: e.content.length > 100 ? e.content.substring(0, 100) + '...' : e.content,
projectName: projectInfo?.name || '',
projectId: projectInfo?.id || '',
projectLogoUrl: e.project?.logoUrl || null,
endorserName: e.endorser.name,
endorsedUserName: e.endorsedUser.name,
endorsementType: e.type,
data: e,
};
}),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Harden mapping: derive projectId/name from both sources; avoid passing full endorsement in data

Safer mapping that reduces '' sentinels and trims payload in data:

-        ...userEndorsements.map((e) => {
-          const isEndorser = e.endorserId === userId;
-          const projectInfo = e.project || (e.projectName ? { name: e.projectName, id: '' } : null);
-
-          return {
-            id: e.id,
-            type: isEndorser ? ('endorsement_given' as const) : ('endorsement_received' as const),
-            timestamp: e.createdAt,
-            title: isEndorser
-              ? `Endorsed ${e.endorsedUser.name || 'a user'}`
-              : `Received endorsement from ${e.endorser.name || 'a user'}`,
-            description: e.content.length > 100 ? e.content.substring(0, 100) + '...' : e.content,
-            projectName: projectInfo?.name || '',
-            projectId: projectInfo?.id || '',
-            projectLogoUrl: e.project?.logoUrl || null,
-            endorserName: e.endorser.name,
-            endorsedUserName: e.endorsedUser.name,
-            endorsementType: e.type,
-            data: e,
-          };
-        }),
+        ...userEndorsements.map((e) => {
+          const isEndorser = e.endorserId === userId;
+          const projectName = e.project?.name ?? e.projectName ?? '';
+          const projectId = e.project?.id ?? e.projectId ?? '';
+          return {
+            id: e.id,
+            type: isEndorser ? ('endorsement_given' as const) : ('endorsement_received' as const),
+            timestamp: e.createdAt,
+            title: isEndorser
+              ? `Endorsed ${e.endorsedUser?.name || 'a user'}`
+              : `Received endorsement from ${e.endorser?.name || 'a user'}`,
+            description: e.content.length > 100 ? e.content.substring(0, 100) + '...' : e.content,
+            projectName,
+            projectId,
+            projectLogoUrl: e.project?.logoUrl ?? null,
+            endorserName: e.endorser?.name,
+            endorsedUserName: e.endorsedUser?.name,
+            endorsementType: e.type,
+            // keep data minimal to avoid PII leakage
+            data: { id: e.id, type: e.type, createdAt: e.createdAt },
+          };
+        }),
📝 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.

Suggested change
...userEndorsements.map((e) => {
const isEndorser = e.endorserId === userId;
const projectInfo = e.project || (e.projectName ? { name: e.projectName, id: '' } : null);
return {
id: e.id,
type: isEndorser ? ('endorsement_given' as const) : ('endorsement_received' as const),
timestamp: e.createdAt,
title: isEndorser
? `Endorsed ${e.endorsedUser.name || 'a user'}`
: `Received endorsement from ${e.endorser.name || 'a user'}`,
description: e.content.length > 100 ? e.content.substring(0, 100) + '...' : e.content,
projectName: projectInfo?.name || '',
projectId: projectInfo?.id || '',
projectLogoUrl: e.project?.logoUrl || null,
endorserName: e.endorser.name,
endorsedUserName: e.endorsedUser.name,
endorsementType: e.type,
data: e,
};
}),
...userEndorsements.map((e) => {
const isEndorser = e.endorserId === userId;
const projectName = e.project?.name ?? e.projectName ?? '';
const projectId = e.project?.id ?? e.projectId ?? '';
return {
id: e.id,
type: isEndorser
? ('endorsement_given' as const)
: ('endorsement_received' as const),
timestamp: e.createdAt,
title: isEndorser
? `Endorsed ${e.endorsedUser?.name || 'a user'}`
: `Received endorsement from ${e.endorser?.name || 'a user'}`,
description:
e.content.length > 100
? e.content.substring(0, 100) + '...'
: e.content,
- projectName: projectInfo?.name || '',
projectName,
projectId,
projectLogoUrl: e.project?.logoUrl ?? null,
endorserName: e.endorser?.name,
endorsedUserName: e.endorsedUser?.name,
endorsementType: e.type,
// keep data minimal to avoid PII leakage
data: { id: e.id, type: e.type, createdAt: e.createdAt },
};
}),
🤖 Prompt for AI Agents
In packages/api/src/routers/profile.ts around lines 240 to 260, the mapping
currently uses '' sentinels for missing project id/name and passes the full
endorsement object in data; change it to derive projectId and projectName from
both e.project and fallback fields (e.g. e.project?.id || e.projectId || null
and e.project?.name || e.projectName || null) and return null (not '') when
absent, set projectLogoUrl from e.project?.logoUrl || null, and replace the full
endorsement in data with a small trimmed object containing only necessary fields
(e.g. id, type, content, createdAt, endorserId, endorsedUserId, projectId) to
avoid leaking unnecessary data.

.string()
.min(10, 'Endorsement must be at least 10 characters')
.max(1000, 'Endorsement must be less than 1000 characters'),
projectId: z.string().optional(),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
projectId: z.string().optional(),
projectId: z.string().uuid().optional(),

Frontend validation for projectId allows any string, but the API expects a valid UUID format, which could cause validation errors when submitting the form.

View Details

Analysis

The frontend schema defines projectId: z.string().optional() (line 45) while the API router expects projectId: z.string().uuid().optional() (in /packages/api/src/routers/endorsements.ts line 14). This mismatch means that if a user somehow provides an invalid UUID string for projectId, the form will pass frontend validation but fail at the API level with a validation error.

While the current UI flow makes this unlikely to occur (since projectId comes from a select dropdown), this validation inconsistency creates a potential failure point and reduces type safety. The frontend should enforce the same validation constraints as the API.


Recommendation

Update the frontend schema to match the API validation:

projectId: z.string().uuid().optional(),

This ensures consistent validation between frontend and backend, preventing potential UUID format errors from reaching the API.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (2)
apps/web/components/user/recent-activity.tsx (2)

22-29: Tighten helper typings to Activity['type'] and make color reflect claim failure

Use the literal union instead of string to prevent silent fallthrough when new activity types get added. Also, color for project_claim should reflect failure when claimSuccess is false.

Apply this diff to the helpers:

-  const getActivityIcon = (type: string, claimSuccess?: boolean) => {
+  const getActivityIcon = (type: Activity['type'], claimSuccess?: boolean) => {
@@
-      case 'project_claim':
+      case 'project_claim':
         return claimSuccess ? (
           <CheckCircle2 className="h-4 w-4 text-emerald-500" />
         ) : (
           <XCircle className="h-4 w-4 text-red-500" />
         );
@@
-  const getActivityColor = (type: string) => {
+  const getActivityColor = (type: Activity['type'], claimSuccess?: boolean) => {
@@
-      case 'project_claim':
-        return 'text-emerald-500';
+      case 'project_claim':
+        return claimSuccess ? 'text-emerald-500' : 'text-red-500';

Follow-up usage change outside the ranges above (update the call site to pass claimSuccess):

// before
className={`font-medium hover:underline ${getActivityColor(activity.type)}`}

// after
className={`font-medium hover:underline ${getActivityColor(activity.type, activity.claimSuccess)}`}

Optional: add an exhaustive check to surface compiler errors when a new Activity['type'] is introduced but not handled:

function assertNever(x: never): never {
  throw new Error(`Unhandled activity type: ${x}`);
}

Then in each switch, replace the default branch with return assertNever(type);.

Also applies to: 77-100, 102-120


93-96: De-duplicate endorsement cases in getActivityIcon

Both branches return the same icon. Stack the cases to reduce duplication.

Apply this diff:

-      case 'endorsement_given':
-        return <ThumbsUp className="h-4 w-4 text-yellow-500" />;
-      case 'endorsement_received':
-        return <ThumbsUp className="h-4 w-4 text-yellow-500" />;
+      case 'endorsement_given':
+      case 'endorsement_received':
+        return <ThumbsUp className="h-4 w-4 text-yellow-500" />;

Optional: If you want stronger visual differentiation from "upvote", consider a different glyph (e.g., BadgeCheck or Handshake) for endorsements.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 49dca12 and 5e4026c.

📒 Files selected for processing (1)
  • apps/web/components/user/recent-activity.tsx (3 hunks)
⏰ 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: Vade Review
🔇 Additional comments (3)
apps/web/components/user/recent-activity.tsx (3)

22-29: LGTM: Added endorsement activity types to the Activity union

The two new literals ('endorsement_given' | 'endorsement_received') are correctly added to the Activity.type union and align with the new feature.


114-116: LGTM: Color mapping for endorsement events

Both endorsement types map to the same highlight color, which is consistent and easy to scan.


22-29: Activity type literals are consistent across layers

Verified that the new literals

  • packages/api/src/routers/profile.ts:25–26 defines
    | 'endorsement_given'
    | 'endorsement_received'
    and at line 246 assigns them exactly as const
  • apps/web/components/user/recent-activity.tsx:28–29 declares the same union, and cases at lines 93–95 and 114–115 handle both values

No other Activity.type switches or duplicate type definitions were found.
No further updates required.

<p className="text-sm text-white">{activity.title}</p>
<Link href={`/projects/${activity.projectId}`}>
<p className="text-sm text-white">{activity.title}</p>
</Link>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Invalid Links for Empty Project IDs

Activity titles are unconditionally wrapped in a link to /projects/${activity.projectId}. For endorsement activities, activity.projectId can be an empty string, creating invalid links like /projects/. This may lead to broken navigation or 404 errors.

Fix in Cursor Fix in Web

Comment on lines +141 to +143
<Button variant="outline" className="rounded-none">
Load More
</Button>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "Load More" button is rendered but has no click handler or functionality, making it non-functional.

View Details

Analysis

The endorsement list component renders a "Load More" button when there are more endorsements available (line 139 condition checks if total count exceeds current loaded endorsements). However, the button has no onClick handler or any functionality implemented (lines 141-143). Users can see and click this button, but nothing will happen, creating a poor user experience.

The component uses useQuery with fixed parameters (limit: 20, offset: 0 hardcoded in line 22-23), so there's no mechanism to load additional endorsements beyond the initial 20.


Recommendation

Implement pagination functionality for the "Load More" button. Add state management for offset/pagination and implement the click handler:

// Add to component state
const [offset, setOffset] = useState(0);
const [allEndorsements, setAllEndorsements] = useState<any[]>([]);

// Update the query to use dynamic offset
const { data, isLoading } = useQuery(
  trpc.endorsements.getEndorsements.queryOptions({
    userId,
    limit: 20,
    offset,
  }),
);

// Update button with click handler
<Button 
  variant="outline" 
  className="rounded-none"
  onClick={() => setOffset(prev => prev + 20)}
  disabled={isLoading}
>
  {isLoading ? 'Loading...' : 'Load More'}
</Button>

Alternatively, remove the "Load More" button entirely if pagination is not intended for the initial implementation.

endorsedUserId: z.string(),
type: z.enum(['project', 'work', 'general']),
content: z.string().min(10).max(1000),
projectId: z.string().uuid().optional(),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The backend validation requires projectId to be a valid UUID, but the frontend can send the string "other" which would fail validation.

View Details

Analysis

There's a validation mismatch between the frontend and backend for the projectId field. The backend expects projectId: z.string().uuid().optional() (line 14), which means if provided, it must be a valid UUID. However, in the frontend dialog (endorsement-dialog.tsx line 115), when users select "Other (Enter manually)" for a project, the form sets projectId to the string "other" before converting it to undefined in the submit handler.

While the submit handler correctly converts "other" to undefined before sending to the backend, this creates a fragile dependency where any changes to the submit logic could break validation. The frontend validation should match the backend constraint to catch issues early.


Recommendation

Update the frontend validation schema in endorsement-dialog.tsx to match the backend constraint. Change line 45 from:

projectId: z.string().optional(),

to:

projectId: z.string().uuid().optional().or(z.literal('other')).optional(),

This ensures the frontend validates that projectId is either a UUID or the literal string "other", matching the expected behavior.

Copy link

@coderabbitai coderabbitai bot left a 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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/web/components/user/recent-activity.tsx (2)

102-120: Color for failed project_claim remains green; pass claimSuccess into getActivityColor

Links for failed claims render as green due to a static mapping. Make color conditional like the icon, and thread claimSuccess into the color helper and callsite.

-  const getActivityColor = (type: string) => {
+  const getActivityColor = (type: Activity['type'], claimSuccess?: boolean) => {
     switch (type) {
       case 'project_created':
         return 'text-blue-500';
       case 'comment':
         return 'text-green-500';
       case 'upvote':
         return 'text-orange-500';
       case 'project_launch':
         return 'text-purple-500';
       case 'project_claim':
-        return 'text-emerald-500';
+        return claimSuccess ? 'text-emerald-500' : 'text-red-500';
       case 'endorsement_given':
       case 'endorsement_received':
         return 'text-yellow-500';
       default:
         return 'text-neutral-500';
     }
   };
@@
-                      <Link
-                        href={`/projects/${activity.projectId}`}
-                        className={`font-medium hover:underline ${getActivityColor(activity.type)}`}
-                      >
+                      <Link
+                        href={`/projects/${activity.projectId}`}
+                        className={`font-medium hover:underline ${getActivityColor(
+                          activity.type,
+                          activity.claimSuccess,
+                        )}`}
+                      >

Also applies to: 161-166


41-60: Throttle typing/clearing can break in the browser build

NodeJS.Timeout is not correct in the client; clearTimeout may run before assignment. Use ReturnType and null checks.

-const throttle = <T extends (...args: any[]) => void>(func: T, delay: number): T => {
-  let timeoutId: NodeJS.Timeout;
+const throttle = <T extends (...args: any[]) => void>(func: T, delay: number): T => {
+  let timeoutId: ReturnType<typeof setTimeout> | null = null;
   let lastExecTime = 0;
   return ((...args: Parameters<T>) => {
     const currentTime = Date.now();
     if (currentTime - lastExecTime > delay) {
       func(...args);
       lastExecTime = currentTime;
     } else {
-      clearTimeout(timeoutId);
-      timeoutId = setTimeout(
-        () => {
-          func(...args);
-          lastExecTime = Date.now();
-        },
-        delay - (currentTime - lastExecTime),
-      );
+      if (timeoutId) clearTimeout(timeoutId);
+      timeoutId = setTimeout(() => {
+        func(...args);
+        lastExecTime = Date.now();
+        timeoutId = null;
+      }, delay - (currentTime - lastExecTime));
     }
   }) as T;
 };
♻️ Duplicate comments (2)
apps/web/components/user/endorsement-list.tsx (2)

20-25: Handle query errors, avoid fetching with empty userId, and wire pagination via a dynamic limit

This fixes “No endorsements yet” on errors, prevents useless requests when userId isn’t ready, and makes “Load More” functional without manual page merging. Mirrors prior feedback.

+import { useState } from 'react';
@@
-export function EndorsementList({ userId }: EndorsementListProps) {
+export function EndorsementList({ userId }: EndorsementListProps) {
   const trpc = useTRPC();
 
-  const { data, isLoading } = useQuery(
-    trpc.endorsements.getEndorsements.queryOptions({
-      userId,
-      limit: 20,
-    }),
-  );
+  const [limit, setLimit] = useState(20);
+  const { data, isLoading, isFetching, isError } = useQuery({
+    ...trpc.endorsements.getEndorsements.queryOptions({
+      userId,
+      limit,
+    }),
+    enabled: Boolean(userId),
+  });
@@
   if (isLoading) {
     return (
       <div className="space-y-4">
@@
     );
   }
 
+  if (isError) {
+    return (
+      <Card className="rounded-none border-neutral-800 bg-neutral-900">
+        <CardContent className="p-8 text-center">
+          <p className="text-red-400">Failed to load endorsements</p>
+        </CardContent>
+      </Card>
+    );
+  }
+
   if (!data?.endorsements.length) {
     return (
-      <div className="p-6 text-center text-neutral-400">
-        <p className="text-neutral-400">No endorsements yet</p>
-      </div>
+      <Card className="rounded-none border-neutral-800 bg-neutral-900">
+        <CardContent className="p-6 text-center">
+          <p className="text-neutral-400">No endorsements yet</p>
+        </CardContent>
+      </Card>
     );
   }
@@
-      {Number(data.total) > data.endorsements.length && (
+      {data && Number(data.total) > data.endorsements.length && (
         <div className="text-center">
-          <Button variant="outline" className="rounded-none">
-            Load More
+          <Button
+            variant="outline"
+            className="rounded-none"
+            onClick={() => setLimit((l) => l + 20)}
+            disabled={isFetching}
+          >
+            {isFetching ? 'Loading...' : 'Load More'}
           </Button>
         </div>
       )}

Also applies to: 27-47, 139-145, 11-11


139-145: “Load More” button was non-functional

Hooked up to increment the limit and disabled while fetching. This addresses the broken UX called out earlier.

🧹 Nitpick comments (5)
apps/web/components/user/recent-activity.tsx (1)

93-97: Differentiate endorsement vs. upvote visually (optional)

Reusing ThumbsUp is fine, but it can blur meaning with the existing 'upvote'. Consider a different icon or subtle badge to disambiguate in dense feeds.

apps/web/components/user/endorsement-list.tsx (4)

64-66: Add alt text for the avatar image

Improves accessibility and avoids Next/Image warnings if you switch later.

-                  <AvatarImage src={endorsement.endorser.image} />
+                  <AvatarImage
+                    src={endorsement.endorser.image}
+                    alt={endorsement.endorser.name || endorsement.endorser.username || 'Endorser'}
+                  />

77-79: Avoid rendering a stray “@” when username is missing

Render the handle only when present.

-                    <p className="text-sm text-neutral-400">
-                      @{endorsement.endorser.username ?? ''}
-                    </p>
+                    {endorsement.endorser.username && (
+                      <p className="text-sm text-neutral-400">@{endorsement.endorser.username}</p>
+                    )}

62-99: Optional: date formatting for work details

startDate/endDate are rendered raw. Consider formatting (e.g., “Jan 2023 – Present”) with date-fns.

Example outside this diff:

import { format, parseISO } from 'date-fns';

// ...
{(wd.startDate || wd.endDate) && (
  <span className="flex items-center gap-1">
    <Calendar className="h-3 w-3" />
    {[wd.startDate, wd.endDate || 'Present']
      .map((d, i) => (d && d !== 'Present' ? format(parseISO(d), 'MMM yyyy') : d))
      .join(' - ')}
  </span>
)}

Also applies to: 108-129


11-11: Optional: use the shared UI Link for analytics consistency

Switching to '@workspace/ui/components/link' provides uniform tracking across the app. If you adopt this, update all anchors in this component.

-import Link from 'next/link';
+import UILink from '@workspace/ui/components/link';
@@
-              <Link href={`/profile/${endorsement.endorser.id}`}>
+              <UILink href={`/profile/${endorsement.endorser.id}`}>
                 <Avatar className="h-12 w-12">
@@
-              </Link>
+              </UILink>
@@
-                    <Link
+                    <UILink
                       href={`/profile/${endorsement.endorser.id}`}
                       className="font-medium text-white hover:underline"
                     >
                       {endorsement.endorser.name}
-                    </Link>
+                    </UILink>
@@
-                        <Link
+                        <UILink
                           href={`/projects/${endorsement.project.id}`}
                           className="inline-flex items-center gap-2 rounded-sm bg-neutral-800 px-3 py-1 text-sm text-neutral-300 hover:bg-neutral-700"
                         >
                           <Folder className="h-3 w-3" />
                           {endorsement.project.name}
-                        </Link>
+                        </UILink>

Also applies to: 62-67, 71-76, 92-98

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 5e4026c and a9f0409.

📒 Files selected for processing (2)
  • apps/web/components/user/endorsement-list.tsx (1 hunks)
  • apps/web/components/user/recent-activity.tsx (4 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/components/user/endorsement-list.tsx (1)
packages/db/src/schema/endorsements.ts (1)
  • endorsement (8-35)
apps/web/components/user/recent-activity.tsx (1)
packages/ui/src/components/link.tsx (1)
  • Link (16-42)
⏰ 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). (2)
  • GitHub Check: Cursor Bugbot
  • GitHub Check: Vade Review
🔇 Additional comments (3)
apps/web/components/user/recent-activity.tsx (1)

22-29: Activity.type union extension looks good

The added 'endorsement_given' and 'endorsement_received' members align with the new feature and keep the union explicit.

apps/web/components/user/endorsement-list.tsx (2)

48-54: Empty state styling parity with the list

Wrap the empty state with Card/CardContent to match the loaded state. Included in the broader diff above.


139-139: Remove redundant Number() cast for data.total

Confirmed that the getEndorsements procedure in packages/api/src/routers/endorsements.ts returns total as a native JavaScript number, so wrapping it with Number(...) in the UI is unnecessary.

Update the UI snippet accordingly:

- {Number(data.total) > data.endorsements.length && (
+ {data.total > data.endorsements.length && (

Comment on lines +143 to 146
<Link href={`/projects/${activity.projectId}`}>
<p className="text-sm text-white">{activity.title}</p>
</Link>
{activity.description && (
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Guard project links when projectId is absent and use the shared UI Link for analytics

For activity types that may not have a project (e.g., some endorsements), the current code would produce “/projects/undefined”. Also, prefer the design-system Link for consistent tracking.

-import Link from 'next/link';
+import UILink from '@workspace/ui/components/link';
@@
-                        <Link href={`/projects/${activity.projectId}`}>
-                          <p className="text-sm text-white">{activity.title}</p>
-                        </Link>
+                        {activity.projectId ? (
+                          <UILink href={`/projects/${activity.projectId}`}>
+                            <p className="text-sm text-white">{activity.title}</p>
+                          </UILink>
+                        ) : (
+                          <p className="text-sm text-white">{activity.title}</p>
+                        )}
@@
-                      <Link
-                        href={`/projects/${activity.projectId}`}
-                        className={`font-medium hover:underline ${getActivityColor(activity.type, activity.claimSuccess)}`}
-                      >
-                        {activity.projectName}
-                      </Link>{' '}
+                      {activity.projectId ? (
+                        <UILink
+                          href={`/projects/${activity.projectId}`}
+                          className={`font-medium hover:underline ${getActivityColor(activity.type, activity.claimSuccess)}`}
+                        >
+                          {activity.projectName}
+                        </UILink>
+                      ) : (
+                        <span className={`font-medium ${getActivityColor(activity.type, activity.claimSuccess)}`}>
+                          {activity.projectName}
+                        </span>
+                      )}{' '}

Also applies to: 162-169, 18-18

🤖 Prompt for AI Agents
In apps/web/components/user/recent-activity.tsx around lines 143-146 (also
update 162-169 and line 18), guard against missing activity.projectId so we
don't render invalid URLs like "/projects/undefined" and replace usages of
Next.js Link with the shared design-system Link component to ensure consistent
analytics; specifically, render the project Link only when activity.projectId is
truthy (or render a non-link fallback/text when absent) and import/use the
shared Link component from the design-system instead of hrefing to an undefined
path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants