1- import { getServiceChildLogger } from '@crowd/logging'
2-
31import { QueryExecutor } from '../queryExecutor'
42
5- const log = getServiceChildLogger ( 'affiliations:resolve' )
6-
73const BLACKLISTED_TITLES = [ 'investor' , 'mentor' , 'board member' ]
84
95export interface IAffiliationPeriod {
@@ -23,10 +19,13 @@ interface IWorkRow {
2319 createdAt : Date | string
2420 isPrimaryWorkExperience : boolean
2521 memberCount : number
26- /** null for memberOrganizations rows; non-null for memberSegmentAffiliations rows */
2722 segmentId : string | null
2823}
2924
25+ /**
26+ * this intentionally differs from the equivalent query in member-organization-affiliation/index.ts
27+ * which uses organizationSegmentsAgg to compute memberCount. This is because the api should be faster
28+ */
3029export async function findWorkExperiencesBulk (
3130 qx : QueryExecutor ,
3231 memberIds : string [ ] ,
@@ -39,10 +38,6 @@ export async function findWorkExperiencesBulk(
3938 WHERE "memberId" IN ($(memberIds:csv))
4039 AND "deletedAt" IS NULL
4140 ),
42- -- Note: this intentionally differs from the equivalent query in member-organization-affiliation/index.ts
43- -- which uses organizationSegmentsAgg to compute memberCount. That approach scans a large
44- -- aggregation table and causes timeouts in an API context. Here we count directly from
45- -- memberOrganizations which is faster and sufficient for the tiebreaker use case.
4641 aggs AS (
4742 SELECT "organizationId", COUNT(DISTINCT "memberId") AS total_count
4843 FROM "memberOrganizations"
@@ -104,8 +99,6 @@ export async function findManualAffiliationsBulk(
10499 )
105100}
106101
107- // ─── Selection priority ───────────────────────────────────────────────────────
108-
109102function durationMs ( org : IWorkRow ) : number {
110103 const start = new Date ( org . dateStart ?? '' ) . getTime ( )
111104 const end = new Date ( org . dateEnd ?? '9999-12-31' ) . getTime ( )
@@ -157,8 +150,6 @@ function selectPrimaryWorkExperience(orgs: IWorkRow[]): IWorkRow {
157150 return longestDateRange ( orgs )
158151}
159152
160- // ─── Timeline helpers ─────────────────────────────────────────────────────────
161-
162153/** Returns the org used to fill gaps — primary undated wins, then earliest-created undated. */
163154function findFallbackOrg ( rows : IWorkRow [ ] ) : IWorkRow | null {
164155 const primaryUndated = rows . find ( ( r ) => r . isPrimaryWorkExperience && ! r . dateStart && ! r . dateEnd )
@@ -221,32 +212,8 @@ function dayBefore(date: Date): Date {
221212 return d
222213}
223214
224- function closeAffiliationWindow (
225- memberId : string ,
226- affiliations : IAffiliationPeriod [ ] ,
227- org : IWorkRow ,
228- windowStart : Date ,
229- windowEnd : Date ,
230- ) : void {
231- log . debug (
232- {
233- memberId,
234- org : org . organizationName ,
235- windowStart : windowStart . toISOString ( ) ,
236- windowEnd : windowEnd . toISOString ( ) ,
237- } ,
238- 'closing affiliation window' ,
239- )
240- affiliations . push ( {
241- organization : org . organizationName ,
242- startDate : windowStart . toISOString ( ) ,
243- endDate : windowEnd . toISOString ( ) ,
244- } )
245- }
246-
247215/** Iterates boundary intervals and builds non-overlapping affiliation windows. */
248216function buildTimeline (
249- memberId : string ,
250217 allRows : IWorkRow [ ] ,
251218 fallbackOrg : IWorkRow | null ,
252219 boundaries : Date [ ] ,
@@ -260,67 +227,33 @@ function buildTimeline(
260227 const boundaryDate = boundaries [ i ]
261228 const activeOrgsAtBoundary = orgsActiveAt ( allRows , boundaryDate )
262229
263- log . debug (
264- {
265- memberId,
266- boundaryDate : boundaryDate . toISOString ( ) ,
267- orgsAtBoundary : activeOrgsAtBoundary . map ( ( r ) => ( {
268- org : r . organizationName ,
269- dateStart : r . dateStart ,
270- dateEnd : r . dateEnd ,
271- isPrimary : r . isPrimaryWorkExperience ,
272- memberCount : r . memberCount ,
273- isManual : r . segmentId !== null ,
274- } ) ) ,
275- } ,
276- 'processing boundary' ,
277- )
278-
279230 // No orgs active at this boundary — close the current window and start tracking a gap
280231 if ( activeOrgsAtBoundary . length === 0 ) {
281232 if ( currentOrg && currentWindowStart ) {
282- closeAffiliationWindow (
283- memberId ,
284- affiliations ,
285- currentOrg ,
286- currentWindowStart ,
287- dayBefore ( boundaryDate ) ,
288- )
233+ affiliations . push ( {
234+ organization : currentOrg . organizationName ,
235+ startDate : currentWindowStart . toISOString ( ) ,
236+ endDate : dayBefore ( boundaryDate ) . toISOString ( ) ,
237+ } )
289238 currentOrg = null
290239 currentWindowStart = null
291240 }
292241
293242 if ( uncoveredPeriodStart === null ) {
294243 uncoveredPeriodStart = boundaryDate
295- log . debug (
296- { memberId, uncoveredPeriodStart : boundaryDate . toISOString ( ) } ,
297- 'uncovered period started' ,
298- )
299244 }
300245
301246 continue
302247 }
303248
304249 // Orgs are active again — close the uncovered period using the fallback org if available
305250 if ( uncoveredPeriodStart !== null ) {
306- log . debug (
307- {
308- memberId,
309- fallbackOrg : fallbackOrg ?. organizationName ?? null ,
310- uncoveredPeriodStart : uncoveredPeriodStart . toISOString ( ) ,
311- uncoveredPeriodEnd : dayBefore ( boundaryDate ) . toISOString ( ) ,
312- } ,
313- 'closing uncovered period with fallback org' ,
314- )
315-
316251 if ( fallbackOrg ) {
317- closeAffiliationWindow (
318- memberId ,
319- affiliations ,
320- fallbackOrg ,
321- uncoveredPeriodStart ,
322- dayBefore ( boundaryDate ) ,
323- )
252+ affiliations . push ( {
253+ organization : fallbackOrg . organizationName ,
254+ startDate : uncoveredPeriodStart . toISOString ( ) ,
255+ endDate : dayBefore ( boundaryDate ) . toISOString ( ) ,
256+ } )
324257 }
325258
326259 uncoveredPeriodStart = null
@@ -330,67 +263,34 @@ function buildTimeline(
330263
331264 // No current window open — start a new one with the winning org
332265 if ( ! currentOrg ) {
333- log . debug (
334- { memberId, org : winningAffiliation . organizationName , from : boundaryDate . toISOString ( ) } ,
335- 'opening affiliation window' ,
336- )
337266 currentOrg = winningAffiliation
338267 currentWindowStart = boundaryDate
339268 continue
340269 }
341270
342271 // Winning org changed — close the current window and open a new one
343272 if ( currentOrg . organizationId !== winningAffiliation . organizationId ) {
344- log . debug (
345- {
346- memberId,
347- from : currentOrg . organizationName ,
348- to : winningAffiliation . organizationName ,
349- at : boundaryDate . toISOString ( ) ,
350- } ,
351- 'affiliation changed' ,
352- )
353- closeAffiliationWindow (
354- memberId ,
355- affiliations ,
356- currentOrg ,
357- currentWindowStart ?? boundaryDate ,
358- dayBefore ( boundaryDate ) ,
359- )
273+ affiliations . push ( {
274+ organization : currentOrg . organizationName ,
275+ startDate : ( currentWindowStart ?? boundaryDate ) . toISOString ( ) ,
276+ endDate : dayBefore ( boundaryDate ) . toISOString ( ) ,
277+ } )
360278 currentOrg = winningAffiliation
361279 currentWindowStart = boundaryDate
362280 }
363281 }
364282
365283 // Close the last open window using the org's actual end date (null = ongoing)
366284 if ( currentOrg && currentWindowStart ) {
367- const endDate = currentOrg . dateEnd ? new Date ( currentOrg . dateEnd ) . toISOString ( ) : null
368- log . debug (
369- {
370- memberId,
371- org : currentOrg . organizationName ,
372- start : currentWindowStart . toISOString ( ) ,
373- endDate,
374- } ,
375- 'closing final affiliation window' ,
376- )
377285 affiliations . push ( {
378286 organization : currentOrg . organizationName ,
379287 startDate : currentWindowStart . toISOString ( ) ,
380- endDate,
288+ endDate : currentOrg . dateEnd ? new Date ( currentOrg . dateEnd ) . toISOString ( ) : null ,
381289 } )
382290 }
383291
384292 // Close a trailing uncovered period using the fallback org (ongoing, no end date)
385293 if ( uncoveredPeriodStart !== null && fallbackOrg ) {
386- log . debug (
387- {
388- memberId,
389- fallbackOrg : fallbackOrg . organizationName ,
390- uncoveredPeriodStart : uncoveredPeriodStart . toISOString ( ) ,
391- } ,
392- 'closing trailing uncovered period with fallback org' ,
393- )
394294 affiliations . push ( {
395295 organization : fallbackOrg . organizationName ,
396296 startDate : uncoveredPeriodStart . toISOString ( ) ,
@@ -401,9 +301,7 @@ function buildTimeline(
401301 return affiliations
402302}
403303
404- // ─── Per-member resolution ────────────────────────────────────────────────────
405-
406- function resolveAffiliationsForMember ( memberId : string , rows : IWorkRow [ ] ) : IAffiliationPeriod [ ] {
304+ function resolveAffiliationsForMember ( rows : IWorkRow [ ] ) : IAffiliationPeriod [ ] {
407305 // If one undated work-experience org is marked primary, drop other undated work-experience orgs
408306 // to avoid infinite conflicts. Manual affiliations (segmentId !== null) are never dropped.
409307 const primaryUndated = rows . find ( ( r ) => r . isPrimaryWorkExperience && ! r . dateStart && ! r . dateEnd )
@@ -414,58 +312,17 @@ function resolveAffiliationsForMember(memberId: string, rows: IWorkRow[]): IAffi
414312 const fallbackOrg = findFallbackOrg ( cleaned )
415313 const datedRows = cleaned . filter ( ( r ) => r . dateStart )
416314
417- log . debug (
418- {
419- memberId,
420- datedRows : datedRows . length ,
421- undatedRows : cleaned . length - datedRows . length ,
422- fallbackOrg : fallbackOrg ?. organizationName ?? null ,
423- datedRowsList : datedRows . map ( ( r ) => ( {
424- org : r . organizationName ,
425- dateStart : r . dateStart ,
426- dateEnd : r . dateEnd ,
427- } ) ) ,
428- } ,
429- 'prepared rows' ,
430- )
431-
432315 if ( datedRows . length === 0 ) {
433316 if ( fallbackOrg ) {
434- log . debug (
435- { memberId, fallbackOrg : fallbackOrg . organizationName } ,
436- 'no dated rows — returning fallback as undated affiliation' ,
437- )
438317 return [ { organization : fallbackOrg . organizationName , startDate : null , endDate : null } ]
439318 }
440- log . debug ( { memberId } , 'no dated rows and no fallback — returning empty affiliations' )
441319 return [ ]
442320 }
443321
444322 const boundaries = collectBoundaries ( datedRows )
445- log . debug (
446- {
447- memberId,
448- boundaries : boundaries . length ,
449- boundaryDates : boundaries . map ( ( b ) => b . toISOString ( ) ) ,
450- } ,
451- 'collected boundaries' ,
452- )
453323
454324 // Pass all cleaned rows (not just dated) so undated orgs compete at every boundary (bug 2 fix)
455- const timeline = buildTimeline ( memberId , cleaned , fallbackOrg , boundaries )
456-
457- log . debug (
458- {
459- memberId,
460- affiliations : timeline . length ,
461- result : timeline . map ( ( a ) => ( {
462- org : a . organization ,
463- startDate : a . startDate ,
464- endDate : a . endDate ,
465- } ) ) ,
466- } ,
467- 'timeline built' ,
468- )
325+ const timeline = buildTimeline ( cleaned , fallbackOrg , boundaries )
469326
470327 return timeline . sort ( ( a , b ) => {
471328 if ( ! a . startDate ) return 1
@@ -474,8 +331,6 @@ function resolveAffiliationsForMember(memberId: string, rows: IWorkRow[]): IAffi
474331 } )
475332}
476333
477- // ─── Public bulk resolver ─────────────────────────────────────────────────────
478-
479334export async function resolveAffiliationsByMemberIds (
480335 qx : QueryExecutor ,
481336 memberIds : string [ ] ,
@@ -494,7 +349,7 @@ export async function resolveAffiliationsByMemberIds(
494349
495350 const result = new Map < string , IAffiliationPeriod [ ] > ( )
496351 for ( const id of memberIds ) {
497- result . set ( id , resolveAffiliationsForMember ( id , byMember . get ( id ) ?? [ ] ) )
352+ result . set ( id , resolveAffiliationsForMember ( byMember . get ( id ) ?? [ ] ) )
498353 }
499354 return result
500355}
0 commit comments