@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/entities/api/records.js +2 -2
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/entities/lib/helpers.js +25 -1
- package/dist/modules/entities/lib/helpers.js.map +2 -2
- package/dist/modules/entities/lib/validation.js +3 -1
- package/dist/modules/entities/lib/validation.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.js +50 -17
- package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/route.js +24 -10
- package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +31 -12
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js +42 -29
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/entities/api/records.ts +2 -2
- package/src/modules/entities/lib/helpers.ts +29 -4
- package/src/modules/entities/lib/validation.ts +10 -2
- package/src/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.ts +57 -18
- package/src/modules/staff/api/timesheets/time-entries/[id]/segments/route.ts +29 -10
- package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +38 -14
- package/src/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.ts +52 -34
|
@@ -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
|
|
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,+BAA+B;AACxC,SAAS,6BAA6B;AACtC,SAAS,oBAAoB;
|
|
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": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAClB,SAAS,8BAA8B;AACvC,SAAS,0BAA0B;AACnC,SAAS,0CAA0C;AACnD,SAAS,qBAAqB;AAC9B,SAAS,2BAA2B;AACpC,SAAS,+BAA+B;AACxC,SAAS,6BAA6B;AACtC,SAAS,oBAAoB;AAE7B,SAAS,gBAAgB;AAEzB,SAAS,gBAAgB,6BAA6B;AACtD,SAAS,yCAAyC;AAClD,SAAS,8BAA8B;AACvC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAEP,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,mCAAmC;AACpE,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,UAAM,OAAO,MAAM,aAAa,KAAK,CAAC,CAAC;AACvC,UAAM,QAAQ;AAAA,MACZ;AAAA,MACA,EAAE,GAAG,MAAM,aAAa,QAAQ;AAAA,MAChC;AAAA,QACE;AAAA,QACA;AAAA,QACA,mBAAmB;AAAA,QACnB,wBAAwB;AAAA,QACxB,iBAAiB,OAAO,cAAc,KAAK,QAAQ,CAAC,KAAK,KAAK,IAAI;AAAA,QAClE,SAAS;AAAA,MACX;AAAA,MACA;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,QACpB,iBAAiB;AAAA,MACnB;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;AAMA,UAAM,UAAU,MAAM,GAAG,cAAc,OAAO,QAAQ;AACpD,YAAM,cAAc,MAAM;AAAA,QACxB;AAAA,QACA;AAAA,QACA,EAAE,IAAI,SAAS,UAAU,gBAAgB,WAAW,KAAK;AAAA,QACzD,EAAE,UAAU,SAAS,kBAAkB;AAAA,QACvC;AAAA,MACF;AACA,UAAI,CAAC,aAAa;AAChB,cAAM,IAAI,cAAc,KAAK,EAAE,OAAO,UAAU,yCAAyC,uBAAuB,EAAE,CAAC;AAAA,MACrH;AAEA,YAAM,cAAc;AAAA,QAClB,UAAU,MAAM;AAAA,QAChB,gBAAgB,MAAM;AAAA,QACtB,aAAa,MAAM;AAAA,QACnB,WAAW,MAAM;AAAA,QACjB,SAAS,MAAM,WAAW;AAAA,QAC1B,aAAa,MAAM;AAAA,MACrB;AACA,YAAM,UAAU,IAAI,OAAO,uBAAuB,WAAoB;AAEtE,YAAM,IAAI,MAAM;AAChB,aAAO;AAAA,IACT,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,QAAQ;AAAA,QACpB,WAAW;AAAA,QACX,eAAe,IAAI;AAAA,QACnB,gBAAgB,IAAI;AAAA,MACtB,CAAC;AAAA,IACH;AAEA,WAAO,aAAa;AAAA,MAClB;AAAA,QACE,IAAI,QAAQ;AAAA,QACZ,aAAa,QAAQ;AAAA,QACrB,WAAW,QAAQ;AAAA,QACnB,SAAS,QAAQ,WAAW;AAAA,QAC5B,aAAa,QAAQ;AAAA,QACrB,WAAW,QAAQ;AAAA,MACrB;AAAA,MACA,EAAE,QAAQ,IAAI;AAAA,IAChB;AAAA,EACF,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,wDAAwD,GAAG;AACzE,WAAO,aAAa;AAAA,MAClB,EAAE,OAAO,UAAU,yCAAyC,sCAAsC,EAAE;AAAA,MACpG,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,aAAa;AAAA,QACX,aAAa;AAAA,QACb,QAAQ;AAAA,MACV;AAAA,MACA,WAAW;AAAA,QACT;AAAA,UACE,QAAQ;AAAA,UACR,aAAa;AAAA,UACb,QAAQ,EAAE,OAAO;AAAA,YACf,IAAI,EAAE,OAAO,EAAE,KAAK;AAAA,YACpB,aAAa,EAAE,OAAO,EAAE,KAAK;AAAA,YAC7B,WAAW,EAAE,OAAO;AAAA,YACpB,SAAS,EAAE,OAAO,EAAE,SAAS;AAAA,YAC7B,aAAa,EAAE,KAAK,CAAC,QAAQ,OAAO,CAAC;AAAA,YACrC,WAAW,EAAE,OAAO;AAAA,UACtB,CAAC;AAAA,QACH;AAAA,QACA,EAAE,QAAQ,KAAK,aAAa,wBAAwB,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QAC5F,EAAE,QAAQ,KAAK,aAAa,mBAAmB,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,QACvF,EAAE,QAAQ,KAAK,aAAa,gBAAgB,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE;AAAA,MACtF;AAAA,IACF;AAAA,EACF;AACF;",
|
|
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 =
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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;
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
|
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,uBAAuB,0BAA0B;
|
|
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.
|
|
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.
|
|
247
|
-
"@open-mercato/shared": "0.6.4-develop.
|
|
248
|
-
"@open-mercato/ui": "0.6.4-develop.
|
|
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.
|
|
254
|
-
"@open-mercato/shared": "0.6.4-develop.
|
|
255
|
-
"@open-mercato/ui": "0.6.4-develop.
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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: {
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
115
|
+
// Apply the segment edit inside a single transaction with a PESSIMISTIC_WRITE
|
|
116
|
+
// lock on the parent time entry row, re-loading the segment under the lock so
|
|
117
|
+
// concurrent segment edits / timer-stop recomputes on the same entry serialize
|
|
118
|
+
// instead of racing on a shared in-memory snapshot (issue #2416).
|
|
119
|
+
let updatedSegment: StaffTimeEntrySegment
|
|
120
|
+
try {
|
|
121
|
+
updatedSegment = await em.transactional(async (trx) => {
|
|
122
|
+
const lockedEntry = await findOneWithDecryption(
|
|
123
|
+
trx,
|
|
124
|
+
StaffTimeEntry,
|
|
125
|
+
{ id: ids.entryId, tenantId, organizationId, deletedAt: null },
|
|
126
|
+
{ lockMode: LockMode.PESSIMISTIC_WRITE },
|
|
127
|
+
scopeCtx,
|
|
128
|
+
)
|
|
129
|
+
if (!lockedEntry) {
|
|
130
|
+
throw new CrudHttpError(404, { error: 'Time entry not found' })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const lockedSegment = await findOneWithDecryption(
|
|
134
|
+
trx,
|
|
135
|
+
StaffTimeEntrySegment,
|
|
136
|
+
{ id: ids.segmentId, timeEntryId: ids.entryId, tenantId, organizationId, deletedAt: null },
|
|
137
|
+
{},
|
|
138
|
+
scopeCtx,
|
|
139
|
+
)
|
|
140
|
+
if (!lockedSegment) {
|
|
141
|
+
throw new CrudHttpError(404, { error: 'Segment not found' })
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (parsed.data.startedAt !== undefined) {
|
|
145
|
+
lockedSegment.startedAt = parsed.data.startedAt
|
|
146
|
+
}
|
|
147
|
+
if (parsed.data.endedAt !== undefined) {
|
|
148
|
+
lockedSegment.endedAt = parsed.data.endedAt ?? null
|
|
149
|
+
}
|
|
150
|
+
if (parsed.data.segmentType !== undefined) {
|
|
151
|
+
lockedSegment.segmentType = parsed.data.segmentType
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await trx.flush()
|
|
155
|
+
return lockedSegment
|
|
156
|
+
})
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (isCrudHttpError(err)) {
|
|
159
|
+
return NextResponse.json(err.body, { status: err.status })
|
|
160
|
+
}
|
|
161
|
+
throw err
|
|
121
162
|
}
|
|
122
163
|
|
|
123
|
-
await em.flush()
|
|
124
|
-
|
|
125
164
|
if (guardResult.afterSuccessCallbacks.length) {
|
|
126
165
|
await runStaffMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {
|
|
127
166
|
tenantId,
|
|
128
167
|
organizationId,
|
|
129
168
|
userId: auth.sub ?? '',
|
|
130
169
|
resourceKind: 'staff.timesheets.time_entry_segment',
|
|
131
|
-
resourceId:
|
|
170
|
+
resourceId: updatedSegment.id,
|
|
132
171
|
operation: 'update',
|
|
133
172
|
requestMethod: req.method,
|
|
134
173
|
requestHeaders: req.headers,
|
|
@@ -138,13 +177,13 @@ export async function PATCH(req: Request) {
|
|
|
138
177
|
return NextResponse.json({
|
|
139
178
|
ok: true,
|
|
140
179
|
item: {
|
|
141
|
-
id:
|
|
142
|
-
timeEntryId:
|
|
143
|
-
startedAt:
|
|
144
|
-
endedAt:
|
|
145
|
-
segmentType:
|
|
146
|
-
createdAt:
|
|
147
|
-
updatedAt:
|
|
180
|
+
id: updatedSegment.id,
|
|
181
|
+
timeEntryId: updatedSegment.timeEntryId,
|
|
182
|
+
startedAt: updatedSegment.startedAt,
|
|
183
|
+
endedAt: updatedSegment.endedAt,
|
|
184
|
+
segmentType: updatedSegment.segmentType,
|
|
185
|
+
createdAt: updatedSegment.createdAt,
|
|
186
|
+
updatedAt: updatedSegment.updatedAt,
|
|
148
187
|
},
|
|
149
188
|
})
|
|
150
189
|
}
|