Skip to content

Refactor sponsor management to remove contact info handling#315

Merged
Starefossen merged 7 commits intomainfrom
sponsor-contacts-billing-migration
Feb 6, 2026
Merged

Refactor sponsor management to remove contact info handling#315
Starefossen merged 7 commits intomainfrom
sponsor-contacts-billing-migration

Conversation

@Starefossen
Copy link
Member

@Starefossen Starefossen commented Feb 6, 2026

User description

  • Updated SponsorContactTable to use SponsorForConferenceExpanded type instead of SponsorWithContactInfo.
  • Removed unnecessary contact info fetching in SponsorIndividualEmailModal.
  • Simplified SponsorTierManagement and SponsorTiersPageClient to use ConferenceSponsor type.
  • Removed sponsor contact info from various schemas and validation logic.
  • Updated sponsor-related sanity functions to align with new data structure.
  • Removed audience management functions related to sponsor contacts.
  • Cleaned up unused imports and types across the codebase.

PR Type

Enhancement


Description

  • Migrated contact persons and billing from sponsor to sponsorForConference

  • Added contact_persons and billing fields to sponsorForConference schema

  • Updated email routes to fetch contacts from CRM records instead of sponsor

  • Removed contact info handling from sponsor management functions

  • Added is_primary flag to identify primary contact for each sponsorship


Diagram Walkthrough

flowchart LR
  A["sponsor document<br/>name, logo, website"] -->|"removed fields"| B["contact_persons<br/>billing"]
  C["sponsorForConference<br/>CRM record"] -->|"added fields"| B
  D["Email routes<br/>Contact sync"] -->|"fetch from"| C
  E["Sponsor management<br/>UI components"] -->|"simplified"| A
  F["CRM forms<br/>Contact editor"] -->|"work with"| C
Loading

File Walkthrough

Relevant files
Tests
1 files
sponsorForConference.test.ts
Added comprehensive tests for contact and billing schemas
+316/-0 
Enhancement
21 files
sanity.ts
Removed contact/billing from sponsor CRUD operations         
+22/-114
sponsor.ts
Simplified sponsor queries and removed audience sync         
+40/-94 
route.ts
Fetch contacts from sponsorForConference instead of sponsor
+16/-22 
route.ts
Updated to use CRM sponsors with contact persons                 
+26/-15 
sanity.ts
Added contact_persons and billing to CRM operations           
+30/-2   
route.ts
Fetch contacts from sponsorForConference CRM record           
+18/-20 
sanity.ts
Removed sponsorContact parameter from conference queries 
+1/-23   
sponsorForConference.ts
Added contact_persons and billing to input schemas             
+17/-0   
types.ts
Removed ConferenceSponsorWithContact type definition         
+1/-31   
types.ts
Added contact_persons and billing to CRM types                     
+8/-0     
types.ts
Removed ConferenceSponsorWithContact from conference type
+2/-6     
route-helpers.ts
Removed sponsorContact option from email route helpers     
+1/-2     
SponsorAddModal.tsx
Removed contact/billing handling from sponsor modal           
+18/-354
SponsorContactEditor.tsx
Updated to work with sponsorForConference CRM records       
+59/-36 
SponsorContactTable.tsx
Updated to display contacts from sponsorForConference       
+31/-35 
SponsorTierManagement.tsx
Removed contact/billing info validation from tier management
+7/-62   
page.tsx
Updated to fetch sponsors from CRM instead of conference 
+22/-32 
SponsorCRMForm.tsx
Updated contact editor to use sponsorForConference data   
+23/-22 
SponsorIndividualEmailModal.tsx
Removed sponsor fetch, use sponsorForConference contacts 
+1/-22   
SponsorTiersPageClient.tsx
Simplified sponsor filtering logic for broadcasts               
+5/-10   
page.tsx
Removed sponsorContact parameter from conference query     
+2/-3     
Configuration changes
3 files
index.ts
Migration to copy contacts and billing to sponsorForConference
+124/-0 
sponsorForConference.ts
Added contact_persons and billing field definitions           
+103/-0 
index.ts
Migration to remove deprecated fields from sponsor             
+44/-0   
Documentation
2 files
SPONSOR_SYSTEM.md
Updated documentation to reflect new contact/billing location
+14/-15 
TRPC_SERVER_ARCHITECTURE.md
Updated example to remove includeContactInfo parameter     
+1/-7     
Additional files
7 files
README.md +0/-1     
SponsorManagementExample.tsx +0/-1     
sponsor.ts +0/-95   
page.tsx +0/-1     
audience.ts +0/-144 
validation.ts +0/-42   
sponsor.ts +0/-7     

- Updated SponsorContactTable to use SponsorForConferenceExpanded type instead of SponsorWithContactInfo.
- Removed unnecessary contact info fetching in SponsorIndividualEmailModal.
- Simplified SponsorTierManagement and SponsorTiersPageClient to use ConferenceSponsor type.
- Removed sponsor contact info from various schemas and validation logic.
- Updated sponsor-related sanity functions to align with new data structure.
- Removed audience management functions related to sponsor contacts.
- Cleaned up unused imports and types across the codebase.
@Starefossen Starefossen requested a review from a team as a code owner February 6, 2026 19:30
@vercel
Copy link

vercel bot commented Feb 6, 2026

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

Project Deployment Actions Updated (UTC)
cloudnativedays Ready Ready Preview, Comment Feb 6, 2026 9:35pm

Request Review

@qodo-code-review
Copy link

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🧪 PR contains tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Data Access

Sponsor reads are performed via the write client, which can have different caching/permissions semantics than the intended read client and may introduce unexpected behavior in production.

export async function getSponsor(id: string): Promise<{
  sponsor?: SponsorExisting
  error?: Error
}> {
  try {
    const sponsor = await clientWrite.fetch(
      `*[_type == "sponsor" && _id == $id][0]{
        _id,
        _createdAt,
        _updatedAt,
        name,
        website,
        logo,
        logo_bright
      }`,
      { id },
    )

    if (!sponsor) {
      return { error: new Error('Sponsor not found') }
    }

    return { sponsor }
  } catch (error) {
    return { error: error as Error }
  }
}

export async function searchSponsors(query: string): Promise<{
  sponsors?: SponsorExisting[]
  error?: Error
}> {
  try {
    const sponsors = await clientWrite.fetch(
      `*[_type == "sponsor" && name match $searchQuery]{
        _id,
        _createdAt,
        _updatedAt,
        name,
        website,
        logo,
        logo_bright
      }`,
      { searchQuery: `${query}*` },
    )

    return { sponsors }
  } catch (error) {
    return { error: error as Error }
  }
}

export async function getAllSponsors(): Promise<{
  sponsors?: SponsorExisting[]
  error?: Error
}> {
  try {
    const sponsors = await clientWrite.fetch(
      `*[_type == "sponsor"] | order(name asc){
        _id,
        _createdAt,
        _updatedAt,
        name,
        website,
        logo,
        logo_bright
      }`,
    )
Primary Contact

The UI allows toggling a primary contact, but removing contacts or saving may result in multiple primaries or no primary; consider enforcing a single primary (or auto-selecting one) before persisting.

})

const handleAddContact = () => {
  const isFirst = contacts.length === 0
  setContacts([
    ...contacts,
    {
      _key: nanoid(),
      name: '',
      email: '',
      phone: '',
      role: '',
      is_primary: isFirst,
    },
  ])
}

const handleUpdateContact = (
  index: number,
  updates: Partial<ContactPerson>,
) => {
  const newContacts = [...contacts]
  newContacts[index] = { ...newContacts[index], ...updates }
  setContacts(newContacts)
}

const handleSetPrimary = (index: number) => {
  setContacts(
    contacts.map((c, i) => ({
      ...c,
      is_primary: i === index,
    })),
  )
}

const handleRemoveContact = (index: number) => {
  setContacts(contacts.filter((_, i) => i !== index))
}

const handleSave = async () => {
  const invalidContacts = contacts.filter((c) => !c.name || !c.email)
  if (invalidContacts.length > 0) {
    showNotification({
      type: 'warning',
      title: 'Incomplete contacts',
      message: 'Please provide at least a name and email for all contacts.',
    })
    return
  }

  if (billing.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(billing.email)) {
    showNotification({
      type: 'warning',
      title: 'Invalid billing email',
      message: 'Please provide a valid billing email address.',
    })
    return
  }

  await updateCRMMutation.mutateAsync({
    id: sponsorForConference._id,
    contact_persons: contacts.map((c) => ({
      ...c,
      phone: c.phone || undefined,
      role: c.role || undefined,
      is_primary: c.is_primary ?? false,
    })),
    billing: billing.email
      ? {
          email: billing.email.trim(),
          reference: billing.reference?.trim() || undefined,
          comments: billing.comments?.trim() || undefined,
        }
      : undefined,
  })
}
Migration Safety

The migration description says it uses setIfMissing to avoid overwriting, but the mutations use set; combined with the “already populated” checks, this may still overwrite certain partially-populated states (e.g., empty arrays or incomplete objects) and should be validated against real data.

export default defineMigration({
  title:
    'Move contact_persons and billing from sponsor to sponsorForConference',
  description:
    'Copies contact_persons[] and billing from each referenced sponsor document into the sponsorForConference document. Adds is_primary=true to the first contact. Uses setIfMissing to avoid overwriting data that has already been set directly on the CRM record.',
  documentTypes: ['sponsorForConference'],

  migrate: {
    async document(doc, context) {
      const sfcDoc = doc as unknown as {
        _id: string
        sponsor?: { _ref: string }
        contact_persons?: unknown[]
        billing?: unknown
      }

      if (!sfcDoc.sponsor?._ref) {
        return []
      }

      const hasContacts =
        Array.isArray(sfcDoc.contact_persons) &&
        sfcDoc.contact_persons.length > 0
      const hasBilling =
        sfcDoc.billing != null &&
        typeof sfcDoc.billing === 'object' &&
        'email' in (sfcDoc.billing as Record<string, unknown>)

      // Skip if both fields are already populated
      if (hasContacts && hasBilling) {
        return []
      }

      const sponsor = await context.client.fetch<Sponsor>(
        `*[_type == "sponsor" && _id == $id][0]{
          _id,
          name,
          contact_persons[]{
            _key,
            name,
            email,
            phone,
            role
          },
          billing{
            email,
            reference,
            comments
          }
        }`,
        { id: sfcDoc.sponsor._ref },
      )

      if (!sponsor) {
        console.log(`  ⚠ Sponsor ${sfcDoc.sponsor._ref} not found, skipping`)
        return []
      }

      const setIfMissingFields: Record<string, unknown> = {}

      // Copy contact_persons with is_primary on first contact
      if (
        !hasContacts &&
        sponsor.contact_persons &&
        sponsor.contact_persons.length > 0
      ) {
        setIfMissingFields.contact_persons = sponsor.contact_persons.map(
          (contact, index) => ({
            ...contact,
            is_primary: index === 0,
          }),
        )
      }

      // Copy billing
      if (!hasBilling && sponsor.billing) {
        setIfMissingFields.billing = sponsor.billing
      }

      if (Object.keys(setIfMissingFields).length === 0) {
        return []
      }

      const contactCount =
        (setIfMissingFields.contact_persons as unknown[])?.length ?? 0
      console.log(
        `  ✓ Copying ${contactCount} contacts and ${setIfMissingFields.billing ? 'billing' : 'no billing'} for ${sponsor.name || sponsor._id}`,
      )

      const mutations = []

      if (setIfMissingFields.contact_persons) {
        mutations.push(
          at('contact_persons', set(setIfMissingFields.contact_persons)),
        )
      }

      if (setIfMissingFields.billing) {
        mutations.push(at('billing', set(setIfMissingFields.billing)))
      }

      return mutations

@qodo-code-review
Copy link

qodo-code-review bot commented Feb 6, 2026

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Ensure a primary contact exists

Modify handleRemoveContact to automatically assign a new primary contact if the
one being removed was the primary, ensuring a primary contact always exists if
there are any contacts.

src/components/admin/sponsor/SponsorContactEditor.tsx [66-79]

 const handleAddContact = () => {
   const isFirst = contacts.length === 0
   setContacts([
     ...contacts,
     {
       _key: nanoid(),
       name: '',
       email: '',
       phone: '',
       role: '',
       is_primary: isFirst,
     },
   ])
 }
 
+const handleUpdateContact = (
+  index: number,
+  updates: Partial<ContactPerson>,
+) => {
+  const newContacts = [...contacts]
+  newContacts[index] = { ...newContacts[index], ...updates }
+  setContacts(newContacts)
+}
+
+const handleSetPrimary = (index: number) => {
+  setContacts(
+    contacts.map((c, i) => ({
+      ...c,
+      is_primary: i === index,
+    })),
+  )
+}
+
+const handleRemoveContact = (index: number) => {
+  const removedContact = contacts[index]
+  const newContacts = contacts.filter((_, i) => i !== index)
+
+  if (removedContact.is_primary && newContacts.length > 0) {
+    newContacts[0].is_primary = true
+  }
+
+  setContacts(newContacts)
+}
+

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies a logic flaw where removing a primary contact can leave the record without one. The proposed fix in handleRemoveContact ensures a new primary is designated, improving data integrity and application robustness.

Medium
General
Check missing CRM sponsor record
Suggestion Impact:Implemented a guard clause that returns createEmailErrorResponse with a 404 when sfc is missing, and then accesses contacts via sfc.contact_persons.

code diff:

-    const contacts = sfc?.contact_persons || []
+    if (!sfc) {
+      return createEmailErrorResponse(
+        'Sponsor not found in this conference',
+        404,
+      )
+    }
+
+    const contacts = sfc.contact_persons || []
     const recipients = contacts

Add a check after fetching the sfc record to ensure it exists, and return a 404
error if it's not found, before attempting to access its contacts.

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

 const sfc = await clientRead.fetch<{
   _id: string
   status: string
   contact_persons?: Array<{ name: string; email: string }>
 }>(
   `*[_type == "sponsorForConference" && sponsor._ref == $sponsorId && conference._ref == $conferenceId][0]{
     _id,
     status,
     contact_persons[]{ name, email }
   }`,
   { sponsorId, conferenceId: conference._id },
 )
-const contacts = sfc?.contact_persons || []
+if (!sfc) {
+  return createEmailErrorResponse(
+    'Sponsor not found in this conference',
+    404,
+  )
+}
+const contacts = sfc.contact_persons || []
 const recipients = contacts
   .filter((c) => c.email)
   .map((c) => ({ email: c.email, name: c.name }))

[Suggestion processed]

Suggestion importance[1-10]: 7

__

Why: The suggestion correctly points out that the code doesn't handle the case where the sponsorForConference record (sfc) is not found. Adding a check for !sfc and returning a 404 error improves error handling and provides a clearer response.

Medium
Enforce single primary contact
Suggestion Impact:The commit added .refine() checks to the contact_persons array schema (in two schema instances) to enforce that no more than one contact has is_primary set to true, with a custom error message.

code diff:

@@ -70,7 +70,10 @@
         is_primary: z.boolean().optional(),
       }),
     )
-    .optional(),
+    .optional()
+    .refine((arr) => !arr || arr.filter((c) => c.is_primary).length <= 1, {
+      message: 'Only one contact can be marked as primary',
+    }),
   billing: BillingInfoSchema.optional(),
 })
 
@@ -106,7 +109,10 @@
         is_primary: z.boolean().optional(),
       }),
     )
-    .optional(),
+    .optional()
+    .refine((arr) => !arr || arr.filter((c) => c.is_primary).length <= 1, {
+      message: 'Only one contact can be marked as primary',
+    }),

Add a .refine() check to the contact_persons Zod schema to validate that at most
one contact person is marked as primary.

src/server/schemas/sponsorForConference.ts [67-73]

 contact_persons: z
   .array(
     ContactPersonSchema.extend({
       is_primary: z.boolean().optional(),
     }),
   )
-  .optional(),
+  .optional()
+  .refine(
+    (arr) => !arr || arr.filter((c) => c.is_primary).length <= 1,
+    { message: 'Only one primary contact allowed' }
+  ),

[Suggestion processed]

Suggestion importance[1-10]: 6

__

Why: This suggestion correctly proposes adding a backend validation using Zod's refine to enforce that only one contact can be primary. This is a good practice for data integrity, making the API more robust against invalid inputs.

Low
  • Update

- Introduced a new document type `sponsorEmailTemplate` in Sanity CMS to manage reusable email templates for sponsor outreach.
- Updated the data model to reflect the addition of the `sponsorEmailTemplate` document type.
- Created migration script to seed initial email templates for various outreach scenarios.
- Implemented CRUD operations for email templates in the sponsor router.
- Added a template picker component in the admin UI for selecting email templates when composing messages.
- Enhanced the email modal to support template selection and dynamic variable processing in email subjects and bodies.
- Updated relevant types and utility functions to handle email template processing and variable substitution.
@Starefossen Starefossen linked an issue Feb 6, 2026 that may be closed by this pull request
3 tasks
@Starefossen Starefossen merged commit e48d958 into main Feb 6, 2026
5 checks passed
@Starefossen Starefossen deleted the sponsor-contacts-billing-migration branch February 6, 2026 21:35
@Starefossen Starefossen linked an issue Feb 7, 2026 that may be closed by this pull request
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Migrate billing and contact_persons from sponsor to sponsorForConference Sponsor Email Templates

1 participant