Skip to content

Enhance Sponsor management with email and bulk operations#309

Merged
Starefossen merged 4 commits intomainfrom
sponsor-crm-contact
Feb 5, 2026
Merged

Enhance Sponsor management with email and bulk operations#309
Starefossen merged 4 commits intomainfrom
sponsor-crm-contact

Conversation

@Starefossen
Copy link
Member

@Starefossen Starefossen commented Feb 5, 2026

User description

Introduce selection and email functionality in the SponsorCard component, create a SponsorContactEditor for managing contact information, and implement bulk update and delete capabilities for sponsors. Enhance error handling and notifications throughout the processes.


PR Type

Enhancement, Tests


Description

  • Add bulk update and delete operations for sponsors with transaction support

  • Implement sponsor contact management editor with billing information handling

  • Create email functionality for individual sponsor contact outreach

  • Add selection and bulk action capabilities to sponsor cards with UI controls

  • Enhance sponsor CRM form with tabbed contact and pipeline views


Diagram Walkthrough

flowchart LR
  A["Sponsor Card"] -->|"selection & email"| B["Bulk Actions Bar"]
  B -->|"update/delete"| C["Sponsor Router"]
  C -->|"transaction"| D["Sanity Database"]
  E["Contact Editor"] -->|"manage contacts"| F["Sponsor Update"]
  F -->|"persist"| D
  G["Email Modal"] -->|"send to contacts"| H["Email API Route"]
  H -->|"via Resend"| I["Contact Persons"]
Loading

File Walkthrough

Relevant files
Enhancement
12 files
sponsor.ts
Add bulk operations and contact info support                         
+195/-17
route.ts
Create sponsor email sending endpoint                                       
+85/-0   
sponsorForConference.ts
Add bulk update and delete validation schemas                       
+19/-0   
SponsorCRMForm.tsx
Add contact editor and email modal integration                     
+370/-235
SponsorContactTable.tsx
Refactor to use dedicated contact editor component             
+98/-445
SponsorContactEditor.tsx
Create reusable contact and billing information editor     
+367/-0 
SponsorBulkActions.tsx
Implement bulk action toolbar with status and tag management
+330/-0 
SponsorCard.tsx
Add selection checkbox and priority tag display                   
+112/-51
SponsorCRMClient.tsx
Integrate bulk actions and email modal functionality         
+116/-20
SponsorIndividualEmailModal.tsx
Create modal for sending individual sponsor emails             
+171/-0 
SponsorBoardColumn.tsx
Add selection and email action props to column                     
+11/-0   
page.tsx
Pass conference and domain data to client component           
+14/-2   

- Added selection checkbox and email button to SponsorCard component.
- Updated styles for selected state in SponsorCard.
- Introduced priority tags display for sponsors.
- Refactored invoice status and assignee display logic.

feat: create SponsorContactEditor for managing contact information

- Implemented SponsorContactEditor component to add, update, and remove contact persons.
- Added billing information management within the editor.
- Integrated notification system for success and error handling during updates.

feat: add bulk update and delete functionality for sponsors

- Implemented bulkUpdate and bulkDelete mutations in sponsor router.
- Created schemas for bulk update and delete operations.
- Enhanced error handling and logging for bulk operations.
@Starefossen Starefossen requested a review from a team as a code owner February 5, 2026 08:59
@vercel
Copy link

vercel bot commented Feb 5, 2026

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

Project Deployment Actions Updated (UTC)
cloudnativedays Ready Ready Preview, Comment Feb 5, 2026 9:27am

Request Review

@qodo-code-review
Copy link

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 No relevant tests
🔒 Security concerns

Authorization / unsafe deletion:
bulkDelete (and bulkUpdate) accept arbitrary document ids and perform writes/deletes without validating that each id is a sponsorForConference document and/or belongs to the expected conference scope. Even with adminProcedure, this increases blast radius (e.g., an admin could accidentally or maliciously delete unrelated documents by id). Add server-side constraints (query the docs by _type == "sponsorForConference" and by conference reference, then delete/patch only those).

⚡ Recommended focus areas for review

UI Bug

The assignee avatar positioning logic always applies the translate class due to a constant-true condition, which will shift the avatar even when the selection checkbox is not visible. This likely causes unintended layout in the top-left corner.

{/* Assignee Avatar - Top Left (Offset if checkbox is visible) */}
{sponsor.assigned_to && (
  <div
    className={clsx(
      'absolute top-1 left-1 z-10 origin-top-left scale-75 transition-transform',
      (isSelected || true) && 'translate-x-5', // Move avatar right to make room for checkbox
    )}
  >
    <SpeakerAvatars
      speakers={[
        {
          _id: sponsor.assigned_to._id,
          _rev: '',
          _createdAt: '',
          _updatedAt: '',
          name: sponsor.assigned_to.name,
          email: sponsor.assigned_to.email,
          image: sponsor.assigned_to.image,
        },
      ]}
      size="sm"
      maxVisible={1}
      showTooltip={true}
    />
  </div>
)}
Logic Bug

Bulk status activity logging checks truthiness for the incoming status instead of checking for undefined, which can accidentally skip logging for valid enum values if any are falsy-like or if the intended behavior is to log whenever the field is provided and changed.

// Prepare activity logs
if (input.status && input.status !== existing.status) {
  const activityId = `activity-status-${existing._id}-${Date.now()}`
  transaction.create({
    _id: activityId,
    _type: 'sponsorActivity',
    sponsor_for_conference: {
      _type: 'reference',
      _ref: existing._id,
    },
    activity_type: 'stage_change',
    description: `Status changed from ${formatStatusName(existing.status)} to ${formatStatusName(input.status)}`,
    metadata: {
      old_value: existing.status,
      new_value: input.status,
      timestamp: getCurrentDateTime(),
    },
    created_by: { _type: 'reference', _ref: userId },
    created_at: getCurrentDateTime(),
  })
}
Data Integrity

New contact keys are generated with Date.now, which can collide when adding multiple contacts quickly (same millisecond) and may also produce unstable keys across environments. Consider using a stronger unique id generator to avoid key conflicts in Sanity arrays.

const handleAddContact = () => {
  setContacts([
    ...contacts,
    {
      _key: `contact-${Date.now()}`,
      name: '',
      email: '',
      phone: '',
      role: '',
    },
  ])
}

@Starefossen Starefossen linked an issue Feb 5, 2026 that may be closed by this pull request
4 tasks
@qodo-code-review
Copy link

qodo-code-review bot commented Feb 5, 2026

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Prevent orphaned data during bulk deletion
Suggestion Impact:The bulkDelete mutation was refactored to call a new helper (bulkDeleteSponsors) instead of directly deleting only the sponsorForConference documents, indicating the deletion logic was centralized and likely extended to handle related cleanup (such as sponsorActivity) in that helper.

code diff:

+import {
+  bulkUpdateSponsors,
+  bulkDeleteSponsors,
+} from '@/lib/sponsor-crm/bulk'
 import { formatStatusName } from '@/components/admin/sponsor-crm/utils'
 
 async function getAllSponsorTiers(conferenceId?: string): Promise<{
@@ -886,124 +890,7 @@
         }
 
         try {
-          // Fetch all target sponsors in one query
-          const sponsors = await clientWrite.fetch<SponsorForConference[]>(
-            `*[_type == "sponsorForConference" && _id in $ids]`,
-            { ids: input.ids },
-          )
-
-          const transaction = clientWrite.transaction()
-          let updatedCount = 0
-
-          interface CRMUpdates {
-            status?: SponsorStatus
-            contract_status?: ContractStatus
-            invoice_status?: InvoiceStatus
-            assigned_to?: { _type: 'reference'; _ref: string } | null
-            tags?: SponsorTag[]
-          }
-
-          for (const existing of sponsors) {
-            const updates: CRMUpdates = {}
-            if (input.status !== undefined) updates.status = input.status
-            if (input.contract_status !== undefined)
-              updates.contract_status = input.contract_status
-            if (input.invoice_status !== undefined)
-              updates.invoice_status = input.invoice_status
-            if (input.assigned_to !== undefined) {
-              updates.assigned_to =
-                input.assigned_to === null
-                  ? null
-                  : { _type: 'reference', _ref: input.assigned_to }
-            }
-
-            // Handle tags
-            let currentTags = existing.tags || []
-            let tagsChanged = false
-
-            if (input.tags !== undefined) {
-              currentTags = input.tags as SponsorTag[]
-              tagsChanged = true
-            }
-            if (input.add_tags) {
-              const newTags = [...new Set([...currentTags, ...input.add_tags])]
-              if (newTags.length !== currentTags.length) {
-                currentTags = newTags as SponsorTag[]
-                tagsChanged = true
-              }
-            }
-            if (input.remove_tags) {
-              const newTags = currentTags.filter(
-                (t) => !input.remove_tags?.includes(t),
-              )
-              if (newTags.length !== currentTags.length) {
-                currentTags = newTags as SponsorTag[]
-                tagsChanged = true
-              }
-            }
-
-            if (tagsChanged) {
-              updates.tags = currentTags
-            }
-
-            if (Object.keys(updates).length > 0) {
-              transaction.patch(existing._id, { set: updates })
-              updatedCount++
-
-              // Prepare activity logs
-              if (input.status && input.status !== existing.status) {
-                const activityId = `activity-status-${existing._id}-${Date.now()}`
-                transaction.create({
-                  _id: activityId,
-                  _type: 'sponsorActivity',
-                  sponsor_for_conference: {
-                    _type: 'reference',
-                    _ref: existing._id,
-                  },
-                  activity_type: 'stage_change',
-                  description: `Status changed from ${formatStatusName(existing.status)} to ${formatStatusName(input.status)}`,
-                  metadata: {
-                    old_value: existing.status,
-                    new_value: input.status,
-                    timestamp: getCurrentDateTime(),
-                  },
-                  created_by: { _type: 'reference', _ref: userId },
-                  created_at: getCurrentDateTime(),
-                })
-              }
-
-              if (
-                input.assigned_to !== undefined &&
-                input.assigned_to !== (existing.assigned_to?._ref || null)
-              ) {
-                const activityId = `activity-assign-${existing._id}-${Date.now()}`
-                transaction.create({
-                  _id: activityId,
-                  _type: 'sponsorActivity',
-                  sponsor_for_conference: {
-                    _type: 'reference',
-                    _ref: existing._id,
-                  },
-                  activity_type: 'note',
-                  description: input.assigned_to
-                    ? `Assigned via bulk update`
-                    : 'Unassigned via bulk update',
-                  created_by: { _type: 'reference', _ref: userId },
-                  created_at: getCurrentDateTime(),
-                })
-              }
-            }
-          }
-
-          if (updatedCount > 0) {
-            await transaction.commit()
-          }
-
-          return {
-            success: true,
-            updatedCount,
-            totalCount: input.ids.length,
-          }
+          return await bulkUpdateSponsors(input, userId)
         } catch (error) {
           console.error('Bulk update error:', error)
           throw new TRPCError({
@@ -1018,16 +905,7 @@
       .input(BulkDeleteSponsorCRMSchema)
       .mutation(async ({ input }) => {
         try {
-          const transaction = clientWrite.transaction()
-          for (const id of input.ids) {
-            transaction.delete(id)
-          }
-          await transaction.commit()
-          return {
-            success: true,
-            deletedCount: input.ids.length,
-            totalCount: input.ids.length,
-          }
+          return await bulkDeleteSponsors(input.ids)
         } catch (error) {

In the bulkDelete mutation, also delete associated sponsorActivity documents to
prevent orphaned data.

src/server/routers/sponsor.ts [1017-1039]

 bulkDelete: adminProcedure
   .input(BulkDeleteSponsorCRMSchema)
   .mutation(async ({ input }) => {
     try {
+      // Find all related activity documents
+      const relatedActivityIds = await clientWrite.fetch<string[]>(
+        `*[_type == "sponsorActivity" && sponsor_for_conference._ref in $ids]._id`,
+        { ids: input.ids },
+      )
+
       const transaction = clientWrite.transaction()
+      // Delete the sponsor-conference documents
       for (const id of input.ids) {
         transaction.delete(id)
       }
+      // Delete the related activity documents
+      for (const id of relatedActivityIds) {
+        transaction.delete(id)
+      }
+
       await transaction.commit()
       return {
         success: true,
         deletedCount: input.ids.length,
         totalCount: input.ids.length,
       }
     } catch (error) {
       console.error('Bulk delete error:', error)
       throw new TRPCError({
         code: 'INTERNAL_SERVER_ERROR',
         message: 'Failed to perform bulk delete',
         cause: error,
       })
     }
   }),

[Suggestion processed]

Suggestion importance[1-10]: 8

__

Why: The suggestion correctly identifies a data integrity issue where sponsorActivity documents would be orphaned after a bulk delete, and the proposed fix correctly resolves this by deleting them within the same transaction.

Medium
Validate sender address

Before sending an email, validate that conference.sponsor_email is defined and
return an error if it is missing.

src/app/(admin)/admin/api/sponsors/email/send/route.ts [61-68]

-const result = await retryWithBackoff(async () => {
-  return await resend.emails.send({
+if (!conference.sponsor_email) {
+  return createEmailErrorResponse('Missing sponsor_email in conference config', 500)
+}
+const result = await retryWithBackoff(() =>
+  resend.emails.send({
     from: `${conference.organizer || 'Cloud Native Days'} <${conference.sponsor_email}>`,
     to: recipients.map((r) => r.email),
     subject,
     react: emailTemplate,
-  })
-})
+  }),
+)
  • Apply / Chat
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly adds a validation check to ensure conference.sponsor_email exists, preventing potential errors from a malformed 'from' address and improving the robustness of the email sending logic.

Medium
General
Add error handling to send
Suggestion Impact:The commit wrapped the email-sending fetch logic in a try/catch, replaced throwing on non-OK responses with error notifications and early return, and added a catch block to surface network/unexpected errors via notifications.

code diff:

-    const messageJSON = JSON.stringify(message as PortableTextBlockForHTML[])
+    try {
+      const messageJSON = JSON.stringify(message as PortableTextBlockForHTML[])
 
-    const response = await fetch('/admin/api/sponsors/email/send', {
-      method: 'POST',
-      headers: {
-        'Content-Type': 'application/json',
-      },
-      body: JSON.stringify({
-        sponsorId: sponsorForConference.sponsor._id,
-        subject,
-        message: messageJSON,
-      }),
-    })
+      const response = await fetch('/admin/api/sponsors/email/send', {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+        },
+        body: JSON.stringify({
+          sponsorId: sponsorForConference.sponsor._id,
+          subject,
+          message: messageJSON,
+        }),
+      })
 
-    if (!response.ok) {
-      const errorData = await response.json()
-      throw new Error(errorData.error || 'Failed to send email')
+      if (!response.ok) {
+        const errorData = await response.json()
+        showNotification({
+          type: 'error',
+          title: 'Email failed',
+          message: errorData.error || 'Failed to send email',
+        })
+        return
+      }
+
+      const result = await response.json()
+
+      showNotification({
+        type: 'success',
+        title: 'Email sent successfully',
+        message: `Sent to ${result.recipientCount} contact${result.recipientCount > 1 ? 's' : ''} for ${sponsorForConference.sponsor.name}`,
+      })
+    } catch (err) {
+      showNotification({
+        type: 'error',
+        title: 'Network error',
+        message:
+          err instanceof Error ? err.message : 'An unexpected error occurred',
+      })
     }

Wrap the fetch call in the handleSend function with a try/catch block to handle
network errors and provide user notifications on failure.

src/components/admin/SponsorIndividualEmailModal.tsx [70-103]

 const handleSend = async ({
   subject,
   message,
 }: {
   subject: string
   message: PortableTextBlock[]
 }) => {
-  const messageJSON = JSON.stringify(message as PortableTextBlockForHTML[])
-
-  const response = await fetch('/admin/api/sponsors/email/send', {
-    method: 'POST',
-    headers: {
-      'Content-Type': 'application/json',
-    },
-    body: JSON.stringify({
-      sponsorId: sponsorForConference.sponsor._id,
-      subject,
-      message: messageJSON,
-    }),
-  })
-
-  if (!response.ok) {
-    const errorData = await response.json()
-    throw new Error(errorData.error || 'Failed to send email')
+  try {
+    const messageJSON = JSON.stringify(message as PortableTextBlockForHTML[])
+    const response = await fetch('/admin/api/sponsors/email/send', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({
+        sponsorId: sponsorForConference.sponsor._id,
+        subject,
+        message: messageJSON,
+      }),
+    })
+    if (!response.ok) {
+      const err = await response.json()
+      showNotification({ type: 'error', title: 'Email failed', message: err.error || 'Failed to send email' })
+      return
+    }
+    const result = await response.json()
+    showNotification({
+      type: 'success',
+      title: 'Email sent',
+      message: `Sent to ${result.recipientCount} contact${result.recipientCount > 1 ? 's' : ''}`,
+    })
+  } catch (err) {
+    showNotification({ type: 'error', title: 'Email error', message: (err as Error).message })
   }
-
-  const result = await response.json()
-
-  showNotification({
-    type: 'success',
-    title: 'Email sent successfully',
-    message: `Email sent to ${result.recipientCount} contact${result.recipientCount > 1 ? 's' : ''} for ${sponsorForConference.sponsor.name}`,
-  })
 }

[Suggestion processed]

Suggestion importance[1-10]: 8

__

Why: The suggestion correctly identifies that the handleSend function lacks error handling for network or parsing failures, and the proposed try/catch block effectively prevents unhandled promise rejections and improves user feedback on errors.

Medium
  • Update

…schemas; implement nanoid for unique contact keys; enhance email sending error handling
@Starefossen Starefossen merged commit bf09827 into main Feb 5, 2026
5 checks passed
@Starefossen Starefossen deleted the sponsor-crm-contact branch February 5, 2026 10:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Contract Template System

1 participant