+
Skip to content

Conversation

TWilson023
Copy link
Collaborator

@TWilson023 TWilson023 commented Aug 27, 2025

Summary by CodeRabbit

  • New Features

    • Two-way messaging between programs and partners with in-app chat, emoji picker, optimistic send, mark-as-read, unread counts, and email notifications.
  • APIs / Notifications

    • Endpoints for listing/counting messages, partner/program notification preferences, batched notification emails, delivery/open tracking, and unsubscribe handling.
  • UI

    • New Messages pages/layouts, two-panel chat UI, program info panel, sidebar Messages nav items with unread badges, partner/program selectors, and messaging upsell.
  • Chores

    • Webhook handling improvements and email template additions.

Copy link
Contributor

vercel bot commented Aug 27, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Sep 15, 2025 3:28pm

Copy link
Contributor

coderabbitai bot commented Aug 27, 2025

Walkthrough

Adds a complete messaging system: Prisma models and schema, Zod schemas/types, server actions and API routes (with Qstash-delayed cron email notifications), Resend webhook handlers, SWR hooks, many UI components/pages/layouts for program and partner messaging, email templates, RBAC and plan-capability flags, small UI/utility changes, and package dependency additions.

Changes

Cohort / File(s) Summary
Prisma schema & exports
packages/prisma/schema/message.prisma, packages/prisma/schema/notification.prisma, packages/prisma/schema/partner.prisma, packages/prisma/schema/program.prisma, packages/prisma/schema/schema.prisma, packages/prisma/schema/workspace.prisma, packages/prisma/schema/bounty.prisma, packages/prisma/client.ts
Add Message model and NotificationEmail model/enum, wire relations (Partner/Program/User), add notification preference fields, remove two bounty indexes, and re-export NotificationEmailType.
Zod schemas & types
apps/web/lib/zod/schemas/messages.ts, apps/web/lib/zod/schemas/partner-profile.ts, apps/web/lib/zod/schemas/workspaces.ts, apps/web/lib/types.ts
New messaging schemas (MessageSchema, query/create schemas), MAX_MESSAGE_LENGTH, extend partner/workspace notification enums, and export Message type.
Server actions (create + mark-read + QStash)
apps/web/lib/actions/partners/message-partner.ts, .../message-program.ts, .../mark-partner-messages-read.ts, .../mark-program-messages-read.ts
Add actions to create messages (partner↔program), schedule Qstash notifications (3 min delay), and bulk-mark messages read.
Cron notification routes
apps/web/app/(ee)/api/cron/messages/notify-program/route.ts, .../notify-partner/route.ts
POST handlers (Qstash-verified) that batch unread messages, build/send Resend batched emails (templates), and persist NotificationEmail records.
Program / Partner messages APIs
apps/web/app/(ee)/api/messages/route.ts, .../messages/count/route.ts, apps/web/app/(ee)/api/partner-profile/messages/route.ts, .../messages/count/route.ts
New GET endpoints returning grouped messages and counts with query validation and permission/plan guards.
Resend webhook & handlers
apps/web/app/api/resend/webhook/route.ts, .../contact-updated.ts, .../email-opened.ts
Svix-verified webhook dispatcher and handlers to update subscription and mark NotificationEmail opened / Message.readInEmail.
Email templates
packages/email/src/templates/new-message-from-program.tsx, .../new-message-from-partner.tsx
New React Email templates for program→partner and partner→program notifications.
SWR hooks
apps/web/lib/swr/use-partner-messages.ts, .../use-program-messages.ts, .../use-partner-messages-count.ts, .../use-program-messages-count.ts, .../use-partner.ts
New hooks to fetch grouped messages and counts; minor SWR key tweak.
Partner dashboard UI
apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/*
Partner two-panel messages layout, pages, optimistic send, auto-mark-read, ProgramInfo panel.
Program dashboard UI
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/*
Program two-panel layout with plan gating (MessagesUpsell), per-partner pages, optimistic send, and list.
Shared messaging components & context
apps/web/ui/messages/messages-panel.tsx, .../messages-list.tsx, .../messages-context.tsx, .../emoji-picker.tsx, .../toggle-side-panel-button.tsx
Chat panel with read/delivery indicators, grouped list, messages context, frimousse emoji picker, and panel toggle.
Navigation & sidebar updates
apps/web/ui/layout/sidebar/app-sidebar-nav.tsx, .../partners-sidebar-nav.tsx, .../sidebar-nav.tsx, apps/web/ui/layout/main-nav.tsx, apps/web/ui/layout/sidebar/program-help-support.tsx
Add Messages nav items and unread badges, expose unreadMessagesCount in SidebarNavData, badge support, adjust top padding via CSS variable, delegate help links to ProgramHelpLinks.
Selectors & partner info components
apps/web/ui/partners/program-selector.tsx, .../partner-selector.tsx, .../program-help-links.tsx, .../partner-info-group.tsx, .../partner-info-stats.tsx, .../partner-details-sheet.tsx
New ProgramSelector, forwardable PartnerSelector props, ProgramHelpLinks, PartnerInfoGroup and PartnerInfoStats, and refactor partner details to use them.
Notification preferences UI & API includes
apps/web/app/(ee)/api/partner-profile/notification-preferences/route.ts, apps/web/app/api/workspaces/[idOrSlug]/notification-preferences/route.ts, apps/web/app/(ee)/partners.dub.co/(dashboard)/account/settings/notifications/page-client.tsx, apps/web/app/(ee)/[slug]/settings/(basic-layout)/notifications/page-client.tsx
Expose and render newMessageFromProgram / newMessageFromPartner flags and UI toggles.
RBAC & plan capabilities
apps/web/lib/api/rbac/permissions.ts, apps/web/lib/plan-capabilities.ts
Add messages.read and messages.write permissions and canMessagePartners capability.
Utilities, UI library changes & deps
apps/web/lib/api/create-id.ts, packages/ui/src/combobox/index.tsx, packages/ui/src/popover.tsx, packages/ui/src/icons/nucleo/face-smile.tsx, packages/ui/src/icons/nucleo/index.ts, apps/web/package.json
Allow "msg_" prefix in createId, Combobox trigger prop, Popover sideOffset prop, add FaceSmile icon, and add frimousse & svix deps.
Minor edits / docs
apps/web/app/(ee)/api/partners/[partnerId]/route.ts, apps/web/app/(ee)/programs/[programSlug]/(enrolled)/links/partner-link-card.tsx
Docstring path update and small object-shorthand refactor.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User as Program User
  participant Web as Web App (Program)
  participant Action as messagePartnerAction
  participant DB as Prisma
  participant Q as QStash
  participant Cron as /api/cron/messages/notify-partner
  participant Resend as Resend

  User->>Web: compose/send message
  Web->>Action: messagePartnerAction(payload)
  Action->>DB: create Message (senderUserId)
  Action->>Q: publishJSON(notify-partner, {programId, partnerId, lastMessageId}, delay=3m)
  Q-->>Cron: POST after delay
  Cron->>DB: query unread recent messages + recipients
  Cron->>Resend: resend.batch.send(NewMessageFromProgram)
  Cron->>DB: create NotificationEmail records
  Cron-->>Resend: 200
Loading
sequenceDiagram
  autonumber
  actor Partner as Partner
  participant WebP as Web App (Partner)
  participant Action as messageProgramAction
  participant DB as Prisma
  participant Q as QStash
  participant Cron as /api/cron/messages/notify-program
  participant Resend as Resend

  Partner->>WebP: compose/send message
  WebP->>Action: messageProgramAction(payload)
  Action->>DB: create Message (senderPartnerId)
  Action->>Q: publishJSON(notify-program, {...}, delay=3m)
  Q-->>Cron: POST after delay
  Cron->>DB: query unread recent messages + recipients
  Cron->>Resend: resend.batch.send(NewMessageFromPartner)
  Cron->>DB: create NotificationEmail records
Loading
sequenceDiagram
  autonumber
  participant Resend as Resend
  participant WH as /api/resend/webhook
  participant H as emailOpened
  participant DB as Prisma

  Resend-->>WH: POST (svix-signed) type=email.opened { email_id, tags }
  WH->>H: dispatch emailOpened(data)
  H->>DB: update NotificationEmail.openedAt
  H->>DB: update Message.readInEmail via related notification email
  H-->>WH: done
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

A rabbit types a message, quick and bright,
Queues it for three minutes, then sends by night.
Badges blink on sidebars, panels swing in place,
Emojis hop into bubbles with grace.
Hooray — messages stitched and shipped, carrot-coded, ace. 🥕📨

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
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.
Title Check ❓ Inconclusive The title "Messages" is related to the changeset — this PR implements a broad messaging feature across backend APIs, Prisma schema, server actions, UI components, and email templates — but the one-word title is overly generic and does not clearly communicate the scope or intent of the change to reviewers. Please replace the title with a concise sentence that describes the primary change and scope; for example, "Add messaging system: Message model, APIs, UI, and notification emails" or "Introduce partner/program messaging and email notifications (Message Prisma model, APIs, UI)".
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch messages

Tip

👮 Agentic pre-merge checks are now available in preview!

Pro plan users can now enable pre-merge checks in their settings to enforce checklists before merging PRs.

  • Built-in checks – Quickly apply ready-made checks to enforce title conventions, require pull request descriptions that follow templates, validate linked issues for compliance, and more.
  • Custom agentic checks – Define your own rules using CodeRabbit’s advanced agentic capabilities to enforce organization-specific policies and workflows. For example, you can instruct CodeRabbit’s agent to verify that API documentation is updated whenever API schema files are modified in a PR. Note: Upto 5 custom checks are currently allowed during the preview period. Pricing for this feature will be announced in a few weeks.

Please see the documentation for more information.

Example:

reviews:
  pre_merge_checks:
    custom_checks:
      - name: "Undocumented Breaking Changes"
        mode: "warning"
        instructions: |
          Pass/fail criteria: All breaking changes to public APIs, CLI flags, environment variables, configuration keys, database schemas, or HTTP/GraphQL endpoints must be documented in the "Breaking Change" section of the PR description and in CHANGELOG.md. Exclude purely internal or private changes (e.g., code not exported from package entry points or explicitly marked as internal).

Please share your feedback with us on this Discord post.


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.

@steven-tey steven-tey changed the base branch from main to bounties August 28, 2025 23:00
@steven-tey steven-tey changed the base branch from bounties to main August 28, 2025 23:01
@steven-tey steven-tey changed the base branch from main to bounties August 28, 2025 23:01
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: 1

Caution

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

⚠️ Outside diff range comments (1)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/partner-link-card.tsx (1)

112-116: Prevent modal from opening when clicking nested links/copy.

Clicks on the shortlink, destination URL, CopyButtons, and chart tiles will bubble to the Card’s onClick and also open the Edit modal. Stop propagation on those interactive children.

-    <CardList.Card
+    <CardList.Card
       innerClassName="px-0 py-0 hover:cursor-pointer group"
       onClick={() => setShowPartnerLinkModal(true)}
     >
...
-                  <a
+                  <a
                     href={partnerLink}
                     target="_blank"
                     rel="noopener noreferrer"
+                    onClick={(e) => e.stopPropagation()}
                     className="truncate text-sm font-semibold leading-6 text-neutral-700 transition-colors hover:text-black"
                   >
...
-                  <span className="flex items-center">
+                  <span
+                    className="flex items-center"
+                    onClick={(e) => e.stopPropagation()}
+                  >
                     <CopyButton
                       value={partnerLink}
                       variant="neutral"
                       className="p-0.5 opacity-0 transition-opacity duration-150 group-hover/shortlink:opacity-100"
                     />
                   </span>
...
-                  <a
+                  <a
                     href={link.url}
                     target="_blank"
                     rel="noopener noreferrer"
+                    onClick={(e) => e.stopPropagation()}
                     className="truncate text-sm text-neutral-500 transition-colors hover:text-neutral-700"
                     title={getPrettyUrl(link.url)}
                   >
...
-                  <span className="flex items-center">
+                  <span
+                    className="flex items-center"
+                    onClick={(e) => e.stopPropagation()}
+                  >
                     <CopyButton
                       value={link.url}
                       variant="neutral"
                       className="p-0.5 opacity-0 transition-opacity duration-150 group-hover/desturl:opacity-100"
                     />
                   </span>
...
-            <Link
+            <Link
               key={chart.key}
               href={`/programs/${programEnrollment?.program.slug}/analytics${getQueryString(
                 {
                   domain: link.domain,
                   key: link.key,
                   event: chart.key === "saleAmount" ? "sales" : chart.key,
                 },
               )}`}
+              onClick={(e) => e.stopPropagation()}
               className="group/chart relative isolate rounded-lg border border-neutral-200 px-2 py-1.5 lg:px-3"
             >

Also applies to: 136-144, 145-151, 156-165, 166-172, 186-196

🧹 Nitpick comments (5)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/partner-link-card.tsx (2)

186-196: Guard against undefined program slug in chart tile href.

If programEnrollment hasn’t loaded, href becomes /programs/undefined/analytics.... Gate rendering or provide a non-navigable fallback until slug is present.

Example approach: compute const slug = programEnrollment?.program.slug; and render a <div> with the same content (no onClick) when !slug, otherwise render the <Link>.


100-105: Normalize currency units (avoid mixing cents vs. dollars).

Chart series data converts saleAmount to dollars, while totals keep cents and divide at render. Keep a single unit (e.g., cents) in data and convert only at display to reduce cognitive overhead.

-  const chartData = useMemo(() => {
-    return timeseries?.map(({ start, clicks, leads, saleAmount }) => ({
-      date: new Date(start),
-      values: { clicks, leads, saleAmount: saleAmount / 100 },
-    }));
-  }, [timeseries]);
+  const chartData = useMemo(() => {
+    return timeseries?.map(({ start, clicks, leads, saleAmount }) => ({
+      date: new Date(start),
+      values: { clicks, leads, saleAmount }, // keep cents
+    }));
+  }, [timeseries]);
-                  {currency ? (
-                    <NumberFlow
-                      value={series.valueAccessor(d)}
-                      format={{ style: "currency", currency: "USD" }}
-                    />
-                  ) : (
+                  {currency ? (
+                    <NumberFlow
+                      value={series.valueAccessor(d) / 100}
+                      format={{ style: "currency", currency: "USD" }}
+                    />
+                  ) : (
                     <NumberFlow value={series.valueAccessor(d)} />
                   )}

Also applies to: 306-314

apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx (3)

59-72: Harden mark-as-read side-effect

Wrap in try/catch and bail early if no unread or already pending to avoid noisy errors and unnecessary revalidations.

-      onSuccess: async (data) => {
-        // Mark unread messages from the program as read
-        if (
-          !isMarkingProgramMessagesRead &&
-          data?.[0]?.messages?.some(
-            (message) => !message.senderPartnerId && !message.readInApp,
-          )
-        ) {
-          await markProgramMessagesRead({
-            programSlug,
-          });
-          mutatePrefix("/api/partner-profile/messages");
-        }
-      },
+      onSuccess: async (data) => {
+        const hasUnread =
+          data?.[0]?.messages?.some(
+            (m) => !m.senderPartnerId && !m.readInApp,
+          ) ?? false;
+        if (isMarkingProgramMessagesRead || !hasUnread) return;
+        try {
+          await markProgramMessagesRead({ programSlug });
+          mutatePrefix("/api/partner-profile/messages");
+        } catch (err) {
+          console.error("Failed to mark messages as read", err);
+        }
+      },

233-240: Prevent reverse‑tabnabbing with window.open

Pass noopener,noreferrer to window.open for safety.

-                onClick={() =>
-                  window.open(`/programs/${programSlug}`, "_blank")
-                }
+                onClick={() =>
+                  window.open(
+                    `/programs/${programSlug}`,
+                    "_blank",
+                    "noopener,noreferrer",
+                  )
+                }

355-379: Disable “Copy link” when no partner link available

Avoid copying an empty string and improve UX with a title.

-        <Button
+        <Button
           icon={
             <div className="relative size-4">
@@
           }
           text={copied ? "Copied link" : "Copy link"}
           className="mt-3 h-8 rounded-lg"
-          onClick={() => copyToClipboard(partnerLink)}
+          disabled={!partnerLink}
+          title={partnerLink ? "Copy referral link" : "No referral link available"}
+          onClick={() => partnerLink && copyToClipboard(partnerLink)}
         />
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 186486b and 106330f.

📒 Files selected for processing (5)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx (1 hunks)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/partner-link-card.tsx (1 hunks)
  • apps/web/app/api/resend/webhook/route.ts (1 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx (1 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/layout.tsx
  • apps/web/app/api/resend/webhook/route.ts
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx (11)
apps/web/lib/actions/partners/mark-program-messages-read.ts (1)
  • markProgramMessagesReadAction (13-34)
apps/web/lib/swr/use-program-messages.ts (1)
  • useProgramMessages (11-44)
apps/web/lib/actions/partners/message-program.ts (1)
  • messageProgramAction (16-58)
apps/web/ui/messages/messages-panel.tsx (1)
  • MessagesPanel (17-267)
apps/web/lib/types.ts (2)
  • ProgramEnrollmentProps (451-451)
  • PayoutsCount (453-457)
apps/web/lib/partners/construct-partner-link.ts (1)
  • constructPartnerLink (4-28)
apps/web/lib/swr/use-partner-analytics.ts (1)
  • usePartnerAnalytics (10-66)
packages/utils/src/constants/misc.ts (1)
  • OG_AVATAR_URL (29-29)
packages/utils/src/functions/urls.ts (1)
  • getPrettyUrl (130-138)
apps/web/ui/partners/program-reward-list.tsx (1)
  • ProgramRewardList (9-89)
apps/web/ui/partners/program-help-links.tsx (1)
  • ProgramHelpLinks (7-62)
⏰ 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 (3)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/links/partner-link-card.tsx (1)

107-110: Shorthand prop change is safe.

Switching to { link } is a no-op; reads cleaner. LGTM.

apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx (2)

133-139: Prop gating is good

Passing messages only when user and partner are loaded prevents send attempts with missing identities.


82-83: redirect() used in a client component — switch to router.replace()

redirect is server-only and will throw at runtime in a client component. Use useRouter + useEffect and remove the redirect import.

- import { redirect, useParams } from "next/navigation";
+ import { useParams, useRouter } from "next/navigation";
- import { useMemo, useState } from "react";
+ import { useEffect, useMemo, useState } from "react";

 export function PartnerMessagesProgramPageClient() {
   const { programSlug } = useParams() as { programSlug: string };
+  const router = useRouter();
   const { user } = useUser();
   const { partner } = usePartnerProfile();
   const { programEnrollment, error: errorProgramEnrollment } =
     useProgramEnrollment();
   const program = programEnrollment?.program;
@@
-  if (errorProgramEnrollment) redirect(`/messages`);
+  useEffect(() => {
+    if (errorProgramEnrollment) {
+      router.replace("/messages");
+    }
+  }, [errorProgramEnrollment, router]);

Also applies to: 34-35, 39-46

⛔ Skipped due to learnings
Learnt from: steven-tey
PR: dubinc/dub#2756
File: apps/web/ui/webhooks/webhook-header.tsx:20-20
Timestamp: 2025-08-18T02:31:22.282Z
Learning: The Next.js redirect() function can be used in both Server Components and Client Components, as well as Route Handlers and Server Actions. It is not server-only as previously thought.
Learnt from: steven-tey
PR: dubinc/dub#2756
File: apps/web/ui/webhooks/webhook-header.tsx:20-20
Timestamp: 2025-08-18T02:31:22.282Z
Learning: The Next.js redirect() function can be used in both Server Components and Client Components, as well as Route Handlers and Server Actions, according to the official Next.js documentation. It is not server-only.
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx:1-1
Timestamp: 2025-08-25T21:03:24.285Z
Learning: In Next.js App Router, Server Components that use hooks can work without "use client" directive if they are only imported by Client Components, as they get "promoted" to run on the client side within the Client Component boundary.

Comment on lines +156 to +167
return data
? [
{
...data[0],
messages: [
...data[0].messages,
result.data.message,
],
},
]
: [];
},
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

Guard against empty SWR data to avoid crashes during optimistic updates

If data is an empty array, spreading data[0] throws. Use optional chaining and fall back to the existing cache.

-                    return data
-                      ? [
-                          {
-                            ...data[0],
-                            messages: [
-                              ...data[0].messages,
-                              result.data.message,
-                            ],
-                          },
-                        ]
-                      : [];
+                    return data?.[0]
+                      ? [
+                          {
+                            ...data[0],
+                            messages: [...data[0].messages, result.data.message],
+                          },
+                        ]
+                      : data ?? [];
@@
-                    optimisticData: (data) =>
-                      data
-                        ? [
-                            {
-                              ...data[0],
-                              messages: [
-                                ...data[0].messages,
-                                {
+                    optimisticData: (data) =>
+                      data?.[0]
+                        ? [
+                            {
+                              ...data[0],
+                              messages: [
+                                ...data[0].messages,
+                                {
                                   delivered: false,
                                   id: `tmp_${uuid()}`,
                                   programId: program!.id,
                                   partnerId: partner!.id,
                                   text: message,
@@
                                   senderPartner: {
                                     id: partner!.id,
                                     name: partner!.name,
                                     image: partner!.image || null,
                                   },
                                 },
                               ],
                             },
                           ]
-                        : [],
+                        : data ?? [],

Also applies to: 169-206

🤖 Prompt for AI Agents
In
apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx
around lines 156-167 (and likewise 169-206), the optimistic-update assumes
data[0] exists and will crash if SWR returns an empty array; guard by checking
that data is non-null and has length before spreading data[0] (e.g. use data &&
data.length > 0 or optional chaining like data?.[0]) and, if empty, return the
existing cache or the unmodified data instead of trying to spread undefined;
ensure the mutate updater returns the previous cached value when no item exists
so optimistic updates do not throw.

const signature = crypto
.createHmac("sha256", secretBytes)
const computedSignature = crypto
.createHmac("sha256", secretBytes.toString())
Copy link
Contributor

@vercel vercel bot Sep 15, 2025

Choose a reason for hiding this comment

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

Suggested change
.createHmac("sha256", secretBytes.toString())
.createHmac("sha256", secretBytes)

The webhook signature verification is broken due to incorrectly converting secretBytes to string, which will cause all webhook signatures to fail validation.

View Details

Analysis

Webhook signature verification fails due to incorrect Buffer.toString() usage

What fails: crypto.createHmac("sha256", secretBytes.toString()) in apps/web/app/api/resend/webhook/route.ts:25 generates incorrect HMAC signatures, causing all webhook requests from Resend to be rejected with "Invalid signature" error.

How to reproduce:

// When webhook secret contains binary data (most cases):
const secretBytes = Buffer.from("K+/jgugG45ywDdQzxYFFzGM7kOs7Ey3icb2warta48Y=", "base64");
const correctHmac = crypto.createHmac("sha256", secretBytes).update("test").digest("base64");
const buggyHmac = crypto.createHmac("sha256", secretBytes.toString()).update("test").digest("base64");
// Result: correctHmac !== buggyHmac (signatures don't match)

Result: Returns 400 "Invalid signature" for all valid webhook requests. Email tracking and subscription updates stop working.

Expected: Should accept valid webhook signatures per Svix manual verification docs which specifies using the Buffer directly: crypto.createHmac('sha256', secretBytes)

`${email} ${unsubscribed ? "unsubscribed from" : "subscribed to"} mailing list. Updating user...`,
);

await prisma.user.update({
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
await prisma.user.update({
await prisma.user.updateMany({

The contact update handler will throw an error if Resend sends a webhook for an email address that doesn't exist in the database.

View Details

Analysis

Webhook fails when Resend sends contact update for non-existent email

What fails: contactUpdated() in /apps/web/app/api/resend/webhook/contact-updated.ts uses prisma.user.update() which throws PrismaClientKnownRequestError (P2025) when no user exists with the provided email

How to reproduce:

# Send Resend webhook with email not in database
curl -X POST /api/resend/webhook \
  -H "Content-Type: application/json" \
  -d '{"type": "contact.updated", "data": {"email": "nonexistent@example.com", "unsubscribed": true}}'

Result: Returns 500 error with "Record to update not found" message, webhook processing fails

Expected: Should handle gracefully since external email providers may send webhooks for emails not in the database

Fix applied: Changed prisma.user.update() to prisma.user.updateMany() which doesn't throw when no records match, following the same pattern used in emailOpened() webhook handler

Per Prisma error reference, update() throws P2025 when no record found, while updateMany() silently succeeds with 0 affected rows.

Comment on lines +22 to +32
await prisma.message.updateMany({
where: {
partnerId,
programId,
senderPartnerId: {
not: null,
},
},
data: {
readInApp: new Date(),
},
Copy link
Contributor

Choose a reason for hiding this comment

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

The mark messages as read function updates all messages without checking if they're already read, causing unnecessary database writes and potentially overwriting timestamps.

View Details
📝 Patch Details
diff --git a/apps/web/lib/actions/partners/mark-partner-messages-read.ts b/apps/web/lib/actions/partners/mark-partner-messages-read.ts
index d0bcbcaff..ed5315200 100644
--- a/apps/web/lib/actions/partners/mark-partner-messages-read.ts
+++ b/apps/web/lib/actions/partners/mark-partner-messages-read.ts
@@ -26,6 +26,7 @@ export const markPartnerMessagesReadAction = authActionClient
         senderPartnerId: {
           not: null,
         },
+        readInApp: null,
       },
       data: {
         readInApp: new Date(),
diff --git a/apps/web/lib/actions/partners/mark-program-messages-read.ts b/apps/web/lib/actions/partners/mark-program-messages-read.ts
index 2548fe1fc..ef71c7b93 100644
--- a/apps/web/lib/actions/partners/mark-program-messages-read.ts
+++ b/apps/web/lib/actions/partners/mark-program-messages-read.ts
@@ -26,6 +26,7 @@ export const markProgramMessagesReadAction = authPartnerActionClient
         partnerId,
         programId,
         senderPartnerId: null,
+        readInApp: null,
       },
       data: {
         readInApp: new Date(),

Analysis

Message read functions update already-read messages causing unnecessary database writes

What fails: markPartnerMessagesReadAction and markProgramMessagesReadAction functions in mark-partner-messages-read.ts and mark-program-messages-read.ts update ALL messages without checking if readInApp is already set

How to reproduce:

  1. Call markPartnerMessagesReadAction on messages that already have readInApp timestamps
  2. Database performs full UPDATE operations on already-read messages
  3. Creates unnecessary row versions and WAL entries per PostgreSQL documentation

Result: Unnecessary database writes for messages already marked as read, potentially overwriting newer timestamps with current timestamp

Expected: Should only update messages where readInApp is null (unread messages) to avoid performance overhead and preserve existing read timestamps

Fix: Added readInApp: null condition to both functions' where clauses to only update unread messages

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: 1

🧹 Nitpick comments (9)
packages/prisma/schema/notification.prisma (5)

13-13: Avoid losing audit trail: prefer onDelete: SetNull over Cascade.

If a Message is deleted, you likely still want to retain the email event. Since messageId is nullable, SetNull fits.

-  message Message? @relation(fields: [messageId], references: [id], onDelete: Cascade)
+  message Message? @relation(fields: [messageId], references: [id], onDelete: SetNull)

1-3: If only “Message” emails exist, make messageId required.

With a single enum variant (Message), letting messageId be null introduces invalid states. If more types are planned, ignore.

 enum NotificationEmailType {
   Message
 }
 
 model NotificationEmail {
   id        String                @id @default(cuid())
   emailId   String // Resend email id
-  messageId String?
+  messageId String
   type      NotificationEmailType
   openedAt  DateTime?
   createdAt DateTime              @default(now())
 
-  message Message? @relation(fields: [messageId], references: [id], onDelete: Cascade)
+  message Message  @relation(fields: [messageId], references: [id], onDelete: Restrict)

Run this only if all records are tied to a Message. Otherwise keep nullable + SetNull as in the prior comment.

Also applies to: 8-8, 13-13


10-11: Add indexes for cron scans and retention queries.

If you query “unopened” or time windows, dedicated indexes help. Composite on (emailId, openedAt) doesn’t cover “openedAt IS NULL” scans efficiently.

   openedAt  DateTime?
   createdAt DateTime              @default(now())

   @@index([emailId, openedAt])
+  @@index([openedAt])
+  @@index([createdAt])
   @@index(type)
   @@index(messageId)

Also applies to: 15-18


11-11: Consider updatedAt for debugging/audit.

Handy for webhook updates and ops visibility.

-  createdAt DateTime              @default(now())
+  createdAt DateTime              @default(now())
+  updatedAt DateTime              @updatedAt

1-3: Index on type is currently low-value.

With a single enum variant, this index won’t filter. Keep if you’ll add variants soon; otherwise drop to reduce index bloat.

-  @@index(type)

Also applies to: 16-16

apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx (4)

74-81: Badge logic: show “9+” and avoid treating 0 as truthy.

Current ternary hides 0 (good) but caps to 9 without “+”. Suggest explicit “9+” for clarity and stricter number check.

Apply:

-    badge: unreadMessagesCount ? Math.min(9, unreadMessagesCount) : undefined,
+    badge:
+      typeof unreadMessagesCount === "number" && unreadMessagesCount > 0
+        ? unreadMessagesCount > 9
+          ? "9+"
+          : unreadMessagesCount
+        : undefined,

142-149: Program‑level Messages entry looks right.

Arrow affordance makes sense for a thread view. Consider adding a program‑scoped unread badge when available in future.


174-195: “Bounties” badge shows “New” when count is 0.

The current truthy check maps 0 to “New”, which is misleading. Prefer undefined for 0.

Apply:

-            badge: programBountiesCount
-              ? programBountiesCount > 99
-                ? "99+"
-                : programBountiesCount
-              : "New",
+            badge:
+              typeof programBountiesCount === "number"
+                ? programBountiesCount > 99
+                  ? "99+"
+                  : programBountiesCount > 0
+                    ? programBountiesCount
+                    : undefined
+                : "New",

327-333: Pass query values as strings to match URLSearchParams and reduce TS casts.

Minor type‑hygiene tweak; behavior unchanged.

Apply:

-  const { count: unreadMessagesCount } = useProgramMessagesCount({
-    enabled: true,
-    query: {
-      unread: true,
-    },
-  });
+  const { count: unreadMessagesCount } = useProgramMessagesCount({
+    enabled: true,
+    query: { unread: "true" },
+  });

Optionally consider SWR opts (e.g., revalidateOnFocus: false) if badge flicker is observed.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 106330f and ca0fc12.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (7)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx (1 hunks)
  • apps/web/app/api/resend/webhook/route.ts (1 hunks)
  • apps/web/package.json (2 hunks)
  • apps/web/ui/layout/sidebar/app-sidebar-nav.tsx (9 hunks)
  • apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx (9 hunks)
  • packages/prisma/schema/bounty.prisma (0 hunks)
  • packages/prisma/schema/notification.prisma (1 hunks)
💤 Files with no reviewable changes (1)
  • packages/prisma/schema/bounty.prisma
🚧 Files skipped from review as they are similar to previous changes (4)
  • apps/web/package.json
  • apps/web/app/api/resend/webhook/route.ts
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx
  • apps/web/ui/layout/sidebar/app-sidebar-nav.tsx
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-05-29T04:45:18.504Z
Learnt from: devkiran
PR: dubinc/dub#2448
File: packages/email/src/templates/partner-program-summary.tsx:0-0
Timestamp: 2025-05-29T04:45:18.504Z
Learning: In the PartnerProgramSummary email template (packages/email/src/templates/partner-program-summary.tsx), the stat titles are hardcoded constants ("Clicks", "Leads", "Sales", "Earnings") that will always match the ICONS object keys after toLowerCase() conversion, so icon lookup failures are not possible.

Applied to files:

  • apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx
🧬 Code graph analysis (1)
apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx (2)
apps/web/ui/layout/sidebar/sidebar-nav.tsx (1)
  • SidebarNavGroups (60-62)
apps/web/lib/swr/use-program-messages-count.ts (1)
  • useProgramMessagesCount (8-36)
⏰ 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 (6)
apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx (6)

6-6: Good: wiring in unread messages count hook.

Import looks correct and local path matches the provided hook.


43-44: Type addition SGTM.

Optional number fits the usage pattern.


46-49: NAV_GROUPS signature update is consistent.

Destructuring only what’s used keeps it tidy.


312-313: Top‑level area handling for /messages is correct.

Setting currentArea to null for /messages prevents conflicting program area UI.


347-348: Data plumb‑through LGTM.

Prop name and usage align with NAV_GROUPS.


19-21: Resolved — Msgs and PaperPlane are exported from @dub/ui/icons.
Icons are implemented at packages/ui/src/icons/nucleo/msgs.tsx and packages/ui/src/icons/nucleo/paper-plane.tsx and re-exported via packages/ui/src/icons/nucleo/index.ts -> packages/ui/src/icons/index.tsx -> packages/ui/src/index.tsx; no change required.


model NotificationEmail {
id String @id @default(cuid())
emailId String // Resend email id
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

Enforce idempotency: make emailId unique.

Duplicate records for the same provider email can corrupt open-tracking and dedupe logic. Add a uniqueness constraint.

 model NotificationEmail {
   id        String                @id @default(cuid())
   emailId   String // Resend email id
   messageId String?
   type      NotificationEmailType
   openedAt  DateTime?
   createdAt DateTime              @default(now())

   message Message? @relation(fields: [messageId], references: [id], onDelete: Cascade)

   @@index([emailId, openedAt])
+  @@unique([emailId])
   @@index(type)
   @@index(messageId)
 }

Also applies to: 15-18

🤖 Prompt for AI Agents
In packages/prisma/schema/notification.prisma around line 7 (and similarly for
lines 15-18), the emailId field is not constrained to be unique which allows
duplicate records for the same provider email; update the model to add a unique
constraint for emailId (or mark the emailId field as @unique) so Prisma will
enforce idempotency and prevent duplicate notification records—apply the same
change to the other affected model/lines and run prisma migrate to generate the
migration.

Comment on lines +17 to +25

await prisma.user.update({
where: {
email,
},
data: {
subscribed: !unsubscribed,
},
});
Copy link
Contributor

Choose a reason for hiding this comment

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

The database update will throw an unhandled error if the email doesn't exist in the users table, causing the webhook to fail and potentially preventing proper processing of other webhook events.

View Details
📝 Patch Details
diff --git a/apps/web/app/api/resend/webhook/contact-updated.ts b/apps/web/app/api/resend/webhook/contact-updated.ts
index f5b4dd4b1..be69d0dcc 100644
--- a/apps/web/app/api/resend/webhook/contact-updated.ts
+++ b/apps/web/app/api/resend/webhook/contact-updated.ts
@@ -15,6 +15,17 @@ export async function contactUpdated({
     `${email} ${unsubscribed ? "unsubscribed from" : "subscribed to"} mailing list. Updating user...`,
   );
 
+  const user = await prisma.user.findUnique({
+    where: {
+      email,
+    },
+  });
+
+  if (!user) {
+    console.log(`User with email ${email} not found, skipping update.`);
+    return;
+  }
+
   await prisma.user.update({
     where: {
       email,

Analysis

Prisma update() throws unhandled P2025 error for non-existent users in contactUpdated webhook

What fails: contactUpdated() in apps/web/app/api/resend/webhook/contact-updated.ts calls prisma.user.update() without checking if the user exists, causing P2025 "Record to update not found" error when email doesn't exist in database

How to reproduce:

// Webhook payload for non-existent email triggers the error
await contactUpdated({
  email: "non-existent@example.com", 
  unsubscribed: true
});

Result: Throws PrismaClientKnownRequestError with code P2025, causing webhook handler to fail and potentially return 500 error to Resend

Expected: Should handle missing users gracefully like other webhook handlers in the codebase (e.g., payouts-item-succeeded.ts uses findUnique check before update)

Documentation: Prisma error P2025 confirms update() throws this error when record not found

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/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx (1)

156-167: Guard optimistic updates when SWR data is empty to prevent crashes

Accessing data[0] when data is [] throws. Use optional chaining and preserve the previous cache if empty. Applies to both mutate updater and optimisticData.

-                    return data
-                      ? [
+                    return data?.[0]
+                      ? [
                           {
                             ...data[0],
                             messages: [
                               ...data[0].messages,
                               result.data.message,
                             ],
                           },
-                        ]
-                      : [];
+                        ]
+                      : data ?? [];
@@
-                    optimisticData: (data) =>
-                      data
-                        ? [
+                    optimisticData: (data) =>
+                      data?.[0]
+                        ? [
                             {
                               ...data[0],
                               messages: [
                                 ...data[0].messages,
                                 {
                                   delivered: false,
                                   id: `tmp_${uuid()}`,
                                   programId: program!.id,
                                   partnerId: partner!.id,
                                   text: message,
@@
                                 },
                               ],
                             },
-                          ]
-                        : [],
+                          ]
+                        : data ?? [],

Also applies to: 169-206

🧹 Nitpick comments (4)
apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx (4)

139-146: Defensive guard: avoid non-null assertions in onSendMessage

onSendMessage uses program!/partner!/user!. Add a quick guard to avoid runtime errors if a user manages to send before data is ready.

-            onSendMessage={async (message) => {
-              const createdAt = new Date();
+            onSendMessage={async (message) => {
+              if (!program || !partner || !user) {
+                toast.error("Still loading, please try again.");
+                return;
+              }
+              const createdAt = new Date();

169-206: Reduce redundant network traffic after optimistic send

You already call mutatePrefix after success. Disable auto revalidate here to avoid double fetches.

                     rollbackOnError: true,
+                    revalidate: false,

316-319: Encode fallback avatar URL to handle spaces/unicode

Avoid broken images for program names with spaces/special chars.

-            src={program.logo || `${OG_AVATAR_URL}${program.name}`}
+            src={program.logo || `${OG_AVATAR_URL}${encodeURIComponent(program.name)}`}

347-353: UX: disable “Copy link” when no referral link exists

Prevents copying an empty string and clarifies state.

-        <input
+        <input
           type="text"
           readOnly
           value={getPrettyUrl(partnerLink)}
           className="border-border-default text-content-default focus:border-border-emphasis bg-bg-default mt-2 block h-10 w-full rounded-md border px-3 text-sm focus:outline-none focus:ring-neutral-500"
         />
@@
-        <Button
+        <Button
+          disabled={!partnerLink}
           icon={
@@
-          onClick={() => copyToClipboard(partnerLink)}
+          onClick={() => partnerLink && copyToClipboard(partnerLink)}
         />

Also applies to: 354-378

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ca0fc12 and c1355e9.

📒 Files selected for processing (2)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx (1 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/[partnerId]/page-client.tsx
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx (13)
apps/web/lib/actions/partners/mark-program-messages-read.ts (1)
  • markProgramMessagesReadAction (13-34)
apps/web/lib/swr/use-program-messages.ts (1)
  • useProgramMessages (11-44)
apps/web/lib/actions/partners/message-program.ts (1)
  • messageProgramAction (16-58)
apps/web/ui/messages/messages-context.tsx (1)
  • useMessagesContext (13-15)
apps/web/ui/messages/toggle-side-panel-button.tsx (1)
  • ToggleSidePanelButton (4-66)
apps/web/ui/messages/messages-panel.tsx (1)
  • MessagesPanel (17-267)
apps/web/lib/types.ts (2)
  • ProgramEnrollmentProps (451-451)
  • PayoutsCount (453-457)
apps/web/lib/partners/construct-partner-link.ts (1)
  • constructPartnerLink (4-28)
apps/web/lib/swr/use-partner-analytics.ts (1)
  • usePartnerAnalytics (10-66)
packages/utils/src/constants/misc.ts (1)
  • OG_AVATAR_URL (29-29)
packages/utils/src/functions/urls.ts (1)
  • getPrettyUrl (130-138)
apps/web/ui/partners/program-reward-list.tsx (1)
  • ProgramRewardList (9-89)
apps/web/ui/partners/program-help-links.tsx (1)
  • ProgramHelpLinks (7-62)
⏰ 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/app/(ee)/partners.dub.co/(dashboard)/messages/[programSlug]/page-client.tsx (2)

133-215: Overall: messaging flow and optimistic UX look solid

Nice end-to-end wiring: temp IDs, rollbackOnError, server action schema validation, and cache fan-out via mutatePrefix.


34-35: Fix: redirect() is server-only — use useRouter + useEffect in Client Component

Calling redirect in a "use client" file will throw. Switch to client navigation.

-import { redirect, useParams } from "next/navigation";
-import { useMemo, useState } from "react";
+import { useParams, useRouter } from "next/navigation";
+import { useEffect, useMemo, useState } from "react";
@@
 export function PartnerMessagesProgramPageClient() {
   const { programSlug } = useParams() as { programSlug: string };
+  const router = useRouter();
@@
-  if (errorProgramEnrollment) redirect(`/messages`);
+  useEffect(() => {
+    if (errorProgramEnrollment) {
+      router.replace("/messages");
+    }
+  }, [errorProgramEnrollment, router]);

Also applies to: 39-41, 82-83

⛔ Skipped due to learnings
Learnt from: steven-tey
PR: dubinc/dub#2756
File: apps/web/ui/webhooks/webhook-header.tsx:20-20
Timestamp: 2025-08-18T02:31:22.282Z
Learning: The Next.js redirect() function can be used in both Server Components and Client Components, as well as Route Handlers and Server Actions. It is not server-only as previously thought.
Learnt from: steven-tey
PR: dubinc/dub#2756
File: apps/web/ui/webhooks/webhook-header.tsx:20-20
Timestamp: 2025-08-18T02:31:22.282Z
Learning: The Next.js redirect() function can be used in both Server Components and Client Components, as well as Route Handlers and Server Actions, according to the official Next.js documentation. It is not server-only.
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx:1-1
Timestamp: 2025-08-25T21:03:24.285Z
Learning: In Next.js App Router, Server Components that use hooks can work without "use client" directive if they are only imported by Client Components, as they get "promoted" to run on the client side within the Client Component boundary.

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: 1

♻️ Duplicate comments (1)
apps/web/ui/messages/messages-panel.tsx (1)

1-1: Add "use client" at the top.

This file uses hooks and navigator; without the directive it will error in Next.js.

+"use client";
+
 import { Message } from "@/lib/types";
🧹 Nitpick comments (7)
apps/web/ui/messages/messages-panel.tsx (7)

41-47: Don’t block sending when messages array is undefined.

Preventing sends until messages are loaded is unnecessary.

-  const sendMessage = () => {
-    if (!messages || !typedMessage.trim()) return;
+  const sendMessage = () => {
+    if (!typedMessage.trim()) return;
 
     onSendMessage(typedMessage.trim());
     setTypedMessage("");
     scrollRef.current?.scrollTo({ top: 0 });
   };

245-261: Disable Send when input is empty.

Avoids no-op click and matches keyboard guard.

 <Button
   variant="primary"
   text={
@@
   }
   onClick={sendMessage}
+  disabled={!typedMessage.trim()}
   className="h-8 w-fit rounded-lg px-4"
 />

250-252: Guard navigator access.

Small SSR-safety improvement even with client directive.

-                      {navigator.platform.startsWith("Mac") ? "⌘" : "^"}
+                      {typeof navigator !== "undefined" && navigator.platform.startsWith("Mac") ? "⌘" : "^"}

123-141: Disable tooltip when sender name is missing.

Prevents empty tooltip content.

-                    <Tooltip content={sender?.name}>
+                    <Tooltip content={sender?.name} disabled={!sender?.name}>

125-132: Harden avatar src/alt; encode name and add lazy loading.

Prevents “undefined” URLs and improves perf.

-                        <img
-                          src={
-                            sender?.image || `${OG_AVATAR_URL}${sender?.name}`
-                          }
-                          alt={`${sender?.name} avatar`}
-                          className="size-8 rounded-full"
-                          draggable={false}
-                        />
+                        <img
+                          src={
+                            sender?.image ??
+                            (sender?.name
+                              ? `${OG_AVATAR_URL}${encodeURIComponent(sender.name)}`
+                              : `${OG_AVATAR_URL}User`)
+                          }
+                          alt={`${sender?.name ?? "User"} avatar`}
+                          loading="lazy"
+                          className="size-8 rounded-full"
+                          draggable={false}
+                        />

71-71: Minor: drop optional chaining in map.

Inside the truthy branch, messages is defined.

-            {messages?.map((message, idx) => {
+            {messages.map((message, idx) => {

233-241: Maintain caret after emoji insert.

Improves UX for consecutive emoji inserts.

-              <EmojiPicker
-                onSelect={(emoji) => {
-                  const pos = selectionStartRef.current;
-                  setTypedMessage((prev) =>
-                    pos !== null
-                      ? prev.slice(0, pos) + emoji + prev.slice(pos)
-                      : prev + emoji,
-                  );
-                  textAreaRef.current?.focus();
-                }}
-              />
+              <EmojiPicker
+                onSelect={(emoji) => {
+                  const pos = selectionStartRef.current;
+                  setTypedMessage((prev) => {
+                    const start = pos ?? prev.length;
+                    const next = prev.slice(0, start) + emoji + prev.slice(start);
+                    requestAnimationFrame(() => {
+                      const el = textAreaRef.current;
+                      if (el) {
+                        const caret = start + emoji.length;
+                        el.setSelectionRange(caret, caret);
+                        selectionStartRef.current = caret;
+                      }
+                    });
+                    return next;
+                  });
+                  textAreaRef.current?.focus();
+                }}
+              />
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c982ce0 and 3103a45.

📒 Files selected for processing (1)
  • apps/web/ui/messages/messages-panel.tsx (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/ui/messages/messages-panel.tsx (5)
apps/web/lib/types.ts (1)
  • Message (589-589)
packages/ui/src/tooltip.tsx (1)
  • Tooltip (32-88)
packages/utils/src/constants/misc.ts (1)
  • OG_AVATAR_URL (29-29)
apps/web/lib/zod/schemas/messages.ts (1)
  • MAX_MESSAGE_LENGTH (6-6)
apps/web/ui/messages/emoji-picker.tsx (1)
  • EmojiPicker (8-76)
⏰ 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 (1)
apps/web/ui/messages/messages-panel.tsx (1)

100-102: Use a stable key (message.id) if present

Time-based keys can collide — prefer a stable id.

-                <Fragment
-                  key={`${new Date(message.createdAt).getTime()}-${message.senderUserId}-${message.senderPartnerId}`}
-                >
+                <Fragment key={message.id}>

Verify Message exposes id in the schema; example search to run locally:

rg -nC2 --type ts 'MessageSchema|interface Message|type Message|id:\s*z\.' -g '!**/dist/**' -g '!**/build/**'

@steven-tey steven-tey merged commit 158a444 into main Sep 15, 2025
9 of 10 checks passed
@steven-tey steven-tey deleted the messages branch September 15, 2025 15:32
This was referenced Sep 29, 2025
@coderabbitai coderabbitai bot mentioned this pull request Oct 10, 2025
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

点击 这是indexloc提供的php浏览器服务,不要输入任何密码和下载