From a0b9cf1c79e9489818d2540f12ae3a7ee6074e21 Mon Sep 17 00:00:00 2001 From: "detail-app[bot]" <180357370+detail-app[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:55:37 +0000 Subject: [PATCH] fix(email): restore two-arg luxon fromObject in matchTeamIntro timeslots --- package.json | 2 +- src/email/templates/matchTeamIntro.test.ts | 318 +++++++++++++++++++++ src/email/templates/matchTeamIntro.ts | 24 +- yarn.lock | 13 +- 4 files changed, 336 insertions(+), 21 deletions(-) create mode 100644 src/email/templates/matchTeamIntro.test.ts diff --git a/package.json b/package.json index 3de577d..bc7b76b 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "@types/graphql-upload": "^8.0.4", "@types/jsonwebtoken": "^8.5.1", "@types/knuth-shuffle-seeded": "^1.0.2", - "@types/luxon": "^1.26.5", + "@types/luxon": "^3.7.5", "@types/node": "^15.3.0", "@types/node-fetch": "2", "@types/nodemailer": "^6.4.1", diff --git a/src/email/templates/matchTeamIntro.test.ts b/src/email/templates/matchTeamIntro.test.ts new file mode 100644 index 0000000..c3a92c4 --- /dev/null +++ b/src/email/templates/matchTeamIntro.test.ts @@ -0,0 +1,318 @@ +/** + * Offline unit tests for matchTeamIntro email template generation. Verifies that the + * "Suggested Meeting Times (all students available)" block (commonTimeslots) is correctly + * computed from students' timeManagementPlan data, and that the luxon DateTime.fromObject + * call in localIntervalToUtc no longer throws InvalidUnitError. + * + * These tests exercise the real getList() public API with a stubbed Prisma client, so they + * cover the actual localIntervalToUtc / findCommonTimeslots code path in matchTeamIntro.ts. + * + * Run with: + * npx tsx src/email/templates/matchTeamIntro.test.ts + */ +import 'reflect-metadata'; +import { MentorStatus, StudentStatus } from '@prisma/client'; +import { ProjectStatus } from '../../enums'; +import Handlebars from 'handlebars'; +import { getList } from './matchTeamIntro'; + +let failures = 0; + +function assert(condition: boolean, message: string): void { + if (!condition) { + failures += 1; + console.error(`FAILED: ${message}`); + } else { + console.log(`PASSED: ${message}`); + } +} + +// JSON.stringify with object keys sorted recursively, so object key order (which is +// semantically insignificant) does not cause spurious failures. Array order is preserved. +function stableStringify(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`; + if (value && typeof value === 'object') { + const obj = value as Record; + return `{${Object.keys(obj).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(',')}}`; + } + return JSON.stringify(value); +} + +function assertEqual(actual: T, expected: T, message: string): void { + const a = stableStringify(actual); + const e = stableStringify(expected); + if (a !== e) { + failures += 1; + console.error(`FAILED: ${message}\n expected: ${e}\n actual: ${a}`); + } else { + console.log(`PASSED: ${message}`); + } +} + +type Interval = { start: number; end: number }; +type TimeManagementPlan = Record; + +interface StudentStub { + timezone?: string; + timeManagementPlan?: TimeManagementPlan; + status: StudentStatus; + email: string; + givenName: string; + surname: string; +} + +function student(overrides: Partial = {}): StudentStub { + return { + status: StudentStatus.ACCEPTED, + email: 'student@example.com', + givenName: 'Student', + surname: 'One', + ...overrides, + }; +} + +function mentor(overrides: Partial> = {}): Record { + return { + status: MentorStatus.ACCEPTED, + email: 'mentor@example.com', + givenName: 'Mentor', + surname: 'One', + ...overrides, + }; +} + +function project(overrides: Partial> = {}): Record { + return { + id: 'project-1', + eventId: 'event-1', + status: ProjectStatus.MATCHED, + students: [] as StudentStub[], + mentors: [] as Record[], + issueUrl: 'https://example.com/issue/1', + description: 'A project', + deliverables: 'Some deliverables', + ...overrides, + }; +} + +// A Prisma stub: project.findMany resolves to a fixed list of projects. No DB needed. +function fakePrisma(projects: Record[]): { project: { findMany: () => Promise[]> } } { + return { + project: { + findMany: async () => projects, + }, + }; +} + +const EVENT = { + id: 'event-1', + name: 'Test Event', + emailSignature: '', + title: 'Test', + startsAt: new Date('2024-01-01T00:00:00Z'), + defaultWeeks: 8, +}; + +// Minutes-since-midnight helpers for readability. +function mins(h: number, m = 0): number { + return h * 60 + m; +} + +// --- Regression: the original bug ----------------------------------------------------------- +// Before the fix, the very first localIntervalToUtc call threw InvalidUnitError, which the +// try/catch in findCommonTimeslots swallowed and returned {}, so getList collapsed +// commonTimeslots to undefined and the email rendered the When2meet {{else}} branch even +// though students supplied fully overlapping availability. +async function testOverlappingSameTimezoneProducesCommonTimeslots(): Promise { + const plan: TimeManagementPlan = { tuesday: [{ start: mins(9), end: mins(10) }] }; + const projects = [ + project({ + students: [ + student({ email: 'a@example.com', givenName: 'A', surname: 'One', timezone: 'America/New_York', timeManagementPlan: plan }), + student({ email: 'b@example.com', givenName: 'B', surname: 'Two', timezone: 'America/New_York', timeManagementPlan: plan }), + ], + mentors: [mentor()], + }), + ]; + + const result = await getList(fakePrisma(projects) as any, EVENT as any); + assertEqual(result.length, 1, 'One email context per project'); + const ctx = result[0] as { project: unknown; commonTimeslots?: Record> }; + assert(ctx.commonTimeslots !== undefined, 'commonTimeslots is populated (not the bug-masked undefined)'); + assertEqual( + ctx.commonTimeslots, + { tuesday: { 'America/New_York': ['9:00 AM - 10:00 AM'] } }, + 'Overlapping same-timezone availability renders a single NY-formatted timeslot on Tuesday', + ); +} + +// --- Cross-timezone overlap proves the UTC conversion is actually correct -------------------- +// NY 9-11am EST and LA 6-8am PST both map to 14:00-16:00 UTC; the overlap must be rendered in +// each student's own timezone. This is the case the bug report highlights: students supplied +// data, a real overlap exists, and the template was written to render it. +async function testOverlappingAcrossTimezonesRendersInEachTimezone(): Promise { + const nyPlan: TimeManagementPlan = { tuesday: [{ start: mins(9), end: mins(11) }] }; + const laPlan: TimeManagementPlan = { tuesday: [{ start: mins(6), end: mins(8) }] }; + const projects = [ + project({ + students: [ + student({ email: 'a@example.com', givenName: 'A', surname: 'One', timezone: 'America/New_York', timeManagementPlan: nyPlan }), + student({ email: 'b@example.com', givenName: 'B', surname: 'Two', timezone: 'America/Los_Angeles', timeManagementPlan: laPlan }), + ], + mentors: [mentor()], + }), + ]; + + const result = await getList(fakePrisma(projects) as any, EVENT as any); + const ctx = result[0] as { commonTimeslots?: Record> }; + assert(ctx.commonTimeslots !== undefined, 'Cross-timezone overlap is detected (not swallowed by a thrown error)'); + assertEqual( + ctx.commonTimeslots, + { + tuesday: { + 'America/Los_Angeles': ['6:00 AM - 8:00 AM'], + 'America/New_York': ['9:00 AM - 11:00 AM'], + }, + }, + 'The 14:00-16:00 UTC overlap is rendered in each student local timezone', + ); +} + +// --- No console.warn should be emitted when students supplied real data --------------------- +// Before the fix, findCommonTimeslots logged `Error occurred while finding common timeslots: +// InvalidUnitError - Invalid unit zone` and returned {}. After the fix, no warning is emitted +// for valid data. Guards against the silent try/catch masking returning. +async function testNoSwallowedErrorWhenDataSupplied(): Promise { + const plan: TimeManagementPlan = { tuesday: [{ start: mins(9), end: mins(10) }] }; + const projects = [ + project({ + students: [ + student({ email: 'a@example.com', givenName: 'A', surname: 'One', timezone: 'America/New_York', timeManagementPlan: plan }), + student({ email: 'b@example.com', givenName: 'B', surname: 'Two', timezone: 'America/New_York', timeManagementPlan: plan }), + ], + mentors: [mentor()], + }), + ]; + + let warned = false; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { + if (String(args[0]).includes('finding common timeslots')) warned = true; + originalWarn.apply(console, args as never); + }; + try { + await getList(fakePrisma(projects) as any, EVENT as any); + } finally { + console.warn = originalWarn; + } + assert(!warned, 'No "Error occurred while finding common timeslots" warning is emitted when students supplied valid overlapping data'); +} + +// --- Legitimate empty result: students supplied no availability data ------------------------- +// When students have a timezone but no timeManagementPlan, findCommonTimeslots legitimately +// returns {} without ever calling localIntervalToUtc. getList must collapse that to undefined +// so the {{else}} When2meet branch renders. This is the correct behavior, NOT the bug. +async function testNoAvailabilityDataYieldsUndefined(): Promise { + const projects = [ + project({ + students: [ + student({ email: 'a@example.com', givenName: 'A', surname: 'One', timezone: 'America/New_York', timeManagementPlan: undefined }), + student({ email: 'b@example.com', givenName: 'B', surname: 'Two', timezone: 'America/New_York', timeManagementPlan: undefined }), + ], + mentors: [mentor()], + }), + ]; + + const result = await getList(fakePrisma(projects) as any, EVENT as any); + const ctx = result[0] as { commonTimeslots?: unknown }; + assert(ctx.commonTimeslots === undefined, 'No timeManagementPlan data legitimately yields commonTimeslots undefined (When2meet branch)'); +} + +// --- End-to-end: the actual Handlebars block from matchTeamIntro.md ------------------------- +// Verifies the template's {{#if commonTimeslots}} / {{else}} branch renders the "Suggested +// Meeting Times" block (and NOT the When2meet fallback) when students supplied data, and the +// When2meet fallback (and NOT the timeslot block) when they did not. This is the user-visible +// behavior the bug report says was silently broken. +const TIMESLOT_BLOCK = `{{#if commonTimeslots}} +**Suggested Meeting Times (all students available):** +{{#each commonTimeslots}} + - {{@key}}: {{#each this}}{{#each this}}{{this}} ({{@../key}}){{#unless @last}} || {{/unless}}{{/each}}{{/each}} +{{/each}} +{{else}} +- **Mentors:** Send a [When2meet](https://www.when2meet.com/) for recurring meeting availability +{{/if}}`; + +const timeslotTemplate = Handlebars.compile(TIMESLOT_BLOCK); + +async function testTemplateRendersSuggestedTimesWhenDataSupplied(): Promise { + const nyPlan: TimeManagementPlan = { tuesday: [{ start: mins(9), end: mins(11) }] }; + const laPlan: TimeManagementPlan = { tuesday: [{ start: mins(6), end: mins(8) }] }; + const projects = [ + project({ + students: [ + student({ email: 'a@example.com', givenName: 'A', surname: 'One', timezone: 'America/New_York', timeManagementPlan: nyPlan }), + student({ email: 'b@example.com', givenName: 'B', surname: 'Two', timezone: 'America/Los_Angeles', timeManagementPlan: laPlan }), + ], + mentors: [mentor()], + }), + ]; + + const result = await getList(fakePrisma(projects) as any, EVENT as any); + const rendered = timeslotTemplate(result[0] as any); + assert( + rendered.includes('**Suggested Meeting Times (all students available):**'), + 'Template renders the "Suggested Meeting Times" header when students supplied overlapping availability', + ); + assert( + rendered.includes('9:00 AM - 11:00 AM (America/New_York)'), + 'Template renders the NY-formatted overlap window labeled with its timezone', + ); + assert( + rendered.includes('6:00 AM - 8:00 AM (America/Los_Angeles)'), + 'Template renders the LA-formatted overlap window labeled with its timezone', + ); + assert( + !rendered.includes('When2meet'), + 'Template does NOT render the When2meet fallback when students supplied overlapping availability', + ); +} + +async function testTemplateRendersWhen2meetFallbackWhenNoData(): Promise { + const projects = [ + project({ + students: [ + student({ email: 'a@example.com', givenName: 'A', surname: 'One', timezone: 'America/New_York', timeManagementPlan: undefined }), + student({ email: 'b@example.com', givenName: 'B', surname: 'Two', timezone: 'America/New_York', timeManagementPlan: undefined }), + ], + mentors: [mentor()], + }), + ]; + + const result = await getList(fakePrisma(projects) as any, EVENT as any); + const rendered = timeslotTemplate(result[0] as any); + assert( + rendered.includes('Send a [When2meet]'), + 'Template renders the When2meet fallback when students supplied no availability data', + ); + assert( + !rendered.includes('Suggested Meeting Times'), + 'Template does NOT render the "Suggested Meeting Times" block when there is no availability data', + ); +} + +async function main(): Promise { + await testOverlappingSameTimezoneProducesCommonTimeslots(); + await testOverlappingAcrossTimezonesRendersInEachTimezone(); + await testNoSwallowedErrorWhenDataSupplied(); + await testNoAvailabilityDataYieldsUndefined(); + await testTemplateRendersSuggestedTimesWhenDataSupplied(); + await testTemplateRendersWhen2meetFallbackWhenNoData(); + + if (failures > 0) { + console.error(`\n${failures} test(s) failed.`); + process.exit(1); + } + console.log('\nAll tests passed.'); +} + +main(); diff --git a/src/email/templates/matchTeamIntro.ts b/src/email/templates/matchTeamIntro.ts index 19d9859..5cc173c 100644 --- a/src/email/templates/matchTeamIntro.ts +++ b/src/email/templates/matchTeamIntro.ts @@ -35,22 +35,14 @@ export async function getList(prisma: PrismaClient, event: PartialEvent): Promis function localIntervalToUtc(day: string, interval: Interval, tz: string) { const weekdays: Record = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 7 }; const baseDay = weekdays[day.toLowerCase()] ?? 1; - const start = DateTime.fromObject({ - year: 2024, - month: 1, - day: baseDay, - hour: Math.floor(interval.start / 60), - minute: interval.start % 60, - zone: tz - }).toUTC(); - const end = DateTime.fromObject({ - year: 2024, - month: 1, - day: baseDay, - hour: Math.floor(interval.end / 60), - minute: interval.end % 60, - zone: tz - }).toUTC(); + const start = DateTime.fromObject( + { year: 2024, month: 1, day: baseDay, hour: Math.floor(interval.start / 60), minute: interval.start % 60 }, + { zone: tz } + ).toUTC(); + const end = DateTime.fromObject( + { year: 2024, month: 1, day: baseDay, hour: Math.floor(interval.end / 60), minute: interval.end % 60 }, + { zone: tz } + ).toUTC(); return { start, end }; } diff --git a/yarn.lock b/yarn.lock index cb8b82a..3993358 100644 --- a/yarn.lock +++ b/yarn.lock @@ -511,10 +511,10 @@ resolved "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz" integrity sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA== -"@types/luxon@^1.26.5": - version "1.27.1" - resolved "https://registry.npmjs.org/@types/luxon/-/luxon-1.27.1.tgz" - integrity sha512-cPiXpOvPFDr2edMnOXlz3UBDApwUfR+cpizvxCy0n3vp9bz/qe8BWzHPIEFcy+ogUOyjKuCISgyq77ELZPmkkg== +"@types/luxon@^3.7.5": + version "3.7.5" + resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-3.7.5.tgz#d223e1adfdadb140eca9c51647aba59b98508128" + integrity sha512-jJ41Q4z6ZVO260MNDdHfW7+7a5iMiX8Mr6ZJHcmgrvhZha6dz5704o/lF2kKl6URjH6ivEL97w9xS/MgpJEphg== "@types/luxon@~3.3.0": version "3.3.3" @@ -572,6 +572,11 @@ resolved "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz" integrity sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw== +"@types/node@^14.11.2": + version "14.18.63" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.63.tgz#1788fa8da838dbb5f9ea994b834278205db6ca2b" + integrity sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ== + "@types/node@^18.11.18": version "18.19.6" resolved "https://registry.npmjs.org/@types/node/-/node-18.19.6.tgz"