@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/messages/api/[id]/conversation/archive/route.js +25 -7
- package/dist/modules/messages/api/[id]/conversation/archive/route.js.map +2 -2
- package/dist/modules/messages/api/[id]/conversation/read/route.js +24 -6
- package/dist/modules/messages/api/[id]/conversation/read/route.js.map +2 -2
- package/dist/modules/messages/api/[id]/route.js +13 -1
- package/dist/modules/messages/api/[id]/route.js.map +2 -2
- package/dist/modules/messages/api/openapi.js +3 -1
- package/dist/modules/messages/api/openapi.js.map +2 -2
- package/dist/modules/messages/commands/conversation.js +77 -0
- package/dist/modules/messages/commands/conversation.js.map +2 -2
- package/dist/modules/messages/components/MessageDetailPageClient.js +16 -4
- package/dist/modules/messages/components/MessageDetailPageClient.js.map +2 -2
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js +18 -0
- package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js.map +2 -2
- package/dist/modules/messages/components/message-detail/panels/MainMessageHeader.js +13 -11
- package/dist/modules/messages/components/message-detail/panels/MainMessageHeader.js.map +2 -2
- package/dist/modules/perspectives/api/[tableId]/[perspectiveId]/route.js +20 -9
- package/dist/modules/perspectives/api/[tableId]/[perspectiveId]/route.js.map +2 -2
- package/dist/modules/perspectives/api/[tableId]/roles/[roleId]/route.js +43 -6
- package/dist/modules/perspectives/api/[tableId]/roles/[roleId]/route.js.map +2 -2
- package/dist/modules/perspectives/api/[tableId]/route.js +21 -4
- package/dist/modules/perspectives/api/[tableId]/route.js.map +2 -2
- package/dist/modules/perspectives/api/openapi.js +1 -0
- package/dist/modules/perspectives/api/openapi.js.map +2 -2
- package/dist/modules/perspectives/data/validators.js +2 -0
- package/dist/modules/perspectives/data/validators.js.map +2 -2
- package/dist/modules/perspectives/services/perspectiveService.js +135 -6
- package/dist/modules/perspectives/services/perspectiveService.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/messages/api/[id]/conversation/archive/route.ts +29 -6
- package/src/modules/messages/api/[id]/conversation/read/route.ts +29 -6
- package/src/modules/messages/api/[id]/route.ts +15 -0
- package/src/modules/messages/api/openapi.ts +2 -0
- package/src/modules/messages/commands/conversation.ts +87 -0
- package/src/modules/messages/components/MessageDetailPageClient.tsx +16 -4
- package/src/modules/messages/components/message-detail/hooks/useMessageDetailsActions.ts +26 -1
- package/src/modules/messages/components/message-detail/panels/MainMessageHeader.tsx +29 -14
- package/src/modules/messages/components/message-detail/types.ts +2 -0
- package/src/modules/messages/i18n/de.json +4 -0
- package/src/modules/messages/i18n/en.json +4 -0
- package/src/modules/messages/i18n/es.json +4 -0
- package/src/modules/messages/i18n/pl.json +4 -0
- package/src/modules/perspectives/api/[tableId]/[perspectiveId]/route.ts +20 -9
- package/src/modules/perspectives/api/[tableId]/roles/[roleId]/route.ts +45 -6
- package/src/modules/perspectives/api/[tableId]/route.ts +23 -1
- package/src/modules/perspectives/api/openapi.ts +1 -1
- package/src/modules/perspectives/data/validators.ts +2 -0
- package/src/modules/perspectives/services/perspectiveService.ts +171 -7
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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 {
|
|
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:
|
|
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: {
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
{
|