diff --git a/src/backend/src/prisma/dev-seed.ts b/src/backend/src/prisma/dev-seed.ts index 1ba282d4a3..64c3d830e1 100644 --- a/src/backend/src/prisma/dev-seed.ts +++ b/src/backend/src/prisma/dev-seed.ts @@ -22,6 +22,7 @@ import { WorkPackageProcess } from './seed/work-package.process.js'; import { ChangeRequestProcess } from './seed/change-request.process.js'; import { EventProcess } from './seed/event.process.js'; import { ReimbursementRequestProcess } from './seed/reimbursement-request.process.js'; +import { SponsorProcess } from './seed/sponsor.process.js'; const prisma = new PrismaClient(); @@ -43,7 +44,8 @@ await new SeedRunner() new EventProcess(), new TaskProcess(), new ChangeRequestProcess(), - new ReimbursementRequestProcess() + new ReimbursementRequestProcess(), + new SponsorProcess() ) .run(); diff --git a/src/backend/src/prisma/factories/sponsor.factory.ts b/src/backend/src/prisma/factories/sponsor.factory.ts new file mode 100644 index 0000000000..8fe8b3dbb7 --- /dev/null +++ b/src/backend/src/prisma/factories/sponsor.factory.ts @@ -0,0 +1,226 @@ +import { Faker } from '@faker-js/faker'; +import { First_Contact_Method, Prisma, Prospective_Sponsor_Status, Sponsor_Value_Type } from '@prisma/client'; +import { addDaysToDate } from 'shared'; + +export const SPONSOR_COUNT = 25; +export const PROSPECTIVE_SPONSOR_COUNT = 25; + +export const ACTIVE_SPONSOR_CHANCE = 0.85; +export const TAX_EXEMPT_CHANCE = 0.4; +export const SPONSOR_HAS_TASKS_CHANCE = 0.5; +export const PROSPECTIVE_HAS_TASKS_CHANCE = 0.35; +export const TASK_DONE_CHANCE = 0.4; +export const TASK_ASSIGNEE_CHANCE = 0.7; +export const TASK_NOTIFY_CHANCE = 0.5; + +export const ACTIVE_YEAR_DETERIORATION = 0.5; + +export const SPONSOR_TIER_FIXTURES: { name: string; colorHexCode: string; minSupportValue: number }[] = [ + { name: 'Unassigned', colorHexCode: '#950', minSupportValue: 0 }, + { name: 'Discount', colorHexCode: '#013220', minSupportValue: 0 }, + { name: 'Grant', colorHexCode: '#8A00C4', minSupportValue: 0 }, + { name: 'Black Flag', colorHexCode: '#020202', minSupportValue: 1000 }, + { name: 'Bronze Pulse', colorHexCode: '#d7b657', minSupportValue: 2500 }, + { name: 'Silver Circuit', colorHexCode: '#a6a6a6', minSupportValue: 5000 }, + { name: 'Red Line', colorHexCode: '#ff0404', minSupportValue: 500 }, + { name: 'Elite Diamond', colorHexCode: '#313493', minSupportValue: 10000 } +]; + +const CONTRIBUTING_TIER_WEIGHTS: { tierName: string; weight: number }[] = [ + { tierName: 'Black Flag', weight: 35 }, + { tierName: 'Red Line', weight: 20 }, + { tierName: 'Bronze Pulse', weight: 20 }, + { tierName: 'Silver Circuit', weight: 15 }, + { tierName: 'Elite Diamond', weight: 10 } +]; + +const SPECIAL_TIER_NAMES = ['Discount', 'Grant', 'Unassigned'] as const; + +const VALUE_TYPE_COMBINATIONS: { weight: number; value: Sponsor_Value_Type[] }[] = [ + { weight: 57, value: [Sponsor_Value_Type.MONETARY] }, + { weight: 22, value: [Sponsor_Value_Type.STOCK] }, + { weight: 11, value: [Sponsor_Value_Type.DISCOUNT] }, + { weight: 7, value: [Sponsor_Value_Type.MONETARY, Sponsor_Value_Type.STOCK] }, + { weight: 3, value: [Sponsor_Value_Type.MONETARY, Sponsor_Value_Type.DISCOUNT] } +]; + +const PROSPECTIVE_STATUS_WEIGHTS: { weight: number; value: Prospective_Sponsor_Status }[] = [ + { weight: 35, value: Prospective_Sponsor_Status.NOT_IN_CONTACT }, + { weight: 35, value: Prospective_Sponsor_Status.IN_PROGRESS }, + { weight: 22, value: Prospective_Sponsor_Status.DECLINED }, + { weight: 8, value: Prospective_Sponsor_Status.ACCEPTED } +]; + +const FIRST_CONTACT_METHODS: First_Contact_Method[] = [ + First_Contact_Method.OUTBOUND_EMAIL, + First_Contact_Method.INBOUND_EMAIL, + First_Contact_Method.INBOUND_FORM +]; + +const SPONSOR_TASK_NOTE_TEMPLATES = [ + 'Add their logo to the website', + 'Send newsletter + resumes', + 'Social media shoutout', + 'Follow up on benefits fulfillment', + 'Schedule facility tour', + 'Confirm annual sponsorship status', + 'Send thank-you and photos' +]; + +const PROSPECTIVE_TASK_NOTE_TEMPLATES = [ + 'Follow up on initial outreach', + 'Send sponsorship packet', + 'Set up intro meeting', + 'Check back after no response', + 'Draft custom proposal' +]; + +export const chooseSponsorTierName = (faker: Faker): string => { + if (faker.datatype.boolean({ probability: 0.12 })) { + return faker.helpers.arrayElement(SPECIAL_TIER_NAMES); + } + return faker.helpers.weightedArrayElement( + CONTRIBUTING_TIER_WEIGHTS.map(({ tierName, weight }) => ({ weight, value: tierName })) + ); +}; + +export const generateSponsorValue = (faker: Faker, tierMinSupportValue: number): number => { + if (tierMinSupportValue === 0) return faker.number.int({ min: 0, max: 500 }); + return faker.number.int({ min: tierMinSupportValue, max: tierMinSupportValue * 2 }); +}; + +export const generateValueTypes = (faker: Faker): Sponsor_Value_Type[] => + faker.helpers.weightedArrayElement(VALUE_TYPE_COMBINATIONS); + +export const generateActiveYears = (faker: Faker, joinDate: Date, now: Date): number[] => { + const joinYear = joinDate.getFullYear(); + const currentYear = now.getFullYear(); + let activeYear = joinYear; + const years = [joinYear]; + while ( + faker.datatype.boolean({ + probability: 0.5 * (activeYear - joinYear === 0 ? 1 : ACTIVE_YEAR_DETERIORATION ** (activeYear - joinYear)) + }) && + activeYear + 1 <= currentYear + ) { + years.push(++activeYear); + } + return years; +}; + +export const generateProspectiveStatus = (faker: Faker): Prospective_Sponsor_Status => + faker.helpers.weightedArrayElement(PROSPECTIVE_STATUS_WEIGHTS); + +export const chooseFirstContactMethod = (faker: Faker): First_Contact_Method => + faker.helpers.arrayElement(FIRST_CONTACT_METHODS); + +export const generateSponsorTaskNote = (faker: Faker): string => faker.helpers.arrayElement(SPONSOR_TASK_NOTE_TEMPLATES); + +export const generateProspectiveTaskNote = (faker: Faker): string => + faker.helpers.arrayElement(PROSPECTIVE_TASK_NOTE_TEMPLATES); + +export const generateTaskCount = (faker: Faker): number => + faker.helpers.weightedArrayElement([ + { weight: 60, value: 1 }, + { weight: 30, value: 2 }, + { weight: 10, value: 3 } + ]); + +export const sponsorTierCreateInput = ( + organizationId: string, + name: string, + colorHexCode: string, + minSupportValue: number +): Prisma.Sponsor_TierCreateInput => ({ + name, + colorHexCode, + minSupportValue, + organization: { connect: { organizationId } } +}); + +export const sponsorContactCreateInput = ( + name: string, + email: string | undefined, + phone: string | undefined, + position: string | undefined +): Prisma.Sponsor_ContactCreateInput => ({ + name, + email: email ?? null, + phone: phone ?? null, + position: position ?? null +}); + +export const sponsorCreateInput = ( + organizationId: string, + name: string, + contactId: string, + sponsorTierId: string, + activeStatus: boolean, + valueTypes: Sponsor_Value_Type[], + sponsorValue: number, + joinDate: Date, + dateCreated: Date, + activeYears: number[], + taxExempt: boolean, + sponsorNotes: string | undefined +): Prisma.SponsorCreateInput => ({ + name, + activeStatus, + valueTypes, + sponsorValue, + joinDate, + dateCreated, + activeYears, + taxExempt, + sponsorNotes: sponsorNotes ?? null, + organization: { connect: { organizationId } }, + contact: { connect: { sponsorContactId: contactId } }, + tier: { connect: { sponsorTierId } } +}); + +export const prospectiveSponsorCreateInput = ( + organizationId: string, + organizationName: string, + status: Prospective_Sponsor_Status, + dateCreated: Date, + lastContactDate: Date | undefined, + firstContactMethod: First_Contact_Method | undefined, + contactorUserId: string | undefined, + contactId: string | undefined, + notes: string | undefined, + dateDeleted: Date | undefined +): Prisma.Prospective_SponsorCreateInput => ({ + organizationName, + status, + dateCreated, + lastContactDate: lastContactDate ?? null, + firstContactMethod: firstContactMethod ?? null, + notes: notes ?? null, + dateDeleted: dateDeleted ?? null, + organization: { connect: { organizationId } }, + ...(contactorUserId ? { contactor: { connect: { userId: contactorUserId } } } : {}), + ...(contactId ? { contact: { connect: { sponsorContactId: contactId } } } : {}) +}); + +export type SponsorTaskParent = { sponsorId: string } | { prospectiveSponsorId: string }; + +export const sponsorTaskCreateInput = ( + parent: SponsorTaskParent, + dueDate: Date, + notifyDate: Date | undefined, + notes: string, + done: boolean, + assigneeUserId: string | undefined +): Prisma.Sponsor_TaskCreateInput => ({ + dueDate, + notifyDate: notifyDate ?? null, + notes, + done, + ...(assigneeUserId ? { assignee: { connect: { userId: assigneeUserId } } } : {}), + ...('sponsorId' in parent + ? { sponsor: { connect: { sponsorId: parent.sponsorId } } } + : { prospectiveSponsor: { connect: { prospectiveSponsorId: parent.prospectiveSponsorId } } }) +}); + +export const notifyDateBefore = (faker: Faker, dueDate: Date): Date => + addDaysToDate(dueDate, -faker.number.int({ min: 1, max: 7 })); diff --git a/src/backend/src/prisma/seed/sponsor.process.ts b/src/backend/src/prisma/seed/sponsor.process.ts new file mode 100644 index 0000000000..1004c27a28 --- /dev/null +++ b/src/backend/src/prisma/seed/sponsor.process.ts @@ -0,0 +1,462 @@ +import { + First_Contact_Method, + Prospective_Sponsor, + Prospective_Sponsor_Status, + Sponsor, + Sponsor_Tier, + Sponsor_Value_Type +} from '@prisma/client'; +import { SeedProcess } from '../processes/seed-process.js'; +import { OrganizationOutput, OrganizationProcess } from './organization.process.js'; +import { UsersOutput, UsersProcess } from './user.process.js'; +import { FullUser } from '../context.js'; +import { addDaysToDate } from 'shared'; +import { + ACTIVE_SPONSOR_CHANCE, + chooseFirstContactMethod, + chooseSponsorTierName, + generateActiveYears, + generateProspectiveStatus, + generateProspectiveTaskNote, + generateSponsorTaskNote, + generateSponsorValue, + generateTaskCount, + generateValueTypes, + notifyDateBefore, + PROSPECTIVE_HAS_TASKS_CHANCE, + PROSPECTIVE_SPONSOR_COUNT, + prospectiveSponsorCreateInput, + sponsorContactCreateInput, + sponsorCreateInput, + SPONSOR_COUNT, + SPONSOR_HAS_TASKS_CHANCE, + SPONSOR_TIER_FIXTURES, + sponsorTaskCreateInput, + sponsorTierCreateInput, + TASK_ASSIGNEE_CHANCE, + TASK_DONE_CHANCE, + TASK_NOTIFY_CHANCE, + TAX_EXEMPT_CHANCE +} from '../factories/sponsor.factory.js'; + +type SponsorInput = OrganizationOutput & UsersOutput; + +export type SponsorOutput = { + sponsorTiers: Sponsor_Tier[]; + sponsors: Sponsor[]; + prospectiveSponsors: Prospective_Sponsor[]; +}; + +const SPONSOR_CONCURRENCY = 8; +type PlannedTask = { + dueDate: Date; + notifyDate: Date | undefined; + notes: string; + done: boolean; + assigneeUserId: string | undefined; +}; +type PlannedSponsor = { + name: string; + contact: { name: string; email?: string; phone?: string; position?: string }; + sponsorTierName: string; + activeStatus: boolean; + valueTypes: Sponsor_Value_Type[]; + sponsorValue: number; + joinDate: Date; + dateCreated: Date; + activeYears: number[]; + taxExempt: boolean; + sponsorNotes: string | undefined; + tasks: PlannedTask[]; +}; +type PlannedConversion = { + sponsorTierName: string; + activeStatus: boolean; + valueTypes: Sponsor_Value_Type[]; + sponsorValue: number; + joinDate: Date; + activeYears: number[]; + taxExempt: boolean; + sponsorNotes: string | undefined; + tasks: PlannedTask[]; +}; + +type PlannedProspective = { + organizationName: string; + status: Prospective_Sponsor_Status; + dateCreated: Date; + lastContactDate: Date | undefined; + firstContactMethod: First_Contact_Method | undefined; + contactorUserId: string | undefined; + contact: { name: string; email?: string; phone?: string; position?: string } | undefined; + notes: string | undefined; + tasks: PlannedTask[]; + dateDeleted: Date | undefined; + conversion: PlannedConversion | undefined; +}; + +export class SponsorProcess extends SeedProcess { + dependencies() { + return [OrganizationProcess, UsersProcess]; + } + + async run({ organization, members, leadership, heads, admins }: SponsorInput): Promise { + const { organizationId } = organization; + const now = new Date(); + + const assignableUsers: FullUser[] = [...members, ...leadership, ...heads, ...admins]; + if (assignableUsers.length === 0) throw new Error('SponsorProcess requires at least one user for task assignment.'); + + const sponsorTiers = await Promise.all( + SPONSOR_TIER_FIXTURES.map((tier) => + this.prisma.sponsor_Tier.create({ + data: sponsorTierCreateInput(organizationId, tier.name, tier.colorHexCode, tier.minSupportValue) + }) + ) + ); + const tierIdByName = new Map(sponsorTiers.map((tier) => [tier.name, tier])); + const minSupportByName = new Map(SPONSOR_TIER_FIXTURES.map((tier) => [tier.name, tier.minSupportValue])); + + const plannedSponsors: PlannedSponsor[] = []; + const usedSponsorNames = new Set(); + + for (let i = 0; i < SPONSOR_COUNT; i++) { + let name = this.faker.company.name(); + while (usedSponsorNames.has(name)) name = `${this.faker.company.name()} ${this.faker.string.alpha(2)}`; + usedSponsorNames.add(name); + + const contactName = this.faker.person.fullName(); + const contact = { + name: contactName, + email: this.faker.internet.email({ firstName: contactName.split(' ')[0] }).toLowerCase(), + phone: this.faker.datatype.boolean({ probability: 0.5 }) ? this.faker.phone.number() : undefined, + position: this.faker.datatype.boolean({ probability: 0.5 }) ? this.faker.person.jobTitle() : undefined + }; + + const sponsorTierName = chooseSponsorTierName(this.faker); + const tierMin = minSupportByName.get(sponsorTierName) ?? 0; + const sponsorValue = generateSponsorValue(this.faker, tierMin); + const valueTypes = generateValueTypes(this.faker); + + const joinDate = this.faker.date.between({ from: addDaysToDate(now, -540), to: now }); + const dateCreated = this.faker.date.between({ from: joinDate, to: now }); + const activeYears = generateActiveYears(this.faker, joinDate, now); + + const activeStatus = this.faker.datatype.boolean({ probability: ACTIVE_SPONSOR_CHANCE }); + const taxExempt = this.faker.datatype.boolean({ probability: TAX_EXEMPT_CHANCE }); + const sponsorNotes = this.faker.datatype.boolean({ probability: 0.4 }) ? this.faker.lorem.sentence() : undefined; + + const tasks = this.planTasks( + this.faker.datatype.boolean({ probability: SPONSOR_HAS_TASKS_CHANCE }), + dateCreated, + now, + assignableUsers, + () => generateSponsorTaskNote(this.faker) + ); + + plannedSponsors.push({ + name, + contact, + sponsorTierName, + activeStatus, + valueTypes, + sponsorValue, + joinDate, + dateCreated, + activeYears, + taxExempt, + sponsorNotes, + tasks + }); + } + + const plannedProspectives: PlannedProspective[] = []; + const usedProspectiveNames = new Set(); + + for (let i = 0; i < PROSPECTIVE_SPONSOR_COUNT; i++) { + let organizationName = this.faker.company.name(); + while (usedProspectiveNames.has(organizationName)) + organizationName = `${this.faker.company.name()} ${this.faker.string.alpha(2)}`; + usedProspectiveNames.add(organizationName); + + const status = generateProspectiveStatus(this.faker); + const dateCreated = this.faker.date.between({ from: addDaysToDate(now, -365), to: now }); + + // NOT_IN_CONTACT prospects have no contact method / contactor / last-contact yet. + const isContacted = status !== Prospective_Sponsor_Status.NOT_IN_CONTACT; + + const lastContactDate = isContacted ? this.faker.date.between({ from: dateCreated, to: now }) : undefined; + const firstContactMethod = isContacted ? chooseFirstContactMethod(this.faker) : undefined; + const contactorUserId = isContacted ? this.faker.helpers.arrayElement(assignableUsers).userId : undefined; + + const contact = isContacted + ? (() => { + const cName = this.faker.person.fullName(); + return { + name: cName, + email: this.faker.internet.email({ firstName: cName.split(' ')[0] }).toLowerCase(), + phone: this.faker.datatype.boolean({ probability: 0.3 }) ? this.faker.phone.number() : undefined, + position: this.faker.datatype.boolean({ probability: 0.3 }) ? this.faker.person.jobTitle() : undefined + }; + })() + : undefined; + + const notes = this.faker.datatype.boolean({ probability: 0.4 }) ? this.faker.lorem.sentence() : undefined; + + const tasks = this.planTasks( + this.faker.datatype.boolean({ probability: PROSPECTIVE_HAS_TASKS_CHANCE }), + dateCreated, + now, + assignableUsers, + () => generateProspectiveTaskNote(this.faker) + ); + + // An ACCEPTED prospective converts into a real Sponsor: the prospective is soft-deleted + // (dateDeleted = acceptance date) and a Sponsor is created with a new contact. + let dateDeleted: Date | undefined; + let conversion: PlannedConversion | undefined; + if (status === Prospective_Sponsor_Status.ACCEPTED) { + // Given that there is contact, we will put that as our from date + const acceptedFrom = lastContactDate ?? dateCreated; + dateDeleted = this.faker.date.between({ from: acceptedFrom, to: now }); + + const sponsorTierName = chooseSponsorTierName(this.faker); + const tierMin = minSupportByName.get(sponsorTierName) ?? 0; + conversion = { + sponsorTierName, + activeStatus: this.faker.datatype.boolean({ probability: ACTIVE_SPONSOR_CHANCE }), + valueTypes: generateValueTypes(this.faker), + sponsorValue: generateSponsorValue(this.faker, tierMin), + joinDate: dateDeleted, + activeYears: generateActiveYears(this.faker, dateDeleted, now), + taxExempt: this.faker.datatype.boolean({ probability: TAX_EXEMPT_CHANCE }), + sponsorNotes: this.faker.datatype.boolean({ probability: 0.4 }) ? this.faker.lorem.sentence() : undefined, + tasks: this.planTasks( + this.faker.datatype.boolean({ probability: SPONSOR_HAS_TASKS_CHANCE }), + dateDeleted, + now, + assignableUsers, + () => generateSponsorTaskNote(this.faker) + ) + }; + } + + plannedProspectives.push({ + organizationName, + status, + dateCreated, + lastContactDate, + firstContactMethod, + contactorUserId, + contact, + notes, + tasks, + dateDeleted, + conversion + }); + } + + const sponsors: Sponsor[] = []; + for (let i = 0; i < plannedSponsors.length; i += SPONSOR_CONCURRENCY) { + const batch = plannedSponsors.slice(i, i + SPONSOR_CONCURRENCY); + const created = await Promise.all(batch.map((planned) => this.writeSponsor(planned, organizationId, tierIdByName))); + sponsors.push(...created); + } + + const prospectiveSponsors: Prospective_Sponsor[] = []; + for (let i = 0; i < plannedProspectives.length; i += SPONSOR_CONCURRENCY) { + const batch = plannedProspectives.slice(i, i + SPONSOR_CONCURRENCY); + const results = await Promise.all( + batch.map((planned) => this.writeProspective(planned, organizationId, tierIdByName)) + ); + for (const { prospective, convertedSponsor } of results) { + prospectiveSponsors.push(prospective); + if (convertedSponsor) sponsors.push(convertedSponsor); + } + } + + return { sponsorTiers, sponsors, prospectiveSponsors }; + } + + private planTasks( + hasTasks: boolean, + createdAfter: Date, + now: Date, + assignableUsers: FullUser[], + noteFor: () => string + ): PlannedTask[] { + if (!hasTasks) return []; + + const count = generateTaskCount(this.faker); + const tasks: PlannedTask[] = []; + for (let i = 0; i < count; i++) { + const dueDate = this.faker.date.between({ + from: createdAfter, + to: addDaysToDate(now, 30) + }); + const notifyDate = this.faker.datatype.boolean({ probability: TASK_NOTIFY_CHANCE }) + ? notifyDateBefore(this.faker, dueDate) + : undefined; + const done = this.faker.datatype.boolean({ probability: TASK_DONE_CHANCE }); + const assigneeUserId = this.faker.datatype.boolean({ probability: TASK_ASSIGNEE_CHANCE }) + ? this.faker.helpers.arrayElement(assignableUsers).userId + : undefined; + + tasks.push({ dueDate, notifyDate, notes: noteFor(), done, assigneeUserId }); + } + return tasks; + } + + private async writeSponsor( + planned: PlannedSponsor, + organizationId: string, + tierIdByName: Map + ): Promise { + const tier = tierIdByName.get(planned.sponsorTierName); + if (!tier) throw new Error(`Missing seeded tier ${planned.sponsorTierName}`); + + const contact = await this.prisma.sponsor_Contact.create({ + data: sponsorContactCreateInput( + planned.contact.name, + planned.contact.email, + planned.contact.phone, + planned.contact.position + ) + }); + + const sponsor = await this.prisma.sponsor.create({ + data: sponsorCreateInput( + organizationId, + planned.name, + contact.sponsorContactId, + tier.sponsorTierId, + planned.activeStatus, + planned.valueTypes, + planned.sponsorValue, + planned.joinDate, + planned.dateCreated, + planned.activeYears, + planned.taxExempt, + planned.sponsorNotes + ) + }); + + await Promise.all( + planned.tasks.map((task) => + this.prisma.sponsor_Task.create({ + data: sponsorTaskCreateInput( + { sponsorId: sponsor.sponsorId }, + task.dueDate, + task.notifyDate, + task.notes, + task.done, + task.assigneeUserId + ) + }) + ) + ); + + return sponsor; + } + + private async writeProspective( + planned: PlannedProspective, + organizationId: string, + tierIdByName: Map + ): Promise<{ prospective: Prospective_Sponsor; convertedSponsor?: Sponsor }> { + let contactId: string | undefined; + if (planned.contact) { + const contact = await this.prisma.sponsor_Contact.create({ + data: sponsorContactCreateInput( + planned.contact.name, + planned.contact.email, + planned.contact.phone, + planned.contact.position + ) + }); + contactId = contact.sponsorContactId; + } + + const prospective = await this.prisma.prospective_Sponsor.create({ + data: prospectiveSponsorCreateInput( + organizationId, + planned.organizationName, + planned.status, + planned.dateCreated, + planned.lastContactDate, + planned.firstContactMethod, + planned.contactorUserId, + contactId, + planned.notes, + planned.dateDeleted + ) + }); + + await Promise.all( + planned.tasks.map((task) => + this.prisma.sponsor_Task.create({ + data: sponsorTaskCreateInput( + { prospectiveSponsorId: prospective.prospectiveSponsorId }, + task.dueDate, + task.notifyDate, + task.notes, + task.done, + task.assigneeUserId + ) + }) + ) + ); + + let convertedSponsor: Sponsor | undefined; + if (planned.conversion) { + if (!planned.contact) throw new Error(`Converted prospective ${planned.organizationName} has no contact to copy.`); + + const conv = planned.conversion; + const tier = tierIdByName.get(conv.sponsorTierName); + if (!tier) throw new Error(`Missing seeded tier ${conv.sponsorTierName}`); + + const sponsorContact = await this.prisma.sponsor_Contact.create({ + data: sponsorContactCreateInput( + planned.contact.name, + planned.contact.email, + planned.contact.phone, + planned.contact.position + ) + }); + + convertedSponsor = await this.prisma.sponsor.create({ + data: sponsorCreateInput( + organizationId, + planned.organizationName, + sponsorContact.sponsorContactId, + tier.sponsorTierId, + conv.activeStatus, + conv.valueTypes, + conv.sponsorValue, + conv.joinDate, + conv.joinDate, + conv.activeYears, + conv.taxExempt, + conv.sponsorNotes + ) + }); + + await Promise.all( + conv.tasks.map((task) => + this.prisma.sponsor_Task.create({ + data: sponsorTaskCreateInput( + { sponsorId: convertedSponsor!.sponsorId }, + task.dueDate, + task.notifyDate, + task.notes, + task.done, + task.assigneeUserId + ) + }) + ) + ); + } + + return { prospective, convertedSponsor }; + } +}