Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 53 additions & 29 deletions src/lib/sf-field-mappings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,51 @@
* for each form type. It also includes hardwired field values that are set for each form.
*/

import type { RFPData } from '../components/forms/schemas/RFP';
import type { DirectGrantData } from '../components/forms/schemas/DirectGrant';
import type { WishlistData } from '../components/forms/schemas/Wishlist';
import type { OfficeHoursData } from '../components/forms/schemas/OfficeHours';

export type FormType = 'rfp' | 'directGrant' | 'wishlist' | 'officeHours';

/**
* System fields that should not be mapped to Salesforce
* - captchaToken: Used for bot protection, not stored in SF
* - fileUpload: Handled as separate ContentVersion upload, not a regular SF field
*/
type SystemFields = 'captchaToken' | 'fileUpload';

/**
* Extract mappable fields from a schema type
* Excludes system fields that shouldn't be mapped to Salesforce
*/
type MappableFields<T> = Exclude<keyof T, SystemFields>;

/**
* Extract all possible keys from a discriminated union type
* Used for Office Hours form which has conditional fields
*/
type UnionKeys<T> = T extends any ? keyof T : never;

/**
* Create a record type with all possible keys from a discriminated union
* Used for Office Hours form mapping validation
*/
type AllUnionFields<T> = Record<UnionKeys<T>, any>;

/**
* Type-safe field mapping helper
* Ensures the mapping object contains all required fields from the schema
*
* @param mapping - Object mapping form field names to Salesforce field names
* @returns The same mapping object with type safety enforced
*/
function createTypedMapping<T extends Record<string, any>>(
mapping: Record<MappableFields<T>, string>
): Record<MappableFields<T>, string> {
return mapping;
}

/**
* Field mapping configuration
* Maps form field names to Salesforce field names
Expand All @@ -28,7 +71,7 @@ export interface HardwiredFields {
/**
* Field mappings for RFP form
*/
const rfpMapping: FieldMapping = {
const rfpMapping = createTypedMapping<RFPData>({
// Contact Information
firstName: 'Application_FirstName__c',
lastName: 'Application_LastName__c',
Expand Down Expand Up @@ -58,12 +101,12 @@ const rfpMapping: FieldMapping = {

// RFP-specific
selectedRFPId: 'Grant_Initiative__c'
};
});

/**
* Field mappings for Direct Grant form
*/
const directGrantMapping: FieldMapping = {
const directGrantMapping = createTypedMapping<DirectGrantData>({
// Contact Information
firstName: 'Application_FirstName__c',
lastName: 'Application_LastName__c',
Expand Down Expand Up @@ -102,12 +145,12 @@ const directGrantMapping: FieldMapping = {
referral: 'Application_Referral__c',
additionalInfo: 'Application_AdditionalInformation__c',
opportunityOutreachConsent: 'Application_OutreachConsent__c'
};
});

/**
* Field mappings for Wishlist form
*/
const wishlistMapping: FieldMapping = {
const wishlistMapping = createTypedMapping<WishlistData>({
// Contact Information
firstName: 'Application_FirstName__c',
lastName: 'Application_LastName__c',
Expand Down Expand Up @@ -149,12 +192,13 @@ const wishlistMapping: FieldMapping = {

// Wishlist-specific
selectedWishlistId: 'Grant_Initiative__c'
};
});

/**
* Field mappings for Office Hours form
* Note: OfficeHoursData is a discriminated union, so we map all fields from both variants
*/
const officeHoursMapping: FieldMapping = {
const officeHoursMapping = createTypedMapping<AllUnionFields<OfficeHoursData>>({
// Contact Information
firstName: 'Application_FirstName__c',
lastName: 'Application_LastName__c',
Expand All @@ -170,7 +214,7 @@ const officeHoursMapping: FieldMapping = {
officeHoursRequest: 'Application_OfficeHours_RequestType__c',
officeHoursReason: 'Application_OfficeHours_Reason__c',

// Project Feedback specific (conditional)
// Project Feedback specific (conditional - only present in Project Feedback variant)
projectName: 'Name',
projectSummary: 'Application_ProjectDescription__c',
projectRepo: 'Application_ProjectRepo__c',
Expand All @@ -180,7 +224,7 @@ const officeHoursMapping: FieldMapping = {
// Additional Details
repeatApplicant: 'Application_Repeat_Applicant__c',
opportunityOutreachConsent: 'Application_OutreachConsent__c'
};
});

/**
* Hardwired field values for each form type
Expand Down Expand Up @@ -244,26 +288,6 @@ export const mapFormDataToSalesforce = (
continue;
}

// Handle special cases for Office Hours form
if (formType === 'officeHours') {
// For Office Hours, Name field is conditional
if (formField === 'projectName' && formData.officeHoursRequest === 'Advice') {
// For Advice requests, Name is set to "FirstName, LastName" in the API route
// This is handled in the API route, not here
continue;
}

// Only include Project Feedback fields if request type is Project Feedback
if (
formData.officeHoursRequest !== 'Project Feedback' &&
['projectName', 'projectSummary', 'projectRepo', 'domain', 'additionalInfo'].includes(
formField
)
) {
continue;
}
}

if (typeof sfField === 'string') {
salesforceData[sfField] = formValue;
}
Expand Down
95 changes: 3 additions & 92 deletions src/pages/api/direct-grant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,55 +6,10 @@ import { verifyCaptcha } from '../../middlewares/verifyCaptcha';
import { MAX_WISHLIST_FILE_SIZE } from '../../constants';
import { DirectGrantSchema } from '../../components/forms/schemas/DirectGrant';
import { createSalesforceRecord, uploadFileToSalesforce, generateCSATToken } from '../../lib/sf';
import { mapFormDataToSalesforce } from '../../lib/sf-field-mappings';
import type { File } from 'formidable';

interface DirectGrantApiRequest extends NextApiRequest {
body: {
// Contact Information
firstName: string;
lastName: string;
email: string;
company: string;
profileType: string;
otherProfileType?: string;
alternativeContact?: string;
website?: string;
country: string;
timezone: string;
applicantProfile: string;

// Project Overview
projectName: string;
projectSummary: string;
projectRepo?: string;
domain: string;
output: string;
budgetRequest: number;
currency: string;

// Project Details
projectStructure: string;
sustainabilityPlan: string;
funding: string;
problemBeingSolved: string;
measuredImpact: string;
successMetrics: string;
ecosystemFit: string;
communityFeedback: string;
openSourceLicense: string;

// Additional Details
repeatApplicant: boolean;
referral: string;
additionalInfo?: string;
opportunityOutreachConsent: boolean;

// Required for submission
captchaToken: string;
};
}

const handler = async (req: DirectGrantApiRequest, res: NextApiResponse) => {
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const fields = { ...req.fields, ...req.files };

if (req.method !== 'POST') {
Expand All @@ -72,51 +27,7 @@ const handler = async (req: DirectGrantApiRequest, res: NextApiResponse) => {
}

try {
const applicationData = {
// Contact Information
Application_FirstName__c: result.data.firstName,
Application_LastName__c: result.data.lastName,
Application_Email__c: result.data.email,
Application_Company__c: result.data.company,
Application_ProfileType__c: result.data.profileType,
Application_Other_ProfileType__c: result.data.otherProfileType,
Application_Alternative_Contact__c: result.data.alternativeContact,
Application_Website__c: result.data.website,
Application_Country__c: result.data.country,
Application_Time_Zone__c: result.data.timezone,
Application_Profile__c: result.data.applicantProfile,

// Project Overview
Name: result.data.projectName,
Application_ProjectDescription__c: result.data.projectSummary,
Application_ProjectRepo__c: result.data.projectRepo,
Application_Domain__c: result.data.domain,
Application_Output__c: result.data.output,
Application_RequestedAmount__c: result.data.budgetRequest,
CurrencyIsoCode: result.data.currency,

// Project Details
Application_ProjectStructure__c: result.data.projectStructure,
Application_SustainabilityPlan__c: result.data.sustainabilityPlan,
Application_OtherFunding__c: result.data.funding,
Application_Problem__c: result.data.problemBeingSolved,
Application_MeasuredImpact__c: result.data.measuredImpact,
Application_SuccessMetric__c: result.data.successMetrics,
Application_EcosystemFit__c: result.data.ecosystemFit,
Application_CommunityFeedback__c: result.data.communityFeedback,
Application_Open_Source_License_Picklist__c: result.data.openSourceLicense,

// Additional Details
Application_Repeat_Applicant__c: result.data.repeatApplicant,
Application_Referral__c: result.data.referral,
Application_AdditionalInformation__c: result.data.additionalInfo,
Application_OutreachConsent__c: result.data.opportunityOutreachConsent,

// Hardwired fields
Application_Stage__c: 'New',
Application_Source__c: 'Webform',
RecordTypeId: '012Vj000008xEVNIA2'
};
const applicationData = mapFormDataToSalesforce(result.data, 'directGrant');

const salesforceResult = await createSalesforceRecord('Application__c', applicationData);

Expand Down
109 changes: 34 additions & 75 deletions src/pages/api/office-hours.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,41 +7,9 @@ import { verifyCaptcha } from '../../middlewares/verifyCaptcha';
import { MAX_WISHLIST_FILE_SIZE } from '../../constants';
import { OfficeHoursSchema } from '../../components/forms/schemas/OfficeHours';
import { createSalesforceRecord, uploadFileToSalesforce, generateCSATToken } from '../../lib/sf';
import { mapFormDataToSalesforce } from '../../lib/sf-field-mappings';

interface OfficeHoursAPIRequest extends NextApiRequest {
body: {
// Contact Information
firstName: string;
lastName: string;
email: string;
company?: string;
profileType: string;
otherProfileType?: string;
alternativeContact?: string;
country: string;
timezone: string;

// Office Hours Request
officeHoursRequest: 'Advice' | 'Project Feedback';
officeHoursReason: string;

// Project Feedback specific (conditional)
projectName?: string;
projectSummary?: string;
projectRepo?: string;
domain?: string;
additionalInfo?: string;

// Additional Details
repeatApplicant: boolean;
opportunityOutreachConsent: boolean;

// Required for submission
captchaToken: string;
};
}

const handler = async (req: OfficeHoursAPIRequest, res: NextApiResponse) => {
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const fields = { ...req.fields, ...req.files };

if (req.method !== 'POST') {
Expand All @@ -59,47 +27,38 @@ const handler = async (req: OfficeHoursAPIRequest, res: NextApiResponse) => {
}

try {
// For "Advice" requests, Application Name is "First Name, Last Name"
// For "Project Feedback" requests, Application Name is the project name
const applicationName =
result.data.officeHoursRequest === 'Advice'
? `${result.data.firstName}, ${result.data.lastName}`
: result.data.projectName;

const applicationData = {
// Contact Information
Application_FirstName__c: result.data.firstName,
Application_LastName__c: result.data.lastName,
Application_Email__c: result.data.email,
Application_Company__c: result.data.company || 'N/A',
Application_ProfileType__c: result.data.profileType,
Application_Other_ProfileType__c: result.data.otherProfileType,
Application_Alternative_Contact__c: result.data.alternativeContact,
Application_Country__c: result.data.country,
Application_Time_Zone__c: result.data.timezone,

// Office Hours Request
Application_OfficeHours_RequestType__c: result.data.officeHoursRequest,
Application_OfficeHours_Reason__c: result.data.officeHoursReason,

// Project Feedback specific fields (only present if Project Feedback)
Name: applicationName,
...(result.data.officeHoursRequest === 'Project Feedback' && {
Application_ProjectDescription__c: result.data.projectSummary,
Application_ProjectRepo__c: result.data.projectRepo,
Application_Domain__c: result.data.domain,
Application_AdditionalInformation__c: result.data.additionalInfo
}),

// Additional Details
Application_Repeat_Applicant__c: result.data.repeatApplicant,
Application_OutreachConsent__c: result.data.opportunityOutreachConsent,

// Hardwired fields
Application_Stage__c: 'New',
Application_Source__c: 'Webform',
RecordTypeId: '012Vj000008z3fVIAQ'
};
// Get base mapped data
const baseApplicationData = mapFormDataToSalesforce(result.data, 'officeHours');

// Apply Office Hours-specific logic
const applicationData: Record<string, any> = { ...baseApplicationData };

// Handle Name field logic
if (result.data.officeHoursRequest === 'Advice') {
// For Advice requests, Name is set to "FirstName, LastName"
if (result.data.firstName && result.data.lastName) {
applicationData.Name = `${result.data.firstName}, ${result.data.lastName}`;
}
} else if (result.data.officeHoursRequest === 'Project Feedback') {
// For Project Feedback, use projectName (already mapped, but ensure it's set)
if (result.data.projectName) {
applicationData.Name = result.data.projectName;
}
}

// Remove Project Feedback fields if request type is Advice
if (result.data.officeHoursRequest === 'Advice') {
delete applicationData.Application_ProjectDescription__c;
delete applicationData.Application_ProjectRepo__c;
delete applicationData.Application_Domain__c;
delete applicationData.Application_AdditionalInformation__c;
}

// Handle company fallback: use 'N/A' instead of firstName + lastName
// Override the default company fallback logic from mapFormDataToSalesforce
if (!result.data.company || result.data.company === '') {
applicationData.Application_Company__c = 'N/A';
}

const salesforceResult = await createSalesforceRecord('Application__c', applicationData);

Expand Down
Loading