@open-mercato/core 0.6.4-develop.4331.1.64a8535120 → 0.6.4-develop.4358.1.233d5675c7

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 (47) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js +15 -10
  3. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js.map +2 -2
  4. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +2 -6
  5. package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
  6. package/dist/modules/entities/api/records.js +2 -2
  7. package/dist/modules/entities/api/records.js.map +2 -2
  8. package/dist/modules/entities/lib/helpers.js +25 -1
  9. package/dist/modules/entities/lib/helpers.js.map +2 -2
  10. package/dist/modules/entities/lib/validation.js +3 -1
  11. package/dist/modules/entities/lib/validation.js.map +2 -2
  12. package/dist/modules/sales/components/documents/AdjustmentDialog.js +10 -12
  13. package/dist/modules/sales/components/documents/AdjustmentDialog.js.map +2 -2
  14. package/dist/modules/sales/components/documents/LineItemDialog.js +10 -10
  15. package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
  16. package/dist/modules/sales/components/documents/PaymentDialog.js +8 -15
  17. package/dist/modules/sales/components/documents/PaymentDialog.js.map +2 -2
  18. package/dist/modules/sales/components/documents/ShipmentDialog.js +10 -14
  19. package/dist/modules/sales/components/documents/ShipmentDialog.js.map +2 -2
  20. package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.js +50 -17
  21. package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.js.map +2 -2
  22. package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/route.js +24 -10
  23. package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/route.js.map +2 -2
  24. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +31 -12
  25. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
  26. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js +42 -29
  27. package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js.map +2 -2
  28. package/dist/modules/workflows/components/EdgeEditDialog.js +3 -9
  29. package/dist/modules/workflows/components/EdgeEditDialog.js.map +2 -2
  30. package/dist/modules/workflows/components/NodeEditDialog.js +3 -9
  31. package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
  32. package/package.json +7 -7
  33. package/src/modules/customers/components/detail/ConfirmDealLostDialog.tsx +15 -10
  34. package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +2 -6
  35. package/src/modules/entities/api/records.ts +2 -2
  36. package/src/modules/entities/lib/helpers.ts +29 -4
  37. package/src/modules/entities/lib/validation.ts +10 -2
  38. package/src/modules/sales/components/documents/AdjustmentDialog.tsx +11 -12
  39. package/src/modules/sales/components/documents/LineItemDialog.tsx +11 -10
  40. package/src/modules/sales/components/documents/PaymentDialog.tsx +9 -16
  41. package/src/modules/sales/components/documents/ShipmentDialog.tsx +10 -14
  42. package/src/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.ts +57 -18
  43. package/src/modules/staff/api/timesheets/time-entries/[id]/segments/route.ts +29 -10
  44. package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +38 -14
  45. package/src/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.ts +52 -34
  46. package/src/modules/workflows/components/EdgeEditDialog.tsx +3 -9
  47. package/src/modules/workflows/components/NodeEditDialog.tsx +3 -9
@@ -8,6 +8,7 @@ import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives
8
8
  import { Button } from '@open-mercato/ui/primitives/button'
9
9
  import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
10
10
  import { Textarea } from '@open-mercato/ui/primitives/textarea'
11
+ import { useDialogKeyHandler } from '@open-mercato/ui/hooks/useDialogKeyHandler'
11
12
 
12
13
  type LossReasonOption = {
13
14
  id: string
@@ -39,6 +40,7 @@ export function ConfirmDealLostDialog({
39
40
  const [lossReasons, setLossReasons] = React.useState<LossReasonOption[]>([])
40
41
  const [reasonListOpen, setReasonListOpen] = React.useState(false)
41
42
  const [error, setError] = React.useState('')
43
+ const [isConfirming, setIsConfirming] = React.useState(false)
42
44
 
43
45
  React.useEffect(() => {
44
46
  if (!open) return
@@ -74,18 +76,21 @@ export function ConfirmDealLostDialog({
74
76
  setError(t('customers.deals.detail.lost.reasonRequired', 'Please select a loss reason'))
75
77
  return
76
78
  }
77
- await onConfirm({
78
- lossReasonId,
79
- lossNotes: lossNotes.trim() || undefined,
80
- })
79
+ setIsConfirming(true)
80
+ try {
81
+ await onConfirm({
82
+ lossReasonId,
83
+ lossNotes: lossNotes.trim() || undefined,
84
+ })
85
+ } finally {
86
+ setIsConfirming(false)
87
+ }
81
88
  }, [lossNotes, lossReasonId, onConfirm, t])
82
89
 
83
- const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
84
- if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
85
- event.preventDefault()
86
- void handleConfirm()
87
- }
88
- }, [handleConfirm])
90
+ const handleKeyDown = useDialogKeyHandler({
91
+ onConfirm: () => void handleConfirm(),
92
+ disabled: isConfirming,
93
+ })
89
94
 
90
95
  return (
91
96
  <Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose() }}>
@@ -13,6 +13,7 @@ import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives
13
13
  import { Button } from '@open-mercato/ui/primitives/button'
14
14
  import { IconButton } from '@open-mercato/ui/primitives/icon-button'
15
15
  import { Dialog, DialogContent, DialogTitle } from '@open-mercato/ui/primitives/dialog'
16
+ import { useDialogKeyHandler } from '@open-mercato/ui/hooks/useDialogKeyHandler'
16
17
  import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
17
18
  import { PhoneNumberField, SwitchableMarkdownInput } from '@open-mercato/ui/backend/inputs'
18
19
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
@@ -444,12 +445,7 @@ export function ScheduleActivityDialog({
444
445
  }
445
446
  }, [callDirection, callOutcome, callPhoneInvalidMessage, callPhoneNumber, isDateMissing, isTimeMissing, state.activityType, state.allDay, state.date, state.description, dealId, state.duration, editData, entityId, state.guestPermissions, state.linkedEntities, state.location, onActivityCreated, onClose, state.participants, state.recurrenceCount, state.recurrenceDays, state.recurrenceEnabled, state.recurrenceEndDate, state.recurrenceEndType, state.reminderMinutes, runGuardedMutation, state.startTime, t, taskPriority, state.title, translateErrorMessage, trimmedCallPhone, trimmedDate, trimmedStartTime, state.visibility, visibleFields]) // eslint-disable-line react-hooks/exhaustive-deps
446
447
 
447
- const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => {
448
- if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
449
- e.preventDefault()
450
- handleSave()
451
- }
452
- }, [handleSave])
448
+ const handleKeyDown = useDialogKeyHandler({ onConfirm: handleSave })
453
449
 
454
450
  return (
455
451
  <Dialog open={open} onOpenChange={(o) => { if (!o) void guardedClose() }}>
@@ -261,7 +261,7 @@ export async function POST(req: Request) {
261
261
  // Validate against custom field definitions
262
262
  try {
263
263
  const { validateCustomFieldValuesServer } = await import('../lib/validation')
264
- const check = await validateCustomFieldValuesServer(em, { entityId, organizationId: targetOrgId, tenantId: auth.tenantId!, values: norm })
264
+ const check = await validateCustomFieldValuesServer(em, { entityId, organizationId: targetOrgId, tenantId: auth.tenantId!, values: norm, rejectUndeclaredKeys: true })
265
265
  if (!check.ok) return NextResponse.json({ error: 'Validation failed', fields: check.fieldErrors }, { status: 400 })
266
266
  } catch { /* ignore if helper missing */ }
267
267
 
@@ -322,7 +322,7 @@ export async function PUT(req: Request) {
322
322
  // Validate against custom field definitions
323
323
  try {
324
324
  const { validateCustomFieldValuesServer } = await import('../lib/validation')
325
- const check = await validateCustomFieldValuesServer(em, { entityId, organizationId: targetOrgId, tenantId: auth.tenantId!, values: norm })
325
+ const check = await validateCustomFieldValuesServer(em, { entityId, organizationId: targetOrgId, tenantId: auth.tenantId!, values: norm, rejectUndeclaredKeys: true })
326
326
  if (!check.ok) return NextResponse.json({ error: 'Validation failed', fields: check.fieldErrors }, { status: 400 })
327
327
  } catch { /* ignore if helper missing */ }
328
328
 
@@ -1,6 +1,10 @@
1
1
  import type { EntityManager } from '@mikro-orm/core'
2
2
  import type { TenantDataEncryptionService } from '@open-mercato/shared/lib/encryption/tenantDataEncryptionService'
3
3
  import { encryptCustomFieldValue, resolveTenantEncryptionService } from '@open-mercato/shared/lib/encryption/customFieldValues'
4
+ import {
5
+ MAX_CUSTOM_FIELD_KEYS_PER_RECORD,
6
+ TOO_MANY_CUSTOM_FIELDS_ERROR,
7
+ } from '@open-mercato/shared/modules/entities/validation'
4
8
  import { CustomFieldDef, CustomFieldValue } from '../data/entities'
5
9
 
6
10
  type Primitive = string | number | boolean | null | undefined
@@ -69,16 +73,32 @@ export async function setRecordCustomFields(
69
73
  if (preferDefs) {
70
74
  const defs = await em.find(CustomFieldDef, {
71
75
  entityId,
76
+ isActive: true,
77
+ deletedAt: null,
72
78
  organizationId: { $in: [organizationId, null] as any },
73
79
  tenantId: { $in: [tenantId, null] as any },
74
80
  })
75
- // Prefer org+tenant-specific over global if duplicates
81
+ const scopeScore = (def: CustomFieldDef) => (def.tenantId ? 2 : 0) + (def.organizationId ? 1 : 0)
76
82
  defsByKey = {}
77
83
  for (const d of defs) {
78
84
  const existing = defsByKey[d.key]
79
- if (!existing ||
80
- (existing.organizationId == null && d.organizationId != null) ||
81
- (existing.tenantId == null && d.tenantId != null)) {
85
+ if (!existing) {
86
+ defsByKey[d.key] = d
87
+ continue
88
+ }
89
+ const nextScore = scopeScore(d)
90
+ const existingScore = scopeScore(existing)
91
+ if (nextScore > existingScore) {
92
+ defsByKey[d.key] = d
93
+ continue
94
+ }
95
+ if (nextScore < existingScore) continue
96
+
97
+ const nextUpdatedAt = d.updatedAt instanceof Date ? d.updatedAt.getTime() : new Date(d.updatedAt).getTime()
98
+ const existingUpdatedAt = existing.updatedAt instanceof Date
99
+ ? existing.updatedAt.getTime()
100
+ : new Date(existing.updatedAt).getTime()
101
+ if (nextUpdatedAt >= existingUpdatedAt) {
82
102
  defsByKey[d.key] = d
83
103
  }
84
104
  }
@@ -93,6 +113,11 @@ export async function setRecordCustomFields(
93
113
  return encryptionService
94
114
  }
95
115
  const keys = Object.keys(values)
116
+ const presentKeyCount = keys.filter((key) => values[key] !== undefined).length
117
+ if (preferDefs && presentKeyCount > MAX_CUSTOM_FIELD_KEYS_PER_RECORD) {
118
+ throw new Error(TOO_MANY_CUSTOM_FIELDS_ERROR)
119
+ }
120
+
96
121
  for (const fieldKey of keys) {
97
122
  const raw = values[fieldKey]
98
123
  if (raw === undefined) continue
@@ -4,7 +4,13 @@ import { validateValuesAgainstDefs } from '@open-mercato/shared/modules/entities
4
4
 
5
5
  export async function validateCustomFieldValuesServer(
6
6
  em: EntityManager,
7
- opts: { entityId: string; organizationId?: string | null; tenantId?: string | null; values: Record<string, any> },
7
+ opts: {
8
+ entityId: string
9
+ organizationId?: string | null
10
+ tenantId?: string | null
11
+ values: Record<string, any>
12
+ rejectUndeclaredKeys?: boolean
13
+ },
8
14
  ): Promise<{ ok: boolean; fieldErrors: Record<string, string> }> {
9
15
  const organizationId = opts.organizationId ?? null
10
16
  const tenantId = opts.tenantId ?? null
@@ -51,5 +57,7 @@ export async function validateCustomFieldValuesServer(
51
57
  byKey.set(d.key, d)
52
58
  }
53
59
  }
54
- return validateValuesAgainstDefs(opts.values, Array.from(byKey.values()) as any)
60
+ return validateValuesAgainstDefs(opts.values, Array.from(byKey.values()) as any, {
61
+ rejectUndeclaredKeys: opts.rejectUndeclaredKeys === true,
62
+ })
55
63
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import * as React from 'react'
6
6
  import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
7
+ import { useDialogKeyHandler } from '@open-mercato/ui/hooks/useDialogKeyHandler'
7
8
  import { Badge } from '@open-mercato/ui/primitives/badge'
8
9
  import { Button } from '@open-mercato/ui/primitives/button'
9
10
  import { Input } from '@open-mercato/ui/primitives/input'
@@ -716,22 +717,20 @@ export function AdjustmentDialog({
716
717
  ]
717
718
  )
718
719
 
720
+ const handleSubmitForm = React.useCallback(
721
+ () => dialogContentRef.current?.querySelector('form')?.requestSubmit(),
722
+ [],
723
+ )
724
+ const handleKeyDown = useDialogKeyHandler({
725
+ onConfirm: handleSubmitForm,
726
+ onCancel: () => onOpenChange(false),
727
+ })
728
+
719
729
  return (
720
730
  <Dialog open={open} onOpenChange={onOpenChange}>
721
731
  <DialogContent
722
732
  className="sm:max-w-5xl"
723
- onKeyDown={(event) => {
724
- if (event.key === 'Escape') {
725
- event.preventDefault()
726
- onOpenChange(false)
727
- return
728
- }
729
- if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
730
- event.preventDefault()
731
- const form = dialogContentRef.current?.querySelector('form')
732
- form?.requestSubmit()
733
- }
734
- }}
733
+ onKeyDown={handleKeyDown}
735
734
  ref={dialogContentRef}
736
735
  >
737
736
  <DialogHeader>
@@ -21,6 +21,7 @@ import {
21
21
  DialogHeader,
22
22
  DialogTitle,
23
23
  } from "@open-mercato/ui/primitives/dialog";
24
+ import { useDialogKeyHandler } from "@open-mercato/ui/hooks/useDialogKeyHandler";
24
25
  import { Button } from "@open-mercato/ui/primitives/button";
25
26
  import { Input } from "@open-mercato/ui/primitives/input";
26
27
  import {
@@ -2795,6 +2796,15 @@ export function LineItemDialog({
2795
2796
  resetForm,
2796
2797
  ]);
2797
2798
 
2799
+ const handleSubmitForm = React.useCallback(
2800
+ () => dialogContentRef.current?.querySelector("form")?.requestSubmit(),
2801
+ [],
2802
+ )
2803
+ const handleKeyDown = useDialogKeyHandler({
2804
+ onConfirm: handleSubmitForm,
2805
+ onCancel: closeDialog,
2806
+ })
2807
+
2798
2808
  return (
2799
2809
  <Dialog
2800
2810
  open={open}
@@ -2803,16 +2813,7 @@ export function LineItemDialog({
2803
2813
  <DialogContent
2804
2814
  className="sm:max-w-5xl"
2805
2815
  ref={dialogContentRef}
2806
- onKeyDown={(event) => {
2807
- if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
2808
- event.preventDefault();
2809
- dialogContentRef.current?.querySelector("form")?.requestSubmit();
2810
- }
2811
- if (event.key === "Escape") {
2812
- event.preventDefault();
2813
- closeDialog();
2814
- }
2815
- }}
2816
+ onKeyDown={handleKeyDown}
2816
2817
  >
2817
2818
  <DialogHeader>
2818
2819
  <DialogTitle>
@@ -9,6 +9,7 @@ import { collectCustomFieldValues } from '@open-mercato/ui/backend/utils/customF
9
9
  import { createCrud, updateCrud } from '@open-mercato/ui/backend/utils/crud'
10
10
  import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
11
11
  import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
12
+ import { useDialogKeyHandler } from '@open-mercato/ui/hooks/useDialogKeyHandler'
12
13
  import { Input } from '@open-mercato/ui/primitives/input'
13
14
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
14
15
  import { E } from '#generated/entities.ids.generated'
@@ -552,29 +553,21 @@ export function PaymentDialog({
552
553
  [currencyCode, mode, onOpenChange, onSaved, orderId, organizationId, payment?.id, t, tenantId]
553
554
  )
554
555
 
555
- const handleShortcutSubmit = React.useCallback(
556
- (event: React.KeyboardEvent<HTMLDivElement>) => {
557
- if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
558
- event.preventDefault()
559
- const form = dialogContentRef.current?.querySelector('form')
560
- form?.requestSubmit()
561
- }
562
- },
563
- []
556
+ const handleSubmitForm = React.useCallback(
557
+ () => dialogContentRef.current?.querySelector('form')?.requestSubmit(),
558
+ [],
564
559
  )
560
+ const handleKeyDown = useDialogKeyHandler({
561
+ onConfirm: handleSubmitForm,
562
+ onCancel: () => onOpenChange(false),
563
+ })
565
564
 
566
565
  return (
567
566
  <Dialog open={open} onOpenChange={onOpenChange}>
568
567
  <DialogContent
569
568
  ref={dialogContentRef}
570
569
  className="sm:max-w-5xl"
571
- onKeyDown={(event) => {
572
- if (event.key === 'Escape') {
573
- event.preventDefault()
574
- onOpenChange(false)
575
- }
576
- handleShortcutSubmit(event)
577
- }}
570
+ onKeyDown={handleKeyDown}
578
571
  >
579
572
  <DialogHeader>
580
573
  <DialogTitle>
@@ -3,6 +3,7 @@
3
3
  import * as React from 'react'
4
4
  import { MapPin, Truck } from 'lucide-react'
5
5
  import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
6
+ import { useDialogKeyHandler } from '@open-mercato/ui/hooks/useDialogKeyHandler'
6
7
  import { Input } from '@open-mercato/ui/primitives/input'
7
8
  import { Textarea } from '@open-mercato/ui/primitives/textarea'
8
9
  import { Label } from '@open-mercato/ui/primitives/label'
@@ -1109,13 +1110,14 @@ export function ShipmentDialog({
1109
1110
  ],
1110
1111
  )
1111
1112
 
1112
- const handleShortcutSubmit = React.useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
1113
- if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
1114
- event.preventDefault()
1115
- const form = dialogContentRef.current?.querySelector('form')
1116
- form?.requestSubmit()
1117
- }
1118
- }, [])
1113
+ const handleSubmitForm = React.useCallback(
1114
+ () => dialogContentRef.current?.querySelector('form')?.requestSubmit(),
1115
+ [],
1116
+ )
1117
+ const handleKeyDown = useDialogKeyHandler({
1118
+ onConfirm: handleSubmitForm,
1119
+ onCancel: onClose,
1120
+ })
1119
1121
 
1120
1122
  const fields = React.useMemo<CrudField[]>(() => {
1121
1123
  const shippingAdjustmentLabel = t(
@@ -1523,13 +1525,7 @@ export function ShipmentDialog({
1523
1525
  <DialogContent
1524
1526
  ref={dialogContentRef}
1525
1527
  className="sm:max-w-5xl"
1526
- onKeyDown={(event) => {
1527
- if (event.key === 'Escape') {
1528
- event.preventDefault()
1529
- onClose()
1530
- }
1531
- handleShortcutSubmit(event)
1532
- }}
1528
+ onKeyDown={handleKeyDown}
1533
1529
  >
1534
1530
  <DialogHeader>
1535
1531
  <DialogTitle>
@@ -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,