-
-
Notifications
You must be signed in to change notification settings - Fork 88
endorsements feature #213
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
endorsements feature #213
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Other AI code review bot(s) detectedCodeRabbit 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. WalkthroughAdds 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
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
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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~70 minutes Possibly related PRs
Poem
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 unit tests
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. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
|
|
||
| {Number(data.total) > data.endorsements.length && ( | ||
| <div className="text-center"> | ||
| <Button variant="outline" className="rounded-none"> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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)}| const { data, isLoading } = useQuery( | ||
| trpc.endorsements.getEndorsements.queryOptions({ | ||
| userId, | ||
| limit: 20, | ||
| }), | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
}| onSuccess: () => { | ||
| toast.success('Endorsement added successfully!'); | ||
| setOpen(false); | ||
| form.reset(); | ||
| }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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');
},
}),
);There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 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
tabis a controlled state, prefervalue={tab}overdefaultValueto 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
endorsementTypeto 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/projectNameis leaky. Consider making them nullable/optional in the union, or scoped via a discriminated union ontype.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 frome.projectName.- After narrowing columns in the query (see above),
data: eis safer, but consider omitting rawdataentirely 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
projectIdas a required string, change the initialization toconst projectId = e.project?.id ?? ''and leave the type as-is.
105-109: Result size and sort are fine; minor note on headroomThe 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 CHECKsExamples:
- If
type = 'project', require one of (project_idnot null ORproject_namenot null).- If
type = 'work', requirework_detailsnot null andwork_details->>'company'present.If you want DB-level enforcement, we can add
check()constraints withsqlexpressions.Would you like me to propose the exact
check()constraints withsqlfor 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.
📒 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.jsonverified—changes approvedAll entries in
packages/db/drizzle/meta/_journal.jsonhave been checked and are in good order:
- Contiguous
idxvalues from 0 through 26 with no gaps or duplicates.- Corresponding migration file
0026_brainy_havok.sqland snapshotpackages/db/drizzle/meta/0026_snapshot.jsonare present.- All
tagvalues 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 neededVerification 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.tspackages/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 CompileThe new
endorsements: endorsementsRouterentry inpackages/api/src/root.tsis correctly wired. However, adding this endpoint changes theAppRoutertype 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:
Regenerate your TRPC client types (e.g. via
trpc-codegenor your existing codegen setup).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=webConfirm 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 solidProfile + git details flow remains consistent with the driver pattern; no concerns here.
151-160: Session is available on publicProcedure—usectx.session.userIdfor privacy gatingI confirmed that
createTRPCContextalways attaches thesessionobject (anduserdata) toctxfor bothpublicProcedureandprotectedProcedure(see packages/api/src/trpc.ts). You can therefore implement your conditional privacy gate inside thispublicProcedureby 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 goodClear, future-proofed enum for endorsement type.
37-52: Relations naming is clear and disambiguates the two user FKsThis matches usage in the API (
with: { endorser, endorsedUser, project }). Good.
| 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(), | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
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.
| 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.
| 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'); | ||
| }, | ||
| }), | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
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.
| 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.
| const { data, isLoading } = useQuery( | ||
| trpc.endorsements.getEndorsements.queryOptions({ | ||
| userId, | ||
| limit: 20, | ||
| }), | ||
| ); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
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.
| 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.
| {Number(data.total) > data.endorsements.length && ( | ||
| <div className="text-center"> | ||
| <Button variant="outline" className="rounded-none"> | ||
| Load More | ||
| </Button> | ||
| </div> | ||
| )} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
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.
| {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.
| <TabsContent value="endorsements" className="mt-2"> | ||
| <EndorsementList userId={profile?.id || ''} /> | ||
| </TabsContent> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
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.
| <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.
| 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, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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.
| import { createTRPCRouter, publicProcedure } from '../trpc'; | ||
| import { desc, eq, or } from 'drizzle-orm'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
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.
| 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.
| "endorser_id" text NOT NULL, | ||
| "endorsed_user_id" text NOT NULL, | ||
| "project_id" uuid, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
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.
| "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.
| 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(), | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
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.
| 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.
c24a79e to
49dca12
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
♻️ 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.andTo 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 rowsIssues:
- 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 containerUsing 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 queriesCurrently, several
findManycalls inpackages/api/src/routers/profile.tspull entire related rows viawith: { project: true, user: true, }This returns all columns for each
projectanduserrecord—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.findManySuggested pattern:
-with: { project: true, user: true } +with: { + project: { select: { id: true, slug: true, title: true } }, + user: { select: { id: true, displayName: true } } +}By tightening your
withclauses, 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.
📒 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 goodImporting EndorsementList here is correct and localizes the feature to Profile Tabs.
126-141: Tabs header update is consistentgrid-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 correctPulling endorsement from @workspace/db/schema is expected for composing activity items.
19-27: Consider avoiding empty-string sentinel for missing projectId; usenullinsteadOur scan found that
projectIdis still declared strictly asstring(without| null) in many downstream locations, for example:
packages/apps/web/hooks/use-launch-updates.ts(interfaceUseLaunchUpdatesOptions)packages/apps/web/components/user/recent-activity.tsx(props forRecentActivity)packages/apps/web/components/user/endorsement-dialog.tsx(form values and mapping)packages/apps/web/components/admin/project-edit-form.tsx(props forProjectEditForm)packages/apps/web/components/project/launch-project-dialog.tsx(props and TRPC calls)packages/apps/web/components/project/debug-permissions.tsxpackages/apps/web/components/project/project-report.tsxpackages/apps/web/components/project/claim-project-dialog.tsxChanging 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
nullproperly before adopting this 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, | ||
| }; | ||
| }), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
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.
| ...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(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| 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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 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 failureUse 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 getActivityIconBoth 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.
📒 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 unionThe 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 eventsBoth endorsement types map to the same highlight color, which is consistent and easy to scan.
22-29: Activity type literals are consistent across layersVerified 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.typeswitches 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> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| <Button variant="outline" className="rounded-none"> | ||
| Load More | ||
| </Button> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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(), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
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 getActivityColorLinks 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 buildNodeJS.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 limitThis 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-functionalHooked 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 imageImproves 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 missingRender 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 detailsstartDate/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 consistencySwitching 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.
📒 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 goodThe 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 listWrap the empty state with Card/CardContent to match the loaded state. Included in the broader diff above.
139-139: Remove redundantNumber()cast fordata.totalConfirmed that the
getEndorsementsprocedure inpackages/api/src/routers/endorsements.tsreturnstotalas a native JavaScriptnumber, so wrapping it withNumber(...)in the UI is unnecessary.Update the UI snippet accordingly:
- {Number(data.total) > data.endorsements.length && ( + {data.total > data.endorsements.length && (
| <Link href={`/projects/${activity.projectId}`}> | ||
| <p className="text-sm text-white">{activity.title}</p> | ||
| </Link> | ||
| {activity.description && ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
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.
Summary by CodeRabbit
New Features
UI