@open-mercato/core 0.6.6-develop.6255.1.a6ee4a57c1 → 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.
@@ -1,5 +1,36 @@
1
- import { enforceCommandOptimisticLock } from "@open-mercato/shared/lib/crud/optimistic-lock-command";
1
+ import { CrudHttpError } from "@open-mercato/shared/lib/crud/errors";
2
+ import {
3
+ buildOptimisticLockConflictBody,
4
+ enforceCommandOptimisticLock,
5
+ enforceRecordGoneIsConflict
6
+ } from "@open-mercato/shared/lib/crud/optimistic-lock-command";
2
7
  import { Perspective, RolePerspective } from "../data/entities.js";
8
+ const PERSPECTIVE_RESOURCE_KIND = "perspectives.perspective";
9
+ const ROLE_PERSPECTIVE_RESOURCE_KIND = "perspectives.role_perspective";
10
+ function resolveRoleLockInput(expectedUpdatedAtByRoleId, roleId, request) {
11
+ if (expectedUpdatedAtByRoleId && Object.prototype.hasOwnProperty.call(expectedUpdatedAtByRoleId, roleId)) {
12
+ return { expected: expectedUpdatedAtByRoleId[roleId] ?? null, request: null };
13
+ }
14
+ return { expected: void 0, request: request ?? null };
15
+ }
16
+ function resolveRoleRecordLockInput(expectedUpdatedAtByPerspectiveId, expectedUpdatedAtByRoleId, record, request) {
17
+ if (expectedUpdatedAtByPerspectiveId && Object.prototype.hasOwnProperty.call(expectedUpdatedAtByPerspectiveId, record.id)) {
18
+ return { expected: expectedUpdatedAtByPerspectiveId[record.id] ?? null, request: null };
19
+ }
20
+ return resolveRoleLockInput(expectedUpdatedAtByRoleId, record.roleId, request);
21
+ }
22
+ function firstExpectedUpdatedAt(expectedUpdatedAtById) {
23
+ if (!expectedUpdatedAtById) return null;
24
+ for (const value of Object.values(expectedUpdatedAtById)) {
25
+ if (value instanceof Date) return value;
26
+ if (typeof value === "string" && value.trim().length > 0) return value;
27
+ }
28
+ return null;
29
+ }
30
+ function throwMissingRoleRecordVersionConflict(record) {
31
+ const current = record.updatedAt instanceof Date && Number.isFinite(record.updatedAt.getTime()) ? record.updatedAt.toISOString() : (/* @__PURE__ */ new Date(0)).toISOString();
32
+ throw new CrudHttpError(409, buildOptimisticLockConflictBody(current, current));
33
+ }
3
34
  const CACHE_TTL_MS = 5 * 60 * 1e3;
4
35
  const nullish = (value) => value == null ? null : value;
5
36
  const scopeKey = (scope) => `${scope.userId}:${scope.tenantId ?? "null"}:${scope.organizationId ?? "null"}`;
@@ -133,7 +164,7 @@ async function saveUserPerspective(em, cache, options) {
133
164
  throw Object.assign(new Error("Perspective not found"), { code: "NOT_FOUND" });
134
165
  }
135
166
  enforceCommandOptimisticLock({
136
- resourceKind: "perspectives.perspective",
167
+ resourceKind: PERSPECTIVE_RESOURCE_KIND,
137
168
  resourceId: entity.id,
138
169
  current: entity.updatedAt ?? null,
139
170
  request: options.request ?? null
@@ -202,7 +233,20 @@ async function deleteUserPerspective(em, cache, options) {
202
233
  tableId,
203
234
  deletedAt: null
204
235
  });
205
- if (!existing) return false;
236
+ if (!existing) {
237
+ enforceRecordGoneIsConflict({
238
+ resourceKind: PERSPECTIVE_RESOURCE_KIND,
239
+ resourceId: perspectiveId,
240
+ request: options.request ?? null
241
+ });
242
+ return false;
243
+ }
244
+ enforceCommandOptimisticLock({
245
+ resourceKind: PERSPECTIVE_RESOURCE_KIND,
246
+ resourceId: existing.id,
247
+ current: existing.updatedAt ?? null,
248
+ request: options.request ?? null
249
+ });
206
250
  existing.deletedAt = /* @__PURE__ */ new Date();
207
251
  existing.isDefault = false;
208
252
  await em.flush();
@@ -217,7 +261,7 @@ async function saveRolePerspectives(em, cache, options) {
217
261
  const organizationId = options.organizationId ?? null;
218
262
  const now = /* @__PURE__ */ new Date();
219
263
  const touchedRoleIds = /* @__PURE__ */ new Set();
220
- const results = [];
264
+ const resultRecords = [];
221
265
  const recordByRole = /* @__PURE__ */ new Map();
222
266
  if (input.roleIds.length) {
223
267
  const existingRecords = await em.find(RolePerspective, {
@@ -230,6 +274,22 @@ async function saveRolePerspectives(em, cache, options) {
230
274
  });
231
275
  for (const existing of existingRecords) recordByRole.set(existing.roleId, existing);
232
276
  }
277
+ const defaultRecordsByRole = /* @__PURE__ */ new Map();
278
+ if (input.setDefault === true && input.roleIds.length) {
279
+ const existingDefaultRecords = await em.find(RolePerspective, {
280
+ roleId: { $in: input.roleIds },
281
+ tableId,
282
+ tenantId,
283
+ organizationId,
284
+ isDefault: true,
285
+ deletedAt: null
286
+ });
287
+ for (const existing of existingDefaultRecords) {
288
+ const records = defaultRecordsByRole.get(existing.roleId) ?? [];
289
+ records.push(existing);
290
+ defaultRecordsByRole.set(existing.roleId, records);
291
+ }
292
+ }
233
293
  for (const roleId of input.roleIds) {
234
294
  let record = recordByRole.get(roleId) ?? null;
235
295
  if (!record) {
@@ -247,12 +307,37 @@ async function saveRolePerspectives(em, cache, options) {
247
307
  em.persist(record);
248
308
  recordByRole.set(roleId, record);
249
309
  } else {
310
+ enforceCommandOptimisticLock({
311
+ resourceKind: ROLE_PERSPECTIVE_RESOURCE_KIND,
312
+ resourceId: record.id,
313
+ current: record.updatedAt ?? null,
314
+ ...resolveRoleRecordLockInput(
315
+ options.expectedUpdatedAtByPerspectiveId,
316
+ options.expectedUpdatedAtByRoleId,
317
+ record,
318
+ options.request ?? null
319
+ )
320
+ });
250
321
  record.settingsJson = input.settings;
251
322
  record.updatedAt = now;
252
323
  if (input.setDefault === true) record.isDefault = true;
253
324
  if (input.setDefault === false) record.isDefault = false;
254
325
  }
255
326
  if (input.setDefault === true) {
327
+ for (const defaultRecord of defaultRecordsByRole.get(roleId) ?? []) {
328
+ if (defaultRecord.id === record.id) continue;
329
+ enforceCommandOptimisticLock({
330
+ resourceKind: ROLE_PERSPECTIVE_RESOURCE_KIND,
331
+ resourceId: defaultRecord.id,
332
+ current: defaultRecord.updatedAt ?? null,
333
+ ...resolveRoleRecordLockInput(
334
+ options.expectedUpdatedAtByPerspectiveId,
335
+ options.expectedUpdatedAtByRoleId,
336
+ defaultRecord,
337
+ options.request ?? null
338
+ )
339
+ });
340
+ }
256
341
  await em.nativeUpdate(
257
342
  RolePerspective,
258
343
  {
@@ -261,6 +346,7 @@ async function saveRolePerspectives(em, cache, options) {
261
346
  tenantId,
262
347
  organizationId,
263
348
  id: { $ne: record.id },
349
+ isDefault: true,
264
350
  deletedAt: null
265
351
  },
266
352
  { isDefault: false, updatedAt: now }
@@ -268,7 +354,7 @@ async function saveRolePerspectives(em, cache, options) {
268
354
  record.isDefault = true;
269
355
  }
270
356
  touchedRoleIds.add(roleId);
271
- results.push(toResolvedRolePerspective(record));
357
+ resultRecords.push(record);
272
358
  }
273
359
  if (input.roleIds.length) {
274
360
  await em.flush();
@@ -277,13 +363,56 @@ async function saveRolePerspectives(em, cache, options) {
277
363
  const tags = Array.from(touchedRoleIds).map((roleId) => roleTag(roleId, tableId, tenantId));
278
364
  await cache.deleteByTags(tags);
279
365
  }
280
- return results;
366
+ return resultRecords.map(toResolvedRolePerspective);
281
367
  }
282
368
  async function clearRolePerspectives(em, cache, options) {
283
369
  const { tableId, roleIds } = options;
284
370
  const tenantId = options.tenantId ?? null;
285
371
  const organizationId = options.organizationId ?? null;
286
372
  if (!roleIds.length) return 0;
373
+ const existingRecords = await em.find(RolePerspective, {
374
+ roleId: { $in: roleIds },
375
+ tableId,
376
+ tenantId,
377
+ organizationId,
378
+ deletedAt: null
379
+ });
380
+ const recordsByRole = /* @__PURE__ */ new Map();
381
+ for (const record of existingRecords) {
382
+ const records = recordsByRole.get(record.roleId) ?? [];
383
+ records.push(record);
384
+ recordsByRole.set(record.roleId, records);
385
+ }
386
+ for (const roleId of roleIds) {
387
+ const records = recordsByRole.get(roleId) ?? [];
388
+ const lockInput = resolveRoleLockInput(options.expectedUpdatedAtByRoleId, roleId, options.request ?? null);
389
+ if (!records.length) {
390
+ const expected = firstExpectedUpdatedAt(options.expectedUpdatedAtByPerspectiveId);
391
+ enforceRecordGoneIsConflict({
392
+ resourceKind: ROLE_PERSPECTIVE_RESOURCE_KIND,
393
+ resourceId: roleId,
394
+ expected: expected ?? lockInput.expected,
395
+ request: expected ? null : lockInput.request
396
+ });
397
+ continue;
398
+ }
399
+ for (const record of records) {
400
+ if (options.expectedUpdatedAtByPerspectiveId && !Object.prototype.hasOwnProperty.call(options.expectedUpdatedAtByPerspectiveId, record.id)) {
401
+ throwMissingRoleRecordVersionConflict(record);
402
+ }
403
+ enforceCommandOptimisticLock({
404
+ resourceKind: ROLE_PERSPECTIVE_RESOURCE_KIND,
405
+ resourceId: record.id,
406
+ current: record.updatedAt ?? null,
407
+ ...resolveRoleRecordLockInput(
408
+ options.expectedUpdatedAtByPerspectiveId,
409
+ options.expectedUpdatedAtByRoleId,
410
+ record,
411
+ options.request ?? null
412
+ )
413
+ });
414
+ }
415
+ }
287
416
  const affected = await em.nativeUpdate(
288
417
  RolePerspective,
289
418
  {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/perspectives/services/perspectiveService.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport { enforceCommandOptimisticLock } from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { Perspective, RolePerspective } from '../data/entities'\nimport type {\n PerspectiveSettings,\n PerspectiveSaveInput,\n RolePerspectiveSaveInput,\n} from '../data/validators'\n\nexport type PerspectiveScope = {\n userId: string\n tenantId?: string | null\n organizationId?: string | null\n}\n\nexport type ResolvedPerspective = {\n id: string\n name: string\n tableId: string\n settings: PerspectiveSettings\n isDefault: boolean\n createdAt: string\n updatedAt?: string | null\n}\n\nexport type ResolvedRolePerspective = {\n id: string\n roleId: string\n tableId: string\n name: string\n settings: PerspectiveSettings\n isDefault: boolean\n tenantId: string | null\n organizationId: string | null\n createdAt: string\n updatedAt?: string | null\n}\n\nexport type PerspectivesState = {\n tableId: string\n personal: ResolvedPerspective[]\n personalDefaultId: string | null\n rolePerspectives: ResolvedRolePerspective[]\n}\n\nconst CACHE_TTL_MS = 5 * 60 * 1000\n\nconst nullish = <T extends string | null | undefined>(value: T): string | null =>\n value == null ? null : value\n\nconst scopeKey = (scope: PerspectiveScope) =>\n `${scope.userId}:${scope.tenantId ?? 'null'}:${scope.organizationId ?? 'null'}`\n\nconst userCacheKey = (scope: PerspectiveScope, tableId: string, roleIds: string[]) =>\n `perspectives:user-state:${scopeKey(scope)}:${tableId}:${roleIds.sort((a, b) => a.localeCompare(b)).join(',')}`\n\nconst userTag = (scope: PerspectiveScope, tableId?: string) =>\n tableId\n ? `perspectives:user:${scopeKey(scope)}:${tableId}`\n : `perspectives:user:${scopeKey(scope)}`\n\nconst roleTag = (roleId: string, tableId?: string, tenantId?: string | null) => {\n const tenant = tenantId ?? 'null'\n return tableId ? `perspectives:role:${roleId}:${tenant}:${tableId}` : `perspectives:role:${roleId}:${tenant}`\n}\n\nfunction isResolvedPerspective(value: unknown): value is ResolvedPerspective {\n if (typeof value !== 'object' || value === null) return false\n const record = value as Partial<ResolvedPerspective>\n return typeof record.id === 'string'\n && typeof record.name === 'string'\n && typeof record.tableId === 'string'\n && typeof record.isDefault === 'boolean'\n && typeof record.createdAt === 'string'\n}\n\nfunction isResolvedRolePerspective(value: unknown): value is ResolvedRolePerspective {\n if (typeof value !== 'object' || value === null) return false\n const record = value as Partial<ResolvedRolePerspective>\n return typeof record.id === 'string'\n && typeof record.roleId === 'string'\n && typeof record.tableId === 'string'\n && typeof record.name === 'string'\n && typeof record.isDefault === 'boolean'\n && typeof record.createdAt === 'string'\n}\n\nfunction isPerspectivesState(value: unknown): value is PerspectivesState {\n if (typeof value !== 'object' || value === null) return false\n const record = value as Partial<PerspectivesState>\n if (typeof record.tableId !== 'string') return false\n if (!Array.isArray(record.personal) || record.personal.some((item) => !isResolvedPerspective(item))) return false\n if (record.personalDefaultId !== null && typeof record.personalDefaultId !== 'string') return false\n if (!Array.isArray(record.rolePerspectives) || record.rolePerspectives.some((item) => !isResolvedRolePerspective(item))) return false\n return true\n}\n\n/**\n * Defensive migration for legacy filter state shapes captured before the\n * advanced-filter tree (SPEC-048). Existing perspectives only store either\n * advanced-filter URL params (tree shape with `v:2` or a `root` key) or\n * undefined \u2014 this helper is a safety net for legacy `FilterValues`-shaped\n * records (flat key/value records of column filters) that could only appear\n * if old saved-view JSON were imported.\n *\n * - Tree-shaped state (`v:2` or `root` key) is passed through unchanged.\n * - Undefined / null filters are passed through unchanged.\n * - Legacy `FilterValues`-shaped records are dropped (set to `undefined`)\n * because there is no reliable mapping back to the new operator model;\n * the user sees an empty tree and can recreate.\n */\nexport function maybeMigrateLegacyFilterValues(settings: PerspectiveSettings): PerspectiveSettings {\n const filters = settings.filters\n if (!filters || typeof filters !== 'object') return settings\n const record = filters as Record<string, unknown>\n if ('v' in record && record.v === 2) return settings\n if ('root' in record) return settings\n if (typeof console !== 'undefined') {\n console.warn('[perspectives] Dropping legacy filterValues shape; please re-create the perspective with the new filter UI.')\n }\n return { ...settings, filters: undefined }\n}\n\nfunction toResolvedPerspective(entity: Perspective): ResolvedPerspective {\n const settings = maybeMigrateLegacyFilterValues((entity.settingsJson ?? {}) as PerspectiveSettings)\n return {\n id: entity.id,\n name: entity.name,\n tableId: entity.tableId,\n isDefault: !!entity.isDefault,\n settings,\n createdAt: entity.createdAt.toISOString(),\n updatedAt: entity.updatedAt ? entity.updatedAt.toISOString() : null,\n }\n}\n\nfunction toResolvedRolePerspective(entity: RolePerspective): ResolvedRolePerspective {\n const settings = maybeMigrateLegacyFilterValues((entity.settingsJson ?? {}) as PerspectiveSettings)\n return {\n id: entity.id,\n roleId: entity.roleId,\n tableId: entity.tableId,\n name: entity.name,\n isDefault: !!entity.isDefault,\n settings,\n tenantId: nullish(entity.tenantId),\n organizationId: nullish(entity.organizationId),\n createdAt: entity.createdAt.toISOString(),\n updatedAt: entity.updatedAt ? entity.updatedAt.toISOString() : null,\n }\n}\n\nexport async function loadPerspectivesState(\n em: EntityManager,\n cache: CacheStrategy | null | undefined,\n options: { scope: PerspectiveScope; tableId: string; roleIds?: string[] },\n): Promise<PerspectivesState> {\n const { scope, tableId } = options\n const roleIds = Array.isArray(options.roleIds) ? options.roleIds.filter((id) => id && id.length > 0) : []\n const uniqueRoles = Array.from(new Set(roleIds))\n const cacheKey = cache && uniqueRoles.length <= 16 ? userCacheKey(scope, tableId, uniqueRoles) : null\n\n if (cache && cacheKey) {\n const cached = await cache.get(cacheKey)\n if (cached && isPerspectivesState(cached)) return cached\n }\n\n const tenantId = scope.tenantId ?? null\n const organizationId = scope.organizationId ?? null\n\n const [personal, roleRecords] = await Promise.all([\n em.find(Perspective, {\n userId: scope.userId,\n tenantId,\n organizationId,\n tableId,\n deletedAt: null,\n }, { orderBy: { updatedAt: 'desc' } }),\n uniqueRoles.length\n ? em.find(RolePerspective, {\n roleId: { $in: uniqueRoles as any },\n tableId,\n deletedAt: null,\n $and: [\n { $or: [{ tenantId }, { tenantId: null }] },\n { $or: [{ organizationId }, { organizationId: null }] },\n ],\n } as any, { orderBy: { updatedAt: 'desc' } })\n : [],\n ])\n\n const personalResolved = personal.map(toResolvedPerspective)\n const personalDefaultId = personalResolved.find((p) => p.isDefault)?.id ?? null\n const roleResolved = roleRecords.map(toResolvedRolePerspective)\n\n const state: PerspectivesState = {\n tableId,\n personal: personalResolved,\n personalDefaultId,\n rolePerspectives: roleResolved,\n }\n\n if (cache && cacheKey) {\n await cache.set(cacheKey, state, {\n ttl: CACHE_TTL_MS,\n tags: [\n userTag(scope, tableId),\n ...uniqueRoles.map((roleId) => roleTag(roleId, tableId, tenantId)),\n ],\n })\n }\n\n return state\n}\n\nexport async function saveUserPerspective(\n em: EntityManager,\n cache: CacheStrategy | null | undefined,\n options: {\n scope: PerspectiveScope\n tableId: string\n input: PerspectiveSaveInput\n request?: Request | Headers | null\n },\n): Promise<ResolvedPerspective> {\n const { scope, tableId, input } = options\n const tenantId = scope.tenantId ?? null\n const organizationId = scope.organizationId ?? null\n\n let entity: Perspective | null = null\n if (input.perspectiveId) {\n entity = await em.findOne(Perspective, {\n id: input.perspectiveId,\n userId: scope.userId,\n tenantId,\n organizationId,\n tableId,\n deletedAt: null,\n })\n if (!entity) {\n throw Object.assign(new Error('Perspective not found'), { code: 'NOT_FOUND' })\n }\n enforceCommandOptimisticLock({\n resourceKind: 'perspectives.perspective',\n resourceId: entity.id,\n current: entity.updatedAt ?? null,\n request: options.request ?? null,\n })\n } else {\n entity = await em.findOne(Perspective, {\n userId: scope.userId,\n tenantId,\n organizationId,\n tableId,\n name: input.name,\n deletedAt: null,\n })\n }\n\n const now = new Date()\n if (!entity) {\n entity = em.create(Perspective, {\n userId: scope.userId,\n tenantId,\n organizationId,\n tableId,\n name: input.name,\n settingsJson: input.settings,\n isDefault: Boolean(input.isDefault),\n createdAt: now,\n updatedAt: now,\n })\n em.persist(entity)\n } else {\n entity.name = input.name\n entity.settingsJson = input.settings\n entity.updatedAt = now\n if (input.isDefault === true) entity.isDefault = true\n if (input.isDefault === false) entity.isDefault = false\n }\n\n if (input.isDefault === true) {\n await em.nativeUpdate(\n Perspective,\n {\n userId: scope.userId,\n tenantId,\n organizationId,\n tableId,\n id: { $ne: entity.id } as any,\n deletedAt: null,\n },\n { isDefault: false, updatedAt: now },\n )\n entity.isDefault = true\n }\n\n await em.flush()\n\n if (cache?.deleteByTags) {\n await cache.deleteByTags([userTag(scope, tableId)])\n }\n\n return toResolvedPerspective(entity)\n}\n\nexport async function deleteUserPerspective(\n em: EntityManager,\n cache: CacheStrategy | null | undefined,\n options: { scope: PerspectiveScope; tableId: string; perspectiveId: string },\n): Promise<boolean> {\n const { scope, tableId, perspectiveId } = options\n const tenantId = scope.tenantId ?? null\n const organizationId = scope.organizationId ?? null\n\n const existing = await em.findOne(Perspective, {\n id: perspectiveId,\n userId: scope.userId,\n tenantId,\n organizationId,\n tableId,\n deletedAt: null,\n })\n if (!existing) return false\n\n existing.deletedAt = new Date()\n existing.isDefault = false\n await em.flush()\n\n if (cache?.deleteByTags) {\n await cache.deleteByTags([userTag(scope, tableId)])\n }\n\n return true\n}\n\nexport async function saveRolePerspectives(\n em: EntityManager,\n cache: CacheStrategy | null | undefined,\n options: {\n tableId: string\n tenantId?: string | null\n organizationId?: string | null\n input: RolePerspectiveSaveInput\n },\n): Promise<ResolvedRolePerspective[]> {\n const { tableId, input } = options\n const tenantId = options.tenantId ?? null\n const organizationId = options.organizationId ?? null\n const now = new Date()\n const touchedRoleIds = new Set<string>()\n\n const results: ResolvedRolePerspective[] = []\n\n // Prefetch every matching role perspective in a single query, then index by role id\n // so the loop resolves create/update without a lookup per role.\n const recordByRole = new Map<string, RolePerspective>()\n if (input.roleIds.length) {\n const existingRecords = await em.find(RolePerspective, {\n roleId: { $in: input.roleIds },\n tableId,\n tenantId,\n organizationId,\n name: input.name,\n deletedAt: null,\n })\n for (const existing of existingRecords) recordByRole.set(existing.roleId, existing)\n }\n\n for (const roleId of input.roleIds) {\n let record = recordByRole.get(roleId) ?? null\n if (!record) {\n record = em.create(RolePerspective, {\n roleId,\n tableId,\n tenantId,\n organizationId,\n name: input.name,\n settingsJson: input.settings,\n isDefault: Boolean(input.setDefault),\n createdAt: now,\n updatedAt: now,\n })\n em.persist(record)\n recordByRole.set(roleId, record)\n } else {\n record.settingsJson = input.settings\n record.updatedAt = now\n if (input.setDefault === true) record.isDefault = true\n if (input.setDefault === false) record.isDefault = false\n }\n\n if (input.setDefault === true) {\n await em.nativeUpdate(\n RolePerspective,\n {\n roleId,\n tableId,\n tenantId,\n organizationId,\n id: { $ne: record.id } as any,\n deletedAt: null,\n },\n { isDefault: false, updatedAt: now },\n )\n record.isDefault = true\n }\n\n touchedRoleIds.add(roleId)\n results.push(toResolvedRolePerspective(record))\n }\n\n if (input.roleIds.length) {\n await em.flush()\n }\n\n if (cache?.deleteByTags && touchedRoleIds.size > 0) {\n const tags = Array.from(touchedRoleIds).map((roleId) => roleTag(roleId, tableId, tenantId))\n await cache.deleteByTags(tags)\n }\n\n return results\n}\n\nexport async function clearRolePerspectives(\n em: EntityManager,\n cache: CacheStrategy | null | undefined,\n options: {\n tableId: string\n tenantId?: string | null\n organizationId?: string | null\n roleIds: string[]\n },\n): Promise<number> {\n const { tableId, roleIds } = options\n const tenantId = options.tenantId ?? null\n const organizationId = options.organizationId ?? null\n if (!roleIds.length) return 0\n\n const affected = await em.nativeUpdate(\n RolePerspective,\n {\n roleId: { $in: roleIds as any },\n tableId,\n tenantId,\n organizationId,\n deletedAt: null,\n },\n { deletedAt: new Date(), isDefault: false },\n )\n\n if (cache?.deleteByTags) {\n const tags = roleIds.map((roleId) => roleTag(roleId, tableId, tenantId))\n await cache.deleteByTags(tags)\n }\n\n return affected\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport type { CacheStrategy } from '@open-mercato/cache'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport {\n buildOptimisticLockConflictBody,\n enforceCommandOptimisticLock,\n enforceRecordGoneIsConflict,\n} from '@open-mercato/shared/lib/crud/optimistic-lock-command'\nimport { Perspective, RolePerspective } from '../data/entities'\nimport type {\n PerspectiveSettings,\n PerspectiveSaveInput,\n RolePerspectiveSaveInput,\n} from '../data/validators'\n\nexport type PerspectiveScope = {\n userId: string\n tenantId?: string | null\n organizationId?: string | null\n}\n\nexport type ResolvedPerspective = {\n id: string\n name: string\n tableId: string\n settings: PerspectiveSettings\n isDefault: boolean\n createdAt: string\n updatedAt?: string | null\n}\n\nexport type ResolvedRolePerspective = {\n id: string\n roleId: string\n tableId: string\n name: string\n settings: PerspectiveSettings\n isDefault: boolean\n tenantId: string | null\n organizationId: string | null\n createdAt: string\n updatedAt?: string | null\n}\n\nexport type PerspectivesState = {\n tableId: string\n personal: ResolvedPerspective[]\n personalDefaultId: string | null\n rolePerspectives: ResolvedRolePerspective[]\n}\n\ntype ExpectedUpdatedAtById = Record<string, string | Date | null | undefined>\n\nconst PERSPECTIVE_RESOURCE_KIND = 'perspectives.perspective'\nconst ROLE_PERSPECTIVE_RESOURCE_KIND = 'perspectives.role_perspective'\n\nfunction resolveRoleLockInput(\n expectedUpdatedAtByRoleId: ExpectedUpdatedAtById | null | undefined,\n roleId: string,\n request: Request | Headers | null | undefined,\n): Pick<Parameters<typeof enforceCommandOptimisticLock>[0], 'expected' | 'request'> {\n if (expectedUpdatedAtByRoleId && Object.prototype.hasOwnProperty.call(expectedUpdatedAtByRoleId, roleId)) {\n return { expected: expectedUpdatedAtByRoleId[roleId] ?? null, request: null }\n }\n return { expected: undefined, request: request ?? null }\n}\n\nfunction resolveRoleRecordLockInput(\n expectedUpdatedAtByPerspectiveId: ExpectedUpdatedAtById | null | undefined,\n expectedUpdatedAtByRoleId: ExpectedUpdatedAtById | null | undefined,\n record: RolePerspective,\n request: Request | Headers | null | undefined,\n): Pick<Parameters<typeof enforceCommandOptimisticLock>[0], 'expected' | 'request'> {\n if (expectedUpdatedAtByPerspectiveId && Object.prototype.hasOwnProperty.call(expectedUpdatedAtByPerspectiveId, record.id)) {\n return { expected: expectedUpdatedAtByPerspectiveId[record.id] ?? null, request: null }\n }\n return resolveRoleLockInput(expectedUpdatedAtByRoleId, record.roleId, request)\n}\n\nfunction firstExpectedUpdatedAt(expectedUpdatedAtById: ExpectedUpdatedAtById | null | undefined): string | Date | null {\n if (!expectedUpdatedAtById) return null\n for (const value of Object.values(expectedUpdatedAtById)) {\n if (value instanceof Date) return value\n if (typeof value === 'string' && value.trim().length > 0) return value\n }\n return null\n}\n\nfunction throwMissingRoleRecordVersionConflict(record: RolePerspective): never {\n const current = record.updatedAt instanceof Date && Number.isFinite(record.updatedAt.getTime())\n ? record.updatedAt.toISOString()\n : new Date(0).toISOString()\n throw new CrudHttpError(409, buildOptimisticLockConflictBody(current, current))\n}\n\nconst CACHE_TTL_MS = 5 * 60 * 1000\n\nconst nullish = <T extends string | null | undefined>(value: T): string | null =>\n value == null ? null : value\n\nconst scopeKey = (scope: PerspectiveScope) =>\n `${scope.userId}:${scope.tenantId ?? 'null'}:${scope.organizationId ?? 'null'}`\n\nconst userCacheKey = (scope: PerspectiveScope, tableId: string, roleIds: string[]) =>\n `perspectives:user-state:${scopeKey(scope)}:${tableId}:${roleIds.sort((a, b) => a.localeCompare(b)).join(',')}`\n\nconst userTag = (scope: PerspectiveScope, tableId?: string) =>\n tableId\n ? `perspectives:user:${scopeKey(scope)}:${tableId}`\n : `perspectives:user:${scopeKey(scope)}`\n\nconst roleTag = (roleId: string, tableId?: string, tenantId?: string | null) => {\n const tenant = tenantId ?? 'null'\n return tableId ? `perspectives:role:${roleId}:${tenant}:${tableId}` : `perspectives:role:${roleId}:${tenant}`\n}\n\nfunction isResolvedPerspective(value: unknown): value is ResolvedPerspective {\n if (typeof value !== 'object' || value === null) return false\n const record = value as Partial<ResolvedPerspective>\n return typeof record.id === 'string'\n && typeof record.name === 'string'\n && typeof record.tableId === 'string'\n && typeof record.isDefault === 'boolean'\n && typeof record.createdAt === 'string'\n}\n\nfunction isResolvedRolePerspective(value: unknown): value is ResolvedRolePerspective {\n if (typeof value !== 'object' || value === null) return false\n const record = value as Partial<ResolvedRolePerspective>\n return typeof record.id === 'string'\n && typeof record.roleId === 'string'\n && typeof record.tableId === 'string'\n && typeof record.name === 'string'\n && typeof record.isDefault === 'boolean'\n && typeof record.createdAt === 'string'\n}\n\nfunction isPerspectivesState(value: unknown): value is PerspectivesState {\n if (typeof value !== 'object' || value === null) return false\n const record = value as Partial<PerspectivesState>\n if (typeof record.tableId !== 'string') return false\n if (!Array.isArray(record.personal) || record.personal.some((item) => !isResolvedPerspective(item))) return false\n if (record.personalDefaultId !== null && typeof record.personalDefaultId !== 'string') return false\n if (!Array.isArray(record.rolePerspectives) || record.rolePerspectives.some((item) => !isResolvedRolePerspective(item))) return false\n return true\n}\n\n/**\n * Defensive migration for legacy filter state shapes captured before the\n * advanced-filter tree (SPEC-048). Existing perspectives only store either\n * advanced-filter URL params (tree shape with `v:2` or a `root` key) or\n * undefined \u2014 this helper is a safety net for legacy `FilterValues`-shaped\n * records (flat key/value records of column filters) that could only appear\n * if old saved-view JSON were imported.\n *\n * - Tree-shaped state (`v:2` or `root` key) is passed through unchanged.\n * - Undefined / null filters are passed through unchanged.\n * - Legacy `FilterValues`-shaped records are dropped (set to `undefined`)\n * because there is no reliable mapping back to the new operator model;\n * the user sees an empty tree and can recreate.\n */\nexport function maybeMigrateLegacyFilterValues(settings: PerspectiveSettings): PerspectiveSettings {\n const filters = settings.filters\n if (!filters || typeof filters !== 'object') return settings\n const record = filters as Record<string, unknown>\n if ('v' in record && record.v === 2) return settings\n if ('root' in record) return settings\n if (typeof console !== 'undefined') {\n console.warn('[perspectives] Dropping legacy filterValues shape; please re-create the perspective with the new filter UI.')\n }\n return { ...settings, filters: undefined }\n}\n\nfunction toResolvedPerspective(entity: Perspective): ResolvedPerspective {\n const settings = maybeMigrateLegacyFilterValues((entity.settingsJson ?? {}) as PerspectiveSettings)\n return {\n id: entity.id,\n name: entity.name,\n tableId: entity.tableId,\n isDefault: !!entity.isDefault,\n settings,\n createdAt: entity.createdAt.toISOString(),\n updatedAt: entity.updatedAt ? entity.updatedAt.toISOString() : null,\n }\n}\n\nfunction toResolvedRolePerspective(entity: RolePerspective): ResolvedRolePerspective {\n const settings = maybeMigrateLegacyFilterValues((entity.settingsJson ?? {}) as PerspectiveSettings)\n return {\n id: entity.id,\n roleId: entity.roleId,\n tableId: entity.tableId,\n name: entity.name,\n isDefault: !!entity.isDefault,\n settings,\n tenantId: nullish(entity.tenantId),\n organizationId: nullish(entity.organizationId),\n createdAt: entity.createdAt.toISOString(),\n updatedAt: entity.updatedAt ? entity.updatedAt.toISOString() : null,\n }\n}\n\nexport async function loadPerspectivesState(\n em: EntityManager,\n cache: CacheStrategy | null | undefined,\n options: { scope: PerspectiveScope; tableId: string; roleIds?: string[] },\n): Promise<PerspectivesState> {\n const { scope, tableId } = options\n const roleIds = Array.isArray(options.roleIds) ? options.roleIds.filter((id) => id && id.length > 0) : []\n const uniqueRoles = Array.from(new Set(roleIds))\n const cacheKey = cache && uniqueRoles.length <= 16 ? userCacheKey(scope, tableId, uniqueRoles) : null\n\n if (cache && cacheKey) {\n const cached = await cache.get(cacheKey)\n if (cached && isPerspectivesState(cached)) return cached\n }\n\n const tenantId = scope.tenantId ?? null\n const organizationId = scope.organizationId ?? null\n\n const [personal, roleRecords] = await Promise.all([\n em.find(Perspective, {\n userId: scope.userId,\n tenantId,\n organizationId,\n tableId,\n deletedAt: null,\n }, { orderBy: { updatedAt: 'desc' } }),\n uniqueRoles.length\n ? em.find(RolePerspective, {\n roleId: { $in: uniqueRoles as any },\n tableId,\n deletedAt: null,\n $and: [\n { $or: [{ tenantId }, { tenantId: null }] },\n { $or: [{ organizationId }, { organizationId: null }] },\n ],\n } as any, { orderBy: { updatedAt: 'desc' } })\n : [],\n ])\n\n const personalResolved = personal.map(toResolvedPerspective)\n const personalDefaultId = personalResolved.find((p) => p.isDefault)?.id ?? null\n const roleResolved = roleRecords.map(toResolvedRolePerspective)\n\n const state: PerspectivesState = {\n tableId,\n personal: personalResolved,\n personalDefaultId,\n rolePerspectives: roleResolved,\n }\n\n if (cache && cacheKey) {\n await cache.set(cacheKey, state, {\n ttl: CACHE_TTL_MS,\n tags: [\n userTag(scope, tableId),\n ...uniqueRoles.map((roleId) => roleTag(roleId, tableId, tenantId)),\n ],\n })\n }\n\n return state\n}\n\nexport async function saveUserPerspective(\n em: EntityManager,\n cache: CacheStrategy | null | undefined,\n options: {\n scope: PerspectiveScope\n tableId: string\n input: PerspectiveSaveInput\n request?: Request | Headers | null\n },\n): Promise<ResolvedPerspective> {\n const { scope, tableId, input } = options\n const tenantId = scope.tenantId ?? null\n const organizationId = scope.organizationId ?? null\n\n let entity: Perspective | null = null\n if (input.perspectiveId) {\n entity = await em.findOne(Perspective, {\n id: input.perspectiveId,\n userId: scope.userId,\n tenantId,\n organizationId,\n tableId,\n deletedAt: null,\n })\n if (!entity) {\n throw Object.assign(new Error('Perspective not found'), { code: 'NOT_FOUND' })\n }\n enforceCommandOptimisticLock({\n resourceKind: PERSPECTIVE_RESOURCE_KIND,\n resourceId: entity.id,\n current: entity.updatedAt ?? null,\n request: options.request ?? null,\n })\n } else {\n entity = await em.findOne(Perspective, {\n userId: scope.userId,\n tenantId,\n organizationId,\n tableId,\n name: input.name,\n deletedAt: null,\n })\n }\n\n const now = new Date()\n if (!entity) {\n entity = em.create(Perspective, {\n userId: scope.userId,\n tenantId,\n organizationId,\n tableId,\n name: input.name,\n settingsJson: input.settings,\n isDefault: Boolean(input.isDefault),\n createdAt: now,\n updatedAt: now,\n })\n em.persist(entity)\n } else {\n entity.name = input.name\n entity.settingsJson = input.settings\n entity.updatedAt = now\n if (input.isDefault === true) entity.isDefault = true\n if (input.isDefault === false) entity.isDefault = false\n }\n\n if (input.isDefault === true) {\n await em.nativeUpdate(\n Perspective,\n {\n userId: scope.userId,\n tenantId,\n organizationId,\n tableId,\n id: { $ne: entity.id } as any,\n deletedAt: null,\n },\n { isDefault: false, updatedAt: now },\n )\n entity.isDefault = true\n }\n\n await em.flush()\n\n if (cache?.deleteByTags) {\n await cache.deleteByTags([userTag(scope, tableId)])\n }\n\n return toResolvedPerspective(entity)\n}\n\nexport async function deleteUserPerspective(\n em: EntityManager,\n cache: CacheStrategy | null | undefined,\n options: {\n scope: PerspectiveScope\n tableId: string\n perspectiveId: string\n request?: Request | Headers | null\n },\n): Promise<boolean> {\n const { scope, tableId, perspectiveId } = options\n const tenantId = scope.tenantId ?? null\n const organizationId = scope.organizationId ?? null\n\n const existing = await em.findOne(Perspective, {\n id: perspectiveId,\n userId: scope.userId,\n tenantId,\n organizationId,\n tableId,\n deletedAt: null,\n })\n if (!existing) {\n enforceRecordGoneIsConflict({\n resourceKind: PERSPECTIVE_RESOURCE_KIND,\n resourceId: perspectiveId,\n request: options.request ?? null,\n })\n return false\n }\n\n enforceCommandOptimisticLock({\n resourceKind: PERSPECTIVE_RESOURCE_KIND,\n resourceId: existing.id,\n current: existing.updatedAt ?? null,\n request: options.request ?? null,\n })\n\n existing.deletedAt = new Date()\n existing.isDefault = false\n await em.flush()\n\n if (cache?.deleteByTags) {\n await cache.deleteByTags([userTag(scope, tableId)])\n }\n\n return true\n}\n\nexport async function saveRolePerspectives(\n em: EntityManager,\n cache: CacheStrategy | null | undefined,\n options: {\n tableId: string\n tenantId?: string | null\n organizationId?: string | null\n input: RolePerspectiveSaveInput\n expectedUpdatedAtByRoleId?: ExpectedUpdatedAtById\n expectedUpdatedAtByPerspectiveId?: ExpectedUpdatedAtById\n request?: Request | Headers | null\n },\n): Promise<ResolvedRolePerspective[]> {\n const { tableId, input } = options\n const tenantId = options.tenantId ?? null\n const organizationId = options.organizationId ?? null\n const now = new Date()\n const touchedRoleIds = new Set<string>()\n\n const resultRecords: RolePerspective[] = []\n\n // Prefetch every matching role perspective in a single query, then index by role id\n // so the loop resolves create/update without a lookup per role.\n const recordByRole = new Map<string, RolePerspective>()\n if (input.roleIds.length) {\n const existingRecords = await em.find(RolePerspective, {\n roleId: { $in: input.roleIds },\n tableId,\n tenantId,\n organizationId,\n name: input.name,\n deletedAt: null,\n })\n for (const existing of existingRecords) recordByRole.set(existing.roleId, existing)\n }\n const defaultRecordsByRole = new Map<string, RolePerspective[]>()\n if (input.setDefault === true && input.roleIds.length) {\n const existingDefaultRecords = await em.find(RolePerspective, {\n roleId: { $in: input.roleIds },\n tableId,\n tenantId,\n organizationId,\n isDefault: true,\n deletedAt: null,\n })\n for (const existing of existingDefaultRecords) {\n const records = defaultRecordsByRole.get(existing.roleId) ?? []\n records.push(existing)\n defaultRecordsByRole.set(existing.roleId, records)\n }\n }\n\n for (const roleId of input.roleIds) {\n let record = recordByRole.get(roleId) ?? null\n if (!record) {\n record = em.create(RolePerspective, {\n roleId,\n tableId,\n tenantId,\n organizationId,\n name: input.name,\n settingsJson: input.settings,\n isDefault: Boolean(input.setDefault),\n createdAt: now,\n updatedAt: now,\n })\n em.persist(record)\n recordByRole.set(roleId, record)\n } else {\n enforceCommandOptimisticLock({\n resourceKind: ROLE_PERSPECTIVE_RESOURCE_KIND,\n resourceId: record.id,\n current: record.updatedAt ?? null,\n ...resolveRoleRecordLockInput(\n options.expectedUpdatedAtByPerspectiveId,\n options.expectedUpdatedAtByRoleId,\n record,\n options.request ?? null,\n ),\n })\n record.settingsJson = input.settings\n record.updatedAt = now\n if (input.setDefault === true) record.isDefault = true\n if (input.setDefault === false) record.isDefault = false\n }\n\n if (input.setDefault === true) {\n for (const defaultRecord of defaultRecordsByRole.get(roleId) ?? []) {\n if (defaultRecord.id === record.id) continue\n enforceCommandOptimisticLock({\n resourceKind: ROLE_PERSPECTIVE_RESOURCE_KIND,\n resourceId: defaultRecord.id,\n current: defaultRecord.updatedAt ?? null,\n ...resolveRoleRecordLockInput(\n options.expectedUpdatedAtByPerspectiveId,\n options.expectedUpdatedAtByRoleId,\n defaultRecord,\n options.request ?? null,\n ),\n })\n }\n await em.nativeUpdate(\n RolePerspective,\n {\n roleId,\n tableId,\n tenantId,\n organizationId,\n id: { $ne: record.id } as any,\n isDefault: true,\n deletedAt: null,\n },\n { isDefault: false, updatedAt: now },\n )\n record.isDefault = true\n }\n\n touchedRoleIds.add(roleId)\n resultRecords.push(record)\n }\n\n if (input.roleIds.length) {\n await em.flush()\n }\n\n if (cache?.deleteByTags && touchedRoleIds.size > 0) {\n const tags = Array.from(touchedRoleIds).map((roleId) => roleTag(roleId, tableId, tenantId))\n await cache.deleteByTags(tags)\n }\n\n return resultRecords.map(toResolvedRolePerspective)\n}\n\nexport async function clearRolePerspectives(\n em: EntityManager,\n cache: CacheStrategy | null | undefined,\n options: {\n tableId: string\n tenantId?: string | null\n organizationId?: string | null\n roleIds: string[]\n expectedUpdatedAtByRoleId?: ExpectedUpdatedAtById\n expectedUpdatedAtByPerspectiveId?: ExpectedUpdatedAtById\n request?: Request | Headers | null\n },\n): Promise<number> {\n const { tableId, roleIds } = options\n const tenantId = options.tenantId ?? null\n const organizationId = options.organizationId ?? null\n if (!roleIds.length) return 0\n\n const existingRecords = await em.find(RolePerspective, {\n roleId: { $in: roleIds as any },\n tableId,\n tenantId,\n organizationId,\n deletedAt: null,\n })\n const recordsByRole = new Map<string, RolePerspective[]>()\n for (const record of existingRecords) {\n const records = recordsByRole.get(record.roleId) ?? []\n records.push(record)\n recordsByRole.set(record.roleId, records)\n }\n\n for (const roleId of roleIds) {\n const records = recordsByRole.get(roleId) ?? []\n const lockInput = resolveRoleLockInput(options.expectedUpdatedAtByRoleId, roleId, options.request ?? null)\n if (!records.length) {\n const expected = firstExpectedUpdatedAt(options.expectedUpdatedAtByPerspectiveId)\n enforceRecordGoneIsConflict({\n resourceKind: ROLE_PERSPECTIVE_RESOURCE_KIND,\n resourceId: roleId,\n expected: expected ?? lockInput.expected,\n request: expected ? null : lockInput.request,\n })\n continue\n }\n for (const record of records) {\n if (\n options.expectedUpdatedAtByPerspectiveId\n && !Object.prototype.hasOwnProperty.call(options.expectedUpdatedAtByPerspectiveId, record.id)\n ) {\n throwMissingRoleRecordVersionConflict(record)\n }\n enforceCommandOptimisticLock({\n resourceKind: ROLE_PERSPECTIVE_RESOURCE_KIND,\n resourceId: record.id,\n current: record.updatedAt ?? null,\n ...resolveRoleRecordLockInput(\n options.expectedUpdatedAtByPerspectiveId,\n options.expectedUpdatedAtByRoleId,\n record,\n options.request ?? null,\n ),\n })\n }\n }\n\n const affected = await em.nativeUpdate(\n RolePerspective,\n {\n roleId: { $in: roleIds as any },\n tableId,\n tenantId,\n organizationId,\n deletedAt: null,\n },\n { deletedAt: new Date(), isDefault: false },\n )\n\n if (cache?.deleteByTags) {\n const tags = roleIds.map((roleId) => roleTag(roleId, tableId, tenantId))\n await cache.deleteByTags(tags)\n }\n\n return affected\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.6-develop.6255.1.a6ee4a57c1",
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.6255.1.a6ee4a57c1",
250
- "@open-mercato/shared": "0.6.6-develop.6255.1.a6ee4a57c1",
251
- "@open-mercato/ui": "0.6.6-develop.6255.1.a6ee4a57c1",
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.6255.1.a6ee4a57c1",
257
- "@open-mercato/shared": "0.6.6-develop.6255.1.a6ee4a57c1",
258
- "@open-mercato/ui": "0.6.6-develop.6255.1.a6ee4a57c1",
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",
@@ -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