@@ -53,6 +53,72 @@ export class TimeTrackerService {
5353 userId = '' ;
5454 employeeId = '' ;
5555
56+ /**
57+ * Promise-chain mutex that serializes timer state mutations.
58+ * toggleApiStart / toggleApiStop / updateTimeLog / addTimeLog all funnel
59+ * through _timerMutex so no two of them can run concurrently. Without this
60+ * guard, an offline-sync stop and an online session start can reach the server
61+ * simultaneously, causing stopPreviousRunningTimers to override stoppedAt and
62+ * trigger cascade deletion of adjacent timelogs.
63+ */
64+ private _timerMutex : Promise < unknown > = Promise . resolve ( ) ;
65+
66+ /**
67+ * Failsafe timeout for a single enqueued operation.
68+ * Set to 2× the HTTP layer timeout so legitimate slow requests are never
69+ * killed early, while a genuinely hung call still releases the chain.
70+ */
71+ private static readonly _OP_TIMEOUT_MS = 120_000 ;
72+
73+ /**
74+ * Enqueue a timer API operation onto the serialization chain.
75+ *
76+ * Guarantees:
77+ * - Operations execute one at a time, in call order.
78+ * - A per-operation timeout (_OP_TIMEOUT_MS) releases the chain if the
79+ * underlying HTTP call never settles, preventing a permanent deadlock.
80+ * - Errors thrown by `fn` propagate to the caller unchanged; the mutex
81+ * chain itself never gets stuck regardless of failures.
82+ *
83+ * @param label Short human-readable name logged at start / end / error.
84+ * @param fn Factory that returns the Promise to await.
85+ */
86+ private _serialized < T > ( label : string , fn : ( ) => Promise < T > ) : Promise < T > {
87+ const result : Promise < T > = this . _timerMutex . then ( async ( ) => {
88+ this . _loggerService . info ( `[TimerMutex] ▶ ${ label } ` ) ;
89+
90+ let timeoutId : ReturnType < typeof setTimeout > | undefined ;
91+
92+ const timeoutRace = new Promise < never > ( ( _ , reject ) => {
93+ timeoutId = setTimeout (
94+ ( ) =>
95+ reject (
96+ new Error (
97+ `[TimerMutex] "${ label } " timed out after ${ TimeTrackerService . _OP_TIMEOUT_MS } ms — mutex released`
98+ )
99+ ) ,
100+ TimeTrackerService . _OP_TIMEOUT_MS
101+ ) ;
102+ } ) ;
103+
104+ try {
105+ return await Promise . race ( [ fn ( ) , timeoutRace ] ) ;
106+ } catch ( error ) {
107+ this . _loggerService . error ( `[TimerMutex] ✖ ${ label } ` , error ) ;
108+ throw error ;
109+ } finally {
110+ clearTimeout ( timeoutId ) ;
111+ this . _loggerService . info ( `[TimerMutex] ■ ${ label } done` ) ;
112+ }
113+ } ) ;
114+
115+ // Absorb rejection on the mutex chain so the next queued operation is
116+ // never blocked by the failure of a previous one. The caller receives
117+ // the real rejection via `result`.
118+ this . _timerMutex = result . catch ( ( ) => { } ) ;
119+ return result ;
120+ }
121+
56122 constructor (
57123 private readonly http : HttpClient ,
58124 private readonly _clientCacheService : ClientCacheService ,
@@ -469,7 +535,7 @@ export class TimeTrackerService {
469535 organizationTeamId : values . organizationTeamId
470536 } ;
471537 this . _loggerService . log . info ( `Toggle Start Timer Request: ${ moment ( ) . format ( ) } ` , body ) ;
472- return firstValueFrom ( this . http . post ( `${ API_PREFIX } /timesheet/timer/start` , { ...body } , options ) ) ;
538+ return this . _serialized ( 'toggleApiStart' , ( ) => firstValueFrom ( this . http . post ( `${ API_PREFIX } /timesheet/timer/start` , { ...body } , options ) ) ) ;
473539 }
474540
475541 toggleApiStop ( values ) {
@@ -516,26 +582,38 @@ export class TimeTrackerService {
516582 // Log request details
517583 this . _loggerService . info < any > ( `Toggle Stop Timer Request: ${ moment ( ) . format ( ) } ` , body ) ;
518584
519- // Perform the API call
520- try {
521- return firstValueFrom ( this . http . post < ITimeLog > ( API_URL , body , options ) ) ;
522- } catch ( error ) {
523- this . _loggerService . error < any > ( `Error stopping timer: ${ moment ( ) . format ( ) } ` , { error, requestBody : body } ) ;
524- throw error ;
525- }
585+ return this . _serialized ( 'toggleApiStop' , ( ) => {
586+ try {
587+ return firstValueFrom ( this . http . post < ITimeLog > ( API_URL , body , options ) ) ;
588+ } catch ( error ) {
589+ this . _loggerService . error < any > ( `Error stopping timer: ${ moment ( ) . format ( ) } ` , { error, requestBody : body } ) ;
590+ throw error ;
591+ }
592+ } ) ;
526593 }
527594
528595 updateTimeLog ( timeLogId : string , payload : Partial < ITimeLog > ) {
529596 const TIMEOUT = 15000 ;
530597 const API_URL = `${ API_PREFIX } /timesheet/time-log/${ timeLogId } ` ;
598+
599+ // Guard: a null organizationId causes the server to silently move the timelog to a
600+ // null-org timesheet, making it invisible in the dashboard without any error or deletion.
601+ if ( ! this . _store . organizationId || ! this . _store . tenantId ) {
602+ const msg = `updateTimeLog aborted for ${ timeLogId } : organizationId or tenantId is null in store` ;
603+ this . _loggerService . log . warn ( msg ) ;
604+ throw new Error ( msg ) ;
605+ }
606+
531607 const timeLogPayload : Partial < ITimeLog > = {
532- startedAt : moment ( payload . startedAt ) . utc ( ) . toDate ( ) ,
608+ ... ( payload . startedAt ? { startedAt : moment ( payload . startedAt ) . utc ( ) . toDate ( ) } : { } ) ,
533609 stoppedAt : moment ( payload . stoppedAt ) . utc ( ) . toDate ( ) ,
610+ ...( payload . isRunning !== undefined ? { isRunning : payload . isRunning } : { } ) ,
534611 isBillable : true ,
535612 logType : TimeLogType . TRACKED ,
536613 source : TimeLogSourceEnum . DESKTOP ,
537614 tenantId : this . _store . tenantId ,
538615 organizationId : this . _store . organizationId ,
616+ organizationContactId : payload . organizationContactId ,
539617 employeeId : this . _store . user ?. employee ?. id ,
540618 ...( payload . description ? { description : payload . description } : { } ) ,
541619 ...( payload . taskId ? { taskId : payload . taskId } : { } ) ,
@@ -545,19 +623,29 @@ export class TimeTrackerService {
545623 headers : new HttpHeaders ( { timeout : TIMEOUT . toString ( ) } )
546624 } ;
547625 this . _loggerService . log . info ( `Update Time Log Request: ${ timeLogId } ${ moment ( ) . format ( ) } ` , timeLogPayload ) ;
548- return firstValueFrom ( this . http . put < ITimeLog > ( API_URL , timeLogPayload , options ) ) ;
626+ return this . _serialized ( `updateTimeLog: ${ timeLogId } ` , ( ) => firstValueFrom ( this . http . put < ITimeLog > ( API_URL , timeLogPayload , options ) ) ) ;
549627 }
550628
551629 addTimeLog ( payload : Partial < ITimeLog > ) {
552630 const TIMEOUT = 15000 ;
553631 const API_URL = `${ API_PREFIX } /timesheet/time-log` ;
632+
633+ // Guard: a null organizationId causes the server to silently move the timelog to a
634+ // null-org timesheet, making it invisible in the dashboard without any error or deletion.
635+ if ( ! this . _store . organizationId || ! this . _store . tenantId ) {
636+ const msg = `addTimeLog aborted: organizationId or tenantId is null in store` ;
637+ this . _loggerService . log . warn ( msg ) ;
638+ throw new Error ( msg ) ;
639+ }
640+
554641 const timeLogPayload : Partial < ITimeLog > = {
555642 startedAt : moment ( payload . startedAt ) . utc ( ) . toDate ( ) ,
556643 stoppedAt : moment ( payload . stoppedAt ) . utc ( ) . toDate ( ) ,
557644 isBillable : true ,
558645 logType : TimeLogType . TRACKED ,
559646 source : TimeLogSourceEnum . DESKTOP ,
560647 tenantId : this . _store . tenantId ,
648+ organizationContactId : payload . organizationContactId ,
561649 organizationId : this . _store . organizationId ,
562650 employeeId : this . _store . user ?. employee ?. id ,
563651 ...( payload . description ? { description : payload . description } : { } ) ,
@@ -569,7 +657,7 @@ export class TimeTrackerService {
569657 headers : new HttpHeaders ( { timeout : TIMEOUT . toString ( ) } )
570658 } ;
571659 this . _loggerService . log . info ( `Add Time Log Request: ${ moment ( ) . format ( ) } ` , timeLogPayload ) ;
572- return firstValueFrom ( this . http . post < ITimeLog > ( API_URL , timeLogPayload , options ) ) ;
660+ return this . _serialized ( 'addTimeLog' , ( ) => firstValueFrom ( this . http . post < ITimeLog > ( API_URL , timeLogPayload , options ) ) ) ;
573661 }
574662
575663 deleteTimeSlot ( values ) {
0 commit comments