@open-mercato/core 0.6.4-develop.4331.1.64a8535120 → 0.6.4-develop.4339.1.fad812f76f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/entities/api/records.js +2 -2
  3. package/dist/modules/entities/api/records.js.map +2 -2
  4. package/dist/modules/entities/lib/helpers.js +25 -1
  5. package/dist/modules/entities/lib/helpers.js.map +2 -2
  6. package/dist/modules/entities/lib/validation.js +3 -1
  7. package/dist/modules/entities/lib/validation.js.map +2 -2
  8. package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.js +50 -17
  9. package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.js.map +2 -2
  10. package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/route.js +24 -10
  11. package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/route.js.map +2 -2
  12. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +31 -12
  13. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
  14. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js +42 -29
  15. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js.map +2 -2
  16. package/package.json +7 -7
  17. package/src/modules/entities/api/records.ts +2 -2
  18. package/src/modules/entities/lib/helpers.ts +29 -4
  19. package/src/modules/entities/lib/validation.ts +10 -2
  20. package/src/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.ts +57 -18
  21. package/src/modules/staff/api/timesheets/time-entries/[id]/segments/route.ts +29 -10
  22. package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +38 -14
  23. package/src/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.ts +52 -34
@@ -9,6 +9,7 @@ import { parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'
9
9
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
10
10
  import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
11
11
  import type { EntityManager } from '@mikro-orm/postgresql'
12
+ import { LockMode } from '@mikro-orm/core'
12
13
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
13
14
  import { StaffTimeEntry, StaffTimeEntrySegment } from '../../../../../data/entities'
14
15
  import { staffTimeEntrySegmentCreateSchema } from '../../../../../data/validators'
@@ -109,17 +110,35 @@ export async function POST(req: Request) {
109
110
  )
110
111
  }
111
112
 
112
- const segmentData = {
113
- tenantId: input.tenantId,
114
- organizationId: input.organizationId,
115
- timeEntryId: input.timeEntryId,
116
- startedAt: input.startedAt,
117
- endedAt: input.endedAt ?? null,
118
- segmentType: input.segmentType,
119
- }
120
- const segment = em.create(StaffTimeEntrySegment, segmentData as never)
113
+ // Create the segment inside a single transaction with a PESSIMISTIC_WRITE
114
+ // lock on the parent time entry row, so segment writes serialize against
115
+ // concurrent timer-stop / segment mutations that recompute the entry from a
116
+ // shared snapshot (issue #2416).
117
+ const segment = await em.transactional(async (trx) => {
118
+ const lockedEntry = await findOneWithDecryption(
119
+ trx,
120
+ StaffTimeEntry,
121
+ { id: entryId, tenantId, organizationId, deletedAt: null },
122
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
123
+ scopeCtx,
124
+ )
125
+ if (!lockedEntry) {
126
+ throw new CrudHttpError(404, { error: translate('staff.timesheets.errors.entryNotFound', 'Time entry not found.') })
127
+ }
128
+
129
+ const segmentData = {
130
+ tenantId: input.tenantId,
131
+ organizationId: input.organizationId,
132
+ timeEntryId: input.timeEntryId,
133
+ startedAt: input.startedAt,
134
+ endedAt: input.endedAt ?? null,
135
+ segmentType: input.segmentType,
136
+ }
137
+ const created = trx.create(StaffTimeEntrySegment, segmentData as never)
121
138
 
122
- await em.flush()
139
+ await trx.flush()
140
+ return created
141
+ })
123
142
 
124
143
  if (guardResult.afterSuccessCallbacks.length) {
125
144
  await runStaffMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {
@@ -7,6 +7,7 @@ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
7
7
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
8
8
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
9
9
  import type { EntityManager } from '@mikro-orm/postgresql'
10
+ import { LockMode } from '@mikro-orm/core'
10
11
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
11
12
  import { StaffTimeEntry, StaffTimeEntrySegment } from '../../../../../data/entities'
12
13
  import { getStaffMemberByUserId } from '../../../../../lib/staffMemberResolver'
@@ -98,20 +99,43 @@ export async function POST(req: Request) {
98
99
  )
99
100
  }
100
101
 
101
- const now = new Date()
102
- entry.startedAt = now
103
- entry.source = 'timer'
104
-
105
- const segmentData = {
106
- tenantId,
107
- organizationId,
108
- timeEntryId: entry.id,
109
- startedAt: now,
110
- segmentType: 'work' as const,
111
- }
112
- em.create(StaffTimeEntrySegment, segmentData as never)
113
-
114
- await em.flush()
102
+ // Start the timer inside a single transaction with a PESSIMISTIC_WRITE lock
103
+ // on the time entry row, re-checking startedAt under the lock so two
104
+ // concurrent timer-start calls on the same entry cannot both create an
105
+ // initial work segment (issue #2416).
106
+ const now = await em.transactional(async (trx) => {
107
+ const lockedEntry = await findOneWithDecryption(
108
+ trx,
109
+ StaffTimeEntry,
110
+ { id: entryId, tenantId, organizationId, deletedAt: null },
111
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
112
+ scopeCtx,
113
+ )
114
+ if (!lockedEntry) {
115
+ throw new CrudHttpError(404, { error: translate('staff.timesheets.errors.entryNotFound', 'Time entry not found.') })
116
+ }
117
+ if (lockedEntry.startedAt) {
118
+ throw new CrudHttpError(409, {
119
+ error: translate('staff.timesheets.errors.timerAlreadyStarted', 'Timer is already started for this entry.'),
120
+ })
121
+ }
122
+
123
+ const startedAt = new Date()
124
+ lockedEntry.startedAt = startedAt
125
+ lockedEntry.source = 'timer'
126
+
127
+ const segmentData = {
128
+ tenantId,
129
+ organizationId,
130
+ timeEntryId: lockedEntry.id,
131
+ startedAt,
132
+ segmentType: 'work' as const,
133
+ }
134
+ trx.create(StaffTimeEntrySegment, segmentData as never)
135
+
136
+ await trx.flush()
137
+ return startedAt
138
+ })
115
139
 
116
140
  void emitStaffEvent('staff.timesheets.time_entry.timer_started', {
117
141
  id: entry.id,
@@ -7,6 +7,7 @@ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
7
7
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
8
8
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
9
9
  import type { EntityManager } from '@mikro-orm/postgresql'
10
+ import { LockMode } from '@mikro-orm/core'
10
11
  import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
11
12
  import { StaffTimeEntry, StaffTimeEntrySegment } from '../../../../../data/entities'
12
13
  import { getStaffMemberByUserId } from '../../../../../lib/staffMemberResolver'
@@ -70,22 +71,6 @@ export async function POST(req: Request) {
70
71
  throw new CrudHttpError(403, { error: translate('staff.timesheets.errors.notOwner', 'You can only manage your own time entries.') })
71
72
  }
72
73
 
73
- const segments = await findWithDecryption(
74
- em,
75
- StaffTimeEntrySegment,
76
- { timeEntryId: entry.id, tenantId, organizationId, deletedAt: null },
77
- {},
78
- scopeCtx,
79
- )
80
-
81
- const activeSegment = segments.find((segment) => !segment.endedAt)
82
- if (!activeSegment) {
83
- return NextResponse.json(
84
- { error: translate('staff.timesheets.errors.noActiveSegment', 'No active timer segment found for this entry.') },
85
- { status: 409 },
86
- )
87
- }
88
-
89
74
  const guardResult = await runStaffMutationGuards(
90
75
  container,
91
76
  {
@@ -107,29 +92,62 @@ export async function POST(req: Request) {
107
92
  )
108
93
  }
109
94
 
110
- const now = new Date()
111
- activeSegment.endedAt = now
112
- entry.endedAt = now
95
+ // Recompute and persist the timer state inside a single transaction with a
96
+ // PESSIMISTIC_WRITE lock on the time entry row, so concurrent timer-stop /
97
+ // segment writes on the same entry serialize instead of racing on a shared
98
+ // in-memory snapshot (issue #2416).
99
+ const { now, durationMinutes } = await em.transactional(async (trx) => {
100
+ const lockedEntry = await findOneWithDecryption(
101
+ trx,
102
+ StaffTimeEntry,
103
+ { id: entryId, tenantId, organizationId, deletedAt: null },
104
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
105
+ scopeCtx,
106
+ )
107
+ if (!lockedEntry) {
108
+ throw new CrudHttpError(404, { error: translate('staff.timesheets.errors.entryNotFound', 'Time entry not found.') })
109
+ }
113
110
 
114
- const allSegments = segments.map((segment) => {
115
- if (segment.id === activeSegment.id) {
116
- return { ...segment, endedAt: now }
111
+ const segments = await findWithDecryption(
112
+ trx,
113
+ StaffTimeEntrySegment,
114
+ { timeEntryId: lockedEntry.id, tenantId, organizationId, deletedAt: null },
115
+ {},
116
+ scopeCtx,
117
+ )
118
+
119
+ const activeSegment = segments.find((segment) => !segment.endedAt)
120
+ if (!activeSegment) {
121
+ throw new CrudHttpError(409, {
122
+ error: translate('staff.timesheets.errors.noActiveSegment', 'No active timer segment found for this entry.'),
123
+ })
117
124
  }
118
- return segment
119
- })
120
125
 
121
- const totalWorkMinutes = allSegments
122
- .filter((segment) => segment.segmentType === 'work' && segment.startedAt && segment.endedAt)
123
- .reduce((sum, segment) => {
124
- const startMs = new Date(segment.startedAt).getTime()
125
- const endMs = new Date(segment.endedAt!).getTime()
126
- return sum + (endMs - startMs)
127
- }, 0)
126
+ const stoppedAt = new Date()
127
+ activeSegment.endedAt = stoppedAt
128
+ lockedEntry.endedAt = stoppedAt
128
129
 
129
- const durationMinutes = Math.round(totalWorkMinutes / 60000)
130
- entry.durationMinutes = durationMinutes
130
+ const allSegments = segments.map((segment) => {
131
+ if (segment.id === activeSegment.id) {
132
+ return { ...segment, endedAt: stoppedAt }
133
+ }
134
+ return segment
135
+ })
136
+
137
+ const totalWorkMinutes = allSegments
138
+ .filter((segment) => segment.segmentType === 'work' && segment.startedAt && segment.endedAt)
139
+ .reduce((sum, segment) => {
140
+ const startMs = new Date(segment.startedAt).getTime()
141
+ const endMs = new Date(segment.endedAt!).getTime()
142
+ return sum + (endMs - startMs)
143
+ }, 0)
131
144
 
132
- await em.flush()
145
+ const computedMinutes = Math.round(totalWorkMinutes / 60000)
146
+ lockedEntry.durationMinutes = computedMinutes
147
+
148
+ await trx.flush()
149
+ return { now: stoppedAt, durationMinutes: computedMinutes }
150
+ })
133
151
 
134
152
  void emitStaffEvent('staff.timesheets.time_entry.timer_stopped', {
135
153
  id: entry.id,