@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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../../src/modules/staff/api/timesheets/time-entries/%5Bid%5D/segments/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { StaffTimeEntry, StaffTimeEntrySegment } from '../../../../../data/entities'\nimport { staffTimeEntrySegmentCreateSchema } from '../../../../../data/validators'\nimport { getStaffMemberByUserId } from '../../../../../lib/staffMemberResolver'\nimport {\n resolveUserFeatures,\n runStaffMutationGuardAfterSuccess,\n runStaffMutationGuards,\n} from '../../../../guards'\n\nfunction extractEntryIdFromUrl(request?: Request): string | null {\n if (!request?.url) return null\n try {\n const url = new URL(request.url)\n const match = url.pathname.match(/\\/time-entries\\/([^/]+)\\/segments/)\n return match?.[1] ?? null\n } catch {\n return null\n }\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['staff.timesheets.manage_own'] },\n}\n\nexport async function POST(req: Request) {\n try {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const { translate } = await resolveTranslations()\n if (!auth) throw new CrudHttpError(401, { error: translate('staff.errors.unauthorized', 'Unauthorized') })\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const tenantId = scope?.tenantId ?? auth.tenantId ?? null\n const organizationId = scope?.selectedId ?? auth.orgId ?? null\n if (!tenantId || !organizationId) {\n throw new CrudHttpError(400, { error: translate('staff.errors.missingScope', 'Missing tenant or organization scope.') })\n }\n\n const em = (container.resolve('em') as EntityManager).fork()\n const scopeCtx = { tenantId, organizationId }\n\n const entryId = extractEntryIdFromUrl(req)\n if (!entryId) {\n throw new CrudHttpError(400, { error: translate('staff.timesheets.errors.missingEntryId', 'Missing entry ID.') })\n }\n\n const entry = await findOneWithDecryption(\n em,\n StaffTimeEntry,\n { id: entryId, tenantId, organizationId, deletedAt: null },\n {},\n scopeCtx,\n )\n if (!entry) {\n throw new CrudHttpError(404, { error: translate('staff.timesheets.errors.entryNotFound', 'Time entry not found.') })\n }\n\n const staffMember = await getStaffMemberByUserId(em, auth.sub, tenantId, organizationId)\n if (!staffMember || entry.staffMemberId !== staffMember.id) {\n throw new CrudHttpError(403, { error: translate('staff.timesheets.errors.notOwner', 'You can only manage your own time entries.') })\n }\n\n const body = await readJsonSafe(req, {})\n const input = parseScopedCommandInput(\n staffTimeEntrySegmentCreateSchema,\n { ...body, timeEntryId: entryId },\n {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: organizationId,\n organizationIds: scope?.filterIds ?? (auth.orgId ? [auth.orgId] : null),\n request: req,\n },\n translate,\n )\n\n const guardResult = await runStaffMutationGuards(\n container,\n {\n tenantId,\n organizationId,\n userId: auth.sub ?? '',\n resourceKind: 'staff.timesheets.time_entry_segment',\n resourceId: entry.id,\n operation: 'create',\n requestMethod: req.method,\n requestHeaders: req.headers,\n mutationPayload: input as unknown as Record<string, unknown>,\n },\n resolveUserFeatures(auth),\n )\n if (!guardResult.ok) {\n return NextResponse.json(\n guardResult.errorBody ?? { error: 'Operation blocked by guard' },\n { status: guardResult.errorStatus ?? 422 },\n )\n }\n\n const segmentData = {\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n timeEntryId: input.timeEntryId,\n startedAt: input.startedAt,\n endedAt: input.endedAt ?? null,\n segmentType: input.segmentType,\n }\n const segment = em.create(StaffTimeEntrySegment, segmentData as never)\n\n await em.flush()\n\n if (guardResult.afterSuccessCallbacks.length) {\n await runStaffMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {\n tenantId,\n organizationId,\n userId: auth.sub ?? '',\n resourceKind: 'staff.timesheets.time_entry_segment',\n resourceId: segment.id,\n operation: 'create',\n requestMethod: req.method,\n requestHeaders: req.headers,\n })\n }\n\n return NextResponse.json(\n {\n id: segment.id,\n timeEntryId: segment.timeEntryId,\n startedAt: segment.startedAt,\n endedAt: segment.endedAt ?? null,\n segmentType: segment.segmentType,\n createdAt: segment.createdAt,\n },\n { status: 201 },\n )\n } catch (err) {\n if (err instanceof CrudHttpError) {\n return NextResponse.json(err.body, { status: err.status })\n }\n const { translate } = await resolveTranslations()\n console.error('staff.timesheets.time-entries.segments.create failed', err)\n return NextResponse.json(\n { error: translate('staff.timesheets.errors.segmentCreate', 'Failed to create time entry segment.') },\n { status: 400 },\n )\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Staff',\n summary: 'Add a segment to a time entry',\n methods: {\n POST: {\n summary: 'Add a segment to a time entry',\n description: 'Creates a new work or break segment for the specified time entry.',\n requestBody: {\n contentType: 'application/json',\n schema: staffTimeEntrySegmentCreateSchema,\n },\n responses: [\n {\n status: 201,\n description: 'Segment created',\n schema: z.object({\n id: z.string().uuid(),\n timeEntryId: z.string().uuid(),\n startedAt: z.string(),\n endedAt: z.string().nullable(),\n segmentType: z.enum(['work', 'break']),\n createdAt: z.string(),\n }),\n },\n { status: 404, description: 'Time entry not found', schema: z.object({ error: z.string() }) },\n { status: 400, description: 'Invalid payload', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { parseScopedCommandInput } from '@open-mercato/shared/lib/api/scoped'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { LockMode } from '@mikro-orm/core'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { StaffTimeEntry, StaffTimeEntrySegment } from '../../../../../data/entities'\nimport { staffTimeEntrySegmentCreateSchema } from '../../../../../data/validators'\nimport { getStaffMemberByUserId } from '../../../../../lib/staffMemberResolver'\nimport {\n resolveUserFeatures,\n runStaffMutationGuardAfterSuccess,\n runStaffMutationGuards,\n} from '../../../../guards'\n\nfunction extractEntryIdFromUrl(request?: Request): string | null {\n if (!request?.url) return null\n try {\n const url = new URL(request.url)\n const match = url.pathname.match(/\\/time-entries\\/([^/]+)\\/segments/)\n return match?.[1] ?? null\n } catch {\n return null\n }\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['staff.timesheets.manage_own'] },\n}\n\nexport async function POST(req: Request) {\n try {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const { translate } = await resolveTranslations()\n if (!auth) throw new CrudHttpError(401, { error: translate('staff.errors.unauthorized', 'Unauthorized') })\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const tenantId = scope?.tenantId ?? auth.tenantId ?? null\n const organizationId = scope?.selectedId ?? auth.orgId ?? null\n if (!tenantId || !organizationId) {\n throw new CrudHttpError(400, { error: translate('staff.errors.missingScope', 'Missing tenant or organization scope.') })\n }\n\n const em = (container.resolve('em') as EntityManager).fork()\n const scopeCtx = { tenantId, organizationId }\n\n const entryId = extractEntryIdFromUrl(req)\n if (!entryId) {\n throw new CrudHttpError(400, { error: translate('staff.timesheets.errors.missingEntryId', 'Missing entry ID.') })\n }\n\n const entry = await findOneWithDecryption(\n em,\n StaffTimeEntry,\n { id: entryId, tenantId, organizationId, deletedAt: null },\n {},\n scopeCtx,\n )\n if (!entry) {\n throw new CrudHttpError(404, { error: translate('staff.timesheets.errors.entryNotFound', 'Time entry not found.') })\n }\n\n const staffMember = await getStaffMemberByUserId(em, auth.sub, tenantId, organizationId)\n if (!staffMember || entry.staffMemberId !== staffMember.id) {\n throw new CrudHttpError(403, { error: translate('staff.timesheets.errors.notOwner', 'You can only manage your own time entries.') })\n }\n\n const body = await readJsonSafe(req, {})\n const input = parseScopedCommandInput(\n staffTimeEntrySegmentCreateSchema,\n { ...body, timeEntryId: entryId },\n {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: organizationId,\n organizationIds: scope?.filterIds ?? (auth.orgId ? [auth.orgId] : null),\n request: req,\n },\n translate,\n )\n\n const guardResult = await runStaffMutationGuards(\n container,\n {\n tenantId,\n organizationId,\n userId: auth.sub ?? '',\n resourceKind: 'staff.timesheets.time_entry_segment',\n resourceId: entry.id,\n operation: 'create',\n requestMethod: req.method,\n requestHeaders: req.headers,\n mutationPayload: input as unknown as Record<string, unknown>,\n },\n resolveUserFeatures(auth),\n )\n if (!guardResult.ok) {\n return NextResponse.json(\n guardResult.errorBody ?? { error: 'Operation blocked by guard' },\n { status: guardResult.errorStatus ?? 422 },\n )\n }\n\n // Create the segment inside a single transaction with a PESSIMISTIC_WRITE\n // lock on the parent time entry row, so segment writes serialize against\n // concurrent timer-stop / segment mutations that recompute the entry from a\n // shared snapshot (issue #2416).\n const segment = await em.transactional(async (trx) => {\n const lockedEntry = await findOneWithDecryption(\n trx,\n StaffTimeEntry,\n { id: entryId, tenantId, organizationId, deletedAt: null },\n { lockMode: LockMode.PESSIMISTIC_WRITE },\n scopeCtx,\n )\n if (!lockedEntry) {\n throw new CrudHttpError(404, { error: translate('staff.timesheets.errors.entryNotFound', 'Time entry not found.') })\n }\n\n const segmentData = {\n tenantId: input.tenantId,\n organizationId: input.organizationId,\n timeEntryId: input.timeEntryId,\n startedAt: input.startedAt,\n endedAt: input.endedAt ?? null,\n segmentType: input.segmentType,\n }\n const created = trx.create(StaffTimeEntrySegment, segmentData as never)\n\n await trx.flush()\n return created\n })\n\n if (guardResult.afterSuccessCallbacks.length) {\n await runStaffMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {\n tenantId,\n organizationId,\n userId: auth.sub ?? '',\n resourceKind: 'staff.timesheets.time_entry_segment',\n resourceId: segment.id,\n operation: 'create',\n requestMethod: req.method,\n requestHeaders: req.headers,\n })\n }\n\n return NextResponse.json(\n {\n id: segment.id,\n timeEntryId: segment.timeEntryId,\n startedAt: segment.startedAt,\n endedAt: segment.endedAt ?? null,\n segmentType: segment.segmentType,\n createdAt: segment.createdAt,\n },\n { status: 201 },\n )\n } catch (err) {\n if (err instanceof CrudHttpError) {\n return NextResponse.json(err.body, { status: err.status })\n }\n const { translate } = await resolveTranslations()\n console.error('staff.timesheets.time-entries.segments.create failed', err)\n return NextResponse.json(\n { error: translate('staff.timesheets.errors.segmentCreate', 'Failed to create time entry segment.') },\n { status: 400 },\n )\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Staff',\n summary: 'Add a segment to a time entry',\n methods: {\n POST: {\n summary: 'Add a segment to a time entry',\n description: 'Creates a new work or break segment for the specified time entry.',\n requestBody: {\n contentType: 'application/json',\n schema: staffTimeEntrySegmentCreateSchema,\n },\n responses: [\n {\n status: 201,\n description: 'Segment created',\n schema: z.object({\n id: z.string().uuid(),\n timeEntryId: z.string().uuid(),\n startedAt: z.string(),\n endedAt: z.string().nullable(),\n segmentType: z.enum(['work', 'break']),\n createdAt: z.string(),\n }),\n },\n { status: 404, description: 'Time entry not found', schema: z.object({ error: z.string() }) },\n { status: 400, description: 'Invalid payload', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -6,6 +6,7 @@ import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/d
6
6
  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
+ import { LockMode } from "@mikro-orm/core";
9
10
  import { StaffTimeEntry, StaffTimeEntrySegment } from "../../../../../data/entities.js";
10
11
  import { getStaffMemberByUserId } from "../../../../../lib/staffMemberResolver.js";
11
12
  import {
@@ -85,18 +86,36 @@ async function POST(req) {
85
86
  { status: guardResult.errorStatus ?? 422 }
86
87
  );
87
88
  }
88
- const now = /* @__PURE__ */ new Date();
89
- entry.startedAt = now;
90
- entry.source = "timer";
91
- const segmentData = {
92
- tenantId,
93
- organizationId,
94
- timeEntryId: entry.id,
95
- startedAt: now,
96
- segmentType: "work"
97
- };
98
- em.create(StaffTimeEntrySegment, segmentData);
99
- await em.flush();
89
+ const now = await em.transactional(async (trx) => {
90
+ const lockedEntry = await findOneWithDecryption(
91
+ trx,
92
+ StaffTimeEntry,
93
+ { id: entryId, tenantId, organizationId, deletedAt: null },
94
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
95
+ scopeCtx
96
+ );
97
+ if (!lockedEntry) {
98
+ throw new CrudHttpError(404, { error: translate("staff.timesheets.errors.entryNotFound", "Time entry not found.") });
99
+ }
100
+ if (lockedEntry.startedAt) {
101
+ throw new CrudHttpError(409, {
102
+ error: translate("staff.timesheets.errors.timerAlreadyStarted", "Timer is already started for this entry.")
103
+ });
104
+ }
105
+ const startedAt = /* @__PURE__ */ new Date();
106
+ lockedEntry.startedAt = startedAt;
107
+ lockedEntry.source = "timer";
108
+ const segmentData = {
109
+ tenantId,
110
+ organizationId,
111
+ timeEntryId: lockedEntry.id,
112
+ startedAt,
113
+ segmentType: "work"
114
+ };
115
+ trx.create(StaffTimeEntrySegment, segmentData);
116
+ await trx.flush();
117
+ return startedAt;
118
+ });
100
119
  void emitStaffEvent("staff.timesheets.time_entry.timer_started", {
101
120
  id: entry.id,
102
121
  staffMemberId: entry.staffMemberId,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../../src/modules/staff/api/timesheets/time-entries/%5Bid%5D/timer-start/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { StaffTimeEntry, StaffTimeEntrySegment } from '../../../../../data/entities'\nimport { getStaffMemberByUserId } from '../../../../../lib/staffMemberResolver'\nimport {\n resolveUserFeatures,\n runStaffMutationGuardAfterSuccess,\n runStaffMutationGuards,\n} from '../../../../guards'\nimport { emitStaffEvent } from '../../../../../events'\n\nfunction extractEntryIdFromUrl(request?: Request): string | null {\n if (!request?.url) return null\n try {\n const url = new URL(request.url)\n const match = url.pathname.match(/\\/time-entries\\/([^/]+)\\/timer-start/)\n return match?.[1] ?? null\n } catch {\n return null\n }\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['staff.timesheets.manage_own'] },\n}\n\nexport async function POST(req: Request) {\n try {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const { translate } = await resolveTranslations()\n if (!auth) throw new CrudHttpError(401, { error: translate('staff.errors.unauthorized', 'Unauthorized') })\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const tenantId = scope?.tenantId ?? auth.tenantId ?? null\n const organizationId = scope?.selectedId ?? auth.orgId ?? null\n if (!tenantId || !organizationId) {\n throw new CrudHttpError(400, { error: translate('staff.errors.missingScope', 'Missing tenant or organization scope.') })\n }\n\n const em = (container.resolve('em') as EntityManager).fork()\n const scopeCtx = { tenantId, organizationId }\n\n const entryId = extractEntryIdFromUrl(req)\n if (!entryId) {\n throw new CrudHttpError(400, { error: translate('staff.timesheets.errors.missingEntryId', 'Missing entry ID.') })\n }\n\n const entry = await findOneWithDecryption(\n em,\n StaffTimeEntry,\n { id: entryId, tenantId, organizationId, deletedAt: null },\n {},\n scopeCtx,\n )\n if (!entry) {\n throw new CrudHttpError(404, { error: translate('staff.timesheets.errors.entryNotFound', 'Time entry not found.') })\n }\n\n const staffMember = await getStaffMemberByUserId(em, auth.sub, tenantId, organizationId)\n if (!staffMember || entry.staffMemberId !== staffMember.id) {\n throw new CrudHttpError(403, { error: translate('staff.timesheets.errors.notOwner', 'You can only manage your own time entries.') })\n }\n\n if (entry.startedAt) {\n return NextResponse.json(\n { error: translate('staff.timesheets.errors.timerAlreadyStarted', 'Timer is already started for this entry.') },\n { status: 409 },\n )\n }\n\n const guardResult = await runStaffMutationGuards(\n container,\n {\n tenantId,\n organizationId,\n userId: auth.sub ?? '',\n resourceKind: 'staff.timesheets.time_entry',\n resourceId: entry.id,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n },\n resolveUserFeatures(auth),\n )\n if (!guardResult.ok) {\n return NextResponse.json(\n guardResult.errorBody ?? { error: 'Operation blocked by guard' },\n { status: guardResult.errorStatus ?? 422 },\n )\n }\n\n const now = new Date()\n entry.startedAt = now\n entry.source = 'timer'\n\n const segmentData = {\n tenantId,\n organizationId,\n timeEntryId: entry.id,\n startedAt: now,\n segmentType: 'work' as const,\n }\n em.create(StaffTimeEntrySegment, segmentData as never)\n\n await em.flush()\n\n void emitStaffEvent('staff.timesheets.time_entry.timer_started', {\n id: entry.id,\n staffMemberId: entry.staffMemberId,\n tenantId: entry.tenantId,\n organizationId: entry.organizationId,\n startedAt: now.toISOString(),\n }, { persistent: true }).catch((err) => {\n console.error('[staff.timesheets] emit timer_started failed', err)\n })\n\n if (guardResult.afterSuccessCallbacks.length) {\n await runStaffMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {\n tenantId,\n organizationId,\n userId: auth.sub ?? '',\n resourceKind: 'staff.timesheets.time_entry',\n resourceId: entry.id,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n })\n }\n\n return NextResponse.json({ ok: true }, { status: 200 })\n } catch (err) {\n if (err instanceof CrudHttpError) {\n return NextResponse.json(err.body, { status: err.status })\n }\n const { translate } = await resolveTranslations()\n console.error('staff.timesheets.time-entries.timer-start failed', err)\n return NextResponse.json(\n { error: translate('staff.timesheets.errors.timerStart', 'Failed to start timer.') },\n { status: 400 },\n )\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Staff',\n summary: 'Start timer for a time entry',\n methods: {\n POST: {\n summary: 'Start timer for a time entry',\n description: 'Starts the timer on a time entry by setting startedAt and creating an initial work segment.',\n responses: [\n { status: 200, description: 'Timer started', schema: z.object({ ok: z.literal(true) }) },\n { status: 404, description: 'Time entry not found', schema: z.object({ error: z.string() }) },\n { status: 409, description: 'Timer already started', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
5
- "mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAClB,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AACnC,SAAS,0CAA0C;AACnD,SAAS,qBAAqB;AAC9B,SAAS,2BAA2B;AACpC,SAAS,6BAA6B;AAGtC,SAAS,gBAAgB,6BAA6B;AACtD,SAAS,8BAA8B;AACvC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,sBAAsB;AAE/B,SAAS,sBAAsB,SAAkC;AAC/D,MAAI,CAAC,SAAS,IAAK,QAAO;AAC1B,MAAI;AACF,UAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;AAC/B,UAAM,QAAQ,IAAI,SAAS,MAAM,sCAAsC;AACvE,WAAO,QAAQ,CAAC,KAAK;AAAA,EACvB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM,iBAAiB,CAAC,6BAA6B,EAAE;AAC9E;AAEA,eAAsB,KAAK,KAAc;AACvC,MAAI;AACF,UAAM,YAAY,MAAM,uBAAuB;AAC/C,UAAM,OAAO,MAAM,mBAAmB,GAAG;AACzC,UAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAChD,QAAI,CAAC,KAAM,OAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,6BAA6B,cAAc,EAAE,CAAC;AAEzG,UAAM,QAAQ,MAAM,mCAAmC,EAAE,WAAW,MAAM,SAAS,IAAI,CAAC;AACxF,UAAM,WAAW,OAAO,YAAY,KAAK,YAAY;AACrD,UAAM,iBAAiB,OAAO,cAAc,KAAK,SAAS;AAC1D,QAAI,CAAC,YAAY,CAAC,gBAAgB;AAChC,YAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,6BAA6B,uCAAuC,EAAE,CAAC;AAAA,IACzH;AAEA,UAAM,KAAM,UAAU,QAAQ,IAAI,EAAoB,KAAK;AAC3D,UAAM,WAAW,EAAE,UAAU,eAAe;AAE5C,UAAM,UAAU,sBAAsB,GAAG;AACzC,QAAI,CAAC,SAAS;AACZ,YAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,0CAA0C,mBAAmB,EAAE,CAAC;AAAA,IAClH;AAEA,UAAM,QAAQ,MAAM;AAAA,MAClB;AAAA,MACA;AAAA,MACA,EAAE,IAAI,SAAS,UAAU,gBAAgB,WAAW,KAAK;AAAA,MACzD,CAAC;AAAA,MACD;AAAA,IACF;AACA,QAAI,CAAC,OAAO;AACV,YAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,yCAAyC,uBAAuB,EAAE,CAAC;AAAA,IACrH;AAEA,UAAM,cAAc,MAAM,uBAAuB,IAAI,KAAK,KAAK,UAAU,cAAc;AACvF,QAAI,CAAC,eAAe,MAAM,kBAAkB,YAAY,IAAI;AAC1D,YAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,oCAAoC,4CAA4C,EAAE,CAAC;AAAA,IACrI;AAEA,QAAI,MAAM,WAAW;AACnB,aAAO,aAAa;AAAA,QAClB,EAAE,OAAO,UAAU,+CAA+C,0CAA0C,EAAE;AAAA,QAC9G,EAAE,QAAQ,IAAI;AAAA,MAChB;AAAA,IACF;AAEA,UAAM,cAAc,MAAM;AAAA,MACxB;AAAA,MACA;AAAA,QACE;AAAA,QACA;AAAA,QACA,QAAQ,KAAK,OAAO;AAAA,QACpB,cAAc;AAAA,QACd,YAAY,MAAM;AAAA,QAClB,WAAW;AAAA,QACX,eAAe,IAAI;AAAA,QACnB,gBAAgB,IAAI;AAAA,MACtB;AAAA,MACA,oBAAoB,IAAI;AAAA,IAC1B;AACA,QAAI,CAAC,YAAY,IAAI;AACnB,aAAO,aAAa;AAAA,QAClB,YAAY,aAAa,EAAE,OAAO,6BAA6B;AAAA,QAC/D,EAAE,QAAQ,YAAY,eAAe,IAAI;AAAA,MAC3C;AAAA,IACF;AAEA,UAAM,MAAM,oBAAI,KAAK;AACrB,UAAM,YAAY;AAClB,UAAM,SAAS;AAEf,UAAM,cAAc;AAAA,MAClB;AAAA,MACA;AAAA,MACA,aAAa,MAAM;AAAA,MACnB,WAAW;AAAA,MACX,aAAa;AAAA,IACf;AACA,OAAG,OAAO,uBAAuB,WAAoB;AAErD,UAAM,GAAG,MAAM;AAEf,SAAK,eAAe,6CAA6C;AAAA,MAC/D,IAAI,MAAM;AAAA,MACV,eAAe,MAAM;AAAA,MACrB,UAAU,MAAM;AAAA,MAChB,gBAAgB,MAAM;AAAA,MACtB,WAAW,IAAI,YAAY;AAAA,IAC7B,GAAG,EAAE,YAAY,KAAK,CAAC,EAAE,MAAM,CAAC,QAAQ;AACtC,cAAQ,MAAM,gDAAgD,GAAG;AAAA,IACnE,CAAC;AAED,QAAI,YAAY,sBAAsB,QAAQ;AAC5C,YAAM,kCAAkC,YAAY,uBAAuB;AAAA,QACzE;AAAA,QACA;AAAA,QACA,QAAQ,KAAK,OAAO;AAAA,QACpB,cAAc;AAAA,QACd,YAAY,MAAM;AAAA,QAClB,WAAW;AAAA,QACX,eAAe,IAAI;AAAA,QACnB,gBAAgB,IAAI;AAAA,MACtB,CAAC;AAAA,IACH;AAEA,WAAO,aAAa,KAAK,EAAE,IAAI,KAAK,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,EACxD,SAAS,KAAK;AACZ,QAAI,eAAe,eAAe;AAChC,aAAO,aAAa,KAAK,IAAI,MAAM,EAAE,QAAQ,IAAI,OAAO,CAAC;AAAA,IAC3D;AACA,UAAM,EAAE,UAAU,IAAI,MAAM,oBAAoB;AAChD,YAAQ,MAAM,oDAAoD,GAAG;AACrE,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,UAAU,sCAAsC,wBAAwB,EAAE;AAAA,MACnF,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF;AACF;AAEO,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,aAAa;AAAA,MACb,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,iBAAiB,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,IAAI,EAAE,CAAC,EAAE;AAAA,QACvF,EAAE,QAAQ,KAAK,aAAa,wBAAwB,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QAC5F,EAAE,QAAQ,KAAK,aAAa,yBAAyB,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QAC7F,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,MACtF;AAAA,IACF;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { LockMode } from '@mikro-orm/core'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { StaffTimeEntry, StaffTimeEntrySegment } from '../../../../../data/entities'\nimport { getStaffMemberByUserId } from '../../../../../lib/staffMemberResolver'\nimport {\n resolveUserFeatures,\n runStaffMutationGuardAfterSuccess,\n runStaffMutationGuards,\n} from '../../../../guards'\nimport { emitStaffEvent } from '../../../../../events'\n\nfunction extractEntryIdFromUrl(request?: Request): string | null {\n if (!request?.url) return null\n try {\n const url = new URL(request.url)\n const match = url.pathname.match(/\\/time-entries\\/([^/]+)\\/timer-start/)\n return match?.[1] ?? null\n } catch {\n return null\n }\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['staff.timesheets.manage_own'] },\n}\n\nexport async function POST(req: Request) {\n try {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const { translate } = await resolveTranslations()\n if (!auth) throw new CrudHttpError(401, { error: translate('staff.errors.unauthorized', 'Unauthorized') })\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const tenantId = scope?.tenantId ?? auth.tenantId ?? null\n const organizationId = scope?.selectedId ?? auth.orgId ?? null\n if (!tenantId || !organizationId) {\n throw new CrudHttpError(400, { error: translate('staff.errors.missingScope', 'Missing tenant or organization scope.') })\n }\n\n const em = (container.resolve('em') as EntityManager).fork()\n const scopeCtx = { tenantId, organizationId }\n\n const entryId = extractEntryIdFromUrl(req)\n if (!entryId) {\n throw new CrudHttpError(400, { error: translate('staff.timesheets.errors.missingEntryId', 'Missing entry ID.') })\n }\n\n const entry = await findOneWithDecryption(\n em,\n StaffTimeEntry,\n { id: entryId, tenantId, organizationId, deletedAt: null },\n {},\n scopeCtx,\n )\n if (!entry) {\n throw new CrudHttpError(404, { error: translate('staff.timesheets.errors.entryNotFound', 'Time entry not found.') })\n }\n\n const staffMember = await getStaffMemberByUserId(em, auth.sub, tenantId, organizationId)\n if (!staffMember || entry.staffMemberId !== staffMember.id) {\n throw new CrudHttpError(403, { error: translate('staff.timesheets.errors.notOwner', 'You can only manage your own time entries.') })\n }\n\n if (entry.startedAt) {\n return NextResponse.json(\n { error: translate('staff.timesheets.errors.timerAlreadyStarted', 'Timer is already started for this entry.') },\n { status: 409 },\n )\n }\n\n const guardResult = await runStaffMutationGuards(\n container,\n {\n tenantId,\n organizationId,\n userId: auth.sub ?? '',\n resourceKind: 'staff.timesheets.time_entry',\n resourceId: entry.id,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n },\n resolveUserFeatures(auth),\n )\n if (!guardResult.ok) {\n return NextResponse.json(\n guardResult.errorBody ?? { error: 'Operation blocked by guard' },\n { status: guardResult.errorStatus ?? 422 },\n )\n }\n\n // Start the timer inside a single transaction with a PESSIMISTIC_WRITE lock\n // on the time entry row, re-checking startedAt under the lock so two\n // concurrent timer-start calls on the same entry cannot both create an\n // initial work segment (issue #2416).\n const now = await em.transactional(async (trx) => {\n const lockedEntry = await findOneWithDecryption(\n trx,\n StaffTimeEntry,\n { id: entryId, tenantId, organizationId, deletedAt: null },\n { lockMode: LockMode.PESSIMISTIC_WRITE },\n scopeCtx,\n )\n if (!lockedEntry) {\n throw new CrudHttpError(404, { error: translate('staff.timesheets.errors.entryNotFound', 'Time entry not found.') })\n }\n if (lockedEntry.startedAt) {\n throw new CrudHttpError(409, {\n error: translate('staff.timesheets.errors.timerAlreadyStarted', 'Timer is already started for this entry.'),\n })\n }\n\n const startedAt = new Date()\n lockedEntry.startedAt = startedAt\n lockedEntry.source = 'timer'\n\n const segmentData = {\n tenantId,\n organizationId,\n timeEntryId: lockedEntry.id,\n startedAt,\n segmentType: 'work' as const,\n }\n trx.create(StaffTimeEntrySegment, segmentData as never)\n\n await trx.flush()\n return startedAt\n })\n\n void emitStaffEvent('staff.timesheets.time_entry.timer_started', {\n id: entry.id,\n staffMemberId: entry.staffMemberId,\n tenantId: entry.tenantId,\n organizationId: entry.organizationId,\n startedAt: now.toISOString(),\n }, { persistent: true }).catch((err) => {\n console.error('[staff.timesheets] emit timer_started failed', err)\n })\n\n if (guardResult.afterSuccessCallbacks.length) {\n await runStaffMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {\n tenantId,\n organizationId,\n userId: auth.sub ?? '',\n resourceKind: 'staff.timesheets.time_entry',\n resourceId: entry.id,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n })\n }\n\n return NextResponse.json({ ok: true }, { status: 200 })\n } catch (err) {\n if (err instanceof CrudHttpError) {\n return NextResponse.json(err.body, { status: err.status })\n }\n const { translate } = await resolveTranslations()\n console.error('staff.timesheets.time-entries.timer-start failed', err)\n return NextResponse.json(\n { error: translate('staff.timesheets.errors.timerStart', 'Failed to start timer.') },\n { status: 400 },\n )\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Staff',\n summary: 'Start timer for a time entry',\n methods: {\n POST: {\n summary: 'Start timer for a time entry',\n description: 'Starts the timer on a time entry by setting startedAt and creating an initial work segment.',\n responses: [\n { status: 200, description: 'Timer started', schema: z.object({ ok: z.literal(true) }) },\n { status: 404, description: 'Time entry not found', schema: z.object({ error: z.string() }) },\n { status: 409, description: 'Timer already started', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -6,6 +6,7 @@ import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/d
6
6
  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
+ import { LockMode } from "@mikro-orm/core";
9
10
  import { StaffTimeEntry, StaffTimeEntrySegment } from "../../../../../data/entities.js";
10
11
  import { getStaffMemberByUserId } from "../../../../../lib/staffMemberResolver.js";
11
12
  import {
@@ -59,20 +60,6 @@ async function POST(req) {
59
60
  if (!staffMember || entry.staffMemberId !== staffMember.id) {
60
61
  throw new CrudHttpError(403, { error: translate("staff.timesheets.errors.notOwner", "You can only manage your own time entries.") });
61
62
  }
62
- const segments = await findWithDecryption(
63
- em,
64
- StaffTimeEntrySegment,
65
- { timeEntryId: entry.id, tenantId, organizationId, deletedAt: null },
66
- {},
67
- scopeCtx
68
- );
69
- const activeSegment = segments.find((segment) => !segment.endedAt);
70
- if (!activeSegment) {
71
- return NextResponse.json(
72
- { error: translate("staff.timesheets.errors.noActiveSegment", "No active timer segment found for this entry.") },
73
- { status: 409 }
74
- );
75
- }
76
63
  const guardResult = await runStaffMutationGuards(
77
64
  container,
78
65
  {
@@ -93,23 +80,49 @@ async function POST(req) {
93
80
  { status: guardResult.errorStatus ?? 422 }
94
81
  );
95
82
  }
96
- const now = /* @__PURE__ */ new Date();
97
- activeSegment.endedAt = now;
98
- entry.endedAt = now;
99
- const allSegments = segments.map((segment) => {
100
- if (segment.id === activeSegment.id) {
101
- return { ...segment, endedAt: now };
83
+ const { now, durationMinutes } = await em.transactional(async (trx) => {
84
+ const lockedEntry = await findOneWithDecryption(
85
+ trx,
86
+ StaffTimeEntry,
87
+ { id: entryId, tenantId, organizationId, deletedAt: null },
88
+ { lockMode: LockMode.PESSIMISTIC_WRITE },
89
+ scopeCtx
90
+ );
91
+ if (!lockedEntry) {
92
+ throw new CrudHttpError(404, { error: translate("staff.timesheets.errors.entryNotFound", "Time entry not found.") });
102
93
  }
103
- return segment;
94
+ const segments = await findWithDecryption(
95
+ trx,
96
+ StaffTimeEntrySegment,
97
+ { timeEntryId: lockedEntry.id, tenantId, organizationId, deletedAt: null },
98
+ {},
99
+ scopeCtx
100
+ );
101
+ const activeSegment = segments.find((segment) => !segment.endedAt);
102
+ if (!activeSegment) {
103
+ throw new CrudHttpError(409, {
104
+ error: translate("staff.timesheets.errors.noActiveSegment", "No active timer segment found for this entry.")
105
+ });
106
+ }
107
+ const stoppedAt = /* @__PURE__ */ new Date();
108
+ activeSegment.endedAt = stoppedAt;
109
+ lockedEntry.endedAt = stoppedAt;
110
+ const allSegments = segments.map((segment) => {
111
+ if (segment.id === activeSegment.id) {
112
+ return { ...segment, endedAt: stoppedAt };
113
+ }
114
+ return segment;
115
+ });
116
+ const totalWorkMinutes = allSegments.filter((segment) => segment.segmentType === "work" && segment.startedAt && segment.endedAt).reduce((sum, segment) => {
117
+ const startMs = new Date(segment.startedAt).getTime();
118
+ const endMs = new Date(segment.endedAt).getTime();
119
+ return sum + (endMs - startMs);
120
+ }, 0);
121
+ const computedMinutes = Math.round(totalWorkMinutes / 6e4);
122
+ lockedEntry.durationMinutes = computedMinutes;
123
+ await trx.flush();
124
+ return { now: stoppedAt, durationMinutes: computedMinutes };
104
125
  });
105
- const totalWorkMinutes = allSegments.filter((segment) => segment.segmentType === "work" && segment.startedAt && segment.endedAt).reduce((sum, segment) => {
106
- const startMs = new Date(segment.startedAt).getTime();
107
- const endMs = new Date(segment.endedAt).getTime();
108
- return sum + (endMs - startMs);
109
- }, 0);
110
- const durationMinutes = Math.round(totalWorkMinutes / 6e4);
111
- entry.durationMinutes = durationMinutes;
112
- await em.flush();
113
126
  void emitStaffEvent("staff.timesheets.time_entry.timer_stopped", {
114
127
  id: entry.id,
115
128
  staffMemberId: entry.staffMemberId,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../../src/modules/staff/api/timesheets/time-entries/%5Bid%5D/timer-stop/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { StaffTimeEntry, StaffTimeEntrySegment } from '../../../../../data/entities'\nimport { getStaffMemberByUserId } from '../../../../../lib/staffMemberResolver'\nimport {\n resolveUserFeatures,\n runStaffMutationGuardAfterSuccess,\n runStaffMutationGuards,\n} from '../../../../guards'\nimport { emitStaffEvent } from '../../../../../events'\n\nfunction extractEntryIdFromUrl(request?: Request): string | null {\n if (!request?.url) return null\n try {\n const url = new URL(request.url)\n const match = url.pathname.match(/\\/time-entries\\/([^/]+)\\/timer-stop/)\n return match?.[1] ?? null\n } catch {\n return null\n }\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['staff.timesheets.manage_own'] },\n}\n\nexport async function POST(req: Request) {\n try {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const { translate } = await resolveTranslations()\n if (!auth) throw new CrudHttpError(401, { error: translate('staff.errors.unauthorized', 'Unauthorized') })\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const tenantId = scope?.tenantId ?? auth.tenantId ?? null\n const organizationId = scope?.selectedId ?? auth.orgId ?? null\n if (!tenantId || !organizationId) {\n throw new CrudHttpError(400, { error: translate('staff.errors.missingScope', 'Missing tenant or organization scope.') })\n }\n\n const em = (container.resolve('em') as EntityManager).fork()\n const scopeCtx = { tenantId, organizationId }\n\n const entryId = extractEntryIdFromUrl(req)\n if (!entryId) {\n throw new CrudHttpError(400, { error: translate('staff.timesheets.errors.missingEntryId', 'Missing entry ID.') })\n }\n\n const entry = await findOneWithDecryption(\n em,\n StaffTimeEntry,\n { id: entryId, tenantId, organizationId, deletedAt: null },\n {},\n scopeCtx,\n )\n if (!entry) {\n throw new CrudHttpError(404, { error: translate('staff.timesheets.errors.entryNotFound', 'Time entry not found.') })\n }\n\n const staffMember = await getStaffMemberByUserId(em, auth.sub, tenantId, organizationId)\n if (!staffMember || entry.staffMemberId !== staffMember.id) {\n throw new CrudHttpError(403, { error: translate('staff.timesheets.errors.notOwner', 'You can only manage your own time entries.') })\n }\n\n const segments = await findWithDecryption(\n em,\n StaffTimeEntrySegment,\n { timeEntryId: entry.id, tenantId, organizationId, deletedAt: null },\n {},\n scopeCtx,\n )\n\n const activeSegment = segments.find((segment) => !segment.endedAt)\n if (!activeSegment) {\n return NextResponse.json(\n { error: translate('staff.timesheets.errors.noActiveSegment', 'No active timer segment found for this entry.') },\n { status: 409 },\n )\n }\n\n const guardResult = await runStaffMutationGuards(\n container,\n {\n tenantId,\n organizationId,\n userId: auth.sub ?? '',\n resourceKind: 'staff.timesheets.time_entry',\n resourceId: entry.id,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n },\n resolveUserFeatures(auth),\n )\n if (!guardResult.ok) {\n return NextResponse.json(\n guardResult.errorBody ?? { error: 'Operation blocked by guard' },\n { status: guardResult.errorStatus ?? 422 },\n )\n }\n\n const now = new Date()\n activeSegment.endedAt = now\n entry.endedAt = now\n\n const allSegments = segments.map((segment) => {\n if (segment.id === activeSegment.id) {\n return { ...segment, endedAt: now }\n }\n return segment\n })\n\n const totalWorkMinutes = allSegments\n .filter((segment) => segment.segmentType === 'work' && segment.startedAt && segment.endedAt)\n .reduce((sum, segment) => {\n const startMs = new Date(segment.startedAt).getTime()\n const endMs = new Date(segment.endedAt!).getTime()\n return sum + (endMs - startMs)\n }, 0)\n\n const durationMinutes = Math.round(totalWorkMinutes / 60000)\n entry.durationMinutes = durationMinutes\n\n await em.flush()\n\n void emitStaffEvent('staff.timesheets.time_entry.timer_stopped', {\n id: entry.id,\n staffMemberId: entry.staffMemberId,\n tenantId: entry.tenantId,\n organizationId: entry.organizationId,\n stoppedAt: now.toISOString(),\n durationMinutes,\n }, { persistent: true }).catch((err) => {\n console.error('[staff.timesheets] emit timer_stopped failed', err)\n })\n\n if (guardResult.afterSuccessCallbacks.length) {\n await runStaffMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {\n tenantId,\n organizationId,\n userId: auth.sub ?? '',\n resourceKind: 'staff.timesheets.time_entry',\n resourceId: entry.id,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n })\n }\n\n return NextResponse.json({ ok: true, durationMinutes }, { status: 200 })\n } catch (err) {\n if (err instanceof CrudHttpError) {\n return NextResponse.json(err.body, { status: err.status })\n }\n const { translate } = await resolveTranslations()\n console.error('staff.timesheets.time-entries.timer-stop failed', err)\n return NextResponse.json(\n { error: translate('staff.timesheets.errors.timerStop', 'Failed to stop timer.') },\n { status: 400 },\n )\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Staff',\n summary: 'Stop timer for a time entry',\n methods: {\n POST: {\n summary: 'Stop timer for a time entry',\n description: 'Stops the active timer segment, recalculates total work duration in minutes, and updates the time entry.',\n responses: [\n {\n status: 200,\n description: 'Timer stopped',\n schema: z.object({ ok: z.literal(true), durationMinutes: z.number() }),\n },\n { status: 404, description: 'Time entry not found', schema: z.object({ error: z.string() }) },\n { status: 409, description: 'No active timer segment', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { LockMode } from '@mikro-orm/core'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { StaffTimeEntry, StaffTimeEntrySegment } from '../../../../../data/entities'\nimport { getStaffMemberByUserId } from '../../../../../lib/staffMemberResolver'\nimport {\n resolveUserFeatures,\n runStaffMutationGuardAfterSuccess,\n runStaffMutationGuards,\n} from '../../../../guards'\nimport { emitStaffEvent } from '../../../../../events'\n\nfunction extractEntryIdFromUrl(request?: Request): string | null {\n if (!request?.url) return null\n try {\n const url = new URL(request.url)\n const match = url.pathname.match(/\\/time-entries\\/([^/]+)\\/timer-stop/)\n return match?.[1] ?? null\n } catch {\n return null\n }\n}\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['staff.timesheets.manage_own'] },\n}\n\nexport async function POST(req: Request) {\n try {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const { translate } = await resolveTranslations()\n if (!auth) throw new CrudHttpError(401, { error: translate('staff.errors.unauthorized', 'Unauthorized') })\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const tenantId = scope?.tenantId ?? auth.tenantId ?? null\n const organizationId = scope?.selectedId ?? auth.orgId ?? null\n if (!tenantId || !organizationId) {\n throw new CrudHttpError(400, { error: translate('staff.errors.missingScope', 'Missing tenant or organization scope.') })\n }\n\n const em = (container.resolve('em') as EntityManager).fork()\n const scopeCtx = { tenantId, organizationId }\n\n const entryId = extractEntryIdFromUrl(req)\n if (!entryId) {\n throw new CrudHttpError(400, { error: translate('staff.timesheets.errors.missingEntryId', 'Missing entry ID.') })\n }\n\n const entry = await findOneWithDecryption(\n em,\n StaffTimeEntry,\n { id: entryId, tenantId, organizationId, deletedAt: null },\n {},\n scopeCtx,\n )\n if (!entry) {\n throw new CrudHttpError(404, { error: translate('staff.timesheets.errors.entryNotFound', 'Time entry not found.') })\n }\n\n const staffMember = await getStaffMemberByUserId(em, auth.sub, tenantId, organizationId)\n if (!staffMember || entry.staffMemberId !== staffMember.id) {\n throw new CrudHttpError(403, { error: translate('staff.timesheets.errors.notOwner', 'You can only manage your own time entries.') })\n }\n\n const guardResult = await runStaffMutationGuards(\n container,\n {\n tenantId,\n organizationId,\n userId: auth.sub ?? '',\n resourceKind: 'staff.timesheets.time_entry',\n resourceId: entry.id,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n },\n resolveUserFeatures(auth),\n )\n if (!guardResult.ok) {\n return NextResponse.json(\n guardResult.errorBody ?? { error: 'Operation blocked by guard' },\n { status: guardResult.errorStatus ?? 422 },\n )\n }\n\n // Recompute and persist the timer state inside a single transaction with a\n // PESSIMISTIC_WRITE lock on the time entry row, so concurrent timer-stop /\n // segment writes on the same entry serialize instead of racing on a shared\n // in-memory snapshot (issue #2416).\n const { now, durationMinutes } = await em.transactional(async (trx) => {\n const lockedEntry = await findOneWithDecryption(\n trx,\n StaffTimeEntry,\n { id: entryId, tenantId, organizationId, deletedAt: null },\n { lockMode: LockMode.PESSIMISTIC_WRITE },\n scopeCtx,\n )\n if (!lockedEntry) {\n throw new CrudHttpError(404, { error: translate('staff.timesheets.errors.entryNotFound', 'Time entry not found.') })\n }\n\n const segments = await findWithDecryption(\n trx,\n StaffTimeEntrySegment,\n { timeEntryId: lockedEntry.id, tenantId, organizationId, deletedAt: null },\n {},\n scopeCtx,\n )\n\n const activeSegment = segments.find((segment) => !segment.endedAt)\n if (!activeSegment) {\n throw new CrudHttpError(409, {\n error: translate('staff.timesheets.errors.noActiveSegment', 'No active timer segment found for this entry.'),\n })\n }\n\n const stoppedAt = new Date()\n activeSegment.endedAt = stoppedAt\n lockedEntry.endedAt = stoppedAt\n\n const allSegments = segments.map((segment) => {\n if (segment.id === activeSegment.id) {\n return { ...segment, endedAt: stoppedAt }\n }\n return segment\n })\n\n const totalWorkMinutes = allSegments\n .filter((segment) => segment.segmentType === 'work' && segment.startedAt && segment.endedAt)\n .reduce((sum, segment) => {\n const startMs = new Date(segment.startedAt).getTime()\n const endMs = new Date(segment.endedAt!).getTime()\n return sum + (endMs - startMs)\n }, 0)\n\n const computedMinutes = Math.round(totalWorkMinutes / 60000)\n lockedEntry.durationMinutes = computedMinutes\n\n await trx.flush()\n return { now: stoppedAt, durationMinutes: computedMinutes }\n })\n\n void emitStaffEvent('staff.timesheets.time_entry.timer_stopped', {\n id: entry.id,\n staffMemberId: entry.staffMemberId,\n tenantId: entry.tenantId,\n organizationId: entry.organizationId,\n stoppedAt: now.toISOString(),\n durationMinutes,\n }, { persistent: true }).catch((err) => {\n console.error('[staff.timesheets] emit timer_stopped failed', err)\n })\n\n if (guardResult.afterSuccessCallbacks.length) {\n await runStaffMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {\n tenantId,\n organizationId,\n userId: auth.sub ?? '',\n resourceKind: 'staff.timesheets.time_entry',\n resourceId: entry.id,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n })\n }\n\n return NextResponse.json({ ok: true, durationMinutes }, { status: 200 })\n } catch (err) {\n if (err instanceof CrudHttpError) {\n return NextResponse.json(err.body, { status: err.status })\n }\n const { translate } = await resolveTranslations()\n console.error('staff.timesheets.time-entries.timer-stop failed', err)\n return NextResponse.json(\n { error: translate('staff.timesheets.errors.timerStop', 'Failed to stop timer.') },\n { status: 400 },\n )\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Staff',\n summary: 'Stop timer for a time entry',\n methods: {\n POST: {\n summary: 'Stop timer for a time entry',\n description: 'Stops the active timer segment, recalculates total work duration in minutes, and updates the time entry.',\n responses: [\n {\n status: 200,\n description: 'Timer stopped',\n schema: z.object({ ok: z.literal(true), durationMinutes: z.number() }),\n },\n { status: 404, description: 'Time entry not found', schema: z.object({ error: z.string() }) },\n { status: 409, description: 'No active timer segment', schema: z.object({ error: z.string() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.4-develop.4331.1.64a8535120",
3
+ "version": "0.6.4-develop.4339.1.fad812f76f",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -243,16 +243,16 @@
243
243
  "zod": "^4.4.3"
244
244
  },
245
245
  "peerDependencies": {
246
- "@open-mercato/ai-assistant": "0.6.4-develop.4331.1.64a8535120",
247
- "@open-mercato/shared": "0.6.4-develop.4331.1.64a8535120",
248
- "@open-mercato/ui": "0.6.4-develop.4331.1.64a8535120",
246
+ "@open-mercato/ai-assistant": "0.6.4-develop.4339.1.fad812f76f",
247
+ "@open-mercato/shared": "0.6.4-develop.4339.1.fad812f76f",
248
+ "@open-mercato/ui": "0.6.4-develop.4339.1.fad812f76f",
249
249
  "react": "^19.0.0",
250
250
  "react-dom": "^19.0.0"
251
251
  },
252
252
  "devDependencies": {
253
- "@open-mercato/ai-assistant": "0.6.4-develop.4331.1.64a8535120",
254
- "@open-mercato/shared": "0.6.4-develop.4331.1.64a8535120",
255
- "@open-mercato/ui": "0.6.4-develop.4331.1.64a8535120",
253
+ "@open-mercato/ai-assistant": "0.6.4-develop.4339.1.fad812f76f",
254
+ "@open-mercato/shared": "0.6.4-develop.4339.1.fad812f76f",
255
+ "@open-mercato/ui": "0.6.4-develop.4339.1.fad812f76f",
256
256
  "@testing-library/dom": "^10.4.1",
257
257
  "@testing-library/jest-dom": "^6.9.1",
258
258
  "@testing-library/react": "^16.3.1",
@@ -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
  }
@@ -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
  }