@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/audit_logs/api/audit-logs/actions/undo/route.js +3 -2
- package/dist/modules/audit_logs/api/audit-logs/actions/undo/route.js.map +2 -2
- package/dist/modules/entities/api/records.js +2 -2
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/entities/lib/helpers.js +25 -1
- package/dist/modules/entities/lib/helpers.js.map +2 -2
- package/dist/modules/entities/lib/validation.js +3 -1
- package/dist/modules/entities/lib/validation.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.js +50 -17
- package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/route.js +24 -10
- package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +31 -12
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js +42 -29
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/audit_logs/api/audit-logs/actions/undo/route.ts +9 -2
- package/src/modules/entities/api/records.ts +2 -2
- package/src/modules/entities/lib/helpers.ts +29 -4
- package/src/modules/entities/lib/validation.ts +10 -2
- package/src/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.ts +57 -18
- package/src/modules/staff/api/timesheets/time-entries/[id]/segments/route.ts +29 -10
- package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +38 -14
- 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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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:
|
|
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:
|
|
142
|
-
timeEntryId:
|
|
143
|
-
startedAt:
|
|
144
|
-
endedAt:
|
|
145
|
-
segmentType:
|
|
146
|
-
createdAt:
|
|
147
|
-
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
entry
|
|
103
|
-
entry
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
entry
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
122
|
-
.
|
|
123
|
-
.
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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,
|