import {where} from "firebase/firestore"; import FirebaseQuery from "./firebase/firebaseQuery"; import {AlumniConversation, EmailTemplateConfig, FlagCodes, InstituteData, StudentPlacementData, UserData, WorkflowStage, YearScoreData} from "./typeDefinitions"; import {convertDate} from "./firebase/util"; import {Descendant} from "slate"; import {PRIMARY_COLOUR} from "./constants"; type PlacementFlagCodeParams = { placement : StudentPlacementData, // Placement we get flag codes for studentData: UserData, // Student associated with the placement workflow: WorkflowStage[], // Workflow associated with the placement institute: InstituteData, // Associated institutes user: UserData // User that is fetching flag codes (staff or student) }; /** * Description of function * */ type PlaceFlagCodeReturn = Promise; export const getPlacementFlagCodes = async ({placement, studentData, workflow, institute, user}: PlacementFlagCodeParams):PlaceFlagCodeReturn => { let flags:FlagCodes[] = placement.flags || []; const firebaseQuery = new FirebaseQuery(); if (!studentData.details.parentEmail && !flags.find((x) => x.includes("noParentEmail"))) { if (workflow.find((obj) => obj.id === placement.status)?.userType === "Parent") { flags.includes("noParentEmailWarning") || flags.push("noParentEmailError"); } else if (workflow.find((obj) => obj.userType === "Parent")) { flags.includes("noParentEmailWarning") || flags.push("noParentEmailError"); } } // If placement after provider review and not verified const placementIsPostProviderReview = placement.leadTimes.some((x) => x.split("_")[0] === "3"); const placementNotEnded = !placement.leadTimes.some((x) => x.split("_")[0] === "8"); const providerUnverified = placement.providerContactId && !institute?.verifiedProviders?.includes(placement.providerContactId); const awaitingProviderInsurance = placement.providerContactId && institute?.awaitingProviderInsurance?.includes(placement.providerContactId); const riskAssessmentNotVerified = !institute?.verifiedRiskAssessments?.includes(placement.placementId || placement.id); const awaitingRiskAssessment = !placement.riskAssessment || institute?.awaitingPlacementRiskAssessments?.includes(placement.placementId || placement.id); const dbsCheckNotVerified = !institute?.verifiedDbsChecks?.includes(placement.placementId || placement.id); const awaitingDbsCheck = !placement.dbsCheck || institute?.awaitingPlacementDbsChecks?.includes(placement.placementId || placement.id); const reminderCount = (await firebaseQuery.getCount(["reminders"], [where("oId", "==", user.oId), where("uid", "==", user.id), where("dueDate", "<=", convertDate(new Date(), "dbstring") as string), where("documentId", "==", placement.id), where("status", "==", "upcoming")])); if (reminderCount) { flags.push("reminder"); } if (placement.inProgress && !placement.flags?.includes("insuranceExpired") && placement.insuranceExpiry && (placement.insuranceExpiry < placement.endDate)) { flags.push("insuranceEarlyExpiry"); } if (user.userType === "Staff" && user.product === "institutes" && placement.insurance && placementIsPostProviderReview && placementNotEnded && providerUnverified && !awaitingProviderInsurance) { console.log("Add insurance flag!"); flags.includes("noInsurance") || flags.push("noInsurance"); } else { flags = flags.filter((x) => x !== "noInsurance"); } if (user.product === "providers" && placement.statusType === "requested") { flags.includes("studentNotAccepted") || flags.push("studentNotAccepted"); } else { flags = flags.filter((x) => x !== "studentNotAccepted"); } if (user.userType === "Staff" && user.product === "institutes" && placementIsPostProviderReview && placementNotEnded && awaitingProviderInsurance) { flags.includes("awaitingInsurance") || flags.push("awaitingInsurance"); } else { flags = flags.filter((x) => x !== "awaitingInsurance"); } if (user.userType === "Staff" && user.product === "institutes" && placement.dbsCheck && workflow.find((stage) => stage.dbsCheck) && placement.dbsCheck !== true && placementIsPostProviderReview && placementNotEnded && dbsCheckNotVerified && !awaitingDbsCheck) { flags.includes("noDbsCheck") || flags.push("noDbsCheck"); } else { flags = flags.filter((x) => x !== "noDbsCheck"); } if (user.userType === "Staff" && user.product === "institutes" && placementIsPostProviderReview && workflow.find((stage) => stage.dbsCheck) && placementNotEnded && awaitingProviderInsurance) { flags.includes("awaitingDbsCheck") || flags.push("awaitingDbsCheck"); } else { flags = flags.filter((x) => x !== "awaitingDbsCheck"); } if (user.userType === "Staff" && user.product === "institutes" && placement.riskAssessment && workflow.find((stage) => stage.riskAssessment) && placement.riskAssessment !== true && placementIsPostProviderReview && placementNotEnded && riskAssessmentNotVerified && !awaitingRiskAssessment) { console.log("Add RA flag!"); flags.includes("noRiskAssessment") || flags.push("noRiskAssessment"); } else { flags = flags.filter((x) => x !== "noRiskAssessment"); } if (user.userType === "Staff" && user.product === "institutes" && placementIsPostProviderReview && workflow.find((stage) => stage.riskAssessment) && placementNotEnded && awaitingProviderInsurance) { flags.includes("awaitingRiskAssessment") || flags.push("awaitingRiskAssessment"); } else { flags = flags.filter((x) => x !== "awaitingRiskAssessment"); } if (placement.inProgress && user.userType === "Staff" && user.product === "providers" && user.oId === placement.providerId && placement.onboarding === null) { flags.includes("addOnboarding") || flags.push("addOnboarding"); } else { flags = flags.filter((x) => x !== "addOnboarding"); } if (placement.inProgress && user.userType === "Staff" && user.product === "providers" && user.oId === placement.providerId && placement.onboarding && (placement.onboarding.completed?.submitted && !placement.onboarding.completed.accepted) && (placement.inProgress || placement.active)) { flags.includes("reviewOnboarding") || flags.push("reviewOnboarding"); } else { flags = flags.filter((x) => x !== "reviewOnboarding"); } if (placement.inProgress && user.userType === "Students" && placement.onboarding && (!placement.onboarding.completed || (placement.onboarding.completed && !placement.onboarding.completed?.submitted)) && (placement.inProgress || placement.active)) { flags.includes("completeOnboarding") || flags.push("completeOnboarding"); } else { flags = flags.filter((x) => x !== "completeOnboarding"); } return flags; }; export function objectsEqualNew(a: any, b: any): boolean { // Works with arrays or objects. if (a === b) { return true; } if (typeof a !== typeof b) { return false; } if (Array.isArray(a) && Array.isArray(b)) { if (a.length !== b.length) { return false; } for (let i = 0; i < a.length; i++) { if (!objectsEqualNew(a[i], b[i])) { return false; } } return true; } if (typeof a === "object" && typeof b === "object" && a !== null && b !== null) { const keysA = Object.keys(a); const keysB = Object.keys(b); if (keysA.length !== keysB.length) { return false; } for (const key of keysA) { if (!keysB.includes(key)) { return false; } if (!objectsEqualNew(a[key], b[key])) { return false; } } return true; } return false; } export const getBookedPlacementDates = (placements?: {[key: string]: StudentPlacementData}, currentPlacementData?: StudentPlacementData) => { if (!placements) return; const dates = Object.entries(placements).reduce((acc, [, p]) => { acc.push(...p.activeDates); return acc; }, [] as string[]); const uniqueDates = [...new Set(dates)].sort((a, b) => a < b ? -1 : 1); const restrictedDates = uniqueDates.map((date) => new Date(date)) if (currentPlacementData?.activeDates){ const currentDateRangeSet = new Set(currentPlacementData.activeDates.map((date: string) => new Date(date).getTime())) return restrictedDates.filter((date) => !currentDateRangeSet.has(date.getTime())) } return restrictedDates } export const getMostRecentAlumniMessage = (conversation: AlumniConversation) => { const { messages } = conversation; if (!messages || Object.keys(messages).length === 0) { return null; // No messages available } const mostRecent = Object.entries(messages) .map(([id, message]) => ({ id, ...message, })) .sort((a, b) => new Date(b.sentAt).getTime() - new Date(a.sentAt).getTime())[0]; return mostRecent || null; // Return most recent or null if none found }; export function buildEmailHTML({preheader, title, salutation, body, primaryColor=PRIMARY_COLOUR, secondaryBody, primaryButton, secondaryButton, primaryImage="https://firebasestorage.googleapis.com/v0/b/placementt-dfa17.appspot.com/o/public%2FCareerThreadLogoForEmail.png?alt=media&token=046b5555-cd79-4fc9-bc89-90395e3fa351v", designatedStaffEmail, organisationName, params, data}:{preheader: string, title: string, salutation?: string, body: string|Descendant[], primaryColor?: string, secondaryBody?: string, primaryButton?: {title: string, url: string}, secondaryButton?: {title: string, url: string}, primaryImage?: string, designatedStaffEmail?: string, organisationName?: string, params?: EmailTemplateConfig, data?: {[key: string]: string|undefined}}) { const serialiseSlate = (nodes: Descendant[]) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any return nodes.map((n:any) => { if (n.type === "paragraph") { const text:string[]=[]; for (let i = 0; i < n.children.length; i++) { const item = n.children[i]; let subtext = item.text as string || "
"; console.log("serialise text", subtext); subtext = subtext.replace("​", ""); if (item.underline) { subtext = `${subtext}`; } if (item.bold) { subtext = `${subtext}`; } if (item.italic) { subtext = `${subtext}`; } text.push(subtext); } return `
${text.join("")}
`; } return; }).filter((a) => a).join("

"); }; const getEmailContentFromTemplate = (template: string|Descendant[], params: EmailTemplateConfig, data: {[key: string]: string|undefined}) => { let finalBody = typeof template === "string" ? template : serialiseSlate(template); console.log("Body before processing", finalBody); // Replace all instances of placeholder with actual data. [...params.params, ...Object.keys(data)].forEach((param) => { console.log(`Replacing {{${param}}} with "${data[param]}"`); finalBody = finalBody.replace(`{{${param}}}`, data[param] as string); }); console.log("Processed body", finalBody); // Still have the {{button}} elements to replace. In this case, split the string up between the button elements. const itemsInBody = finalBody.split(/(\{\{button\}\})/); const itemsInBodyWithFormattedButtons = itemsInBody.map((item) => { if (item !== "{{button}}" || !params.button) return item; let title = params.button.text; let url = "https://careerthread.co.uk"+params.button.link; // Replace any params in the url or title with the relevant data. [...params.params, ...Object.keys(data)].forEach((param) => { title = title.replace(`{{${param}}}`, data[param] as string); }); [...params.params, ...Object.keys(data)].forEach((param) => { url = url.replace(`{{${param}}}`, data[param] as string); }); return { title: title, url: url, }; }); return itemsInBodyWithFormattedButtons; }; let formattedBody = (params && data) ? "" : body; let formattedSecondaryBody = (params && data) ? "" : secondaryBody; let formattedPrimaryButton = (params && data) ? undefined : primaryButton; let formattedSecondaryButton = (params && data) ? undefined : secondaryButton; if (params && data) { const processedBody = getEmailContentFromTemplate(body, params, data); processedBody.forEach((item) => { if (typeof item === "string") { if (!formattedBody) { formattedBody = item; return; } formattedSecondaryBody = item; return; } // Must be a button if (!formattedPrimaryButton) { formattedPrimaryButton = item; return; } formattedSecondaryButton = item; }); formattedBody = processedBody[0] as string; } const emailHeadAndStylesHTML = ` `; const primaryButtonTextLinkHTML = formattedPrimaryButton ? `
Or go to ${formattedPrimaryButton.url}
` : ""; const primaryButtonHTML = formattedPrimaryButton ? ` ${formattedPrimaryButton.title} ` : ""; const secondaryButtonTextHTML = formattedSecondaryButton ? ` ${formattedSecondaryButton.title} ` : ""; const buttonTableHTML = (primaryButtonHTML || secondaryButtonTextHTML) ? ` ${primaryButtonTextLinkHTML}
${primaryButtonHTML} ${secondaryButtonTextHTML}
` : ""; const secondaryBodyHTML = formattedSecondaryBody ? `

${formattedSecondaryBody}
` : ""; const organisationContactSentence = (designatedStaffEmail || organisationName) ? `
${organisationName ? `This email was sent by CareerThread on behalf of ${organisationName}.` : designatedStaffEmail ? `Please do not reply directly to this email. For any queries, contact ${designatedStaffEmail}` : ""}
` : ""; return ` ${emailHeadAndStylesHTML}
${title}
${salutation ? `
${salutation},

` : ""}
${formattedBody}
${buttonTableHTML} ${secondaryBodyHTML}
If you do not recognise this email, you can safely ignore it.
${organisationContactSentence}
`; } export const getWeightsForOES = (years: number) => { let weights:number[] = []; if (years < 1) { throw new Error("Cannot get score for 0 years of data.") } const yearArray = Array.from({ length: years }, (_, i) => i); const decayDenominator = yearArray.reduce((acc, _, k) => { const exp = Math.exp(-k); return acc + exp; }, 0); console.log("decayDenominator", decayDenominator); for (let i = 0; i < yearArray?.length; i++) { weights.push((Math.exp(-i))/decayDenominator); } console.log("weights", weights); return { weights: weights, sum: Math.round(weights.reduce((acc, v) => acc+v, 0) * 10000) / 10000, }; } export const calculateYearScore = (inputData: YearScoreData):number => { // Returns a score between 1 and -1 based on the inputted data. const normaliseScore = (e: number) => 2 * (e - 0.5); // Maximum score of 1 if invited to lots of events. NOTE: Also used as confidence score. // Minimum score of 0 if not invited to any events. Will not influence overall score. const eventInviteConfidence = Math.min((inputData.eventsInvited || 0)/3 , 1); const feedbackConfidence = Math.min((inputData.eventsFinished || 0)/3 , 1); const eventAttendedScore = Math.min((inputData.eventReplied || 0)/5 , 1); // NOTE: If not invited to anything yet, score is 0 because eventInviteScore is 0. Replies can never be more than invited, so min = 0, max = 1. Normalise makes range -1 -> 1 const eventsRepliedToScore = eventInviteConfidence * normaliseScore((inputData.eventReplied || 0)/(inputData.eventsInvited || 1)); // NOTE: If not invited to anything yet, score is 0 because feedbackConfidence is 0. feedbackGiven can never be more than eventReplied, so min = 0, max = 1. Normalise makes range -1 -> 1 const feedbackScore = feedbackConfidence * normaliseScore((inputData.feedbackGiven || 0)/(inputData.eventsFinished || 1)); // If not requested any events, 0. Maximum score of 1 if requested lots of events. const requests = Math.min((inputData.eventRequests || 0)/3 , 1); const campaignConfidence = Math.min((inputData.campaignsSent || 0)/3 , 1); // Only include this score if campaigns have been sent to the user. Otherwise, undefined. // Looks at top interactions across all emails that have been sent const campaignInteractions = inputData.campaignsSent === 0 ? undefined : campaignConfidence * ( -1 * (inputData.campaignsFailed || 0) + 0.4 * (inputData.campaignsOpened || 0) + 1 * (inputData.campaignsClicked || 0) ) / (inputData.campaignsSent || 1); // ENSURE THESE VALUES + THE CAMPAIGN SCORE MULTIPLIER (IN RETURN STATEMENT) ADD UP TO 1. console.table({ eventAttendedScore: eventAttendedScore, eventsRepliedToScore: eventsRepliedToScore, feedbackScore: feedbackScore, requests: requests, campaignInteractions: campaignInteractions, }) const scoreBeforeCampaignScore = ( 0.45 * eventAttendedScore + 0.2 * eventsRepliedToScore + 0.15 * feedbackScore + 0.1 * requests ) const round = (s: number) => Math.round(s * 10000) / 10000 if (!campaignInteractions) return round(scoreBeforeCampaignScore / 0.9); return round(scoreBeforeCampaignScore + (0.1 * campaignInteractions)); } export const calculateOverallEngagementScore = (inputData: YearScoreData[]) => { const yearWeights = getWeightsForOES(inputData.length); let weightedYearScores:number = 0; for (let i = 0; i < inputData.length; i++) { const weight = yearWeights.weights[i]; const yearScore = calculateYearScore(inputData[i]); console.log("Year Score", i, yearScore, weight) weightedYearScores += weight * yearScore; } return Math.round(10*(5 + (5 * weightedYearScores)))/10; } export const generateAcademicYears = (number = 5) => { const now = new Date(); const currentYear = now.getMonth() >= 8 ? now.getFullYear() : now.getFullYear() - 1; const years: {startDate: string, endDate: string}[] = []; for (let i = 0; i < number; i++) { const start = new Date(currentYear - i, 8, 1); // 1 Sept YYYY const end = new Date(currentYear - i + 1, 7, 31); // 31 Aug YYYY+1 years.push({ startDate: convertDate(start, "dbstring") as string, endDate: convertDate(end, "dbstring") as string, }); } return years; }; // NOTE: Fetches a list of months in MMM YY format (eg. JUN 25) between two given dates, inclusive export function getMonthsBetweenDates({startDate, endDate}: { startDate: string; endDate: string }) { const out: string[] = []; let [y, m] = startDate.split("-").map(Number); const [ey, em] = endDate.split("-").map(Number); while (y < ey || (y === ey && m <= em)) { const date = new Date(y, m - 1); // JS months are 0-based const label = date.toLocaleString("en-GB", {month: "short", year: "2-digit"}).replace(",", ""); out.push(label); m++; if (m > 12) { m = 1; y++; } } return out; } // NOTE: Uses the output from getMonthsBetweenDates and checks what specific month index within that list of dates a specific date lies within. export function getMonthIndexForDate(date: string, months: string[]): number { const [year, month] = date.split("-").map(Number); const dateLabel = new Date(year, month - 1) .toLocaleString("en-GB", {month: "short", year: "2-digit"}) .replace(",", ""); return months.indexOf(dateLabel); } export function darkenHex(hex: string, amount = 10): string { // Ensure hex starts with # hex = hex.replace(/^#/, ''); // Expand shorthand form (#abc → #aabbcc) if (hex.length === 3) { hex = hex.split('').map(c => c + c).join(''); } // Convert to RGB const num = parseInt(hex, 16); const factor = 1 - amount / 100; const r = Math.max(0, Math.min(255, Math.round(((num >> 16) & 255) * factor))); const g = Math.max(0, Math.min(255, Math.round(((num >> 8) & 255) * factor))); const b = Math.max(0, Math.min(255, Math.round((num & 255) * factor))); // Recombine and return hex return ( '#' + [r, g, b] .map(x => x.toString(16).padStart(2, '0')) .join('') ); }