@open-mercato/core 0.6.6-develop.6255.1.a6ee4a57c1 → 0.6.6-develop.6257.1.6d0af84d26

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 (49) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/messages/api/[id]/conversation/archive/route.js +25 -7
  3. package/dist/modules/messages/api/[id]/conversation/archive/route.js.map +2 -2
  4. package/dist/modules/messages/api/[id]/conversation/read/route.js +24 -6
  5. package/dist/modules/messages/api/[id]/conversation/read/route.js.map +2 -2
  6. package/dist/modules/messages/api/[id]/route.js +13 -1
  7. package/dist/modules/messages/api/[id]/route.js.map +2 -2
  8. package/dist/modules/messages/api/openapi.js +3 -1
  9. package/dist/modules/messages/api/openapi.js.map +2 -2
  10. package/dist/modules/messages/commands/conversation.js +77 -0
  11. package/dist/modules/messages/commands/conversation.js.map +2 -2
  12. package/dist/modules/messages/components/MessageDetailPageClient.js +16 -4
  13. package/dist/modules/messages/components/MessageDetailPageClient.js.map +2 -2
  14. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js +18 -0
  15. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js.map +2 -2
  16. package/dist/modules/messages/components/message-detail/panels/MainMessageHeader.js +13 -11
  17. package/dist/modules/messages/components/message-detail/panels/MainMessageHeader.js.map +2 -2
  18. package/dist/modules/perspectives/api/[tableId]/[perspectiveId]/route.js +20 -9
  19. package/dist/modules/perspectives/api/[tableId]/[perspectiveId]/route.js.map +2 -2
  20. package/dist/modules/perspectives/api/[tableId]/roles/[roleId]/route.js +43 -6
  21. package/dist/modules/perspectives/api/[tableId]/roles/[roleId]/route.js.map +2 -2
  22. package/dist/modules/perspectives/api/[tableId]/route.js +21 -4
  23. package/dist/modules/perspectives/api/[tableId]/route.js.map +2 -2
  24. package/dist/modules/perspectives/api/openapi.js +1 -0
  25. package/dist/modules/perspectives/api/openapi.js.map +2 -2
  26. package/dist/modules/perspectives/data/validators.js +2 -0
  27. package/dist/modules/perspectives/data/validators.js.map +2 -2
  28. package/dist/modules/perspectives/services/perspectiveService.js +135 -6
  29. package/dist/modules/perspectives/services/perspectiveService.js.map +2 -2
  30. package/package.json +7 -7
  31. package/src/modules/messages/api/[id]/conversation/archive/route.ts +29 -6
  32. package/src/modules/messages/api/[id]/conversation/read/route.ts +29 -6
  33. package/src/modules/messages/api/[id]/route.ts +15 -0
  34. package/src/modules/messages/api/openapi.ts +2 -0
  35. package/src/modules/messages/commands/conversation.ts +87 -0
  36. package/src/modules/messages/components/MessageDetailPageClient.tsx +16 -4
  37. package/src/modules/messages/components/message-detail/hooks/useMessageDetailsActions.ts +26 -1
  38. package/src/modules/messages/components/message-detail/panels/MainMessageHeader.tsx +29 -14
  39. package/src/modules/messages/components/message-detail/types.ts +2 -0
  40. package/src/modules/messages/i18n/de.json +4 -0
  41. package/src/modules/messages/i18n/en.json +4 -0
  42. package/src/modules/messages/i18n/es.json +4 -0
  43. package/src/modules/messages/i18n/pl.json +4 -0
  44. package/src/modules/perspectives/api/[tableId]/[perspectiveId]/route.ts +20 -9
  45. package/src/modules/perspectives/api/[tableId]/roles/[roleId]/route.ts +45 -6
  46. package/src/modules/perspectives/api/[tableId]/route.ts +23 -1
  47. package/src/modules/perspectives/api/openapi.ts +1 -1
  48. package/src/modules/perspectives/data/validators.ts +2 -0
  49. package/src/modules/perspectives/services/perspectiveService.ts +171 -7
@@ -97,6 +97,8 @@ export type MessageDetail = {
97
97
  objects?: MessageDetailObject[]
98
98
  }>
99
99
  isRead: boolean
100
+ conversationArchived?: boolean
101
+ conversationAllUnread?: boolean
100
102
  }
101
103
 
102
104
  export type ActionResult = {
@@ -9,6 +9,7 @@
9
9
  "messages.actions.edit": "Bearbeiten",
10
10
  "messages.actions.executing": "Wird ausgeführt...",
11
11
  "messages.actions.forwardAll": "Alle weiterleiten",
12
+ "messages.actions.markAllRead": "Alle als gelesen markieren",
12
13
  "messages.actions.markAllUnread": "Alle als ungelesen markieren",
13
14
  "messages.actions.markRead": "Als gelesen markieren",
14
15
  "messages.actions.markUnread": "Als ungelesen markieren",
@@ -16,6 +17,7 @@
16
17
  "messages.actions.taken": "Aktion ausgeführt",
17
18
  "messages.actions.title": "Aktionen",
18
19
  "messages.actions.unarchive": "Wiederherstellen",
20
+ "messages.actions.unarchiveConversation": "Konversation wiederherstellen",
19
21
  "messages.attachFiles": "Dateien anhängen",
20
22
  "messages.attachObject": "Objekt anhängen",
21
23
  "messages.attachedFiles": "Anhänge",
@@ -146,7 +148,9 @@
146
148
  "messages.flash.actionSuccess": "Aktion abgeschlossen.",
147
149
  "messages.flash.conversationArchived": "Konversation archiviert.",
148
150
  "messages.flash.conversationDeleted": "Konversation gelöscht.",
151
+ "messages.flash.conversationMarkedRead": "Konversation als gelesen markiert.",
149
152
  "messages.flash.conversationMarkedUnread": "Konversation als ungelesen markiert.",
153
+ "messages.flash.conversationUnarchived": "Konversation wiederhergestellt.",
150
154
  "messages.flash.deleted": "Nachricht gelöscht.",
151
155
  "messages.flash.draftSaved": "Entwurf gespeichert.",
152
156
  "messages.flash.forwardSuccess": "Nachricht weitergeleitet.",
@@ -9,6 +9,7 @@
9
9
  "messages.actions.edit": "Edit",
10
10
  "messages.actions.executing": "Executing...",
11
11
  "messages.actions.forwardAll": "Forward all",
12
+ "messages.actions.markAllRead": "Mark all read",
12
13
  "messages.actions.markAllUnread": "Mark all unread",
13
14
  "messages.actions.markRead": "Mark read",
14
15
  "messages.actions.markUnread": "Mark unread",
@@ -16,6 +17,7 @@
16
17
  "messages.actions.taken": "Action taken",
17
18
  "messages.actions.title": "Actions",
18
19
  "messages.actions.unarchive": "Unarchive",
20
+ "messages.actions.unarchiveConversation": "Unarchive conversation",
19
21
  "messages.attachFiles": "Attach files",
20
22
  "messages.attachObject": "Attach object",
21
23
  "messages.attachedFiles": "Attachments",
@@ -146,7 +148,9 @@
146
148
  "messages.flash.actionSuccess": "Action completed.",
147
149
  "messages.flash.conversationArchived": "Conversation archived.",
148
150
  "messages.flash.conversationDeleted": "Conversation deleted.",
151
+ "messages.flash.conversationMarkedRead": "Conversation marked read.",
149
152
  "messages.flash.conversationMarkedUnread": "Conversation marked unread.",
153
+ "messages.flash.conversationUnarchived": "Conversation restored from archive.",
150
154
  "messages.flash.deleted": "Message deleted.",
151
155
  "messages.flash.draftSaved": "Draft saved.",
152
156
  "messages.flash.forwardSuccess": "Message forwarded.",
@@ -9,6 +9,7 @@
9
9
  "messages.actions.edit": "Editar",
10
10
  "messages.actions.executing": "Ejecutando...",
11
11
  "messages.actions.forwardAll": "Reenviar todo",
12
+ "messages.actions.markAllRead": "Marcar todo como leído",
12
13
  "messages.actions.markAllUnread": "Marcar todo como no leído",
13
14
  "messages.actions.markRead": "Marcar como leído",
14
15
  "messages.actions.markUnread": "Marcar como no leído",
@@ -16,6 +17,7 @@
16
17
  "messages.actions.taken": "Acción realizada",
17
18
  "messages.actions.title": "Acciones",
18
19
  "messages.actions.unarchive": "Desarchivar",
20
+ "messages.actions.unarchiveConversation": "Desarchivar conversación",
19
21
  "messages.attachFiles": "Adjuntar archivos",
20
22
  "messages.attachObject": "Adjuntar objeto",
21
23
  "messages.attachedFiles": "Adjuntos",
@@ -146,7 +148,9 @@
146
148
  "messages.flash.actionSuccess": "Acción completada.",
147
149
  "messages.flash.conversationArchived": "Conversación archivada.",
148
150
  "messages.flash.conversationDeleted": "Conversación eliminada.",
151
+ "messages.flash.conversationMarkedRead": "Conversación marcada como leída.",
149
152
  "messages.flash.conversationMarkedUnread": "Conversación marcada como no leída.",
153
+ "messages.flash.conversationUnarchived": "Conversación desarchivada.",
150
154
  "messages.flash.deleted": "Mensaje eliminado.",
151
155
  "messages.flash.draftSaved": "Borrador guardado.",
152
156
  "messages.flash.forwardSuccess": "Mensaje reenviado.",
@@ -9,6 +9,7 @@
9
9
  "messages.actions.edit": "Edytuj",
10
10
  "messages.actions.executing": "Wykonywanie...",
11
11
  "messages.actions.forwardAll": "Przekaż cały wątek",
12
+ "messages.actions.markAllRead": "Oznacz wszystko jako przeczytane",
12
13
  "messages.actions.markAllUnread": "Oznacz wszystko jako nieprzeczytane",
13
14
  "messages.actions.markRead": "Oznacz jako przeczytane",
14
15
  "messages.actions.markUnread": "Oznacz jako nieprzeczytane",
@@ -16,6 +17,7 @@
16
17
  "messages.actions.taken": "Wykonana akcja",
17
18
  "messages.actions.title": "Akcje",
18
19
  "messages.actions.unarchive": "Przywróć z archiwum",
20
+ "messages.actions.unarchiveConversation": "Przywróć konwersację z archiwum",
19
21
  "messages.attachFiles": "Dołącz pliki",
20
22
  "messages.attachObject": "Dołącz obiekt",
21
23
  "messages.attachedFiles": "Załączniki",
@@ -146,7 +148,9 @@
146
148
  "messages.flash.actionSuccess": "Akcja wykonana.",
147
149
  "messages.flash.conversationArchived": "Konwersacja zarchiwizowana.",
148
150
  "messages.flash.conversationDeleted": "Konwersacja usunięta.",
151
+ "messages.flash.conversationMarkedRead": "Konwersacja oznaczona jako przeczytana.",
149
152
  "messages.flash.conversationMarkedUnread": "Konwersacja oznaczona jako nieprzeczytana.",
153
+ "messages.flash.conversationUnarchived": "Konwersacja przywrócona z archiwum.",
150
154
  "messages.flash.deleted": "Wiadomość usunięta.",
151
155
  "messages.flash.draftSaved": "Szkic zapisany.",
152
156
  "messages.flash.forwardSuccess": "Wiadomość przekazana.",
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
5
6
  import {
6
7
  runCrudMutationGuardAfterSuccess,
7
8
  validateCrudMutationGuard,
@@ -59,15 +60,24 @@ export async function DELETE(req: Request, ctx: { params: { tableId: string; per
59
60
  return NextResponse.json(guardResult.body, { status: guardResult.status })
60
61
  }
61
62
 
62
- const deleted = await deleteUserPerspective(em, cache, {
63
- scope: {
64
- userId: auth.sub,
65
- tenantId: auth.tenantId ?? null,
66
- organizationId: auth.orgId ?? null,
67
- },
68
- tableId,
69
- perspectiveId,
70
- })
63
+ let deleted = false
64
+ try {
65
+ deleted = await deleteUserPerspective(em, cache, {
66
+ scope: {
67
+ userId: auth.sub,
68
+ tenantId: auth.tenantId ?? null,
69
+ organizationId: auth.orgId ?? null,
70
+ },
71
+ tableId,
72
+ perspectiveId,
73
+ request: req,
74
+ })
75
+ } catch (err) {
76
+ if (isCrudHttpError(err)) {
77
+ return NextResponse.json(err.body, { status: err.status })
78
+ }
79
+ throw err
80
+ }
71
81
 
72
82
  if (deleted && guardResult?.ok && guardResult.shouldRunAfterSuccess) {
73
83
  await runCrudMutationGuardAfterSuccess(container, {
@@ -101,6 +111,7 @@ const perspectiveDeleteDoc: OpenApiMethodDoc = {
101
111
  errors: [
102
112
  { status: 400, description: 'Invalid identifiers supplied', schema: perspectivesErrorSchema },
103
113
  { status: 401, description: 'Authentication required', schema: perspectivesErrorSchema },
114
+ { status: 409, description: 'Optimistic lock conflict', schema: perspectivesErrorSchema },
104
115
  { status: 404, description: 'Perspective not found', schema: perspectivesErrorSchema },
105
116
  ],
106
117
  }
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { z } from 'zod'
3
3
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
4
4
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
5
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
5
6
  import {
6
7
  runCrudMutationGuardAfterSuccess,
7
8
  validateCrudMutationGuard,
@@ -25,6 +26,11 @@ const decodeParam = (value: string | string[] | undefined): string => {
25
26
  }
26
27
  }
27
28
 
29
+ const rolePerspectiveDeleteBodySchema = z.object({
30
+ roleExpectedUpdatedAtByRoleId: z.record(z.string().uuid(), z.string().min(1).nullable()).optional(),
31
+ roleExpectedUpdatedAtByPerspectiveId: z.record(z.string().uuid(), z.string().min(1).nullable()).optional(),
32
+ }).optional()
33
+
28
34
  export async function DELETE(req: Request, ctx: { params: { tableId: string; roleId: string } }) {
29
35
  const auth = await getAuthFromRequest(req)
30
36
  if (!auth) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
@@ -35,6 +41,22 @@ export async function DELETE(req: Request, ctx: { params: { tableId: string; rol
35
41
  return NextResponse.json({ error: 'Invalid parameters' }, { status: 400 })
36
42
  }
37
43
 
44
+ let parsedBody: z.infer<typeof rolePerspectiveDeleteBodySchema> = undefined
45
+ if (req.body && (req.headers.get('content-type') ?? '').includes('application/json')) {
46
+ try {
47
+ const rawBody = await req.text()
48
+ if (rawBody.trim().length > 0) {
49
+ const parsed = rolePerspectiveDeleteBodySchema.safeParse(JSON.parse(rawBody))
50
+ if (!parsed.success) {
51
+ return NextResponse.json({ error: 'Invalid payload', details: parsed.error.flatten() }, { status: 400 })
52
+ }
53
+ parsedBody = parsed.data
54
+ }
55
+ } catch {
56
+ return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
57
+ }
58
+ }
59
+
38
60
  const container = await createRequestContainer()
39
61
  const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager
40
62
  const cache = ((): import('@open-mercato/cache').CacheStrategy | null => {
@@ -67,12 +89,23 @@ export async function DELETE(req: Request, ctx: { params: { tableId: string; rol
67
89
  return NextResponse.json(guardResult.body, { status: guardResult.status })
68
90
  }
69
91
 
70
- const clearedCount = await clearRolePerspectives(em, cache, {
71
- tableId,
72
- tenantId: auth.tenantId ?? null,
73
- organizationId: auth.orgId ?? null,
74
- roleIds: [roleId],
75
- })
92
+ let clearedCount = 0
93
+ try {
94
+ clearedCount = await clearRolePerspectives(em, cache, {
95
+ tableId,
96
+ tenantId: auth.tenantId ?? null,
97
+ organizationId: auth.orgId ?? null,
98
+ roleIds: [roleId],
99
+ expectedUpdatedAtByRoleId: parsedBody?.roleExpectedUpdatedAtByRoleId,
100
+ expectedUpdatedAtByPerspectiveId: parsedBody?.roleExpectedUpdatedAtByPerspectiveId,
101
+ request: req,
102
+ })
103
+ } catch (err) {
104
+ if (isCrudHttpError(err)) {
105
+ return NextResponse.json(err.body, { status: err.status })
106
+ }
107
+ throw err
108
+ }
76
109
 
77
110
  if (clearedCount > 0 && guardResult?.ok && guardResult.shouldRunAfterSuccess) {
78
111
  await runCrudMutationGuardAfterSuccess(container, {
@@ -100,6 +133,11 @@ const rolePerspectiveDeleteDoc: OpenApiMethodDoc = {
100
133
  summary: 'Clear role perspectives for a table',
101
134
  description: 'Removes all role-level perspectives associated with the provided role identifier for the table.',
102
135
  tags: [perspectivesTag],
136
+ requestBody: {
137
+ contentType: 'application/json',
138
+ schema: rolePerspectiveDeleteBodySchema,
139
+ description: 'Optional per-role or per-perspective optimistic-lock tokens.',
140
+ },
103
141
  responses: [
104
142
  { status: 200, description: 'Role perspectives cleared.', schema: perspectivesSuccessSchema },
105
143
  ],
@@ -107,6 +145,7 @@ const rolePerspectiveDeleteDoc: OpenApiMethodDoc = {
107
145
  { status: 400, description: 'Invalid identifiers supplied', schema: perspectivesErrorSchema },
108
146
  { status: 401, description: 'Authentication required', schema: perspectivesErrorSchema },
109
147
  { status: 403, description: 'Missing perspectives.role_defaults feature', schema: perspectivesErrorSchema },
148
+ { status: 409, description: 'Optimistic lock conflict', schema: perspectivesErrorSchema },
110
149
  { status: 404, description: 'Role not found in scope', schema: perspectivesErrorSchema },
111
150
  ],
112
151
  }
@@ -96,15 +96,26 @@ export async function GET(_req: Request, ctx: { params: { tableId: string } }) {
96
96
  const availableRoles = canApplyToRoles
97
97
  ? await em.find(Role, { ...roleScope as any, deletedAt: null } as any, { orderBy: { name: 'asc' } })
98
98
  : assignedRoles
99
+ const manageableRoleIds = canApplyToRoles
100
+ ? availableRoles.map((role) => role.id)
101
+ : assignedRoleIds
99
102
 
100
103
  const state = await loadPerspectivesState(em, cache, {
101
104
  scope: buildScope(auth),
102
105
  tableId,
103
106
  roleIds: assignedRoleIds,
104
107
  })
108
+ const manageableState = manageableRoleIds.length === assignedRoleIds.length
109
+ && manageableRoleIds.every((roleId) => assignedRoleIds.includes(roleId))
110
+ ? state
111
+ : await loadPerspectivesState(em, cache, {
112
+ scope: buildScope(auth),
113
+ tableId,
114
+ roleIds: manageableRoleIds,
115
+ })
105
116
 
106
117
  const rolePerspectiveByRole = new Map<string, { hasDefault: boolean; count: number }>()
107
- for (const item of state.rolePerspectives) {
118
+ for (const item of manageableState.rolePerspectives) {
108
119
  const entry = rolePerspectiveByRole.get(item.roleId) ?? { hasDefault: false, count: 0 }
109
120
  entry.count += 1
110
121
  entry.hasDefault = entry.hasDefault || item.isDefault
@@ -119,6 +130,10 @@ export async function GET(_req: Request, ctx: { params: { tableId: string } }) {
119
130
  ...rp,
120
131
  roleName: availableRoles.find((role) => role.id === rp.roleId)?.name ?? assignedRoles.find((role) => role.id === rp.roleId)?.name ?? null,
121
132
  })),
133
+ manageableRolePerspectives: manageableState.rolePerspectives.map((rp) => ({
134
+ ...rp,
135
+ roleName: availableRoles.find((role) => role.id === rp.roleId)?.name ?? assignedRoles.find((role) => role.id === rp.roleId)?.name ?? null,
136
+ })),
122
137
  roles: availableRoles.map((role) => {
123
138
  const stats = rolePerspectiveByRole.get(role.id)
124
139
  return {
@@ -269,6 +284,9 @@ export async function POST(req: Request, ctx: { params: { tableId: string } }) {
269
284
  settings: parsed.data.settings,
270
285
  setDefault: parsed.data.setRoleDefault ?? false,
271
286
  },
287
+ expectedUpdatedAtByRoleId: parsed.data.roleExpectedUpdatedAtByRoleId,
288
+ expectedUpdatedAtByPerspectiveId: parsed.data.roleExpectedUpdatedAtByPerspectiveId,
289
+ request: req,
272
290
  })
273
291
  }
274
292
  },
@@ -279,6 +297,9 @@ export async function POST(req: Request, ctx: { params: { tableId: string } }) {
279
297
  tenantId: auth.tenantId ?? null,
280
298
  organizationId: auth.orgId ?? null,
281
299
  roleIds: clearRoleIds,
300
+ expectedUpdatedAtByRoleId: parsed.data.roleExpectedUpdatedAtByRoleId,
301
+ expectedUpdatedAtByPerspectiveId: parsed.data.roleExpectedUpdatedAtByPerspectiveId,
302
+ request: req,
282
303
  })
283
304
  }
284
305
  },
@@ -359,6 +380,7 @@ const perspectivesPostDoc: OpenApiMethodDoc = {
359
380
  { status: 400, description: 'Validation failed or invalid roles provided', schema: perspectivesErrorSchema },
360
381
  { status: 401, description: 'Authentication required', schema: perspectivesErrorSchema },
361
382
  { status: 403, description: 'Missing perspectives.role_defaults feature for role updates', schema: perspectivesErrorSchema },
383
+ { status: 409, description: 'Optimistic lock conflict', schema: perspectivesErrorSchema },
362
384
  ],
363
385
  }
364
386
 
@@ -29,6 +29,7 @@ export const perspectivesIndexResponseSchema = z.object({
29
29
  perspectives: z.array(perspectiveDtoSchema),
30
30
  defaultPerspectiveId: z.string().uuid().nullable(),
31
31
  rolePerspectives: z.array(rolePerspectiveDtoSchema),
32
+ manageableRolePerspectives: z.array(rolePerspectiveDtoSchema),
32
33
  roles: z.array(
33
34
  z.object({
34
35
  id: z.string().uuid(),
@@ -49,4 +50,3 @@ export const perspectiveSaveResponseSchema = z.object({
49
50
  export const perspectivesSuccessSchema = z.object({
50
51
  success: z.literal(true),
51
52
  })
52
-
@@ -22,6 +22,8 @@ export const perspectiveSaveSchema = z.object({
22
22
  isDefault: z.boolean().optional(),
23
23
  applyToRoles: z.array(z.string().uuid()).optional(),
24
24
  clearRoleIds: z.array(z.string().uuid()).optional(),
25
+ roleExpectedUpdatedAtByRoleId: z.record(z.string().uuid(), z.string().min(1).nullable()).optional(),
26
+ roleExpectedUpdatedAtByPerspectiveId: z.record(z.string().uuid(), z.string().min(1).nullable()).optional(),
25
27
  setRoleDefault: z.boolean().optional(),
26
28
  })
27
29
 
@@ -1,6 +1,11 @@
1
1
  import type { EntityManager } from '@mikro-orm/postgresql'
2
2
  import type { CacheStrategy } from '@open-mercato/cache'
3
- import { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
3
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
4
+ import {
5
+ buildOptimisticLockConflictBody,
6
+ enforceCommandOptimisticLock,
7
+ enforceRecordGoneIsConflict,
8
+ } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
4
9
  import { Perspective, RolePerspective } from '../data/entities'
5
10
  import type {
6
11
  PerspectiveSettings,
@@ -44,6 +49,50 @@ export type PerspectivesState = {
44
49
  rolePerspectives: ResolvedRolePerspective[]
45
50
  }
46
51
 
52
+ type ExpectedUpdatedAtById = Record<string, string | Date | null | undefined>
53
+
54
+ const PERSPECTIVE_RESOURCE_KIND = 'perspectives.perspective'
55
+ const ROLE_PERSPECTIVE_RESOURCE_KIND = 'perspectives.role_perspective'
56
+
57
+ function resolveRoleLockInput(
58
+ expectedUpdatedAtByRoleId: ExpectedUpdatedAtById | null | undefined,
59
+ roleId: string,
60
+ request: Request | Headers | null | undefined,
61
+ ): Pick<Parameters<typeof enforceCommandOptimisticLock>[0], 'expected' | 'request'> {
62
+ if (expectedUpdatedAtByRoleId && Object.prototype.hasOwnProperty.call(expectedUpdatedAtByRoleId, roleId)) {
63
+ return { expected: expectedUpdatedAtByRoleId[roleId] ?? null, request: null }
64
+ }
65
+ return { expected: undefined, request: request ?? null }
66
+ }
67
+
68
+ function resolveRoleRecordLockInput(
69
+ expectedUpdatedAtByPerspectiveId: ExpectedUpdatedAtById | null | undefined,
70
+ expectedUpdatedAtByRoleId: ExpectedUpdatedAtById | null | undefined,
71
+ record: RolePerspective,
72
+ request: Request | Headers | null | undefined,
73
+ ): Pick<Parameters<typeof enforceCommandOptimisticLock>[0], 'expected' | 'request'> {
74
+ if (expectedUpdatedAtByPerspectiveId && Object.prototype.hasOwnProperty.call(expectedUpdatedAtByPerspectiveId, record.id)) {
75
+ return { expected: expectedUpdatedAtByPerspectiveId[record.id] ?? null, request: null }
76
+ }
77
+ return resolveRoleLockInput(expectedUpdatedAtByRoleId, record.roleId, request)
78
+ }
79
+
80
+ function firstExpectedUpdatedAt(expectedUpdatedAtById: ExpectedUpdatedAtById | null | undefined): string | Date | null {
81
+ if (!expectedUpdatedAtById) return null
82
+ for (const value of Object.values(expectedUpdatedAtById)) {
83
+ if (value instanceof Date) return value
84
+ if (typeof value === 'string' && value.trim().length > 0) return value
85
+ }
86
+ return null
87
+ }
88
+
89
+ function throwMissingRoleRecordVersionConflict(record: RolePerspective): never {
90
+ const current = record.updatedAt instanceof Date && Number.isFinite(record.updatedAt.getTime())
91
+ ? record.updatedAt.toISOString()
92
+ : new Date(0).toISOString()
93
+ throw new CrudHttpError(409, buildOptimisticLockConflictBody(current, current))
94
+ }
95
+
47
96
  const CACHE_TTL_MS = 5 * 60 * 1000
48
97
 
49
98
  const nullish = <T extends string | null | undefined>(value: T): string | null =>
@@ -242,7 +291,7 @@ export async function saveUserPerspective(
242
291
  throw Object.assign(new Error('Perspective not found'), { code: 'NOT_FOUND' })
243
292
  }
244
293
  enforceCommandOptimisticLock({
245
- resourceKind: 'perspectives.perspective',
294
+ resourceKind: PERSPECTIVE_RESOURCE_KIND,
246
295
  resourceId: entity.id,
247
296
  current: entity.updatedAt ?? null,
248
297
  request: options.request ?? null,
@@ -308,7 +357,12 @@ export async function saveUserPerspective(
308
357
  export async function deleteUserPerspective(
309
358
  em: EntityManager,
310
359
  cache: CacheStrategy | null | undefined,
311
- options: { scope: PerspectiveScope; tableId: string; perspectiveId: string },
360
+ options: {
361
+ scope: PerspectiveScope
362
+ tableId: string
363
+ perspectiveId: string
364
+ request?: Request | Headers | null
365
+ },
312
366
  ): Promise<boolean> {
313
367
  const { scope, tableId, perspectiveId } = options
314
368
  const tenantId = scope.tenantId ?? null
@@ -322,7 +376,21 @@ export async function deleteUserPerspective(
322
376
  tableId,
323
377
  deletedAt: null,
324
378
  })
325
- if (!existing) return false
379
+ if (!existing) {
380
+ enforceRecordGoneIsConflict({
381
+ resourceKind: PERSPECTIVE_RESOURCE_KIND,
382
+ resourceId: perspectiveId,
383
+ request: options.request ?? null,
384
+ })
385
+ return false
386
+ }
387
+
388
+ enforceCommandOptimisticLock({
389
+ resourceKind: PERSPECTIVE_RESOURCE_KIND,
390
+ resourceId: existing.id,
391
+ current: existing.updatedAt ?? null,
392
+ request: options.request ?? null,
393
+ })
326
394
 
327
395
  existing.deletedAt = new Date()
328
396
  existing.isDefault = false
@@ -343,6 +411,9 @@ export async function saveRolePerspectives(
343
411
  tenantId?: string | null
344
412
  organizationId?: string | null
345
413
  input: RolePerspectiveSaveInput
414
+ expectedUpdatedAtByRoleId?: ExpectedUpdatedAtById
415
+ expectedUpdatedAtByPerspectiveId?: ExpectedUpdatedAtById
416
+ request?: Request | Headers | null
346
417
  },
347
418
  ): Promise<ResolvedRolePerspective[]> {
348
419
  const { tableId, input } = options
@@ -351,7 +422,7 @@ export async function saveRolePerspectives(
351
422
  const now = new Date()
352
423
  const touchedRoleIds = new Set<string>()
353
424
 
354
- const results: ResolvedRolePerspective[] = []
425
+ const resultRecords: RolePerspective[] = []
355
426
 
356
427
  // Prefetch every matching role perspective in a single query, then index by role id
357
428
  // so the loop resolves create/update without a lookup per role.
@@ -367,6 +438,22 @@ export async function saveRolePerspectives(
367
438
  })
368
439
  for (const existing of existingRecords) recordByRole.set(existing.roleId, existing)
369
440
  }
441
+ const defaultRecordsByRole = new Map<string, RolePerspective[]>()
442
+ if (input.setDefault === true && input.roleIds.length) {
443
+ const existingDefaultRecords = await em.find(RolePerspective, {
444
+ roleId: { $in: input.roleIds },
445
+ tableId,
446
+ tenantId,
447
+ organizationId,
448
+ isDefault: true,
449
+ deletedAt: null,
450
+ })
451
+ for (const existing of existingDefaultRecords) {
452
+ const records = defaultRecordsByRole.get(existing.roleId) ?? []
453
+ records.push(existing)
454
+ defaultRecordsByRole.set(existing.roleId, records)
455
+ }
456
+ }
370
457
 
371
458
  for (const roleId of input.roleIds) {
372
459
  let record = recordByRole.get(roleId) ?? null
@@ -385,6 +472,17 @@ export async function saveRolePerspectives(
385
472
  em.persist(record)
386
473
  recordByRole.set(roleId, record)
387
474
  } else {
475
+ enforceCommandOptimisticLock({
476
+ resourceKind: ROLE_PERSPECTIVE_RESOURCE_KIND,
477
+ resourceId: record.id,
478
+ current: record.updatedAt ?? null,
479
+ ...resolveRoleRecordLockInput(
480
+ options.expectedUpdatedAtByPerspectiveId,
481
+ options.expectedUpdatedAtByRoleId,
482
+ record,
483
+ options.request ?? null,
484
+ ),
485
+ })
388
486
  record.settingsJson = input.settings
389
487
  record.updatedAt = now
390
488
  if (input.setDefault === true) record.isDefault = true
@@ -392,6 +490,20 @@ export async function saveRolePerspectives(
392
490
  }
393
491
 
394
492
  if (input.setDefault === true) {
493
+ for (const defaultRecord of defaultRecordsByRole.get(roleId) ?? []) {
494
+ if (defaultRecord.id === record.id) continue
495
+ enforceCommandOptimisticLock({
496
+ resourceKind: ROLE_PERSPECTIVE_RESOURCE_KIND,
497
+ resourceId: defaultRecord.id,
498
+ current: defaultRecord.updatedAt ?? null,
499
+ ...resolveRoleRecordLockInput(
500
+ options.expectedUpdatedAtByPerspectiveId,
501
+ options.expectedUpdatedAtByRoleId,
502
+ defaultRecord,
503
+ options.request ?? null,
504
+ ),
505
+ })
506
+ }
395
507
  await em.nativeUpdate(
396
508
  RolePerspective,
397
509
  {
@@ -400,6 +512,7 @@ export async function saveRolePerspectives(
400
512
  tenantId,
401
513
  organizationId,
402
514
  id: { $ne: record.id } as any,
515
+ isDefault: true,
403
516
  deletedAt: null,
404
517
  },
405
518
  { isDefault: false, updatedAt: now },
@@ -408,7 +521,7 @@ export async function saveRolePerspectives(
408
521
  }
409
522
 
410
523
  touchedRoleIds.add(roleId)
411
- results.push(toResolvedRolePerspective(record))
524
+ resultRecords.push(record)
412
525
  }
413
526
 
414
527
  if (input.roleIds.length) {
@@ -420,7 +533,7 @@ export async function saveRolePerspectives(
420
533
  await cache.deleteByTags(tags)
421
534
  }
422
535
 
423
- return results
536
+ return resultRecords.map(toResolvedRolePerspective)
424
537
  }
425
538
 
426
539
  export async function clearRolePerspectives(
@@ -431,6 +544,9 @@ export async function clearRolePerspectives(
431
544
  tenantId?: string | null
432
545
  organizationId?: string | null
433
546
  roleIds: string[]
547
+ expectedUpdatedAtByRoleId?: ExpectedUpdatedAtById
548
+ expectedUpdatedAtByPerspectiveId?: ExpectedUpdatedAtById
549
+ request?: Request | Headers | null
434
550
  },
435
551
  ): Promise<number> {
436
552
  const { tableId, roleIds } = options
@@ -438,6 +554,54 @@ export async function clearRolePerspectives(
438
554
  const organizationId = options.organizationId ?? null
439
555
  if (!roleIds.length) return 0
440
556
 
557
+ const existingRecords = await em.find(RolePerspective, {
558
+ roleId: { $in: roleIds as any },
559
+ tableId,
560
+ tenantId,
561
+ organizationId,
562
+ deletedAt: null,
563
+ })
564
+ const recordsByRole = new Map<string, RolePerspective[]>()
565
+ for (const record of existingRecords) {
566
+ const records = recordsByRole.get(record.roleId) ?? []
567
+ records.push(record)
568
+ recordsByRole.set(record.roleId, records)
569
+ }
570
+
571
+ for (const roleId of roleIds) {
572
+ const records = recordsByRole.get(roleId) ?? []
573
+ const lockInput = resolveRoleLockInput(options.expectedUpdatedAtByRoleId, roleId, options.request ?? null)
574
+ if (!records.length) {
575
+ const expected = firstExpectedUpdatedAt(options.expectedUpdatedAtByPerspectiveId)
576
+ enforceRecordGoneIsConflict({
577
+ resourceKind: ROLE_PERSPECTIVE_RESOURCE_KIND,
578
+ resourceId: roleId,
579
+ expected: expected ?? lockInput.expected,
580
+ request: expected ? null : lockInput.request,
581
+ })
582
+ continue
583
+ }
584
+ for (const record of records) {
585
+ if (
586
+ options.expectedUpdatedAtByPerspectiveId
587
+ && !Object.prototype.hasOwnProperty.call(options.expectedUpdatedAtByPerspectiveId, record.id)
588
+ ) {
589
+ throwMissingRoleRecordVersionConflict(record)
590
+ }
591
+ enforceCommandOptimisticLock({
592
+ resourceKind: ROLE_PERSPECTIVE_RESOURCE_KIND,
593
+ resourceId: record.id,
594
+ current: record.updatedAt ?? null,
595
+ ...resolveRoleRecordLockInput(
596
+ options.expectedUpdatedAtByPerspectiveId,
597
+ options.expectedUpdatedAtByRoleId,
598
+ record,
599
+ options.request ?? null,
600
+ ),
601
+ })
602
+ }
603
+ }
604
+
441
605
  const affected = await em.nativeUpdate(
442
606
  RolePerspective,
443
607
  {