@open-mercato/core 0.6.6-develop.6257.1.6d0af84d26 → 0.6.6-develop.6290.1.4bb5a8ba3f

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 (220) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/carrier_shipment_idempotency_key/index.js +19 -0
  3. package/dist/generated/entities/carrier_shipment_idempotency_key/index.js.map +7 -0
  4. package/dist/generated/entities.ids.generated.js +2 -1
  5. package/dist/generated/entities.ids.generated.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +10 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/catalog/lib/productResolution.js +45 -0
  9. package/dist/modules/catalog/lib/productResolution.js.map +7 -0
  10. package/dist/modules/communication_channels/workers/channel-import-history.js +1 -1
  11. package/dist/modules/communication_channels/workers/channel-import-history.js.map +2 -2
  12. package/dist/modules/currencies/api/fetch-configs/route.js +34 -0
  13. package/dist/modules/currencies/api/fetch-configs/route.js.map +2 -2
  14. package/dist/modules/currencies/components/CurrencyFetchingConfig.js +17 -15
  15. package/dist/modules/currencies/components/CurrencyFetchingConfig.js.map +2 -2
  16. package/dist/modules/customers/api/companies/[id]/route.js +176 -161
  17. package/dist/modules/customers/api/companies/[id]/route.js.map +2 -2
  18. package/dist/modules/customers/api/people/[id]/route.js +75 -59
  19. package/dist/modules/customers/api/people/[id]/route.js.map +2 -2
  20. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js +17 -10
  21. package/dist/modules/customers/backend/config/customers/pipeline-stages/page.js.map +2 -2
  22. package/dist/modules/customers/components/detail/ActivitiesAddNewMenu.js +6 -4
  23. package/dist/modules/customers/components/detail/ActivitiesAddNewMenu.js.map +2 -2
  24. package/dist/modules/customers/components/detail/AiActionChips.js +5 -2
  25. package/dist/modules/customers/components/detail/AiActionChips.js.map +2 -2
  26. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js +19 -21
  27. package/dist/modules/customers/components/detail/schedule/ParticipantsField.js.map +2 -2
  28. package/dist/modules/data_sync/api/runs/[id]/cancel.js +1 -1
  29. package/dist/modules/data_sync/api/runs/[id]/cancel.js.map +2 -2
  30. package/dist/modules/data_sync/backend/data-sync/page.js +9 -45
  31. package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
  32. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +11 -17
  33. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
  34. package/dist/modules/data_sync/components/IntegrationScheduleTab.js +12 -10
  35. package/dist/modules/data_sync/components/IntegrationScheduleTab.js.map +2 -2
  36. package/dist/modules/data_sync/lib/id-mapping.js +46 -1
  37. package/dist/modules/data_sync/lib/id-mapping.js.map +2 -2
  38. package/dist/modules/data_sync/lib/sync-engine.js +2 -2
  39. package/dist/modules/data_sync/lib/sync-engine.js.map +2 -2
  40. package/dist/modules/data_sync/lib/syncRunStatus.js +30 -0
  41. package/dist/modules/data_sync/lib/syncRunStatus.js.map +7 -0
  42. package/dist/modules/data_sync/workers/sync-scheduled.js +13 -14
  43. package/dist/modules/data_sync/workers/sync-scheduled.js.map +2 -2
  44. package/dist/modules/directory/components/TenantSelect.js +29 -16
  45. package/dist/modules/directory/components/TenantSelect.js.map +2 -2
  46. package/dist/modules/entities/api/records.js +6 -0
  47. package/dist/modules/entities/api/records.js.map +2 -2
  48. package/dist/modules/feature_toggles/api/global/route.js +17 -15
  49. package/dist/modules/feature_toggles/api/global/route.js.map +2 -2
  50. package/dist/modules/feature_toggles/api/openapi.js +2 -2
  51. package/dist/modules/feature_toggles/api/openapi.js.map +2 -2
  52. package/dist/modules/feature_toggles/commands/global.js +14 -0
  53. package/dist/modules/feature_toggles/commands/global.js.map +2 -2
  54. package/dist/modules/inbox_ops/backend/inbox-ops/page.js +6 -0
  55. package/dist/modules/inbox_ops/backend/inbox-ops/page.js.map +2 -2
  56. package/dist/modules/messages/components/message-detail/hooks/useMessageDetails.js +3 -1
  57. package/dist/modules/messages/components/message-detail/hooks/useMessageDetails.js.map +2 -2
  58. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js +12 -5
  59. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsActions.js.map +2 -2
  60. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.js +10 -1
  61. package/dist/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.js.map +2 -2
  62. package/dist/modules/payment_gateways/api/cancel/route.js +5 -0
  63. package/dist/modules/payment_gateways/api/cancel/route.js.map +2 -2
  64. package/dist/modules/payment_gateways/api/capture/route.js +5 -0
  65. package/dist/modules/payment_gateways/api/capture/route.js.map +2 -2
  66. package/dist/modules/payment_gateways/api/refund/route.js +5 -0
  67. package/dist/modules/payment_gateways/api/refund/route.js.map +2 -2
  68. package/dist/modules/payment_gateways/lib/gateway-service.js +52 -25
  69. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  70. package/dist/modules/payment_gateways/lib/status-machine.js +13 -0
  71. package/dist/modules/payment_gateways/lib/status-machine.js.map +2 -2
  72. package/dist/modules/perspectives/api/[tableId]/route.js +8 -2
  73. package/dist/modules/perspectives/api/[tableId]/route.js.map +2 -2
  74. package/dist/modules/perspectives/data/entities.js +33 -3
  75. package/dist/modules/perspectives/data/entities.js.map +2 -2
  76. package/dist/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.js +63 -0
  77. package/dist/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.js.map +7 -0
  78. package/dist/modules/perspectives/services/perspectiveService.js +17 -0
  79. package/dist/modules/perspectives/services/perspectiveService.js.map +2 -2
  80. package/dist/modules/planner/commands/availability-weekly.js +33 -1
  81. package/dist/modules/planner/commands/availability-weekly.js.map +2 -2
  82. package/dist/modules/planner/components/AvailabilityRulesEditor.js +14 -2
  83. package/dist/modules/planner/components/AvailabilityRulesEditor.js.map +2 -2
  84. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/hiddenWidgetsStorage.js +35 -0
  85. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/hiddenWidgetsStorage.js.map +7 -0
  86. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js +18 -19
  87. package/dist/modules/portal/frontend/[orgSlug]/portal/dashboard/page.js.map +2 -2
  88. package/dist/modules/progress/api/active/route.js +1 -1
  89. package/dist/modules/progress/api/active/route.js.map +2 -2
  90. package/dist/modules/progress/lib/progressService.js.map +1 -1
  91. package/dist/modules/progress/lib/progressServiceImpl.js +33 -8
  92. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  93. package/dist/modules/query_index/api/status.js +19 -24
  94. package/dist/modules/query_index/api/status.js.map +3 -3
  95. package/dist/modules/query_index/lib/coverage.js +35 -0
  96. package/dist/modules/query_index/lib/coverage.js.map +2 -2
  97. package/dist/modules/query_index/lib/engine.js +11 -6
  98. package/dist/modules/query_index/lib/engine.js.map +2 -2
  99. package/dist/modules/resources/api/resource-types.js +5 -37
  100. package/dist/modules/resources/api/resource-types.js.map +2 -2
  101. package/dist/modules/resources/api/resourceTypeCounts.js +35 -0
  102. package/dist/modules/resources/api/resourceTypeCounts.js.map +7 -0
  103. package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js +1 -1
  104. package/dist/modules/resources/backend/resources/resource-types/[id]/edit/page.js.map +1 -1
  105. package/dist/modules/resources/backend/resources/resource-types/page.js +2 -1
  106. package/dist/modules/resources/backend/resources/resource-types/page.js.map +2 -2
  107. package/dist/modules/sales/components/documents/ReturnDialog.js +4 -1
  108. package/dist/modules/sales/components/documents/ReturnDialog.js.map +2 -2
  109. package/dist/modules/sales/components/documents/ShipmentDialog.js +17 -2
  110. package/dist/modules/sales/components/documents/ShipmentDialog.js.map +2 -2
  111. package/dist/modules/sales/di.js +4 -0
  112. package/dist/modules/sales/di.js.map +2 -2
  113. package/dist/modules/sales/services/salesOrderService.js +26 -0
  114. package/dist/modules/sales/services/salesOrderService.js.map +7 -0
  115. package/dist/modules/shipping_carriers/api/shipments/route.js +8 -0
  116. package/dist/modules/shipping_carriers/api/shipments/route.js.map +2 -2
  117. package/dist/modules/shipping_carriers/data/entities.js +38 -0
  118. package/dist/modules/shipping_carriers/data/entities.js.map +2 -2
  119. package/dist/modules/shipping_carriers/data/validators.js +2 -1
  120. package/dist/modules/shipping_carriers/data/validators.js.map +2 -2
  121. package/dist/modules/shipping_carriers/lib/shipment-idempotency.js +76 -0
  122. package/dist/modules/shipping_carriers/lib/shipment-idempotency.js.map +7 -0
  123. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.js +2 -1
  124. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.js.map +2 -2
  125. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js +20 -2
  126. package/dist/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.js.map +2 -2
  127. package/dist/modules/shipping_carriers/lib/shipping-service.js +82 -43
  128. package/dist/modules/shipping_carriers/lib/shipping-service.js.map +2 -2
  129. package/dist/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.js +14 -0
  130. package/dist/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.js.map +7 -0
  131. package/dist/modules/staff/lib/timesheets-ui/ListView.js +15 -6
  132. package/dist/modules/staff/lib/timesheets-ui/ListView.js.map +2 -2
  133. package/dist/modules/translations/components/TranslationManager.js +9 -18
  134. package/dist/modules/translations/components/TranslationManager.js.map +2 -2
  135. package/generated/entities/carrier_shipment_idempotency_key/index.ts +8 -0
  136. package/generated/entities.ids.generated.ts +2 -1
  137. package/generated/entity-fields-registry.ts +10 -0
  138. package/package.json +9 -8
  139. package/src/modules/catalog/lib/productResolution.ts +75 -0
  140. package/src/modules/communication_channels/workers/channel-import-history.ts +1 -1
  141. package/src/modules/currencies/api/fetch-configs/route.ts +36 -0
  142. package/src/modules/currencies/components/CurrencyFetchingConfig.tsx +20 -17
  143. package/src/modules/currencies/i18n/de.json +2 -1
  144. package/src/modules/currencies/i18n/en.json +2 -1
  145. package/src/modules/currencies/i18n/es.json +2 -1
  146. package/src/modules/currencies/i18n/pl.json +2 -1
  147. package/src/modules/customers/api/companies/[id]/route.ts +214 -189
  148. package/src/modules/customers/api/people/[id]/route.ts +106 -80
  149. package/src/modules/customers/backend/config/customers/pipeline-stages/page.tsx +19 -12
  150. package/src/modules/customers/components/detail/ActivitiesAddNewMenu.tsx +8 -6
  151. package/src/modules/customers/components/detail/AiActionChips.tsx +6 -3
  152. package/src/modules/customers/components/detail/schedule/ParticipantsField.tsx +16 -18
  153. package/src/modules/data_sync/api/runs/[id]/cancel.ts +1 -1
  154. package/src/modules/data_sync/backend/data-sync/page.tsx +11 -58
  155. package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +13 -20
  156. package/src/modules/data_sync/components/IntegrationScheduleTab.tsx +10 -8
  157. package/src/modules/data_sync/lib/id-mapping.ts +58 -1
  158. package/src/modules/data_sync/lib/sync-engine.ts +2 -2
  159. package/src/modules/data_sync/lib/syncRunStatus.ts +39 -0
  160. package/src/modules/data_sync/workers/sync-scheduled.ts +13 -14
  161. package/src/modules/directory/components/TenantSelect.tsx +33 -16
  162. package/src/modules/entities/api/records.ts +14 -0
  163. package/src/modules/feature_toggles/api/global/route.ts +16 -14
  164. package/src/modules/feature_toggles/api/openapi.ts +2 -2
  165. package/src/modules/feature_toggles/commands/global.ts +15 -0
  166. package/src/modules/inbox_ops/backend/inbox-ops/page.tsx +7 -0
  167. package/src/modules/messages/components/message-detail/hooks/useMessageDetails.ts +2 -0
  168. package/src/modules/messages/components/message-detail/hooks/useMessageDetailsActions.ts +14 -4
  169. package/src/modules/messages/components/message-detail/hooks/useMessageDetailsQueries.ts +15 -1
  170. package/src/modules/payment_gateways/api/cancel/route.ts +5 -0
  171. package/src/modules/payment_gateways/api/capture/route.ts +5 -0
  172. package/src/modules/payment_gateways/api/refund/route.ts +5 -0
  173. package/src/modules/payment_gateways/lib/gateway-service.ts +58 -25
  174. package/src/modules/payment_gateways/lib/status-machine.ts +16 -0
  175. package/src/modules/perspectives/api/[tableId]/route.ts +8 -2
  176. package/src/modules/perspectives/data/entities.ts +43 -3
  177. package/src/modules/perspectives/migrations/.snapshot-open-mercato.json +69 -25
  178. package/src/modules/perspectives/migrations/Migration20260619120000_perspectives_live_row_uniqueness.ts +65 -0
  179. package/src/modules/perspectives/services/perspectiveService.ts +17 -0
  180. package/src/modules/planner/commands/availability-weekly.ts +48 -1
  181. package/src/modules/planner/components/AvailabilityRulesEditor.tsx +31 -10
  182. package/src/modules/portal/frontend/[orgSlug]/portal/dashboard/hiddenWidgetsStorage.ts +36 -0
  183. package/src/modules/portal/frontend/[orgSlug]/portal/dashboard/page.tsx +22 -23
  184. package/src/modules/progress/api/active/route.ts +1 -1
  185. package/src/modules/progress/lib/progressService.ts +2 -2
  186. package/src/modules/progress/lib/progressServiceImpl.ts +33 -8
  187. package/src/modules/query_index/api/status.ts +28 -32
  188. package/src/modules/query_index/lib/coverage.ts +63 -0
  189. package/src/modules/query_index/lib/engine.ts +37 -24
  190. package/src/modules/resources/api/resource-types.ts +4 -44
  191. package/src/modules/resources/api/resourceTypeCounts.ts +74 -0
  192. package/src/modules/resources/backend/resources/resource-types/[id]/edit/page.tsx +1 -1
  193. package/src/modules/resources/backend/resources/resource-types/page.tsx +1 -0
  194. package/src/modules/sales/components/documents/ReturnDialog.tsx +4 -1
  195. package/src/modules/sales/components/documents/ShipmentDialog.tsx +20 -2
  196. package/src/modules/sales/di.ts +6 -0
  197. package/src/modules/sales/services/salesOrderService.ts +38 -0
  198. package/src/modules/shipping_carriers/api/shipments/route.ts +8 -0
  199. package/src/modules/shipping_carriers/data/entities.ts +33 -0
  200. package/src/modules/shipping_carriers/data/validators.ts +1 -0
  201. package/src/modules/shipping_carriers/i18n/de.json +1 -0
  202. package/src/modules/shipping_carriers/i18n/en.json +1 -0
  203. package/src/modules/shipping_carriers/i18n/es.json +1 -0
  204. package/src/modules/shipping_carriers/i18n/pl.json +1 -0
  205. package/src/modules/shipping_carriers/lib/shipment-idempotency.ts +119 -0
  206. package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/shipmentApi.ts +3 -1
  207. package/src/modules/shipping_carriers/lib/shipment-wizard/hooks/useShipmentWizard.ts +24 -2
  208. package/src/modules/shipping_carriers/lib/shipping-service.ts +87 -43
  209. package/src/modules/shipping_carriers/migrations/.snapshot-open-mercato.json +197 -0
  210. package/src/modules/shipping_carriers/migrations/Migration20260618173616_shipping_carriers.ts +14 -0
  211. package/src/modules/staff/i18n/de.json +1 -0
  212. package/src/modules/staff/i18n/en.json +1 -0
  213. package/src/modules/staff/i18n/es.json +1 -0
  214. package/src/modules/staff/i18n/pl.json +1 -0
  215. package/src/modules/staff/lib/timesheets-ui/ListView.tsx +15 -7
  216. package/src/modules/translations/components/TranslationManager.tsx +17 -24
  217. package/src/modules/translations/i18n/de.json +1 -0
  218. package/src/modules/translations/i18n/en.json +1 -0
  219. package/src/modules/translations/i18n/es.json +1 -0
  220. package/src/modules/translations/i18n/pl.json +1 -0
@@ -26,10 +26,19 @@ export function useMessageDetailsQueries({
26
26
  [id, scopeVersion],
27
27
  )
28
28
 
29
+ const suppressAutoMarkReadRef = React.useRef(false)
30
+
31
+ React.useEffect(() => {
32
+ suppressAutoMarkReadRef.current = false
33
+ }, [id])
34
+
29
35
  const detailQuery = useQuery<MessageDetail | null>({
30
36
  queryKey: detailQueryKey,
31
37
  queryFn: async () => {
32
- const call = await apiCall<MessageDetail>(`/api/messages/${encodeURIComponent(id)}`)
38
+ const detailUrl = suppressAutoMarkReadRef.current
39
+ ? `/api/messages/${encodeURIComponent(id)}?skipMarkRead=1`
40
+ : `/api/messages/${encodeURIComponent(id)}`
41
+ const call = await apiCall<MessageDetail>(detailUrl)
33
42
  if (call.status === 404) {
34
43
  return null
35
44
  }
@@ -97,6 +106,10 @@ export function useMessageDetailsQueries({
97
106
  return call.result
98
107
  }, [detailQueryKey, id, queryClient, t])
99
108
 
109
+ const suppressAutoMarkRead = React.useCallback(() => {
110
+ suppressAutoMarkReadRef.current = true
111
+ }, [])
112
+
100
113
  const listItemComponentKey = detail?.typeDefinition.ui?.listItemComponent ?? null
101
114
  const contentComponentKey = detail?.typeDefinition.ui?.contentComponent ?? null
102
115
  const actionsComponentKey = detail?.typeDefinition.ui?.actionsComponent ?? null
@@ -111,6 +124,7 @@ export function useMessageDetailsQueries({
111
124
  attachmentsQuery,
112
125
  attachments,
113
126
  refreshDetailWithoutAutoMarkRead,
127
+ suppressAutoMarkRead,
114
128
  listItemComponentKey,
115
129
  contentComponentKey,
116
130
  actionsComponentKey,
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
3
3
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
5
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
5
6
  import { cancelSchema } from '../../data/validators'
6
7
  import type { PaymentGatewayService } from '../../lib/gateway-service'
7
8
  import { paymentGatewaysTag } from '../openapi'
@@ -73,6 +74,9 @@ export async function POST(req: Request) {
73
74
  })
74
75
  return NextResponse.json(result)
75
76
  } catch (err: unknown) {
77
+ if (isCrudHttpError(err)) {
78
+ return NextResponse.json(err.body, { status: err.status })
79
+ }
76
80
  const message = err instanceof Error ? err.message : 'Cancel failed'
77
81
  return NextResponse.json({ error: message }, { status: 502 })
78
82
  }
@@ -87,6 +91,7 @@ export const openApi = {
87
91
  tags: [paymentGatewaysTag],
88
92
  responses: [
89
93
  { status: 200, description: 'Payment cancelled' },
94
+ { status: 409, description: 'Invalid payment status transition' },
90
95
  { status: 422, description: 'Invalid payload' },
91
96
  { status: 502, description: 'Gateway provider error' },
92
97
  ],
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
3
3
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
5
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
5
6
  import { captureSchema } from '../../data/validators'
6
7
  import type { PaymentGatewayService } from '../../lib/gateway-service'
7
8
  import { paymentGatewaysTag } from '../openapi'
@@ -73,6 +74,9 @@ export async function POST(req: Request) {
73
74
  })
74
75
  return NextResponse.json(result)
75
76
  } catch (err: unknown) {
77
+ if (isCrudHttpError(err)) {
78
+ return NextResponse.json(err.body, { status: err.status })
79
+ }
76
80
  const message = err instanceof Error ? err.message : 'Capture failed'
77
81
  return NextResponse.json({ error: message }, { status: 502 })
78
82
  }
@@ -87,6 +91,7 @@ export const openApi = {
87
91
  tags: [paymentGatewaysTag],
88
92
  responses: [
89
93
  { status: 200, description: 'Payment captured' },
94
+ { status: 409, description: 'Invalid payment status transition' },
90
95
  { status: 422, description: 'Invalid payload' },
91
96
  { status: 502, description: 'Gateway provider error' },
92
97
  ],
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'
2
2
  import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
3
3
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
4
  import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
5
+ import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
5
6
  import { refundSchema } from '../../data/validators'
6
7
  import type { PaymentGatewayService } from '../../lib/gateway-service'
7
8
  import { paymentGatewaysTag } from '../openapi'
@@ -74,6 +75,9 @@ export async function POST(req: Request) {
74
75
  })
75
76
  return NextResponse.json(result)
76
77
  } catch (err: unknown) {
78
+ if (isCrudHttpError(err)) {
79
+ return NextResponse.json(err.body, { status: err.status })
80
+ }
77
81
  const message = err instanceof Error ? err.message : 'Refund failed'
78
82
  return NextResponse.json({ error: message }, { status: 502 })
79
83
  }
@@ -88,6 +92,7 @@ export const openApi = {
88
92
  tags: [paymentGatewaysTag],
89
93
  responses: [
90
94
  { status: 200, description: 'Payment refunded' },
95
+ { status: 409, description: 'Invalid payment status transition' },
91
96
  { status: 422, description: 'Invalid payload' },
92
97
  { status: 502, description: 'Gateway provider error' },
93
98
  ],
@@ -14,10 +14,34 @@ import {
14
14
  import type { CredentialsService } from '../../integrations/lib/credentials-service'
15
15
  import type { IntegrationStateService } from '../../integrations/lib/state-service'
16
16
  import type { IntegrationLogService } from '../../integrations/lib/log-service'
17
+ import { conflict } from '@open-mercato/shared/lib/crud/errors'
17
18
  import { GatewayTransaction } from '../data/entities'
18
- import { isValidTransition } from './status-machine'
19
+ import { canApplyManualAction, isValidTransition, type ManualGatewayAction } from './status-machine'
19
20
  import { emitPaymentGatewayEvent } from '../events'
20
21
 
22
+ function assertManualActionAllowed(action: ManualGatewayAction, transaction: GatewayTransaction): void {
23
+ const current = transaction.unifiedStatus as UnifiedPaymentStatus
24
+ if (!canApplyManualAction(action, current)) {
25
+ throw conflict(`Cannot ${action} a payment in status "${current}"`)
26
+ }
27
+ }
28
+
29
+ function applyAdapterResultStatus(
30
+ action: ManualGatewayAction,
31
+ transaction: GatewayTransaction,
32
+ resultStatus: UnifiedPaymentStatus,
33
+ ): boolean {
34
+ const current = transaction.unifiedStatus as UnifiedPaymentStatus
35
+ if (resultStatus === current) return false
36
+ if (!isValidTransition(current, resultStatus)) {
37
+ throw conflict(
38
+ `Gateway returned status "${resultStatus}" which is not a valid transition from "${current}" for ${action}`,
39
+ )
40
+ }
41
+ transaction.unifiedStatus = resultStatus
42
+ return true
43
+ }
44
+
21
45
  export interface PaymentGatewayServiceDeps {
22
46
  em: EntityManager
23
47
  integrationCredentialsService: CredentialsService
@@ -197,6 +221,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
197
221
 
198
222
  async capturePayment(transactionId: string, amount: number | undefined, scope: { organizationId: string; tenantId: string }): Promise<CaptureResult> {
199
223
  const transaction = await findTransactionOrThrow(transactionId, scope)
224
+ assertManualActionAllowed('capture', transaction)
200
225
  const { adapter, credentials } = await resolveAdapterAndCredentials(
201
226
  transaction.providerKey,
202
227
  { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
@@ -208,16 +233,18 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
208
233
  credentials,
209
234
  })
210
235
 
211
- transaction.unifiedStatus = result.status
236
+ const statusChanged = applyAdapterResultStatus('capture', transaction, result.status)
212
237
  transaction.gatewayMetadata = { ...transaction.gatewayMetadata, captureResult: result.providerData }
213
238
  await em.flush()
214
- await emitStatusEvent(result.status, {
215
- transactionId: transaction.id,
216
- paymentId: transaction.paymentId,
217
- providerKey: transaction.providerKey,
218
- organizationId: transaction.organizationId,
219
- tenantId: transaction.tenantId,
220
- })
239
+ if (statusChanged) {
240
+ await emitStatusEvent(result.status, {
241
+ transactionId: transaction.id,
242
+ paymentId: transaction.paymentId,
243
+ providerKey: transaction.providerKey,
244
+ organizationId: transaction.organizationId,
245
+ tenantId: transaction.tenantId,
246
+ })
247
+ }
221
248
  await writeTransactionLog(
222
249
  transaction.providerKey,
223
250
  { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
@@ -241,6 +268,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
241
268
  scope: { organizationId: string; tenantId: string },
242
269
  ): Promise<RefundResult> {
243
270
  const transaction = await findTransactionOrThrow(transactionId, scope)
271
+ assertManualActionAllowed('refund', transaction)
244
272
  const { adapter, credentials } = await resolveAdapterAndCredentials(
245
273
  transaction.providerKey,
246
274
  { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
@@ -253,17 +281,19 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
253
281
  credentials,
254
282
  })
255
283
 
256
- transaction.unifiedStatus = result.status
284
+ const statusChanged = applyAdapterResultStatus('refund', transaction, result.status)
257
285
  transaction.gatewayRefundId = result.refundId
258
286
  transaction.gatewayMetadata = { ...transaction.gatewayMetadata, refundResult: result.providerData }
259
287
  await em.flush()
260
- await emitStatusEvent(result.status, {
261
- transactionId: transaction.id,
262
- paymentId: transaction.paymentId,
263
- providerKey: transaction.providerKey,
264
- organizationId: transaction.organizationId,
265
- tenantId: transaction.tenantId,
266
- })
288
+ if (statusChanged) {
289
+ await emitStatusEvent(result.status, {
290
+ transactionId: transaction.id,
291
+ paymentId: transaction.paymentId,
292
+ providerKey: transaction.providerKey,
293
+ organizationId: transaction.organizationId,
294
+ tenantId: transaction.tenantId,
295
+ })
296
+ }
267
297
  await writeTransactionLog(
268
298
  transaction.providerKey,
269
299
  { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
@@ -287,6 +317,7 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
287
317
  scope: { organizationId: string; tenantId: string },
288
318
  ): Promise<CancelResult> {
289
319
  const transaction = await findTransactionOrThrow(transactionId, scope)
320
+ assertManualActionAllowed('cancel', transaction)
290
321
  const { adapter, credentials } = await resolveAdapterAndCredentials(
291
322
  transaction.providerKey,
292
323
  { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
@@ -298,15 +329,17 @@ export function createPaymentGatewayService(deps: PaymentGatewayServiceDeps) {
298
329
  credentials,
299
330
  })
300
331
 
301
- transaction.unifiedStatus = result.status
332
+ const statusChanged = applyAdapterResultStatus('cancel', transaction, result.status)
302
333
  await em.flush()
303
- await emitStatusEvent(result.status, {
304
- transactionId: transaction.id,
305
- paymentId: transaction.paymentId,
306
- providerKey: transaction.providerKey,
307
- organizationId: transaction.organizationId,
308
- tenantId: transaction.tenantId,
309
- })
334
+ if (statusChanged) {
335
+ await emitStatusEvent(result.status, {
336
+ transactionId: transaction.id,
337
+ paymentId: transaction.paymentId,
338
+ providerKey: transaction.providerKey,
339
+ organizationId: transaction.organizationId,
340
+ tenantId: transaction.tenantId,
341
+ })
342
+ }
310
343
  await writeTransactionLog(
311
344
  transaction.providerKey,
312
345
  { organizationId: transaction.organizationId, tenantId: transaction.tenantId },
@@ -26,3 +26,19 @@ export function isValidTransition(from: UnifiedPaymentStatus, to: UnifiedPayment
26
26
  export function isTerminalStatus(status: UnifiedPaymentStatus): boolean {
27
27
  return TERMINAL_STATUSES.has(status)
28
28
  }
29
+
30
+ export type ManualGatewayAction = 'capture' | 'refund' | 'cancel'
31
+
32
+ const MANUAL_ACTION_TARGET_STATUSES: Record<ManualGatewayAction, UnifiedPaymentStatus[]> = {
33
+ capture: ['captured', 'partially_captured'],
34
+ refund: ['refunded', 'partially_refunded'],
35
+ cancel: ['cancelled'],
36
+ }
37
+
38
+ export function canApplyManualAction(action: ManualGatewayAction, from: UnifiedPaymentStatus): boolean {
39
+ if (isTerminalStatus(from)) return false
40
+ const targets = MANUAL_ACTION_TARGET_STATUSES[action]
41
+ if (!targets) return false
42
+ if (targets.includes(from)) return true
43
+ return targets.some((target) => isValidTransition(from, target))
44
+ }
@@ -3,7 +3,7 @@ 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
5
  import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
6
- import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
6
+ import { isCrudHttpError, isUniqueViolation } from '@open-mercato/shared/lib/crud/errors'
7
7
  import {
8
8
  runCrudMutationGuardAfterSuccess,
9
9
  validateCrudMutationGuard,
@@ -308,6 +308,12 @@ export async function POST(req: Request, ctx: { params: { tableId: string } }) {
308
308
  if (isCrudHttpError(err)) {
309
309
  return NextResponse.json(err.body, { status: err.status })
310
310
  }
311
+ if (isUniqueViolation(err)) {
312
+ return NextResponse.json({
313
+ error: 'A view with this name already exists.',
314
+ code: 'duplicate_name',
315
+ }, { status: 409 })
316
+ }
311
317
  throw err
312
318
  }
313
319
 
@@ -380,7 +386,7 @@ const perspectivesPostDoc: OpenApiMethodDoc = {
380
386
  { status: 400, description: 'Validation failed or invalid roles provided', schema: perspectivesErrorSchema },
381
387
  { status: 401, description: 'Authentication required', schema: perspectivesErrorSchema },
382
388
  { status: 403, description: 'Missing perspectives.role_defaults feature for role updates', schema: perspectivesErrorSchema },
383
- { status: 409, description: 'Optimistic lock conflict', schema: perspectivesErrorSchema },
389
+ { status: 409, description: 'Optimistic lock conflict or perspective name already exists', schema: perspectivesErrorSchema },
384
390
  ],
385
391
  }
386
392
 
@@ -1,8 +1,28 @@
1
- import { Entity, Index, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'
1
+ import { Entity, Index, PrimaryKey, Property } from '@mikro-orm/decorators/legacy'
2
2
 
3
3
  @Entity({ tableName: 'perspectives' })
4
4
  @Index({ name: 'perspectives_user_scope_idx', properties: ['userId', 'tenantId', 'organizationId', 'tableId'] })
5
- @Unique({ properties: ['userId', 'tenantId', 'organizationId', 'tableId', 'name'] })
5
+ // Live-row uniqueness is owned by partial indexes; ordinary unique decorators cannot express these scope rules.
6
+ @Index({
7
+ name: 'perspectives_live_user_org_uq',
8
+ expression:
9
+ 'create unique index "perspectives_live_user_org_uq" on "perspectives" ("user_id", "tenant_id", "organization_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is not null and "organization_id" is not null',
10
+ })
11
+ @Index({
12
+ name: 'perspectives_live_user_tenant_uq',
13
+ expression:
14
+ 'create unique index "perspectives_live_user_tenant_uq" on "perspectives" ("user_id", "tenant_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is not null and "organization_id" is null',
15
+ })
16
+ @Index({
17
+ name: 'perspectives_live_user_org_only_uq',
18
+ expression:
19
+ 'create unique index "perspectives_live_user_org_only_uq" on "perspectives" ("user_id", "organization_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is null and "organization_id" is not null',
20
+ })
21
+ @Index({
22
+ name: 'perspectives_live_user_global_uq',
23
+ expression:
24
+ 'create unique index "perspectives_live_user_global_uq" on "perspectives" ("user_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is null and "organization_id" is null',
25
+ })
6
26
  export class Perspective {
7
27
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
8
28
  id!: string
@@ -40,7 +60,27 @@ export class Perspective {
40
60
 
41
61
  @Entity({ tableName: 'role_perspectives' })
42
62
  @Index({ name: 'role_perspectives_role_scope_idx', properties: ['roleId', 'tenantId', 'organizationId', 'tableId'] })
43
- @Unique({ properties: ['roleId', 'tenantId', 'organizationId', 'tableId', 'name'] })
63
+ // Live-row uniqueness is owned by partial indexes; ordinary unique decorators cannot express these scope rules.
64
+ @Index({
65
+ name: 'role_perspectives_live_role_org_uq',
66
+ expression:
67
+ 'create unique index "role_perspectives_live_role_org_uq" on "role_perspectives" ("role_id", "tenant_id", "organization_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is not null and "organization_id" is not null',
68
+ })
69
+ @Index({
70
+ name: 'role_perspectives_live_role_tenant_uq',
71
+ expression:
72
+ 'create unique index "role_perspectives_live_role_tenant_uq" on "role_perspectives" ("role_id", "tenant_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is not null and "organization_id" is null',
73
+ })
74
+ @Index({
75
+ name: 'role_perspectives_live_role_org_only_uq',
76
+ expression:
77
+ 'create unique index "role_perspectives_live_role_org_only_uq" on "role_perspectives" ("role_id", "organization_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is null and "organization_id" is not null',
78
+ })
79
+ @Index({
80
+ name: 'role_perspectives_live_role_global_uq',
81
+ expression:
82
+ 'create unique index "role_perspectives_live_role_global_uq" on "role_perspectives" ("role_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is null and "organization_id" is null',
83
+ })
44
84
  export class RolePerspective {
45
85
  @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
46
86
  id!: string
@@ -175,7 +175,7 @@
175
175
  "autoincrement": false,
176
176
  "primary": false,
177
177
  "nullable": false,
178
- "unique": true,
178
+ "unique": false,
179
179
  "length": null,
180
180
  "precision": null,
181
181
  "scale": null,
@@ -197,18 +197,40 @@
197
197
  "unique": true
198
198
  },
199
199
  {
200
- "columnNames": [
201
- "user_id",
202
- "tenant_id",
203
- "organization_id",
204
- "table_id",
205
- "name"
206
- ],
207
- "composite": true,
208
- "constraint": true,
209
- "keyName": "perspectives_user_id_tenant_id_organization_id_ta_0c702_unique",
200
+ "columnNames": [],
201
+ "composite": false,
202
+ "constraint": false,
203
+ "keyName": "perspectives_live_user_global_uq",
210
204
  "primary": false,
211
- "unique": true
205
+ "unique": false,
206
+ "expression": "create unique index \"perspectives_live_user_global_uq\" on \"perspectives\" (\"user_id\", \"table_id\", \"name\") where \"deleted_at\" is null and \"tenant_id\" is null and \"organization_id\" is null"
207
+ },
208
+ {
209
+ "columnNames": [],
210
+ "composite": false,
211
+ "constraint": false,
212
+ "keyName": "perspectives_live_user_org_only_uq",
213
+ "primary": false,
214
+ "unique": false,
215
+ "expression": "create unique index \"perspectives_live_user_org_only_uq\" on \"perspectives\" (\"user_id\", \"organization_id\", \"table_id\", \"name\") where \"deleted_at\" is null and \"tenant_id\" is null and \"organization_id\" is not null"
216
+ },
217
+ {
218
+ "columnNames": [],
219
+ "composite": false,
220
+ "constraint": false,
221
+ "keyName": "perspectives_live_user_org_uq",
222
+ "primary": false,
223
+ "unique": false,
224
+ "expression": "create unique index \"perspectives_live_user_org_uq\" on \"perspectives\" (\"user_id\", \"tenant_id\", \"organization_id\", \"table_id\", \"name\") where \"deleted_at\" is null and \"tenant_id\" is not null and \"organization_id\" is not null"
225
+ },
226
+ {
227
+ "columnNames": [],
228
+ "composite": false,
229
+ "constraint": false,
230
+ "keyName": "perspectives_live_user_tenant_uq",
231
+ "primary": false,
232
+ "unique": false,
233
+ "expression": "create unique index \"perspectives_live_user_tenant_uq\" on \"perspectives\" (\"user_id\", \"tenant_id\", \"table_id\", \"name\") where \"deleted_at\" is null and \"tenant_id\" is not null and \"organization_id\" is null"
212
234
  },
213
235
  {
214
236
  "columnNames": [
@@ -335,7 +357,7 @@
335
357
  "autoincrement": false,
336
358
  "primary": false,
337
359
  "nullable": false,
338
- "unique": true,
360
+ "unique": false,
339
361
  "length": null,
340
362
  "precision": null,
341
363
  "scale": null,
@@ -421,18 +443,40 @@
421
443
  "unique": true
422
444
  },
423
445
  {
424
- "columnNames": [
425
- "role_id",
426
- "tenant_id",
427
- "organization_id",
428
- "table_id",
429
- "name"
430
- ],
431
- "composite": true,
432
- "constraint": true,
433
- "keyName": "role_perspectives_role_id_tenant_id_organization__f0fc6_unique",
446
+ "columnNames": [],
447
+ "composite": false,
448
+ "constraint": false,
449
+ "keyName": "role_perspectives_live_role_global_uq",
434
450
  "primary": false,
435
- "unique": true
451
+ "unique": false,
452
+ "expression": "create unique index \"role_perspectives_live_role_global_uq\" on \"role_perspectives\" (\"role_id\", \"table_id\", \"name\") where \"deleted_at\" is null and \"tenant_id\" is null and \"organization_id\" is null"
453
+ },
454
+ {
455
+ "columnNames": [],
456
+ "composite": false,
457
+ "constraint": false,
458
+ "keyName": "role_perspectives_live_role_org_only_uq",
459
+ "primary": false,
460
+ "unique": false,
461
+ "expression": "create unique index \"role_perspectives_live_role_org_only_uq\" on \"role_perspectives\" (\"role_id\", \"organization_id\", \"table_id\", \"name\") where \"deleted_at\" is null and \"tenant_id\" is null and \"organization_id\" is not null"
462
+ },
463
+ {
464
+ "columnNames": [],
465
+ "composite": false,
466
+ "constraint": false,
467
+ "keyName": "role_perspectives_live_role_org_uq",
468
+ "primary": false,
469
+ "unique": false,
470
+ "expression": "create unique index \"role_perspectives_live_role_org_uq\" on \"role_perspectives\" (\"role_id\", \"tenant_id\", \"organization_id\", \"table_id\", \"name\") where \"deleted_at\" is null and \"tenant_id\" is not null and \"organization_id\" is not null"
471
+ },
472
+ {
473
+ "columnNames": [],
474
+ "composite": false,
475
+ "constraint": false,
476
+ "keyName": "role_perspectives_live_role_tenant_uq",
477
+ "primary": false,
478
+ "unique": false,
479
+ "expression": "create unique index \"role_perspectives_live_role_tenant_uq\" on \"role_perspectives\" (\"role_id\", \"tenant_id\", \"table_id\", \"name\") where \"deleted_at\" is null and \"tenant_id\" is not null and \"organization_id\" is null"
436
480
  },
437
481
  {
438
482
  "columnNames": [
@@ -455,4 +499,4 @@
455
499
  ],
456
500
  "views": [],
457
501
  "nativeEnums": {}
458
- }
502
+ }
@@ -0,0 +1,65 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20260619120000_perspectives_live_row_uniqueness extends Migration {
4
+
5
+ override async up(): Promise<void> {
6
+ this.addSql(`
7
+ with ranked as (
8
+ select id,
9
+ row_number() over (
10
+ partition by user_id, tenant_id, organization_id, table_id, name
11
+ order by coalesce(updated_at, created_at) desc, created_at desc, id desc
12
+ ) as rn
13
+ from perspectives
14
+ where deleted_at is null
15
+ )
16
+ update perspectives
17
+ set deleted_at = now(), is_default = false
18
+ from ranked
19
+ where perspectives.id = ranked.id and ranked.rn > 1;
20
+ `);
21
+ this.addSql(`alter table "perspectives" drop constraint if exists "perspectives_user_id_tenant_id_organization_id_ta_2d725_unique";`);
22
+ this.addSql(`alter table "perspectives" drop constraint if exists "perspectives_user_id_tenant_id_organization_id_ta_0c702_unique";`);
23
+ this.addSql(`create unique index if not exists "perspectives_live_user_org_uq" on "perspectives" ("user_id", "tenant_id", "organization_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is not null and "organization_id" is not null;`);
24
+ this.addSql(`create unique index if not exists "perspectives_live_user_tenant_uq" on "perspectives" ("user_id", "tenant_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is not null and "organization_id" is null;`);
25
+ this.addSql(`create unique index if not exists "perspectives_live_user_org_only_uq" on "perspectives" ("user_id", "organization_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is null and "organization_id" is not null;`);
26
+ this.addSql(`create unique index if not exists "perspectives_live_user_global_uq" on "perspectives" ("user_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is null and "organization_id" is null;`);
27
+
28
+ this.addSql(`
29
+ with ranked as (
30
+ select id,
31
+ row_number() over (
32
+ partition by role_id, tenant_id, organization_id, table_id, name
33
+ order by coalesce(updated_at, created_at) desc, created_at desc, id desc
34
+ ) as rn
35
+ from role_perspectives
36
+ where deleted_at is null
37
+ )
38
+ update role_perspectives
39
+ set deleted_at = now(), is_default = false
40
+ from ranked
41
+ where role_perspectives.id = ranked.id and ranked.rn > 1;
42
+ `);
43
+ this.addSql(`alter table "role_perspectives" drop constraint if exists "role_perspectives_role_id_tenant_id_organization__c5467_unique";`);
44
+ this.addSql(`alter table "role_perspectives" drop constraint if exists "role_perspectives_role_id_tenant_id_organization__f0fc6_unique";`);
45
+ this.addSql(`create unique index if not exists "role_perspectives_live_role_org_uq" on "role_perspectives" ("role_id", "tenant_id", "organization_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is not null and "organization_id" is not null;`);
46
+ this.addSql(`create unique index if not exists "role_perspectives_live_role_tenant_uq" on "role_perspectives" ("role_id", "tenant_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is not null and "organization_id" is null;`);
47
+ this.addSql(`create unique index if not exists "role_perspectives_live_role_org_only_uq" on "role_perspectives" ("role_id", "organization_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is null and "organization_id" is not null;`);
48
+ this.addSql(`create unique index if not exists "role_perspectives_live_role_global_uq" on "role_perspectives" ("role_id", "table_id", "name") where "deleted_at" is null and "tenant_id" is null and "organization_id" is null;`);
49
+ }
50
+
51
+ override async down(): Promise<void> {
52
+ this.addSql(`drop index if exists "perspectives_live_user_org_uq";`);
53
+ this.addSql(`drop index if exists "perspectives_live_user_tenant_uq";`);
54
+ this.addSql(`drop index if exists "perspectives_live_user_org_only_uq";`);
55
+ this.addSql(`drop index if exists "perspectives_live_user_global_uq";`);
56
+ this.addSql(`alter table "perspectives" add constraint "perspectives_user_id_tenant_id_organization_id_ta_2d725_unique" unique ("user_id", "tenant_id", "organization_id", "table_id", "name");`);
57
+
58
+ this.addSql(`drop index if exists "role_perspectives_live_role_org_uq";`);
59
+ this.addSql(`drop index if exists "role_perspectives_live_role_tenant_uq";`);
60
+ this.addSql(`drop index if exists "role_perspectives_live_role_org_only_uq";`);
61
+ this.addSql(`drop index if exists "role_perspectives_live_role_global_uq";`);
62
+ this.addSql(`alter table "role_perspectives" add constraint "role_perspectives_role_id_tenant_id_organization__c5467_unique" unique ("role_id", "tenant_id", "organization_id", "table_id", "name");`);
63
+ }
64
+
65
+ }
@@ -296,6 +296,23 @@ export async function saveUserPerspective(
296
296
  current: entity.updatedAt ?? null,
297
297
  request: options.request ?? null,
298
298
  })
299
+ if (entity.name !== input.name) {
300
+ const duplicate = await em.findOne(Perspective, {
301
+ userId: scope.userId,
302
+ tenantId,
303
+ organizationId,
304
+ tableId,
305
+ name: input.name,
306
+ id: { $ne: entity.id } as any,
307
+ deletedAt: null,
308
+ })
309
+ if (duplicate) {
310
+ throw new CrudHttpError(409, {
311
+ error: 'A view with this name already exists.',
312
+ code: 'duplicate_name',
313
+ })
314
+ }
315
+ }
299
316
  } else {
300
317
  entity = await em.findOne(Perspective, {
301
318
  userId: scope.userId,