@open-mercato/core 0.6.6-develop.6253.1.a8c2b49d90 → 0.6.6-develop.6256.1.9fc16aedc4
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/calendar/TimeGrid.js +3 -2
- package/dist/modules/customers/components/calendar/TimeGrid.js.map +2 -2
- package/dist/modules/customers/lib/calendar/grid.js +5 -2
- package/dist/modules/customers/lib/calendar/grid.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/dist/modules/query_index/lib/engine.js +5 -1
- package/dist/modules/query_index/lib/engine.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/customers/components/calendar/TimeGrid.tsx +3 -2
- package/src/modules/customers/lib/calendar/grid.ts +10 -2
- 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
- package/src/modules/query_index/lib/engine.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6256.1.9fc16aedc4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -246,16 +246,16 @@
|
|
|
246
246
|
"zod": "^4.4.3"
|
|
247
247
|
},
|
|
248
248
|
"peerDependencies": {
|
|
249
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
250
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
251
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
249
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6256.1.9fc16aedc4",
|
|
250
|
+
"@open-mercato/shared": "0.6.6-develop.6256.1.9fc16aedc4",
|
|
251
|
+
"@open-mercato/ui": "0.6.6-develop.6256.1.9fc16aedc4",
|
|
252
252
|
"react": "^19.0.0",
|
|
253
253
|
"react-dom": "^19.0.0"
|
|
254
254
|
},
|
|
255
255
|
"devDependencies": {
|
|
256
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
257
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
258
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
256
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6256.1.9fc16aedc4",
|
|
257
|
+
"@open-mercato/shared": "0.6.6-develop.6256.1.9fc16aedc4",
|
|
258
|
+
"@open-mercato/ui": "0.6.6-develop.6256.1.9fc16aedc4",
|
|
259
259
|
"@testing-library/dom": "^10.4.1",
|
|
260
260
|
"@testing-library/jest-dom": "^6.9.1",
|
|
261
261
|
"@testing-library/react": "^16.3.1",
|
|
@@ -172,6 +172,7 @@ export function TimeGrid({
|
|
|
172
172
|
const scrollRef = React.useRef<HTMLDivElement | null>(null)
|
|
173
173
|
const nowMs = Date.now()
|
|
174
174
|
const today = startOfDay(new Date())
|
|
175
|
+
const todayMs = today.getTime()
|
|
175
176
|
const anchorMs = anchor.getTime()
|
|
176
177
|
const [selectedId, setSelectedId] = React.useState<string | null>(null)
|
|
177
178
|
const [drag, setDrag] = React.useState<DragState | null>(null)
|
|
@@ -179,8 +180,8 @@ export function TimeGrid({
|
|
|
179
180
|
const dayStarts = React.useMemo(() => {
|
|
180
181
|
const rangeStart = getVisibleRange(days === 7 ? 'week' : 'day', new Date(anchorMs), 0).from
|
|
181
182
|
const all = Array.from({ length: days }, (_, index) => addDays(rangeStart, index))
|
|
182
|
-
return days === 7 ? applyWeekendVisibility(all, showWeekends) : all
|
|
183
|
-
}, [days, anchorMs, showWeekends])
|
|
183
|
+
return days === 7 ? applyWeekendVisibility(all, showWeekends, new Date(todayMs)) : all
|
|
184
|
+
}, [days, anchorMs, showWeekends, todayMs])
|
|
184
185
|
|
|
185
186
|
const dayColumns = React.useMemo(
|
|
186
187
|
() => dayStarts.map((dayStart) => buildDayColumn(dayStart, items)),
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isSameDay } from 'date-fns/isSameDay'
|
|
2
|
+
|
|
1
3
|
export const DRAG_SNAP_MINUTES = 15
|
|
2
4
|
export const MIN_DRAG_DURATION_MINUTES = 30
|
|
3
5
|
const MINUTES_PER_DAY = 24 * 60
|
|
@@ -7,9 +9,15 @@ export function isWeekendDay(date: Date): boolean {
|
|
|
7
9
|
return weekday === 0 || weekday === 6
|
|
8
10
|
}
|
|
9
11
|
|
|
10
|
-
export function applyWeekendVisibility(
|
|
12
|
+
export function applyWeekendVisibility(
|
|
13
|
+
days: Date[],
|
|
14
|
+
showWeekends: boolean,
|
|
15
|
+
keepWeekendDate?: Date | null,
|
|
16
|
+
): Date[] {
|
|
11
17
|
if (showWeekends) return days
|
|
12
|
-
const workingDays = days.filter(
|
|
18
|
+
const workingDays = days.filter(
|
|
19
|
+
(day) => !isWeekendDay(day) || (keepWeekendDate != null && isSameDay(day, keepWeekendDate)),
|
|
20
|
+
)
|
|
13
21
|
return workingDays.length > 0 ? workingDays : days
|
|
14
22
|
}
|
|
15
23
|
|
|
@@ -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
|
{
|
|
@@ -935,7 +935,11 @@ export class HybridQueryEngine implements QueryEngine {
|
|
|
935
935
|
const cap = resolveEncryptedSortMaxRows()
|
|
936
936
|
const phase1Root = db.selectFrom(`${baseTable} as b` as any)
|
|
937
937
|
let phase1 = await applyQueryShape(phase1Root)
|
|
938
|
-
const
|
|
938
|
+
const scopeFieldNames = [
|
|
939
|
+
hasTenantColumn ? 'tenant_id' : null,
|
|
940
|
+
hasOrganizationColumn ? 'organization_id' : null,
|
|
941
|
+
].filter((field): field is string => field !== null)
|
|
942
|
+
const sortFieldNames = Array.from(new Set(['id', ...scopeFieldNames, ...resolvedSorts.map((s) => String(s.field))]))
|
|
939
943
|
phase1 = applySelection(phase1, sortFieldNames)
|
|
940
944
|
if (cap !== null) {
|
|
941
945
|
phase1 = phase1.limit(cap).orderBy(qualify('id'), 'asc' as any)
|