Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@TWilson023
Copy link
Collaborator

@TWilson023 TWilson023 commented Sep 9, 2025

Summary by CodeRabbit

  • New Features

    • Partner area expanded: Links, Payouts, About, Comments (view/create/update/delete with optimistic updates) and comment counts; partner stats, nav, info, advanced settings, payouts view.
    • Message composer: new shared MessageInput with emoji support and keyboard send.
  • Improvements

    • Partner URLs standardized to /program/partners/{id} across app, email, Slack and redirects.
    • Bounties now respect partner eligibility when a partner is specified.
    • Faster partner navigation with prefetch and refined sidebar active state.
  • Style

    • Minor layout and icon refinements.

@vercel
Copy link
Contributor

vercel bot commented Sep 9, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Sep 16, 2025 6:41pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 9, 2025

Walkthrough

Adds partner-focused pages and routing, implements partner comments end-to-end (DB model, APIs, actions, hooks, UI), filters bounties by partner eligibility via program enrollment, introduces enrollment update action + audit, refactors the messaging composer into a shared MessageInput, and applies multiple UI/redirect/navigation tweaks.

Changes

Cohort / File(s) Summary
Partner routing: query → path
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/customers/[customerId]/page-client.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/payouts/payout-details-sheet.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx, apps/web/ui/links/link-builder/link-partner-details.tsx, apps/web/ui/partners/overview/blocks/partners-block.tsx, apps/web/ui/partners/partner-row-item.tsx, apps/web/lib/integrations/slack/transform.ts, packages/email/src/templates/new-message-from-partner.tsx
Replaced partner links using ?partnerId= with path-based /program/partners/{partnerId} URLs and updated corresponding targets.
Partner pages & layout
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/layout.tsx, .../partner-nav.tsx, .../partner-info.tsx, .../partner-stats.tsx, .../links/page-client.tsx, .../links/page.tsx, .../payouts/page-client.tsx, .../payouts/page.tsx, .../about/page-client.tsx, .../about/page.tsx
Added partner-focused layout, nav, and pages (Links, Payouts, About) with controls, modals, and responsive components.
Partner comments (DB, API, actions, hooks, UI)
packages/prisma/schema/comment.prisma, packages/prisma/schema/partner.prisma, packages/prisma/schema/program.prisma, packages/prisma/schema/schema.prisma, apps/web/app/(ee)/api/partners/[partnerId]/comments/route.ts, apps/web/app/(ee)/api/partners/[partnerId]/comments/count/route.ts, apps/web/lib/actions/partners/create-partner-comment.ts, apps/web/lib/actions/partners/delete-partner-comment.ts, apps/web/lib/actions/partners/update-partner-comment.ts, apps/web/lib/swr/use-partner-comments.ts, apps/web/lib/swr/use-partner-comments-count.ts, apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/comments/page-client.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/comments/page.tsx, apps/web/lib/types.ts, apps/web/lib/zod/schemas/programs.ts
Added PartnerComment Prisma model and relations; list & count API endpoints; create/update/delete server actions; SWR hooks; zod schemas/types; client UI with optimistic create/delete and validation.
Bounties filtering by partner eligibility
apps/web/app/(ee)/api/bounties/route.ts, apps/web/lib/zod/schemas/bounties.ts, apps/web/lib/api/programs/get-program-enrollment-or-throw.ts
GET /api/bounties now parses partnerId, optionally fetches enrollment (includeProgram) and filters results to bounties the partner can participate in (no groups OR groups matching enrollment.groupId or program.defaultGroupId).
Partner enrollment update & audit
apps/web/ui/partners/partner-advanced-settings-modal.tsx, apps/web/lib/actions/partners/update-partner-enrollment.ts, apps/web/lib/api/audit-logs/schemas.ts, apps/web/lib/zod/schemas/partners.ts, apps/web/lib/api/programs/get-program-enrollment-or-throw.ts
Added modal and server action to update enrollment (tenantId) and related links transactionally; emits audit-log action partner.enrollment_updated; updated schemas/types (companyName added to enrolled partner schema).
Messaging input & icons
apps/web/ui/messages/messages-panel.tsx, apps/web/ui/shared/message-input.tsx, packages/ui/src/icons/nucleo/msg.tsx, packages/ui/src/icons/nucleo/index.ts
Replaced inline composer with reusable MessageInput (autosize, emoji, Cmd/Ctrl+Enter send); MessagesPanel props adjusted; new Msg SVG icon added and re-exported.
Navigation, redirects, and hooks
apps/web/ui/layout/sidebar/app-sidebar-nav.tsx, apps/web/lib/middleware/utils/app-redirect.ts, packages/ui/src/hooks/use-scroll-progress.ts
Refined sidebar active-state logic; added redirect rewriting /.../program/partners/{id}/.../program/partners/{id}/links; useScrollProgress now supports horizontal direction.
Partners table navigation & prefetch
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx
Centralized partner URL helper; row click/aux click navigate/open partner path; added prefetch on hover via router.prefetch.
Minor UI & component tweaks
apps/web/ui/layout/page-content/index.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/import-export-buttons.tsx, apps/web/ui/partners/program-reward-list.tsx, apps/web/ui/partners/online-presence-summary.tsx, apps/web/ui/partners/partner-info-group.tsx
Small class and API updates: added min-w-0 classes, adjusted icon sizing, ProgramRewardList accepts variant/iconClassName, OnlinePresenceSummary adds showLabels prop, PartnerInfoGroup adds changeButtonText/className props.

Sequence Diagram(s)

sequenceDiagram
  actor U as User
  participant UI as Partner Comments UI
  participant Action as createPartnerCommentAction
  participant DB as Prisma

  U->>UI: submit comment (text)
  UI->>UI: show optimistic comment (tmp id)
  UI->>Action: createPartnerCommentAction({ partnerId, text, createdAt })
  Action->>DB: INSERT PartnerComment (resolve programId)
  DB-->>Action: created comment with user
  Action-->>UI: { comment }
  UI->>UI: replace optimistic entry and revalidate /api/partners/{id}/comments
Loading
sequenceDiagram
  actor C as Client
  participant API as GET /api/bounties
  participant EN as getProgramEnrollmentOrThrow
  participant DB as Prisma

  C->>API: GET /api/bounties?workspaceId&partnerId=...
  API->>EN: if partnerId present -> lookup enrollment (includeProgram)
  EN-->>API: enrollment or null
  API->>DB: query bounties with filter: groups none OR groupId in [enrollment.groupId, program.defaultGroupId]
  DB-->>API: bounties[]
  API-->>C: JSON bounties
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

I nibble keys and stitch new trails,
Partners rest in paths, not veils.
Comments like carrots, fresh and bright,
Bounties matched by group tonight.
A hop, a patch—dashboard’s delight 🐇✨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "Enrolled partner page" is concise and accurately reflects the primary purpose of the changeset — adding the enrolled-partner UI and its supporting routes, hooks, schemas, actions, and Prisma model changes. It is specific enough for a reviewer scanning PR history to understand the main change without extraneous detail.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch enrolled-partner-page

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment on lines +17 to +22
await prisma.partnerComment.delete({
where: {
id: commentId,
programId,
},
});
Copy link
Contributor

Choose a reason for hiding this comment

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

The delete comment action allows any authenticated user to delete any comment in their workspace, not just their own comments.

View Details
📝 Patch Details
diff --git a/apps/web/lib/actions/partners/delete-partner-comment.ts b/apps/web/lib/actions/partners/delete-partner-comment.ts
index 0453d02df..4c41e1885 100644
--- a/apps/web/lib/actions/partners/delete-partner-comment.ts
+++ b/apps/web/lib/actions/partners/delete-partner-comment.ts
@@ -9,7 +9,7 @@ import { authActionClient } from "../safe-action";
 export const deletePartnerCommentAction = authActionClient
   .schema(deleteProgramPartnerCommentSchema)
   .action(async ({ parsedInput, ctx }) => {
-    const { workspace } = ctx;
+    const { workspace, user } = ctx;
     const { commentId } = parsedInput;
 
     const programId = getDefaultProgramIdOrThrow(workspace);
@@ -18,6 +18,7 @@ export const deletePartnerCommentAction = authActionClient
       where: {
         id: commentId,
         programId,
+        userId: user.id,
       },
     });
   });

Analysis

Authorization bypass in deletePartnerCommentAction allows users to delete other users' comments

What fails: deletePartnerCommentAction() in apps/web/lib/actions/partners/delete-partner-comment.ts checks commentId and programId but missing userId check, allowing any workspace member to delete any comment in their program

How to reproduce:

# As authenticated user A in workspace:
# Call deletePartnerCommentAction with commentId created by user B
# Action succeeds despite user A not owning the comment

Result: Any authenticated workspace member can delete comments created by other users within the same program, violating ownership-based authorization

Expected: Users should only be able to delete their own comments per Prisma security best practices requiring ownership verification before deletion

Root cause: Missing userId: user.id constraint in Prisma delete query where clause

)
}
/>
<Link href={`/program/partners/${partnerId}`} target="_blank">
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
<Link href={`/program/partners/${partnerId}`} target="_blank">
<Link href={`/${workspaceSlug}/program/partners/${partnerId}`} target="_blank">

The "View profile" link is missing the workspace slug, which will result in a broken navigation link.

View Details

Analysis

Missing workspace slug in partner profile navigation link

What fails: Link component in ProgramMessagesPartnerPageClient (line 219) navigates to /program/partners/${partnerId} instead of /${workspaceSlug}/program/partners/${partnerId}, causing broken navigation

How to reproduce:

  1. Navigate to program messages for any partner
  2. Click "View profile" button in the right panel
  3. Link attempts to navigate to /program/partners/[partnerId] route which doesn't exist

Result: Navigation fails because Next.js App Router requires all dynamic segments including [slug] to be present in navigation hrefs

Expected: Should navigate to /${workspaceSlug}/program/partners/${partnerId} as confirmed by the route structure at apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/ and similar patterns in partner-nav.tsx

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/partner-nav.tsx (1)

134-145: Add rel="noopener noreferrer" to target=_blank Commissions link; confirm final route.

Missing rel is a security gap (reverse‑tabnabbing). Also please re‑confirm that this is the intended canonical route for Commissions in this PR.

Apply:

-        <Link
-          href={`/${workspaceSlug}/program/commissions?partnerId=${partnerId}`}
-          target="_blank"
+        <Link
+          href={`/${workspaceSlug}/program/commissions?partnerId=${partnerId}`}
+          target="_blank"
+          rel="noopener noreferrer"

Optionally verify the route shape used elsewhere:

#!/bin/bash
# Inspect commissions routes/usages
rg -nP -C2 '/program/(partners/[^/]+/commissions|commissions\?partnerId=)' apps/web | sed -n '1,200p'
fd -H 'commissions' apps/web/app | sed -n '1,200p'
🧹 Nitpick comments (4)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/partner-nav.tsx (4)

32-49: Wheel listener should be non‑passive to allow preventDefault.

Chrome may ignore preventDefault on passive listeners; set passive: false and mirror options in removeEventListener.

   useEffect(() => {
     if (!containerRef.current) return;

-    const handleWheel = (event: WheelEvent) => {
+    const handleWheel = (event: WheelEvent) => {
       if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;
       event.preventDefault();
 
       containerRef.current?.scrollBy({
         left: event.deltaY,
         behavior: "smooth",
       });
     };
 
-    containerRef.current.addEventListener("wheel", handleWheel);
+    const wheelOptions: AddEventListenerOptions = { passive: false };
+    containerRef.current.addEventListener("wheel", handleWheel, wheelOptions);
 
-    return () =>
-      containerRef.current?.removeEventListener("wheel", handleWheel);
+    return () =>
+      containerRef.current?.removeEventListener("wheel", handleWheel, wheelOptions);
   }, []);

51-55: Initialize scroll progress on mount to avoid a stale gradient until first scroll/resize.

Call update once after mount.

   const { scrollProgress, updateScrollProgress } = useScrollProgress(
     containerRef,
     { direction: "horizontal" },
   );
+
+  useEffect(() => {
+    updateScrollProgress();
+  }, [updateScrollProgress]);

99-106: Make active‑tab detection robust for nested routes; add aria‑current for a11y.

endsWith fails if viewing a nested child (e.g., /comments/123). Use last segment and expose aria-current.

-            {tabs.map(({ id, label, icon: Icon, badge }) => {
-              const isSelected = pathname.endsWith(`/${id}`);
+            {tabs.map(({ id, label, icon: Icon, badge }) => {
+              const lastSeg = pathname.replace(/\/$/, "").split("/").pop();
+              const isSelected = lastSeg === id || pathname.includes(`/${id}/`);
               return (
                 <Link
                   key={id}
                   href={`/${workspaceSlug}/program/partners/${partnerId}/${id}`}
                   data-selected={isSelected}
+                  aria-current={isSelected ? "page" : undefined}
                   className={cn(

Also applies to: 120-129


103-105: Optional: centralize partner URL generation

There's already a getPartnerUrl helper in apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (around line 91). Import and use it here instead of the inline template — or extract it to a shared util — to avoid string-template drift across files.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 438a3bc and 74d34fe.

📒 Files selected for processing (1)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/partner-nav.tsx (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/partner-nav.tsx (3)
apps/web/lib/swr/use-workspace.ts (1)
  • useWorkspace (6-45)
apps/web/lib/swr/use-partner-comments-count.ts (1)
  • usePartnerCommentsCount (5-31)
packages/ui/src/hooks/use-scroll-progress.ts (1)
  • useScrollProgress (6-37)
⏰ 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: build
  • GitHub Check: Vade Review
🔇 Additional comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/partner-nav.tsx (1)

74-85: Comments badge logic LGTM.

Hides on zero, caps at 99+, and renders correctly as string/number. Nice.

Also applies to: 115-119

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (1)
apps/web/lib/actions/partners/update-partner-enrollment.ts (1)

13-17: Validate or drop workspaceId to match the schema.

workspaceId is required by the schema but ignored at runtime. Either validate it against ctx.workspace.id or remove it from the schema.

Option A — validate:

-  .action(async ({ parsedInput, ctx }) => {
+  .action(async ({ parsedInput, ctx }) => {
     const { workspace, user } = ctx;
-    const { partnerId, tenantId } = parsedInput;
+    const { partnerId, tenantId, workspaceId } = parsedInput;
+    if (workspaceId !== workspace.id) {
+      throw new Error("workspaceId mismatch");
+    }

Option B — drop from schema:

-const updatePartnerEnrollmentSchema = z.object({
-  workspaceId: z.string(),
+const updatePartnerEnrollmentSchema = z.object({
   partnerId: z.string(),
   tenantId: z.string().nullable(),
});

Also applies to: 23-25

🧹 Nitpick comments (4)
apps/web/lib/actions/partners/update-partner-enrollment.ts (4)

26-31: Ensure programId is the canonical ID (not a slug) before write queries.

getProgramEnrollmentOrThrow accepts slugs or IDs, but Prisma writes below require the ID. Guard by resolving the ID from the fetched enrollment.

-    const programId = getDefaultProgramIdOrThrow(workspace);
-
-    const { partner } = await getProgramEnrollmentOrThrow({
-      partnerId,
-      programId,
-      includePartner: true,
-    });
+    const programIdentifier = getDefaultProgramIdOrThrow(workspace);
+    const enrollment = await getProgramEnrollmentOrThrow({
+      partnerId,
+      programId: programIdentifier,
+      includePartner: true,
+      includeProgram: true,
+    });
+    const { partner } = enrollment;
+    const programId = enrollment.program.id; // canonical ID

Also applies to: 34-38, 46-49


64-77: Limit PII/noise in audit metadata.

Logging the full partner object may capture PII and bloat logs. Prefer a minimal, stable subset.

Example:

-          targets: [
-            {
-              type: "partner",
-              id: partnerId,
-              metadata: partner,
-            },
-          ],
+          targets: [
+            {
+              type: "partner",
+              id: partnerId,
+              metadata: {
+                id: partner.id,
+                name: partner.name,
+                handle: partner.handle,
+                companyName: partner.companyName,
+              },
+            },
+          ],

39-59: Short-circuit no-op writes when tenantId is unchanged.

Avoid unnecessary writes and downstream logging when the value doesn’t change.

-    const programEnrollment = await prisma.$transaction(async (tx) => {
+    if (enrollment.tenantId === tenantId) {
+      return; // nothing to do
+    }
+    const programEnrollment = await prisma.$transaction(async (tx) => {

61-63: Scale recordLink for large link sets.

If programEnrollment.links can be large, chunk or stream to avoid timeouts/limits in the background task. Also confirm recordLink accepts an array.

Example (conceptual):

-    waitUntil(
-      Promise.allSettled([
-        recordLink(programEnrollment.links),
+    const batches = [];
+    for (let i = 0; i < programEnrollment.links.length; i += 500) {
+      batches.push(programEnrollment.links.slice(i, i + 500));
+    }
+    waitUntil(
+      Promise.allSettled([
+        ...batches.map((batch) => recordLink(batch)),
         recordAuditLog({ /* ... */ }),
       ]),
     );
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 74d34fe and 4e5205e.

📒 Files selected for processing (2)
  • apps/web/lib/actions/partners/update-partner-enrollment.ts (1 hunks)
  • apps/web/lib/zod/schemas/partners.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/lib/zod/schemas/partners.ts
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/actions/partners/update-partner-enrollment.ts (1)
apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (1)
  • getProgramEnrollmentOrThrow (6-97)
⏰ 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: build
🔇 Additional comments (2)
apps/web/lib/actions/partners/update-partner-enrollment.ts (2)

61-79: Background tasks wiring LGTM.

Passing a single promise via waitUntil(Promise.allSettled([...])) correctly retains both async side-effects post-response.


16-17: Confirm DB allows setting tenantId to NULL.

You update both Links and ProgramEnrollment to a nullable tenantId. Verify the columns are nullable and not part of non-null FKs/unique constraints.

Also applies to: 41-45, 51-52

@@ -0,0 +1,91 @@
import { MAX_MESSAGE_LENGTH } from "@/lib/zod/schemas/messages";
Copy link
Contributor

Choose a reason for hiding this comment

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

The MessageInput component imports MAX_MESSAGE_LENGTH from messages schema but is used for partner comments, which should use MAX_PROGRAM_PARTNER_COMMENT_LENGTH instead.

View Details
📝 Patch Details
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/comments/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/comments/page-client.tsx
index 2e73c3ce9..87789b954 100644
--- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/comments/page-client.tsx
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/comments/page-client.tsx
@@ -7,6 +7,7 @@ import { usePartnerComments } from "@/lib/swr/use-partner-comments";
 import useUser from "@/lib/swr/use-user";
 import useWorkspace from "@/lib/swr/use-workspace";
 import { PartnerCommentProps } from "@/lib/types";
+import { MAX_PROGRAM_PARTNER_COMMENT_LENGTH } from "@/lib/zod/schemas/programs";
 import { ThreeDots } from "@/ui/shared/icons";
 import { MessageInput } from "@/ui/shared/message-input";
 import { Button, LoadingSpinner, Popover, Trash } from "@dub/ui";
@@ -33,6 +34,7 @@ export function ProgramPartnerCommentsPageClient() {
   return (
     <div className="mt-4">
       <MessageInput
+        maxLength={MAX_PROGRAM_PARTNER_COMMENT_LENGTH}
         onSendMessage={(text) => {
           if (!user) return false;
 
diff --git a/apps/web/ui/shared/message-input.tsx b/apps/web/ui/shared/message-input.tsx
index 4a7218b9b..fde89f3ce 100644
--- a/apps/web/ui/shared/message-input.tsx
+++ b/apps/web/ui/shared/message-input.tsx
@@ -11,12 +11,14 @@ export function MessageInput({
   placeholder = "Type a message...",
   sendButtonText = "Send",
   className,
+  maxLength = MAX_MESSAGE_LENGTH,
 }: {
   onSendMessage: (message: string) => void | false;
   autoFocus?: boolean;
   placeholder?: string;
   sendButtonText?: string;
   className?: string;
+  maxLength?: number;
 }) {
   const textAreaRef = useRef<HTMLTextAreaElement>(null);
   const selectionStartRef = useRef<number | null>(null);
@@ -42,7 +44,7 @@ export function MessageInput({
         className="placeholder:text-content-subtle block max-h-24 w-full resize-none border-none p-3 text-base focus:ring-0 sm:text-sm"
         placeholder={placeholder}
         value={typedMessage}
-        maxLength={MAX_MESSAGE_LENGTH}
+        maxLength={maxLength}
         onChange={(e) => setTypedMessage(e.currentTarget.value)}
         onKeyDown={(e) => {
           if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {

Analysis

MessageInput uses wrong maxLength constant for partner comments validation

What fails: MessageInput component in apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/comments/page-client.tsx uses MAX_MESSAGE_LENGTH (2000) from messages schema, but partner comments are validated server-side against MAX_PROGRAM_PARTNER_COMMENT_LENGTH (2000) from programs schema.

How to reproduce:

  1. Navigate to partner comments page at [slug]/program/partners/[partnerId]/comments
  2. Compare client-side maxLength validation in MessageInput component vs server-side validation in createPartnerCommentSchema

Result: Semantic mismatch between client and server validation constants. Currently both are 2000 chars so no functional issue, but if either constant changes independently, validation will become inconsistent.

Expected: Partner comment input should use MAX_PROGRAM_PARTNER_COMMENT_LENGTH for semantic correctness and future-proofing against independent constant changes.

Evidence: MessageInput imports from /lib/zod/schemas/messages.ts but partner comments validate against /lib/zod/schemas/programs.ts schema in createPartnerCommentSchema.

@steven-tey steven-tey merged commit 63fba8b into main Sep 16, 2025
8 checks passed
@steven-tey steven-tey deleted the enrolled-partner-page branch September 16, 2025 18:49
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (4)
apps/web/lib/actions/partners/update-partner-comment.ts (1)

13-17: Validate or drop workspaceId from schema

updatePartnerCommentSchema includes workspaceId but the action ignores it. Either assert equality with ctx.workspace.id here or remove it from the schema to avoid cross-tenant confusion.

apps/web/lib/actions/partners/delete-partner-comment.ts (2)

10-13: Validate or drop workspaceId

Schema includes workspaceId but action doesn’t verify it against ctx.workspace.id. Validate here or remove from schema.


9-22: Enforce ownership and fix Prisma delete selector

delete needs a unique selector; { id, programId } isn’t unique. Also enforce userId to prevent deleting others’ comments. Use deleteMany + count check, and return a result.

 export const deletePartnerCommentAction = authActionClient
   .schema(deletePartnerCommentSchema)
   .action(async ({ parsedInput, ctx }) => {
-    const { workspace } = ctx;
+    const { workspace, user } = ctx;
     const { commentId } = parsedInput;
 
     const programId = getDefaultProgramIdOrThrow(workspace);
 
-    await prisma.partnerComment.delete({
-      where: {
-        id: commentId,
-        programId,
-      },
-    });
+    const { count } = await prisma.partnerComment.deleteMany({
+      where: { id: commentId, programId, userId: user.id },
+    });
+    if (count === 0) {
+      throw new Error("Comment not found or not owned by you.");
+    }
+    return { success: true };
   });
apps/web/lib/actions/partners/create-partner-comment.ts (1)

17-18: Don’t accept client-supplied createdAt; set on server

Client timestamps are spoofable and race-prone. Set createdAt server-side (or rely on DB default) and remove it from input.

-    const { partnerId, text, createdAt } = parsedInput;
+    const { partnerId, text, workspaceId } = parsedInput;
+    if (workspaceId !== workspace.id) {
+      throw new Error("workspaceId mismatch");
+    }
...
-    const comment = await prisma.partnerComment.create({
+    const comment = await prisma.partnerComment.create({
       data: {
         programId,
         partnerId,
         userId: user.id,
         text,
-        createdAt,
       },
🧹 Nitpick comments (10)
apps/web/lib/actions/partners/create-partner-comment.ts (1)

13-15: Schema alignment required

After dropping createdAt from input, update createPartnerCommentSchema accordingly and trim text to prevent whitespace-only comments.

apps/web/lib/zod/schemas/programs.ts (3)

188-191: Align date handling with JSON transport

These are serialized to ISO strings over HTTP. Use z.coerce.date() to accept strings and Dates seamlessly.

-  createdAt: z.date(),
-  updatedAt: z.date(),
+  createdAt: z.coerce.date(),
+  updatedAt: z.coerce.date(),

195-209: Remove client createdAt; trim comment text

Let the server/DB set timestamps. Also trim text to disallow whitespace-only.

-export const createPartnerCommentSchema = z.object({
-  workspaceId: z.string(),
-  partnerId: z.string(),
-  text: z.string().min(1).max(MAX_PROGRAM_PARTNER_COMMENT_LENGTH),
-  createdAt: z.coerce
-    .date()
-    .refine(
-      (date) =>
-        date.getTime() <= Date.now() &&
-        date.getTime() >= Date.now() - 1000 * 60,
-      {
-        message: "Comment timestamp must be within the last 60 seconds",
-      },
-    ),
-});
+export const createPartnerCommentSchema = z.object({
+  workspaceId: z.string(),
+  partnerId: z.string(),
+  text: z.string().trim().min(1).max(MAX_PROGRAM_PARTNER_COMMENT_LENGTH),
+});

211-215: Trim on update as well

Add .trim() to ensure updates can’t set whitespace-only text.

-export const updatePartnerCommentSchema = z.object({
+export const updatePartnerCommentSchema = z.object({
   workspaceId: z.string(),
   id: z.string(),
-  text: z.string().min(1).max(MAX_PROGRAM_PARTNER_COMMENT_LENGTH),
+  text: z.string().trim().min(1).max(MAX_PROGRAM_PARTNER_COMMENT_LENGTH),
 });
apps/web/app/(ee)/api/partners/[partnerId]/comments/route.ts (2)

27-28: Date schema vs JSON: prefer z.coerce.date() in schema

NextResponse.json returns strings; downstream code re-parses with new Date(...). Update PartnerCommentSchema to z.coerce.date() (see schema comment) so server- and client-side validation are consistent. No code change needed here if schema is updated.


14-25: Consider pagination to avoid unbounded result sets

If comment volumes grow, add take/skip (or cursor) with query params.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/comments/page-client.tsx (4)

61-66: Stop sending client createdAt

After moving timestamping server-side, remove createdAt from the action call.

-              const result = await createPartnerComment({
-                workspaceId: workspaceId!,
-                partnerId,
-                text,
-                createdAt,
-              });
+              const result = await createPartnerComment({
+                workspaceId: workspaceId!,
+                partnerId,
+                text,
+              });

73-81: Replace optimistic entry to avoid duplicates; don’t revalidate immediately

Filter out the optimistic item when inserting the real one; also set revalidate: false to avoid double-fetch churn.

-              return data
-                ? [result.data.comment, ...data]
-                : [result.data.comment];
+              const real = result.data.comment;
+              return data
+                ? [real, ...data.filter((c) => c.id !== optimisticComment.id)]
+                : [real];
             },
             {
               optimisticData: (data) =>
                 data ? [optimisticComment, ...data] : [optimisticComment],
               rollbackOnError: true,
+              revalidate: false,
             },

191-239: Hide delete action for undelivered optimistic comments

Avoid sending delete mutations for temporary tmp_* comments.

-        {comment ? (
+        {comment && comment.delivered !== false ? (
           <Popover
             content={
               <div className="grid w-full grid-cols-1 gap-px p-2 sm:w-48">
                 <Button
                   text="Delete comment"
                   variant="danger-outline"
                   onClick={async () => {
                     setOpenPopover(false);
 
                     if (
                       !confirm("Are you sure you want to delete this comment?")
                     )
                       return;
 
                     await deleteComment({
                       workspaceId: workspaceId!,
                       commentId: comment.id,
                     });
                   }}
                   icon={<Trash className="size-4" />}
                   className="h-9 justify-start px-2 font-medium"
                 />
               </div>
             }
             align="end"
             openPopover={openPopover}
             setOpenPopover={setOpenPopover}
           >
             <Button

155-157: Avatar/name fallback robustness

Handle null names and URL-encode to prevent broken avatars.

-              src={comment.user.image || `${OG_AVATAR_URL}${comment.user.name}`}
-              alt={`${comment.user.name} avatar`}
+              src={
+                comment.user.image ||
+                `${OG_AVATAR_URL}${encodeURIComponent(comment.user.name || "User")}`
+              }
+              alt={`${comment.user.name || "User"} avatar`}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4e5205e and 259d5b5.

📒 Files selected for processing (8)
  • apps/web/app/(ee)/api/partners/[partnerId]/comments/route.ts (1 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/comments/page-client.tsx (1 hunks)
  • apps/web/lib/actions/partners/create-partner-comment.ts (1 hunks)
  • apps/web/lib/actions/partners/delete-partner-comment.ts (1 hunks)
  • apps/web/lib/actions/partners/update-partner-comment.ts (1 hunks)
  • apps/web/lib/swr/use-partner-comments.ts (1 hunks)
  • apps/web/lib/types.ts (2 hunks)
  • apps/web/lib/zod/schemas/programs.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/web/lib/swr/use-partner-comments.ts
  • apps/web/lib/types.ts
🧰 Additional context used
🧬 Code graph analysis (6)
apps/web/lib/actions/partners/delete-partner-comment.ts (1)
apps/web/lib/zod/schemas/programs.ts (1)
  • deletePartnerCommentSchema (217-220)
apps/web/lib/actions/partners/update-partner-comment.ts (1)
apps/web/lib/zod/schemas/programs.ts (2)
  • updatePartnerCommentSchema (211-215)
  • PartnerCommentSchema (178-191)
apps/web/lib/zod/schemas/programs.ts (1)
apps/web/lib/zod/schemas/users.ts (1)
  • UserSchema (3-7)
apps/web/app/(ee)/api/partners/[partnerId]/comments/route.ts (2)
apps/web/lib/auth/workspace.ts (1)
  • withWorkspace (41-435)
apps/web/lib/zod/schemas/programs.ts (1)
  • PartnerCommentSchema (178-191)
apps/web/lib/actions/partners/create-partner-comment.ts (2)
apps/web/lib/zod/schemas/programs.ts (2)
  • createPartnerCommentSchema (195-209)
  • PartnerCommentSchema (178-191)
apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (1)
  • getProgramEnrollmentOrThrow (6-97)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/[partnerId]/comments/page-client.tsx (7)
apps/web/lib/swr/use-workspace.ts (1)
  • useWorkspace (6-45)
apps/web/lib/swr/use-partner-comments.ts (1)
  • usePartnerComments (6-34)
apps/web/lib/actions/partners/create-partner-comment.ts (1)
  • createPartnerCommentAction (13-42)
apps/web/ui/shared/message-input.tsx (1)
  • MessageInput (8-91)
apps/web/lib/actions/partners/delete-partner-comment.ts (1)
  • deletePartnerCommentAction (9-23)
packages/utils/src/constants/misc.ts (1)
  • OG_AVATAR_URL (29-29)
packages/ui/src/popover.tsx (1)
  • Popover (25-102)
⏰ 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: api-tests
  • GitHub Check: Vade Review

Comment on lines +20 to +31
const comment = await prisma.partnerComment.update({
where: {
id,
programId,
userId: user.id,
},
data: {
text,
},
include: {
user: true,
},
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue

Fix Prisma update: non-unique WHERE; enforce ownership via updateMany + fetch

update requires a unique selector. { id, programId, userId } isn’t a unique input. Use updateMany guarded by { id, programId, userId }, check count, then fetch the record to return. This also correctly enforces ownership.

-    const comment = await prisma.partnerComment.update({
-      where: {
-        id,
-        programId,
-        userId: user.id,
-      },
-      data: {
-        text,
-      },
-      include: {
-        user: true,
-      },
-    });
+    const { count } = await prisma.partnerComment.updateMany({
+      where: { id, programId, userId: user.id },
+      data: { text },
+    });
+    if (count === 0) {
+      throw new Error("Comment not found or not owned by you.");
+    }
+    const comment = await prisma.partnerComment.findUnique({
+      where: { id },
+      include: { user: true },
+    });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const comment = await prisma.partnerComment.update({
where: {
id,
programId,
userId: user.id,
},
data: {
text,
},
include: {
user: true,
},
const { count } = await prisma.partnerComment.updateMany({
where: { id, programId, userId: user.id },
data: { text },
});
if (count === 0) {
throw new Error("Comment not found or not owned by you.");
}
const comment = await prisma.partnerComment.findUnique({
where: { id },
include: { user: true },
});
🤖 Prompt for AI Agents
In apps/web/lib/actions/partners/update-partner-comment.ts around lines 20 to
31, the prisma.partnerComment.update call uses a non-unique selector ({ id,
programId, userId }) which Prisma rejects; replace it with
prisma.partnerComment.updateMany({ where: { id, programId, userId }, data: {
text } }), check the returned count and throw a NotFound/Forbidden error if
count === 0, then retrieve the updated comment with
prisma.partnerComment.findUnique or findFirst (include: { user: true }) and
return that record so ownership is enforced and a proper comment object is
returned.

⚠️ Potential issue

🧩 Analysis chain

Verify PartnerComment unique keys before merging

Confirm there’s no composite unique on (id, programId, userId) so the updateMany approach is required.


🏁 Script executed:

#!/bin/bash
# Verify PartnerComment uniques (no composite on programId/userId)
fd -HI --type f 'schema.prisma' packages/prisma | while read -r f; do
  echo "---- $f ----"
  sed -n '1,240p' "$f" | sed -n '/model \(PartnerComment\|Comment\)/,/^}/p'
done
rg -n --hidden -S '(@@unique|@@id).*(programId|userId).*' packages/prisma || true

Length of output: 730


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Listing .prisma files under packages/prisma:"
if command -v fd >/dev/null 2>&1; then
  fd -HI -e prisma packages/prisma || true
else
  find packages/prisma -type f -name '*.prisma' -print || true
fi

echo
echo "Search for 'model PartnerComment' (case-sensitive) in prisma schema files:"
rg -n --hidden -S 'model\s+PartnerComment\b' packages/prisma || true

echo
echo "If found, print the PartnerComment model blocks:"
rg -n --hidden -S 'model\s+PartnerComment\b' packages/prisma | cut -d: -f1 | uniq | while read -r f; do
  echo "---- $f ----"
  sed -n '/model\s\+PartnerComment\b/,/}/p' "$f" || true
done

echo
echo "Search for any occurrence of 'PartnerComment' anywhere in the repo (case-sensitive):"
rg -n --hidden -S 'PartnerComment' || true

echo
echo "Search for @@unique or @@id lines mentioning programId, userId, or id across prisma schema files:"
rg -n --hidden -S '(@@unique|@@id).*(programId|userId|id)' packages/prisma || true

echo
echo "If a PartnerComment model was found, show any field-level @unique or @id annotations inside it:"
rg -n --hidden -S 'model\s+PartnerComment\b' packages/prisma | cut -d: -f1 | uniq | while read -r f; do
  echo "---- field-level @unique/@id in $f ----"
  sed -n '/model\s\+PartnerComment\b/,/}/p' "$f" | rg -n --hidden -S '@unique|@id' || true
done

echo
echo "Search for prisma.client usages of partnerComment (update/updateMany) in the codebase:"
rg -n --hidden -S 'prisma\.partnerComment\.(update|updateMany|findUnique|findFirst|findMany|upsert|delete)' || true

Length of output: 8684


Fix prisma.partnerComment.update where — PartnerComment.id is the only unique key

packages/prisma/schema/comment.prisma shows PartnerComment has id @id and no composite @@unique on (programId, userId); using where: { id, programId, userId } is invalid. Change to one of:

  • Use the unique id: prisma.partnerComment.update({ where: { id }, data: { text }, include: { user: true } })
  • If you must ensure programId+userId match: use updateMany({ where: { id, programId, userId }, data: { text } }) then fetch the record with findUnique({ where: { id }, include: { user: true } }) (or perform both in a transaction).
🤖 Prompt for AI Agents
In apps/web/lib/actions/partners/update-partner-comment.ts around lines 20 to
31, the prisma.partnerComment.update call uses a non-unique composite where {
id, programId, userId } although PartnerComment.id is the only unique key;
replace the where with the unique key (where: { id }) and keep data: { text }
and include: { user: true } OR if you need to enforce programId+userId matching,
switch to updateMany({ where: { id, programId, userId }, data: { text } }) and
then fetch the updated record with findUnique({ where: { id }, include: { user:
true } }) (or perform both operations inside a transaction).

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants