-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Messages #2781
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Messages #2781
Conversation
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAdds 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
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
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
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
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (1 passed)
✨ Finishing touches
🧪 Generate unit tests
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.
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. Comment |
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx
Outdated
Show resolved
Hide resolved
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/messages/page-client.tsx
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (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-effectWrap 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.openPass 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 availableAvoid 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
📒 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 goodPassing 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.
return data | ||
? [ | ||
{ | ||
...data[0], | ||
messages: [ | ||
...data[0].messages, | ||
result.data.message, | ||
], | ||
}, | ||
] | ||
: []; | ||
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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()) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
.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({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
await prisma.message.updateMany({ | ||
where: { | ||
partnerId, | ||
programId, | ||
senderPartnerId: { | ||
not: null, | ||
}, | ||
}, | ||
data: { | ||
readInApp: new Date(), | ||
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The 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:
- Call
markPartnerMessagesReadAction
on messages that already havereadInApp
timestamps - Database performs full UPDATE operations on already-read messages
- 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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
🧹 Nitpick comments (9)
packages/prisma/schema/notification.prisma (5)
13-13
: Avoid losing audit trail: preferonDelete: SetNull
overCascade
.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, makemessageId
required.With a single enum variant (
Message
), lettingmessageId
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
: ConsiderupdatedAt
for debugging/audit.Handy for webhook updates and ops visibility.
- createdAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt
1-3
: Index ontype
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
⛔ 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
|
||
await prisma.user.update({ | ||
where: { | ||
email, | ||
}, | ||
data: { | ||
subscribed: !unsubscribed, | ||
}, | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The 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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
♻️ 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 crashesAccessing 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 onSendMessageonSendMessage 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 sendYou 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/unicodeAvoid 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 existsPrevents 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
📒 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 solidNice 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 ComponentCalling 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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 1
♻️ Duplicate comments (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
📒 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 presentTime-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/**'
Summary by CodeRabbit
New Features
APIs / Notifications
UI
Chores