@open-mercato/core 0.6.4-develop.4326.1.9a8cfb5ccb → 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 (26) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/audit_logs/api/audit-logs/actions/undo/route.js +3 -2
  3. package/dist/modules/audit_logs/api/audit-logs/actions/undo/route.js.map +2 -2
  4. package/dist/modules/entities/api/records.js +2 -2
  5. package/dist/modules/entities/api/records.js.map +2 -2
  6. package/dist/modules/entities/lib/helpers.js +25 -1
  7. package/dist/modules/entities/lib/helpers.js.map +2 -2
  8. package/dist/modules/entities/lib/validation.js +3 -1
  9. package/dist/modules/entities/lib/validation.js.map +2 -2
  10. package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.js +50 -17
  11. package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.js.map +2 -2
  12. package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/route.js +24 -10
  13. package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/route.js.map +2 -2
  14. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +31 -12
  15. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
  16. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js +42 -29
  17. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js.map +2 -2
  18. package/package.json +7 -7
  19. package/src/modules/audit_logs/api/audit-logs/actions/undo/route.ts +9 -2
  20. package/src/modules/entities/api/records.ts +2 -2
  21. package/src/modules/entities/lib/helpers.ts +29 -4
  22. package/src/modules/entities/lib/validation.ts +10 -2
  23. package/src/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.ts +57 -18
  24. package/src/modules/staff/api/timesheets/time-entries/[id]/segments/route.ts +29 -10
  25. package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +38 -14
  26. package/src/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.ts +52 -34
@@ -6,7 +6,9 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
6
  import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
7
7
  import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
8
8
  import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
9
+ import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
9
10
  import type { EntityManager } from '@mikro-orm/postgresql'
11
+ import { LockMode } from '@mikro-orm/core'
10
12
  import { StaffTimeEntry, StaffTimeEntrySegment } from '../../../../../../data/entities'
11
13
  import { staffTimeEntrySegmentUpdateSchema } from '../../../../../../data/validators'
12
14
  import { getStaffMemberByUserId } from '../../../../../../lib/staffMemberResolver'
@@ -110,25 +112,62 @@ export async function PATCH(req: Request) {
110
112
  )
111
113
  }
112
114
 
113
- if (parsed.data.startedAt !== undefined) {
114
- segment.startedAt = parsed.data.startedAt
115
- }
116
- if (parsed.data.endedAt !== undefined) {
117
- segment.endedAt = parsed.data.endedAt ?? null
118
- }
119
- if (parsed.data.segmentType !== undefined) {
120
- segment.segmentType = parsed.data.segmentType
115
+ // Apply the segment edit inside a single transaction with a PESSIMISTIC_WRITE
116
+ // lock on the parent time entry row, re-loading the segment under the lock so
117
+ // concurrent segment edits / timer-stop recomputes on the same entry serialize
118
+ // instead of racing on a shared in-memory snapshot (issue #2416).
119
+ let updatedSegment: StaffTimeEntrySegment
120
+ try {
121
+ updatedSegment = await em.transactional(async (trx) => {
122
+ const lockedEntry = await findOneWithDecryption(
123
+ trx,
124
+ StaffTimeEntry,
125
+ { id: ids.entryId, tenantId, organizationId, deletedAt: null },
126
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
127
+ scopeCtx,
128
+ )
129
+ if (!lockedEntry) {
130
+ throw new CrudHttpError(404, { error: 'Time entry not found' })
131
+ }
132
+
133
+ const lockedSegment = await findOneWithDecryption(
134
+ trx,
135
+ StaffTimeEntrySegment,
136
+ { id: ids.segmentId, timeEntryId: ids.entryId, tenantId, organizationId, deletedAt: null },
137
+ {},
138
+ scopeCtx,
139
+ )
140
+ if (!lockedSegment) {
141
+ throw new CrudHttpError(404, { error: 'Segment not found' })
142
+ }
143
+
144
+ if (parsed.data.startedAt !== undefined) {
145
+ lockedSegment.startedAt = parsed.data.startedAt
146
+ }
147
+ if (parsed.data.endedAt !== undefined) {
148
+ lockedSegment.endedAt = parsed.data.endedAt ?? null
149
+ }
150
+ if (parsed.data.segmentType !== undefined) {
151
+ lockedSegment.segmentType = parsed.data.segmentType
152
+ }
153
+
154
+ await trx.flush()
155
+ return lockedSegment
156
+ })
157
+ } catch (err) {
158
+ if (isCrudHttpError(err)) {
159
+ return NextResponse.json(err.body, { status: err.status })
160
+ }
161
+ throw err
121
162
  }
122
163
 
123
- await em.flush()
124
-
125
164
  if (guardResult.afterSuccessCallbacks.length) {
126
165
  await runStaffMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {
127
166
  tenantId,
128
167
  organizationId,
129
168
  userId: auth.sub ?? '',
130
169
  resourceKind: 'staff.timesheets.time_entry_segment',
131
- resourceId: segment.id,
170
+ resourceId: updatedSegment.id,
132
171
  operation: 'update',
133
172
  requestMethod: req.method,
134
173
  requestHeaders: req.headers,
@@ -138,13 +177,13 @@ export async function PATCH(req: Request) {
138
177
  return NextResponse.json({
139
178
  ok: true,
140
179
  item: {
141
- id: segment.id,
142
- timeEntryId: segment.timeEntryId,
143
- startedAt: segment.startedAt,
144
- endedAt: segment.endedAt,
145
- segmentType: segment.segmentType,
146
- createdAt: segment.createdAt,
147
- updatedAt: segment.updatedAt,
180
+ id: updatedSegment.id,
181
+ timeEntryId: updatedSegment.timeEntryId,
182
+ startedAt: updatedSegment.startedAt,
183
+ endedAt: updatedSegment.endedAt,
184
+ segmentType: updatedSegment.segmentType,
185
+ createdAt: updatedSegment.createdAt,
186
+ updatedAt: updatedSegment.updatedAt,
148
187
  },
149
188
  })
150
189
  }
@@ -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,