@open-mercato/core 0.6.6-develop.6151.1.3028861dfe → 0.6.6-develop.6156.1.5ac693a69a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/messages/api/[id]/actions/[actionId]/route.js +32 -0
  3. package/dist/modules/messages/api/[id]/actions/[actionId]/route.js.map +2 -2
  4. package/dist/modules/messages/api/[id]/archive/route.js +63 -0
  5. package/dist/modules/messages/api/[id]/archive/route.js.map +2 -2
  6. package/dist/modules/messages/api/[id]/attachments/route.js +63 -0
  7. package/dist/modules/messages/api/[id]/attachments/route.js.map +2 -2
  8. package/dist/modules/messages/api/[id]/conversation/archive/route.js +32 -0
  9. package/dist/modules/messages/api/[id]/conversation/archive/route.js.map +2 -2
  10. package/dist/modules/messages/api/[id]/conversation/read/route.js +32 -0
  11. package/dist/modules/messages/api/[id]/conversation/read/route.js.map +2 -2
  12. package/dist/modules/messages/api/[id]/conversation/route.js +32 -0
  13. package/dist/modules/messages/api/[id]/conversation/route.js.map +2 -2
  14. package/dist/modules/messages/api/[id]/forward/route.js +32 -0
  15. package/dist/modules/messages/api/[id]/forward/route.js.map +2 -2
  16. package/dist/modules/messages/api/[id]/read/route.js +63 -0
  17. package/dist/modules/messages/api/[id]/read/route.js.map +2 -2
  18. package/dist/modules/messages/api/[id]/reply/route.js +32 -0
  19. package/dist/modules/messages/api/[id]/reply/route.js.map +2 -2
  20. package/dist/modules/messages/api/[id]/route.js +63 -0
  21. package/dist/modules/messages/api/[id]/route.js.map +2 -2
  22. package/dist/modules/messages/api/guards.js +31 -0
  23. package/dist/modules/messages/api/guards.js.map +7 -0
  24. package/dist/modules/messages/api/route.js +32 -0
  25. package/dist/modules/messages/api/route.js.map +2 -2
  26. package/dist/modules/staff/api/timesheets/time-entries/start-timer/route.js +107 -0
  27. package/dist/modules/staff/api/timesheets/time-entries/start-timer/route.js.map +7 -0
  28. package/dist/modules/staff/commands/timesheets-entries.js +142 -1
  29. package/dist/modules/staff/commands/timesheets-entries.js.map +3 -3
  30. package/dist/modules/staff/data/validators.js +8 -0
  31. package/dist/modules/staff/data/validators.js.map +2 -2
  32. package/dist/modules/staff/lib/timesheets-ui/TimerBar.js +8 -22
  33. package/dist/modules/staff/lib/timesheets-ui/TimerBar.js.map +2 -2
  34. package/dist/modules/staff/lib/timesheets-ui/startTimer.js +22 -0
  35. package/dist/modules/staff/lib/timesheets-ui/startTimer.js.map +7 -0
  36. package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js +6 -16
  37. package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js.map +2 -2
  38. package/package.json +7 -7
  39. package/src/modules/messages/api/[id]/actions/[actionId]/route.ts +34 -0
  40. package/src/modules/messages/api/[id]/archive/route.ts +63 -0
  41. package/src/modules/messages/api/[id]/attachments/route.ts +63 -0
  42. package/src/modules/messages/api/[id]/conversation/archive/route.ts +33 -0
  43. package/src/modules/messages/api/[id]/conversation/read/route.ts +33 -0
  44. package/src/modules/messages/api/[id]/conversation/route.ts +33 -0
  45. package/src/modules/messages/api/[id]/forward/route.ts +33 -0
  46. package/src/modules/messages/api/[id]/read/route.ts +63 -0
  47. package/src/modules/messages/api/[id]/reply/route.ts +33 -0
  48. package/src/modules/messages/api/[id]/route.ts +65 -0
  49. package/src/modules/messages/api/guards.ts +59 -0
  50. package/src/modules/messages/api/route.ts +33 -0
  51. package/src/modules/staff/api/timesheets/time-entries/start-timer/route.ts +110 -0
  52. package/src/modules/staff/commands/timesheets-entries.ts +166 -1
  53. package/src/modules/staff/data/validators.ts +9 -0
  54. package/src/modules/staff/i18n/de.json +1 -0
  55. package/src/modules/staff/i18n/en.json +1 -0
  56. package/src/modules/staff/i18n/es.json +1 -0
  57. package/src/modules/staff/i18n/pl.json +1 -0
  58. package/src/modules/staff/lib/timesheets-ui/TimerBar.tsx +9 -25
  59. package/src/modules/staff/lib/timesheets-ui/startTimer.ts +40 -0
  60. package/src/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.tsx +9 -18
@@ -7,15 +7,18 @@ import { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
7
7
  import { buildChanges, emitCrudSideEffects, emitCrudUndoSideEffects } from '@open-mercato/shared/lib/commands/helpers'
8
8
  import { makeCreateRedo } from '@open-mercato/shared/lib/commands/redo'
9
9
  import type { CrudIndexerConfig } from '@open-mercato/shared/lib/crud/types'
10
- import { StaffTimeEntry, StaffTimeProject, type StaffTimeEntrySource } from '../data/entities'
10
+ import { StaffTimeEntry, StaffTimeEntrySegment, StaffTimeProject, type StaffTimeEntrySource } from '../data/entities'
11
+ import { emitStaffEvent } from '../events'
11
12
 
12
13
  const timeEntryCrudIndexer: CrudIndexerConfig<StaffTimeEntry> = {
13
14
  entityType: 'staff:staff_time_entry',
14
15
  }
15
16
  import {
16
17
  staffTimeEntryCreateSchema,
18
+ staffTimeEntryStartTimerSchema,
17
19
  staffTimeEntryUpdateSchema,
18
20
  type StaffTimeEntryCreateInput,
21
+ type StaffTimeEntryStartTimerInput,
19
22
  type StaffTimeEntryUpdateInput,
20
23
  } from '../data/validators'
21
24
  import { staffTimeEntryCrudEvents } from '../lib/crud'
@@ -281,6 +284,167 @@ const createTimeEntryCommand: CommandHandler<StaffTimeEntryCreateInput, { timeEn
281
284
  }),
282
285
  }
283
286
 
287
+ const startTimerCommand: CommandHandler<StaffTimeEntryStartTimerInput, { timeEntryId: string }> = {
288
+ id: 'staff.timesheets.time_entries.start_timer',
289
+ async execute(rawInput, ctx) {
290
+ const parsed = staffTimeEntryStartTimerSchema.parse(rawInput)
291
+ ensureTenantScope(ctx, parsed.tenantId)
292
+ ensureOrganizationScope(ctx, parsed.organizationId)
293
+
294
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
295
+
296
+ // Ownership enforcement mirrors createTimeEntryCommand: callers without
297
+ // `staff.timesheets.manage_all` can only start their own timer, so the
298
+ // request body's staffMemberId can't start a timer under a colleague's id.
299
+ let effectiveStaffMemberId = parsed.staffMemberId
300
+ if (!(await callerHasManageAll(ctx))) {
301
+ const callerStaffMemberId = await resolveCallerStaffMemberId(em, ctx)
302
+ if (!callerStaffMemberId) {
303
+ const { translate } = await resolveTranslations()
304
+ throw new CrudHttpError(403, {
305
+ error: translate('staff.timesheets.errors.noStaffMember', 'No staff member linked to your account.'),
306
+ })
307
+ }
308
+ effectiveStaffMemberId = callerStaffMemberId
309
+ }
310
+
311
+ await assertTimeProjectInScope(em, parsed.timeProjectId ?? null, parsed.tenantId, parsed.organizationId)
312
+
313
+ const scopeCtx = { tenantId: parsed.tenantId, organizationId: parsed.organizationId }
314
+
315
+ // Create the timer entry AND start it inside a single transaction so a
316
+ // partial failure can never leave an orphaned, never-started timer entry
317
+ // (issue #3311 — the legacy two-request create-then-start flow). The
318
+ // single-active-timer invariant (#2855) is re-checked here so a second
319
+ // surface cannot create a parallel running timer for the same staff member.
320
+ const { entry, startedAt } = await em.transactional(async (trx) => {
321
+ const otherRunningEntry = await findOneWithDecryption(
322
+ trx,
323
+ StaffTimeEntry,
324
+ {
325
+ tenantId: parsed.tenantId,
326
+ organizationId: parsed.organizationId,
327
+ staffMemberId: effectiveStaffMemberId,
328
+ startedAt: { $ne: null },
329
+ endedAt: null,
330
+ deletedAt: null,
331
+ },
332
+ {},
333
+ scopeCtx,
334
+ )
335
+ if (otherRunningEntry) {
336
+ const { translate } = await resolveTranslations()
337
+ throw new CrudHttpError(409, {
338
+ error: translate(
339
+ 'staff.timesheets.errors.timerAlreadyRunning',
340
+ 'Another timer is already running. Stop it before starting a new one.',
341
+ ),
342
+ })
343
+ }
344
+
345
+ const startedAt = new Date()
346
+ const entry = trx.create(StaffTimeEntry, {
347
+ tenantId: parsed.tenantId,
348
+ organizationId: parsed.organizationId,
349
+ staffMemberId: effectiveStaffMemberId,
350
+ date: parsed.date,
351
+ durationMinutes: 0,
352
+ startedAt,
353
+ endedAt: null,
354
+ notes: parsed.notes ?? null,
355
+ timeProjectId: parsed.timeProjectId ?? null,
356
+ customerId: null,
357
+ dealId: null,
358
+ orderId: null,
359
+ source: 'timer',
360
+ createdAt: startedAt,
361
+ updatedAt: startedAt,
362
+ deletedAt: null,
363
+ })
364
+ // Flush so the DB-generated id is populated before the work segment
365
+ // references it; both writes commit together when the transaction closes.
366
+ await trx.flush()
367
+
368
+ const segmentData = {
369
+ tenantId: parsed.tenantId,
370
+ organizationId: parsed.organizationId,
371
+ timeEntryId: entry.id,
372
+ startedAt,
373
+ segmentType: 'work' as const,
374
+ }
375
+ trx.create(StaffTimeEntrySegment, segmentData as never)
376
+ await trx.flush()
377
+
378
+ return { entry, startedAt }
379
+ })
380
+
381
+ await emitCrudSideEffects({
382
+ dataEngine: ctx.container.resolve('dataEngine'),
383
+ action: 'created',
384
+ entity: entry,
385
+ identifiers: { id: entry.id, organizationId: entry.organizationId, tenantId: entry.tenantId },
386
+ events: staffTimeEntryCrudEvents,
387
+ indexer: timeEntryCrudIndexer,
388
+ })
389
+
390
+ void emitStaffEvent('staff.timesheets.time_entry.timer_started', {
391
+ id: entry.id,
392
+ staffMemberId: effectiveStaffMemberId,
393
+ tenantId: parsed.tenantId,
394
+ organizationId: parsed.organizationId,
395
+ startedAt: startedAt.toISOString(),
396
+ }, { persistent: true }).catch((err) => {
397
+ console.error('[staff.timesheets] emit timer_started failed', err)
398
+ })
399
+
400
+ return { timeEntryId: entry.id }
401
+ },
402
+ captureAfter: async (_input, result, ctx) => {
403
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
404
+ const snapshot = await loadTimeEntrySnapshot(em, result.timeEntryId)
405
+ if (!snapshot) return null
406
+ return { snapshot }
407
+ },
408
+ buildLog: async ({ result, ctx }) => {
409
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
410
+ const snapshot = await loadTimeEntrySnapshot(em, result.timeEntryId)
411
+ if (!snapshot) return null
412
+ const { translate } = await resolveTranslations()
413
+ return {
414
+ actionLabel: translate('staff.audit.timesheets.time_entries.startTimer', 'Start timer'),
415
+ resourceKind: 'staff.timesheets.time_entry',
416
+ resourceId: snapshot.id,
417
+ tenantId: snapshot.tenantId,
418
+ organizationId: snapshot.organizationId,
419
+ snapshotAfter: snapshot,
420
+ payload: {
421
+ undo: {
422
+ after: snapshot,
423
+ } satisfies TimeEntryUndoPayload,
424
+ },
425
+ }
426
+ },
427
+ undo: async ({ logEntry, ctx }) => {
428
+ const payload = extractUndoPayload<TimeEntryUndoPayload>(logEntry)
429
+ const after = payload?.after
430
+ if (!after) return
431
+ const em = (ctx.container.resolve('em') as EntityManager).fork()
432
+ const entry = await em.findOne(StaffTimeEntry, { id: after.id })
433
+ if (entry) {
434
+ entry.deletedAt = new Date()
435
+ await em.flush()
436
+
437
+ await emitCrudUndoSideEffects({
438
+ dataEngine: ctx.container.resolve('dataEngine'),
439
+ action: 'deleted',
440
+ entity: entry,
441
+ identifiers: { id: entry.id, organizationId: entry.organizationId, tenantId: entry.tenantId },
442
+ events: staffTimeEntryCrudEvents,
443
+ })
444
+ }
445
+ },
446
+ }
447
+
284
448
  const updateTimeEntryCommand: CommandHandler<StaffTimeEntryUpdateInput, { timeEntryId: string }> = {
285
449
  id: 'staff.timesheets.time_entries.update',
286
450
  async prepare(rawInput, ctx) {
@@ -531,5 +695,6 @@ const deleteTimeEntryCommand: CommandHandler<{ id?: string }, { timeEntryId: str
531
695
  }
532
696
 
533
697
  registerCommand(createTimeEntryCommand)
698
+ registerCommand(startTimerCommand)
534
699
  registerCommand(updateTimeEntryCommand)
535
700
  registerCommand(deleteTimeEntryCommand)
@@ -291,6 +291,14 @@ export const staffTimeEntryUpdateSchema = z.object({
291
291
  notes: z.string().max(2000).optional().nullable(),
292
292
  })
293
293
 
294
+ export const staffTimeEntryStartTimerSchema = z.object({
295
+ ...scopedCreateFields,
296
+ staffMemberId: z.string().uuid(),
297
+ date: z.coerce.date(),
298
+ timeProjectId: z.string().uuid().optional().nullable(),
299
+ notes: z.string().max(2000).optional().nullable(),
300
+ })
301
+
294
302
  export const staffTimeEntryBulkItemSchema = z.object({
295
303
  id: z.string().uuid().optional().nullable(),
296
304
  date: z.coerce.date(),
@@ -369,6 +377,7 @@ export const staffMyProjectVisibilityUpdateSchema = z.object({
369
377
 
370
378
  export type StaffTimeEntryCreateInput = z.infer<typeof staffTimeEntryCreateSchema>
371
379
  export type StaffTimeEntryUpdateInput = z.infer<typeof staffTimeEntryUpdateSchema>
380
+ export type StaffTimeEntryStartTimerInput = z.infer<typeof staffTimeEntryStartTimerSchema>
372
381
  export type StaffTimeEntryBulkSaveInput = z.infer<typeof staffTimeEntryBulkSaveSchema>
373
382
  export type StaffTimeEntrySegmentCreateInput = z.infer<typeof staffTimeEntrySegmentCreateSchema>
374
383
  export type StaffTimeEntrySegmentUpdateInput = z.infer<typeof staffTimeEntrySegmentUpdateSchema>
@@ -120,6 +120,7 @@
120
120
  "staff.audit.teams.update": "Team aktualisieren",
121
121
  "staff.audit.timesheets.time_entries.create": "Zeiteintrag erstellen",
122
122
  "staff.audit.timesheets.time_entries.delete": "Zeiteintrag löschen",
123
+ "staff.audit.timesheets.time_entries.startTimer": "Timer starten",
123
124
  "staff.audit.timesheets.time_entries.update": "Zeiteintrag aktualisieren",
124
125
  "staff.audit.timesheets.time_project_members.assign": "Zeitprojektmitglied zuweisen",
125
126
  "staff.audit.timesheets.time_project_members.unassign": "Zeitprojektmitglied entfernen",
@@ -120,6 +120,7 @@
120
120
  "staff.audit.teams.update": "Update team",
121
121
  "staff.audit.timesheets.time_entries.create": "Create time entry",
122
122
  "staff.audit.timesheets.time_entries.delete": "Delete time entry",
123
+ "staff.audit.timesheets.time_entries.startTimer": "Start timer",
123
124
  "staff.audit.timesheets.time_entries.update": "Update time entry",
124
125
  "staff.audit.timesheets.time_project_members.assign": "Assign time project member",
125
126
  "staff.audit.timesheets.time_project_members.unassign": "Unassign time project member",
@@ -120,6 +120,7 @@
120
120
  "staff.audit.teams.update": "Actualizar equipo",
121
121
  "staff.audit.timesheets.time_entries.create": "Crear registro de tiempo",
122
122
  "staff.audit.timesheets.time_entries.delete": "Eliminar registro de tiempo",
123
+ "staff.audit.timesheets.time_entries.startTimer": "Iniciar temporizador",
123
124
  "staff.audit.timesheets.time_entries.update": "Actualizar registro de tiempo",
124
125
  "staff.audit.timesheets.time_project_members.assign": "Asignar miembro del proyecto de tiempo",
125
126
  "staff.audit.timesheets.time_project_members.unassign": "Desasignar miembro del proyecto de tiempo",
@@ -120,6 +120,7 @@
120
120
  "staff.audit.teams.update": "Zaktualizuj zespół",
121
121
  "staff.audit.timesheets.time_entries.create": "Utwórz wpis czasu",
122
122
  "staff.audit.timesheets.time_entries.delete": "Usuń wpis czasu",
123
+ "staff.audit.timesheets.time_entries.startTimer": "Uruchom licznik czasu",
123
124
  "staff.audit.timesheets.time_entries.update": "Zaktualizuj wpis czasu",
124
125
  "staff.audit.timesheets.time_project_members.assign": "Przypisz członka projektu czasu",
125
126
  "staff.audit.timesheets.time_project_members.unassign": "Usuń przypisanie członka projektu czasu",
@@ -8,6 +8,7 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
8
8
  import { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
9
9
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
10
10
  import { ProjectColorDot } from './ProjectColorDot'
11
+ import { startTimerEntry } from './startTimer'
11
12
 
12
13
  type ProjectOption = {
13
14
  id: string
@@ -140,31 +141,14 @@ export function TimerBar({ projects, staffMemberId, onTimerStopped }: TimerBarPr
140
141
  setIsStarting(true)
141
142
  try {
142
143
  const today = getToday()
143
- const createResponse = await apiCallOrThrow(
144
- '/api/staff/timesheets/time-entries',
145
- {
146
- method: 'POST',
147
- headers: { 'Content-Type': 'application/json' },
148
- body: JSON.stringify({
149
- staffMemberId,
150
- timeProjectId: selectedProjectId,
151
- date: today,
152
- durationMinutes: 0,
153
- source: 'timer',
154
- notes: description || null,
155
- }),
156
- },
157
- )
158
-
159
- const created = createResponse.result as Record<string, unknown> | null
160
- const entryId = created?.id as string | undefined
161
-
162
- await apiCallOrThrow(
163
- `/api/staff/timesheets/time-entries/${entryId}/timer-start`,
164
- { method: 'POST' },
165
- )
166
-
167
- setActiveEntryId(entryId ?? null)
144
+ const { id: entryId } = await startTimerEntry({
145
+ staffMemberId,
146
+ timeProjectId: selectedProjectId,
147
+ date: today,
148
+ notes: description || null,
149
+ })
150
+
151
+ setActiveEntryId(entryId)
168
152
  setActiveProjectId(selectedProjectId)
169
153
  startElapsedCounter(new Date().toISOString())
170
154
  } catch {
@@ -0,0 +1,40 @@
1
+ import { apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
2
+
3
+ export type StartTimerEntryInput = {
4
+ staffMemberId: string
5
+ timeProjectId: string
6
+ date: string
7
+ notes?: string | null
8
+ }
9
+
10
+ export type StartedTimerEntry = {
11
+ id: string | null
12
+ }
13
+
14
+ /**
15
+ * Starts a timesheet timer through the single atomic server endpoint
16
+ * (`/api/staff/timesheets/time-entries/start-timer`), which creates the
17
+ * timer-sourced entry AND starts it in one transaction. This replaces the
18
+ * legacy create-then-start two-request sequence so a partial failure (a failed
19
+ * second request, or the browser navigating between the two calls) can no
20
+ * longer leave an orphaned, never-started timer entry (issue #3311). Both the
21
+ * TimerBar and the dashboard time-reporting widget call this helper so they
22
+ * share error handling and refresh semantics.
23
+ */
24
+ export async function startTimerEntry(input: StartTimerEntryInput): Promise<StartedTimerEntry> {
25
+ const response = await apiCallOrThrow<Record<string, unknown>>(
26
+ '/api/staff/timesheets/time-entries/start-timer',
27
+ {
28
+ method: 'POST',
29
+ headers: { 'Content-Type': 'application/json' },
30
+ body: JSON.stringify({
31
+ staffMemberId: input.staffMemberId,
32
+ timeProjectId: input.timeProjectId,
33
+ date: input.date,
34
+ notes: input.notes ?? null,
35
+ }),
36
+ },
37
+ )
38
+ const result = response.result as Record<string, unknown> | null
39
+ return { id: (result?.id as string | undefined) ?? null }
40
+ }
@@ -6,6 +6,7 @@ import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/ap
6
6
  import { Button } from '@open-mercato/ui/primitives/button'
7
7
  import { Input } from '@open-mercato/ui/primitives/input'
8
8
  import { useT } from '@open-mercato/shared/lib/i18n/context'
9
+ import { startTimerEntry } from '../../../lib/timesheets-ui/startTimer'
9
10
  import { DEFAULT_SETTINGS, hydrateSettings, type TimeReportingSettings } from './config'
10
11
 
11
12
  type ProjectOption = { id: string; name: string; code: string | null }
@@ -138,25 +139,15 @@ const TimeReportingWidget: React.FC<DashboardWidgetComponentProps<TimeReportingS
138
139
  setActionLoading(true)
139
140
  try {
140
141
  const today = new Date().toISOString().slice(0, 10)
141
- // Create entry + start timer
142
- const createRes = await apiCall<Record<string, unknown>>('/api/staff/timesheets/time-entries', {
143
- method: 'POST',
144
- headers: { 'Content-Type': 'application/json' },
145
- body: JSON.stringify({
146
- staffMemberId,
147
- date: today,
148
- timeProjectId: selectedProjectId,
149
- durationMinutes: 0,
150
- notes: notes.trim() || null,
151
- source: 'timer',
152
- }),
142
+ // Single atomic create+start request (issue #3311): a partial failure can
143
+ // no longer leave an orphaned, unstarted timer entry, and a rejected start
144
+ // now surfaces here instead of being silently ignored.
145
+ await startTimerEntry({
146
+ staffMemberId,
147
+ timeProjectId: selectedProjectId,
148
+ date: today,
149
+ notes: notes.trim() || null,
153
150
  })
154
- if (!createRes.ok) throw new Error('Failed to create entry')
155
- const body = createRes.result as Record<string, unknown> | null
156
- const entryId = String(body?.id ?? (body?.item as Record<string, unknown> | undefined)?.id ?? '')
157
- if (!entryId) throw new Error('Failed to extract entry ID')
158
-
159
- await apiCall(`/api/staff/timesheets/time-entries/${entryId}/timer-start`, { method: 'POST' })
160
151
 
161
152
  onSettingsChange({ ...hydrated, lastProjectId: selectedProjectId })
162
153
  await loadState()