@open-mercato/core 0.6.4-develop.4331.1.64a8535120 → 0.6.4-develop.4358.1.233d5675c7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js +15 -10
- package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js.map +2 -2
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js +2 -6
- package/dist/modules/customers/components/detail/ScheduleActivityDialog.js.map +2 -2
- package/dist/modules/entities/api/records.js +2 -2
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/entities/lib/helpers.js +25 -1
- package/dist/modules/entities/lib/helpers.js.map +2 -2
- package/dist/modules/entities/lib/validation.js +3 -1
- package/dist/modules/entities/lib/validation.js.map +2 -2
- package/dist/modules/sales/components/documents/AdjustmentDialog.js +10 -12
- package/dist/modules/sales/components/documents/AdjustmentDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/LineItemDialog.js +10 -10
- package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/PaymentDialog.js +8 -15
- package/dist/modules/sales/components/documents/PaymentDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/ShipmentDialog.js +10 -14
- package/dist/modules/sales/components/documents/ShipmentDialog.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/dist/modules/workflows/components/EdgeEditDialog.js +3 -9
- package/dist/modules/workflows/components/EdgeEditDialog.js.map +2 -2
- package/dist/modules/workflows/components/NodeEditDialog.js +3 -9
- package/dist/modules/workflows/components/NodeEditDialog.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/customers/components/detail/ConfirmDealLostDialog.tsx +15 -10
- package/src/modules/customers/components/detail/ScheduleActivityDialog.tsx +2 -6
- 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/sales/components/documents/AdjustmentDialog.tsx +11 -12
- package/src/modules/sales/components/documents/LineItemDialog.tsx +11 -10
- package/src/modules/sales/components/documents/PaymentDialog.tsx +9 -16
- package/src/modules/sales/components/documents/ShipmentDialog.tsx +10 -14
- 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
- package/src/modules/workflows/components/EdgeEditDialog.tsx +3 -9
- package/src/modules/workflows/components/NodeEditDialog.tsx +3 -9
package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../../../src/modules/staff/api/timesheets/time-entries/%5Bid%5D/segments/%5BsegmentId%5D/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\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 { StaffTimeEntry, StaffTimeEntrySegment } from '../../../../../../data/entities'\nimport { staffTimeEntrySegmentUpdateSchema } from '../../../../../../data/validators'\nimport { getStaffMemberByUserId } from '../../../../../../lib/staffMemberResolver'\nimport {\n resolveUserFeatures,\n runStaffMutationGuardAfterSuccess,\n runStaffMutationGuards,\n} from '../../../../../guards'\n\nconst routeMetadata = {\n PATCH: { requireAuth: true, requireFeatures: ['staff.timesheets.manage_own'] },\n}\n\nexport const metadata = routeMetadata\n\nfunction extractIdsFromUrl(request?: Request): { entryId: string; segmentId: 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 if (!match?.[1] || !match?.[2]) return null\n return { entryId: match[1], segmentId: match[2] }\n } catch {\n return null\n }\n}\n\nexport async function PATCH(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const ids = extractIdsFromUrl(req)\n if (!ids) {\n return NextResponse.json({ error: 'Segment id is required' }, { status: 400 })\n }\n\n const rawBody = await readJsonSafe<Record<string, unknown>>(req, null)\n if (!rawBody) {\n return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })\n }\n\n const parsed = staffTimeEntrySegmentUpdateSchema.safeParse({ ...rawBody, id: ids.segmentId })\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid payload', details: parsed.error.flatten() }, { status: 400 })\n }\n\n const container = await createRequestContainer()\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 return NextResponse.json({ error: 'Missing tenant or organization scope.' }, { status: 400 })\n }\n\n const em = (container.resolve('em') as EntityManager).fork()\n const scopeCtx = { tenantId, organizationId }\n\n const entry = await findOneWithDecryption(em, StaffTimeEntry, { id: ids.entryId, tenantId, organizationId, deletedAt: null }, {}, scopeCtx)\n if (!entry) {\n return NextResponse.json({ error: 'Time entry not found' }, { status: 404 })\n }\n\n const staffMember = await getStaffMemberByUserId(em, auth.sub, tenantId, organizationId)\n if (!staffMember || entry.staffMemberId !== staffMember.id) {\n return NextResponse.json({ error: 'You can only manage your own time entries.' }, { status: 403 })\n }\n\n const segment = await findOneWithDecryption(em, StaffTimeEntrySegment, {\n id: ids.segmentId,\n timeEntryId: ids.entryId,\n tenantId,\n organizationId,\n deletedAt: null,\n }, {}, scopeCtx)\n\n if (!segment) {\n return NextResponse.json({ error: 'Segment not found' }, { status: 404 })\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: segment.id,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n mutationPayload: parsed.data 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 if (parsed.data.startedAt !== undefined) {\n
|
|
5
|
-
"mappings": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAElB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,0CAA0C;AACnD,SAAS,6BAA6B;AACtC,SAAS,oBAAoB;
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { LockMode } from '@mikro-orm/core'\nimport { StaffTimeEntry, StaffTimeEntrySegment } from '../../../../../../data/entities'\nimport { staffTimeEntrySegmentUpdateSchema } from '../../../../../../data/validators'\nimport { getStaffMemberByUserId } from '../../../../../../lib/staffMemberResolver'\nimport {\n resolveUserFeatures,\n runStaffMutationGuardAfterSuccess,\n runStaffMutationGuards,\n} from '../../../../../guards'\n\nconst routeMetadata = {\n PATCH: { requireAuth: true, requireFeatures: ['staff.timesheets.manage_own'] },\n}\n\nexport const metadata = routeMetadata\n\nfunction extractIdsFromUrl(request?: Request): { entryId: string; segmentId: 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 if (!match?.[1] || !match?.[2]) return null\n return { entryId: match[1], segmentId: match[2] }\n } catch {\n return null\n }\n}\n\nexport async function PATCH(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const ids = extractIdsFromUrl(req)\n if (!ids) {\n return NextResponse.json({ error: 'Segment id is required' }, { status: 400 })\n }\n\n const rawBody = await readJsonSafe<Record<string, unknown>>(req, null)\n if (!rawBody) {\n return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })\n }\n\n const parsed = staffTimeEntrySegmentUpdateSchema.safeParse({ ...rawBody, id: ids.segmentId })\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid payload', details: parsed.error.flatten() }, { status: 400 })\n }\n\n const container = await createRequestContainer()\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 return NextResponse.json({ error: 'Missing tenant or organization scope.' }, { status: 400 })\n }\n\n const em = (container.resolve('em') as EntityManager).fork()\n const scopeCtx = { tenantId, organizationId }\n\n const entry = await findOneWithDecryption(em, StaffTimeEntry, { id: ids.entryId, tenantId, organizationId, deletedAt: null }, {}, scopeCtx)\n if (!entry) {\n return NextResponse.json({ error: 'Time entry not found' }, { status: 404 })\n }\n\n const staffMember = await getStaffMemberByUserId(em, auth.sub, tenantId, organizationId)\n if (!staffMember || entry.staffMemberId !== staffMember.id) {\n return NextResponse.json({ error: 'You can only manage your own time entries.' }, { status: 403 })\n }\n\n const segment = await findOneWithDecryption(em, StaffTimeEntrySegment, {\n id: ids.segmentId,\n timeEntryId: ids.entryId,\n tenantId,\n organizationId,\n deletedAt: null,\n }, {}, scopeCtx)\n\n if (!segment) {\n return NextResponse.json({ error: 'Segment not found' }, { status: 404 })\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: segment.id,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n mutationPayload: parsed.data 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 // Apply the segment edit inside a single transaction with a PESSIMISTIC_WRITE\n // lock on the parent time entry row, re-loading the segment under the lock so\n // concurrent segment edits / timer-stop recomputes on the same entry serialize\n // instead of racing on a shared in-memory snapshot (issue #2416).\n let updatedSegment: StaffTimeEntrySegment\n try {\n updatedSegment = await em.transactional(async (trx) => {\n const lockedEntry = await findOneWithDecryption(\n trx,\n StaffTimeEntry,\n { id: ids.entryId, tenantId, organizationId, deletedAt: null },\n { lockMode: LockMode.PESSIMISTIC_WRITE },\n scopeCtx,\n )\n if (!lockedEntry) {\n throw new CrudHttpError(404, { error: 'Time entry not found' })\n }\n\n const lockedSegment = await findOneWithDecryption(\n trx,\n StaffTimeEntrySegment,\n { id: ids.segmentId, timeEntryId: ids.entryId, tenantId, organizationId, deletedAt: null },\n {},\n scopeCtx,\n )\n if (!lockedSegment) {\n throw new CrudHttpError(404, { error: 'Segment not found' })\n }\n\n if (parsed.data.startedAt !== undefined) {\n lockedSegment.startedAt = parsed.data.startedAt\n }\n if (parsed.data.endedAt !== undefined) {\n lockedSegment.endedAt = parsed.data.endedAt ?? null\n }\n if (parsed.data.segmentType !== undefined) {\n lockedSegment.segmentType = parsed.data.segmentType\n }\n\n await trx.flush()\n return lockedSegment\n })\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n throw 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_segment',\n resourceId: updatedSegment.id,\n operation: 'update',\n requestMethod: req.method,\n requestHeaders: req.headers,\n })\n }\n\n return NextResponse.json({\n ok: true,\n item: {\n id: updatedSegment.id,\n timeEntryId: updatedSegment.timeEntryId,\n startedAt: updatedSegment.startedAt,\n endedAt: updatedSegment.endedAt,\n segmentType: updatedSegment.segmentType,\n createdAt: updatedSegment.createdAt,\n updatedAt: updatedSegment.updatedAt,\n },\n })\n}\n\nconst errorSchema = z.object({ error: z.string() })\nconst segmentResponseSchema = z.object({\n ok: z.literal(true),\n item: z.object({\n id: z.string(),\n timeEntryId: z.string(),\n startedAt: z.string(),\n endedAt: z.string().nullable(),\n segmentType: z.enum(['work', 'break']),\n createdAt: z.string(),\n updatedAt: z.string(),\n }),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Staff',\n summary: 'Time entry segment management',\n methods: {\n PATCH: {\n summary: 'Update a time entry segment',\n description: 'Updates fields on an existing time entry segment (startedAt, endedAt, segmentType).',\n requestBody: {\n contentType: 'application/json',\n schema: staffTimeEntrySegmentUpdateSchema.omit({ id: true }),\n },\n responses: [\n { status: 200, description: 'Segment updated successfully', schema: segmentResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid payload or missing segment id', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 404, description: 'Segment not found', schema: errorSchema },\n ],\n },\n },\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -8,6 +8,7 @@ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
|
|
|
8
8
|
import { parseScopedCommandInput } from "@open-mercato/shared/lib/api/scoped";
|
|
9
9
|
import { findOneWithDecryption } from "@open-mercato/shared/lib/encryption/find";
|
|
10
10
|
import { readJsonSafe } from "@open-mercato/shared/lib/http/readJsonSafe";
|
|
11
|
+
import { LockMode } from "@mikro-orm/core";
|
|
11
12
|
import { StaffTimeEntry, StaffTimeEntrySegment } from "../../../../../data/entities.js";
|
|
12
13
|
import { staffTimeEntrySegmentCreateSchema } from "../../../../../data/validators.js";
|
|
13
14
|
import { getStaffMemberByUserId } from "../../../../../lib/staffMemberResolver.js";
|
|
@@ -96,16 +97,29 @@ async function POST(req) {
|
|
|
96
97
|
{ status: guardResult.errorStatus ?? 422 }
|
|
97
98
|
);
|
|
98
99
|
}
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
100
|
+
const segment = await em.transactional(async (trx) => {
|
|
101
|
+
const lockedEntry = await findOneWithDecryption(
|
|
102
|
+
trx,
|
|
103
|
+
StaffTimeEntry,
|
|
104
|
+
{ id: entryId, tenantId, organizationId, deletedAt: null },
|
|
105
|
+
{ lockMode: LockMode.PESSIMISTIC_WRITE },
|
|
106
|
+
scopeCtx
|
|
107
|
+
);
|
|
108
|
+
if (!lockedEntry) {
|
|
109
|
+
throw new CrudHttpError(404, { error: translate("staff.timesheets.errors.entryNotFound", "Time entry not found.") });
|
|
110
|
+
}
|
|
111
|
+
const segmentData = {
|
|
112
|
+
tenantId: input.tenantId,
|
|
113
|
+
organizationId: input.organizationId,
|
|
114
|
+
timeEntryId: input.timeEntryId,
|
|
115
|
+
startedAt: input.startedAt,
|
|
116
|
+
endedAt: input.endedAt ?? null,
|
|
117
|
+
segmentType: input.segmentType
|
|
118
|
+
};
|
|
119
|
+
const created = trx.create(StaffTimeEntrySegment, segmentData);
|
|
120
|
+
await trx.flush();
|
|
121
|
+
return created;
|
|
122
|
+
});
|
|
109
123
|
if (guardResult.afterSuccessCallbacks.length) {
|
|
110
124
|
await runStaffMutationGuardAfterSuccess(guardResult.afterSuccessCallbacks, {
|
|
111
125
|
tenantId,
|
|
@@ -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": "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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 =
|
|
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
|
}
|
|
@@ -25,6 +25,7 @@ import { Plus, Trash2 } from "lucide-react";
|
|
|
25
25
|
import { BusinessRulesSelector } from "./BusinessRulesSelector.js";
|
|
26
26
|
import { JsonBuilder } from "@open-mercato/ui/backend/JsonBuilder";
|
|
27
27
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
28
|
+
import { useDialogKeyHandler } from "@open-mercato/ui/hooks/useDialogKeyHandler";
|
|
28
29
|
import { useConfirmDialog } from "@open-mercato/ui/backend/confirm-dialog";
|
|
29
30
|
function EdgeEditDialog({ edge, isOpen, onClose, onSave, onDelete }) {
|
|
30
31
|
const t = useT();
|
|
@@ -229,18 +230,11 @@ function EdgeEditDialog({ edge, isOpen, onClose, onSave, onDelete }) {
|
|
|
229
230
|
if (!edge) return;
|
|
230
231
|
onDelete(edge.id);
|
|
231
232
|
};
|
|
232
|
-
const handleKeyDown = (
|
|
233
|
-
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
234
|
-
handleSave();
|
|
235
|
-
}
|
|
236
|
-
if (e.key === "Escape") {
|
|
237
|
-
onClose();
|
|
238
|
-
}
|
|
239
|
-
};
|
|
233
|
+
const handleKeyDown = useDialogKeyHandler({ onConfirm: handleSave, onCancel: onClose });
|
|
240
234
|
if (!isOpen || !edge) return null;
|
|
241
235
|
const triggerVariant = trigger === "auto" ? "default" : trigger === "manual" ? "secondary" : "outline";
|
|
242
236
|
return /* @__PURE__ */ jsxs(Dialog, { open: isOpen, onOpenChange: onClose, children: [
|
|
243
|
-
/* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-4xl max-h-[90vh] overflow-y-auto", children: [
|
|
237
|
+
/* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-4xl max-h-[90vh] overflow-y-auto", onKeyDown: handleKeyDown, children: [
|
|
244
238
|
/* @__PURE__ */ jsxs(DialogHeader, { children: [
|
|
245
239
|
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 mb-2", children: [
|
|
246
240
|
/* @__PURE__ */ jsx(DialogTitle, { children: t("workflows.edgeEditor.title") }),
|