@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.
Files changed (29) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customers/components/calendar/TimeGrid.js +3 -2
  3. package/dist/modules/customers/components/calendar/TimeGrid.js.map +2 -2
  4. package/dist/modules/customers/lib/calendar/grid.js +5 -2
  5. package/dist/modules/customers/lib/calendar/grid.js.map +2 -2
  6. package/dist/modules/perspectives/api/[tableId]/[perspectiveId]/route.js +20 -9
  7. package/dist/modules/perspectives/api/[tableId]/[perspectiveId]/route.js.map +2 -2
  8. package/dist/modules/perspectives/api/[tableId]/roles/[roleId]/route.js +43 -6
  9. package/dist/modules/perspectives/api/[tableId]/roles/[roleId]/route.js.map +2 -2
  10. package/dist/modules/perspectives/api/[tableId]/route.js +21 -4
  11. package/dist/modules/perspectives/api/[tableId]/route.js.map +2 -2
  12. package/dist/modules/perspectives/api/openapi.js +1 -0
  13. package/dist/modules/perspectives/api/openapi.js.map +2 -2
  14. package/dist/modules/perspectives/data/validators.js +2 -0
  15. package/dist/modules/perspectives/data/validators.js.map +2 -2
  16. package/dist/modules/perspectives/services/perspectiveService.js +135 -6
  17. package/dist/modules/perspectives/services/perspectiveService.js.map +2 -2
  18. package/dist/modules/query_index/lib/engine.js +5 -1
  19. package/dist/modules/query_index/lib/engine.js.map +2 -2
  20. package/package.json +7 -7
  21. package/src/modules/customers/components/calendar/TimeGrid.tsx +3 -2
  22. package/src/modules/customers/lib/calendar/grid.ts +10 -2
  23. package/src/modules/perspectives/api/[tableId]/[perspectiveId]/route.ts +20 -9
  24. package/src/modules/perspectives/api/[tableId]/roles/[roleId]/route.ts +45 -6
  25. package/src/modules/perspectives/api/[tableId]/route.ts +23 -1
  26. package/src/modules/perspectives/api/openapi.ts +1 -1
  27. package/src/modules/perspectives/data/validators.ts +2 -0
  28. package/src/modules/perspectives/services/perspectiveService.ts +171 -7
  29. 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.6253.1.a8c2b49d90",
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.6253.1.a8c2b49d90",
250
- "@open-mercato/shared": "0.6.6-develop.6253.1.a8c2b49d90",
251
- "@open-mercato/ui": "0.6.6-develop.6253.1.a8c2b49d90",
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.6253.1.a8c2b49d90",
257
- "@open-mercato/shared": "0.6.6-develop.6253.1.a8c2b49d90",
258
- "@open-mercato/ui": "0.6.6-develop.6253.1.a8c2b49d90",
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(days: Date[], showWeekends: boolean): Date[] {
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((day) => !isWeekendDay(day))
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
- 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
  {
@@ -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 sortFieldNames = Array.from(new Set(['id', ...resolvedSorts.map((s) => String(s.field))]))
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)