@open-mercato/core 0.6.6 → 0.6.7-develop.6552.1.5208a19369

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 (268) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/gateway_session_initialization/index.js +23 -0
  3. package/dist/generated/entities/gateway_session_initialization/index.js.map +7 -0
  4. package/dist/generated/entities.ids.generated.js +1 -0
  5. package/dist/generated/entities.ids.generated.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +12 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/api_keys/api/keys/route.js +30 -13
  9. package/dist/modules/api_keys/api/keys/route.js.map +2 -2
  10. package/dist/modules/api_keys/cli.js +2 -1
  11. package/dist/modules/api_keys/cli.js.map +2 -2
  12. package/dist/modules/attachments/api/file/[id]/route.js +9 -7
  13. package/dist/modules/attachments/api/file/[id]/route.js.map +2 -2
  14. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js +9 -7
  15. package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
  16. package/dist/modules/attachments/api/route.js +21 -17
  17. package/dist/modules/attachments/api/route.js.map +2 -2
  18. package/dist/modules/attachments/components/AttachmentContentPreview.js +6 -35
  19. package/dist/modules/attachments/components/AttachmentContentPreview.js.map +2 -2
  20. package/dist/modules/attachments/lib/reconcileOrganization.js +110 -0
  21. package/dist/modules/attachments/lib/reconcileOrganization.js.map +7 -0
  22. package/dist/modules/attachments/lib/requestScope.js +10 -0
  23. package/dist/modules/attachments/lib/requestScope.js.map +7 -0
  24. package/dist/modules/audit_logs/backend/audit-logs/page.js +14 -24
  25. package/dist/modules/audit_logs/backend/audit-logs/page.js.map +2 -2
  26. package/dist/modules/auth/api/autologin.js +85 -0
  27. package/dist/modules/auth/api/autologin.js.map +7 -0
  28. package/dist/modules/auth/api/users/route.js +6 -2
  29. package/dist/modules/auth/api/users/route.js.map +2 -2
  30. package/dist/modules/auth/cli.js +3 -2
  31. package/dist/modules/auth/cli.js.map +2 -2
  32. package/dist/modules/auth/lib/autologin.js +19 -0
  33. package/dist/modules/auth/lib/autologin.js.map +7 -0
  34. package/dist/modules/configs/lib/upgrade-actions.js +22 -0
  35. package/dist/modules/configs/lib/upgrade-actions.js.map +2 -2
  36. package/dist/modules/customer_accounts/api/admin/roles/[id]/acl.js +1 -0
  37. package/dist/modules/customer_accounts/api/admin/roles/[id]/acl.js.map +2 -2
  38. package/dist/modules/customer_accounts/api/admin/roles/[id].js +17 -3
  39. package/dist/modules/customer_accounts/api/admin/roles/[id].js.map +2 -2
  40. package/dist/modules/customer_accounts/api/portal/users/[id]/roles.js +6 -1
  41. package/dist/modules/customer_accounts/api/portal/users/[id]/roles.js.map +2 -2
  42. package/dist/modules/customer_accounts/api/portal/users-invite.js +6 -1
  43. package/dist/modules/customer_accounts/api/portal/users-invite.js.map +2 -2
  44. package/dist/modules/customer_accounts/services/customerInvitationService.js +1 -0
  45. package/dist/modules/customer_accounts/services/customerInvitationService.js.map +2 -2
  46. package/dist/modules/customer_accounts/services/customerRbacService.js +6 -1
  47. package/dist/modules/customer_accounts/services/customerRbacService.js.map +2 -2
  48. package/dist/modules/customer_accounts/widgets/injection-table.js +0 -2
  49. package/dist/modules/customer_accounts/widgets/injection-table.js.map +2 -2
  50. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +7 -1
  51. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
  52. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js +2 -2
  53. package/dist/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.js.map +2 -2
  54. package/dist/modules/customers/components/detail/CompanyDetailTabs.js +37 -43
  55. package/dist/modules/customers/components/detail/CompanyDetailTabs.js.map +2 -2
  56. package/dist/modules/customers/components/detail/DealDetailTabs.js +23 -35
  57. package/dist/modules/customers/components/detail/DealDetailTabs.js.map +2 -2
  58. package/dist/modules/customers/components/detail/DetailTabsLayout.js +6 -21
  59. package/dist/modules/customers/components/detail/DetailTabsLayout.js.map +2 -2
  60. package/dist/modules/customers/components/detail/MobilePersonDetail.js +26 -32
  61. package/dist/modules/customers/components/detail/MobilePersonDetail.js.map +2 -2
  62. package/dist/modules/customers/components/detail/PersonDetailTabs.js +29 -39
  63. package/dist/modules/customers/components/detail/PersonDetailTabs.js.map +2 -2
  64. package/dist/modules/customers/components/detail/useDealsAccess.js +16 -0
  65. package/dist/modules/customers/components/detail/useDealsAccess.js.map +7 -0
  66. package/dist/modules/dashboards/api/roles/widgets/route.js +22 -3
  67. package/dist/modules/dashboards/api/roles/widgets/route.js.map +2 -2
  68. package/dist/modules/dashboards/api/users/widgets/route.js +22 -3
  69. package/dist/modules/dashboards/api/users/widgets/route.js.map +2 -2
  70. package/dist/modules/dashboards/cli.js +4 -3
  71. package/dist/modules/dashboards/cli.js.map +2 -2
  72. package/dist/modules/dashboards/lib/widgetAssignmentScope.js +9 -1
  73. package/dist/modules/dashboards/lib/widgetAssignmentScope.js.map +2 -2
  74. package/dist/modules/dictionaries/commands/entries.js +7 -14
  75. package/dist/modules/dictionaries/commands/entries.js.map +2 -2
  76. package/dist/modules/dictionaries/commands/entry-operations.js +7 -16
  77. package/dist/modules/dictionaries/commands/entry-operations.js.map +2 -2
  78. package/dist/modules/dictionaries/commands/factory.js +9 -3
  79. package/dist/modules/dictionaries/commands/factory.js.map +2 -2
  80. package/dist/modules/dictionaries/fields/dictionary.js +56 -41
  81. package/dist/modules/dictionaries/fields/dictionary.js.map +2 -2
  82. package/dist/modules/directory/utils/organizationScope.js +10 -3
  83. package/dist/modules/directory/utils/organizationScope.js.map +2 -2
  84. package/dist/modules/entities/api/records.js +5 -4
  85. package/dist/modules/entities/api/records.js.map +2 -2
  86. package/dist/modules/entities/backend/entities/system/[entityId]/page.meta.js +1 -0
  87. package/dist/modules/entities/backend/entities/system/[entityId]/page.meta.js.map +2 -2
  88. package/dist/modules/entities/backend/entities/user/[entityId]/page.js +49 -49
  89. package/dist/modules/entities/backend/entities/user/[entityId]/page.js.map +2 -2
  90. package/dist/modules/entities/backend/entities/user/[entityId]/page.meta.js +1 -0
  91. package/dist/modules/entities/backend/entities/user/[entityId]/page.meta.js.map +2 -2
  92. package/dist/modules/entities/cli.js +2 -1
  93. package/dist/modules/entities/cli.js.map +2 -2
  94. package/dist/modules/feature_toggles/api/check/boolean/route.js +0 -14
  95. package/dist/modules/feature_toggles/api/check/boolean/route.js.map +2 -2
  96. package/dist/modules/feature_toggles/defaults.json +8 -0
  97. package/dist/modules/integrations/backend/integrations/[id]/page.js +9 -80
  98. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  99. package/dist/modules/payment_gateways/data/entities.js +45 -0
  100. package/dist/modules/payment_gateways/data/entities.js.map +2 -2
  101. package/dist/modules/payment_gateways/lib/gateway-service.js +173 -38
  102. package/dist/modules/payment_gateways/lib/gateway-service.js.map +3 -3
  103. package/dist/modules/payment_gateways/lib/session-idempotency.js +104 -0
  104. package/dist/modules/payment_gateways/lib/session-idempotency.js.map +7 -0
  105. package/dist/modules/payment_gateways/migrations/Migration20260709220938_payment_gateways.js +11 -0
  106. package/dist/modules/payment_gateways/migrations/Migration20260709220938_payment_gateways.js.map +7 -0
  107. package/dist/modules/planner/backend/planner/availability-rulesets/[id]/page.js +16 -14
  108. package/dist/modules/planner/backend/planner/availability-rulesets/[id]/page.js.map +2 -2
  109. package/dist/modules/planner/components/AvailabilityRulesEditor.js +18 -30
  110. package/dist/modules/planner/components/AvailabilityRulesEditor.js.map +2 -2
  111. package/dist/modules/query_index/api/status.js +2 -8
  112. package/dist/modules/query_index/api/status.js.map +2 -2
  113. package/dist/modules/resources/backend/resources/resources/[id]/page.js +24 -24
  114. package/dist/modules/resources/backend/resources/resources/[id]/page.js.map +2 -2
  115. package/dist/modules/sales/backend/sales/channels/create/page.js +6 -0
  116. package/dist/modules/sales/backend/sales/channels/create/page.js.map +2 -2
  117. package/dist/modules/sales/backend/sales/channels/create/page.meta.js +2 -0
  118. package/dist/modules/sales/backend/sales/channels/create/page.meta.js.map +2 -2
  119. package/dist/modules/sales/backend/sales/channels/offers/page.js +6 -0
  120. package/dist/modules/sales/backend/sales/channels/offers/page.js.map +2 -2
  121. package/dist/modules/sales/backend/sales/channels/offers/page.meta.js +2 -0
  122. package/dist/modules/sales/backend/sales/channels/offers/page.meta.js.map +2 -2
  123. package/dist/modules/sales/backend/sales/channels/page.js +6 -0
  124. package/dist/modules/sales/backend/sales/channels/page.js.map +2 -2
  125. package/dist/modules/sales/backend/sales/channels/page.meta.js +2 -0
  126. package/dist/modules/sales/backend/sales/channels/page.meta.js.map +2 -2
  127. package/dist/modules/sales/backend/sales/documents/[id]/page.js +6 -4
  128. package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
  129. package/dist/modules/sales/commands/documents.js +44 -0
  130. package/dist/modules/sales/commands/documents.js.map +2 -2
  131. package/dist/modules/sales/commands/returns.js +2 -27
  132. package/dist/modules/sales/commands/returns.js.map +2 -2
  133. package/dist/modules/sales/components/SalesChannelsDisabledNotice.js +15 -0
  134. package/dist/modules/sales/components/SalesChannelsDisabledNotice.js.map +7 -0
  135. package/dist/modules/sales/components/documents/AddressesSection.js +6 -2
  136. package/dist/modules/sales/components/documents/AddressesSection.js.map +2 -2
  137. package/dist/modules/sales/components/documents/ItemsSection.js +1 -0
  138. package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
  139. package/dist/modules/sales/components/documents/LineItemDialog.js +9 -0
  140. package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
  141. package/dist/modules/sales/components/documents/SalesDocumentForm.js +51 -3
  142. package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
  143. package/dist/modules/sales/components/documents/SalesDocumentsTable.js +10 -8
  144. package/dist/modules/sales/components/documents/SalesDocumentsTable.js.map +2 -2
  145. package/dist/modules/sales/components/documents/ShipmentDialog.js +59 -38
  146. package/dist/modules/sales/components/documents/ShipmentDialog.js.map +2 -2
  147. package/dist/modules/sales/components/useSalesChannelsEnabled.js +44 -0
  148. package/dist/modules/sales/components/useSalesChannelsEnabled.js.map +7 -0
  149. package/dist/modules/sales/lib/salesChannelsToggle.js +18 -0
  150. package/dist/modules/sales/lib/salesChannelsToggle.js.map +7 -0
  151. package/dist/modules/sales/lib/salesChannelsToggleId.js +5 -0
  152. package/dist/modules/sales/lib/salesChannelsToggleId.js.map +7 -0
  153. package/dist/modules/sales/lib/shipments/snapshots.js +27 -1
  154. package/dist/modules/sales/lib/shipments/snapshots.js.map +2 -2
  155. package/dist/modules/staff/backend/staff/team-members/[id]/page.js +22 -25
  156. package/dist/modules/staff/backend/staff/team-members/[id]/page.js.map +2 -2
  157. package/dist/modules/staff/backend/staff/teams/[id]/edit/page.js +12 -14
  158. package/dist/modules/staff/backend/staff/teams/[id]/edit/page.js.map +2 -2
  159. package/dist/modules/staff/lib/timesheets-projects-ui/SavedViewTabs.js +10 -29
  160. package/dist/modules/staff/lib/timesheets-projects-ui/SavedViewTabs.js.map +2 -2
  161. package/dist/modules/sync_excel/lib/adapters/customers.js +19 -20
  162. package/dist/modules/sync_excel/lib/adapters/customers.js.map +2 -2
  163. package/dist/modules/sync_excel/lib/parser.js +185 -0
  164. package/dist/modules/sync_excel/lib/parser.js.map +2 -2
  165. package/dist/modules/sync_excel/lib/upload-storage.js +20 -1
  166. package/dist/modules/sync_excel/lib/upload-storage.js.map +2 -2
  167. package/generated/entities/gateway_session_initialization/index.ts +10 -0
  168. package/generated/entities.ids.generated.ts +1 -0
  169. package/generated/entity-fields-registry.ts +12 -0
  170. package/package.json +9 -8
  171. package/src/modules/api_keys/api/keys/route.ts +36 -13
  172. package/src/modules/api_keys/cli.ts +2 -3
  173. package/src/modules/attachments/api/file/[id]/route.ts +11 -7
  174. package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +11 -7
  175. package/src/modules/attachments/api/route.ts +26 -21
  176. package/src/modules/attachments/components/AttachmentContentPreview.tsx +9 -44
  177. package/src/modules/attachments/lib/reconcileOrganization.ts +188 -0
  178. package/src/modules/attachments/lib/requestScope.ts +27 -0
  179. package/src/modules/audit_logs/backend/audit-logs/page.tsx +12 -22
  180. package/src/modules/auth/api/autologin.ts +100 -0
  181. package/src/modules/auth/api/users/route.ts +11 -2
  182. package/src/modules/auth/cli.ts +3 -2
  183. package/src/modules/auth/lib/autologin.ts +36 -0
  184. package/src/modules/configs/i18n/de.json +5 -1
  185. package/src/modules/configs/i18n/en.json +5 -1
  186. package/src/modules/configs/i18n/es.json +5 -1
  187. package/src/modules/configs/i18n/pl.json +5 -1
  188. package/src/modules/configs/lib/upgrade-actions.ts +24 -0
  189. package/src/modules/customer_accounts/AGENTS.md +8 -6
  190. package/src/modules/customer_accounts/api/admin/roles/[id]/acl.ts +1 -0
  191. package/src/modules/customer_accounts/api/admin/roles/[id].ts +17 -3
  192. package/src/modules/customer_accounts/api/portal/users/[id]/roles.ts +6 -1
  193. package/src/modules/customer_accounts/api/portal/users-invite.ts +6 -1
  194. package/src/modules/customer_accounts/services/customerInvitationService.ts +1 -0
  195. package/src/modules/customer_accounts/services/customerRbacService.ts +9 -2
  196. package/src/modules/customer_accounts/widgets/injection-table.ts +0 -2
  197. package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +11 -1
  198. package/src/modules/customers/backend/customers/deals/pipeline/components/ViewTabsRow.tsx +6 -2
  199. package/src/modules/customers/components/detail/CompanyDetailTabs.tsx +39 -55
  200. package/src/modules/customers/components/detail/DealDetailTabs.tsx +24 -49
  201. package/src/modules/customers/components/detail/DetailTabsLayout.tsx +13 -24
  202. package/src/modules/customers/components/detail/MobilePersonDetail.tsx +25 -31
  203. package/src/modules/customers/components/detail/PersonDetailTabs.tsx +29 -50
  204. package/src/modules/customers/components/detail/useDealsAccess.ts +19 -0
  205. package/src/modules/dashboards/api/roles/widgets/route.ts +22 -3
  206. package/src/modules/dashboards/api/users/widgets/route.ts +22 -3
  207. package/src/modules/dashboards/cli.ts +4 -9
  208. package/src/modules/dashboards/lib/widgetAssignmentScope.ts +18 -0
  209. package/src/modules/dictionaries/commands/entries.ts +7 -16
  210. package/src/modules/dictionaries/commands/entry-operations.ts +8 -18
  211. package/src/modules/dictionaries/commands/factory.ts +8 -2
  212. package/src/modules/dictionaries/fields/dictionary.tsx +55 -32
  213. package/src/modules/dictionaries/i18n/de.json +2 -0
  214. package/src/modules/dictionaries/i18n/en.json +2 -0
  215. package/src/modules/dictionaries/i18n/es.json +2 -0
  216. package/src/modules/dictionaries/i18n/pl.json +2 -0
  217. package/src/modules/directory/utils/organizationScope.ts +36 -2
  218. package/src/modules/entities/api/records.ts +5 -7
  219. package/src/modules/entities/backend/entities/system/[entityId]/page.meta.ts +1 -0
  220. package/src/modules/entities/backend/entities/user/[entityId]/page.meta.ts +1 -0
  221. package/src/modules/entities/backend/entities/user/[entityId]/page.tsx +51 -49
  222. package/src/modules/entities/cli.ts +2 -1
  223. package/src/modules/entities/i18n/de.json +34 -0
  224. package/src/modules/entities/i18n/en.json +34 -0
  225. package/src/modules/entities/i18n/es.json +34 -0
  226. package/src/modules/entities/i18n/pl.json +34 -0
  227. package/src/modules/feature_toggles/api/check/boolean/route.ts +0 -15
  228. package/src/modules/feature_toggles/defaults.json +8 -0
  229. package/src/modules/integrations/backend/integrations/[id]/page.tsx +16 -59
  230. package/src/modules/payment_gateways/data/entities.ts +39 -0
  231. package/src/modules/payment_gateways/lib/gateway-service.ts +211 -40
  232. package/src/modules/payment_gateways/lib/session-idempotency.ts +142 -0
  233. package/src/modules/payment_gateways/migrations/.snapshot-open-mercato.json +236 -0
  234. package/src/modules/payment_gateways/migrations/Migration20260709220938_payment_gateways.ts +10 -0
  235. package/src/modules/planner/backend/planner/availability-rulesets/[id]/page.tsx +14 -20
  236. package/src/modules/planner/components/AvailabilityRulesEditor.tsx +16 -34
  237. package/src/modules/query_index/api/status.ts +2 -8
  238. package/src/modules/resources/backend/resources/resources/[id]/page.tsx +27 -37
  239. package/src/modules/sales/backend/sales/channels/create/page.meta.ts +3 -0
  240. package/src/modules/sales/backend/sales/channels/create/page.tsx +7 -0
  241. package/src/modules/sales/backend/sales/channels/offers/page.meta.ts +3 -0
  242. package/src/modules/sales/backend/sales/channels/offers/page.tsx +7 -0
  243. package/src/modules/sales/backend/sales/channels/page.meta.ts +4 -0
  244. package/src/modules/sales/backend/sales/channels/page.tsx +7 -0
  245. package/src/modules/sales/backend/sales/documents/[id]/page.tsx +7 -5
  246. package/src/modules/sales/commands/documents.ts +65 -0
  247. package/src/modules/sales/commands/returns.ts +2 -32
  248. package/src/modules/sales/components/SalesChannelsDisabledNotice.tsx +21 -0
  249. package/src/modules/sales/components/documents/AddressesSection.tsx +6 -2
  250. package/src/modules/sales/components/documents/ItemsSection.tsx +5 -0
  251. package/src/modules/sales/components/documents/LineItemDialog.tsx +10 -0
  252. package/src/modules/sales/components/documents/SalesDocumentForm.tsx +53 -3
  253. package/src/modules/sales/components/documents/SalesDocumentsTable.tsx +10 -8
  254. package/src/modules/sales/components/documents/ShipmentDialog.tsx +65 -40
  255. package/src/modules/sales/components/useSalesChannelsEnabled.ts +59 -0
  256. package/src/modules/sales/i18n/de.json +5 -0
  257. package/src/modules/sales/i18n/en.json +5 -0
  258. package/src/modules/sales/i18n/es.json +5 -0
  259. package/src/modules/sales/i18n/pl.json +5 -0
  260. package/src/modules/sales/lib/salesChannelsToggle.ts +26 -0
  261. package/src/modules/sales/lib/salesChannelsToggleId.ts +1 -0
  262. package/src/modules/sales/lib/shipments/snapshots.ts +30 -0
  263. package/src/modules/staff/backend/staff/team-members/[id]/page.tsx +25 -34
  264. package/src/modules/staff/backend/staff/teams/[id]/edit/page.tsx +11 -17
  265. package/src/modules/staff/lib/timesheets-projects-ui/SavedViewTabs.tsx +11 -28
  266. package/src/modules/sync_excel/lib/adapters/customers.ts +20 -22
  267. package/src/modules/sync_excel/lib/parser.ts +245 -0
  268. package/src/modules/sync_excel/lib/upload-storage.ts +23 -1
@@ -13,6 +13,7 @@ import { StorageDriverFactory } from '../lib/drivers'
13
13
  import { OcrService, shouldUseLlmOcr } from '../lib/ocrService'
14
14
  import { clearAttachmentThumbnailCache } from '../lib/thumbnailCache'
15
15
  import { assertAttachmentScopeInvariant } from '../lib/access'
16
+ import { resolveAttachmentOrganizationId } from '../lib/requestScope'
16
17
  import {
17
18
  mergeAttachmentMetadata,
18
19
  normalizeAttachmentAssignments,
@@ -198,10 +199,11 @@ export async function GET(req: Request) {
198
199
  }
199
200
  const { entityId, recordId, page, pageSize } = parsedQuery.data
200
201
 
201
- const { resolve } = await createRequestContainer()
202
- const em = resolve('em') as EntityManager
202
+ const container = await createRequestContainer()
203
+ const em = container.resolve('em') as EntityManager
204
+ const orgId = await resolveAttachmentOrganizationId(container, auth, req)
203
205
  const filter: Record<string, unknown> = { entityId, recordId, tenantId: auth.tenantId! }
204
- if (auth.orgId) filter.organizationId = auth.orgId
206
+ if (orgId) filter.organizationId = orgId
205
207
  const orderBy: Record<string, 'ASC' | 'DESC'> = { createdAt: 'DESC' }
206
208
  const usePaging = typeof page === 'number' && typeof pageSize === 'number'
207
209
  const total = usePaging ? await em.count(Attachment, filter) : null
@@ -224,7 +226,7 @@ export async function GET(req: Request) {
224
226
  },
225
227
  {
226
228
  tenantId: auth.tenantId ?? null,
227
- organizationId: auth.orgId ?? null,
229
+ organizationId: orgId ?? null,
228
230
  },
229
231
  )
230
232
  return NextResponse.json({
@@ -275,7 +277,6 @@ export async function POST(req: Request) {
275
277
  }, { status: 400 })
276
278
  }
277
279
  const tenantId = auth.tenantId
278
- const orgId = auth.orgId
279
280
 
280
281
  const contentType = req.headers.get('content-type') || ''
281
282
  if (!contentType.toLowerCase().includes('multipart/form-data')) {
@@ -301,11 +302,13 @@ export async function POST(req: Request) {
301
302
  const tags = parseFormTags(form.get('tags'))
302
303
  const assignmentsFromForm = parseFormAssignments(form.get('assignments'))
303
304
 
304
- const { resolve } = await createRequestContainer()
305
- const em = resolve('em') as EntityManager
306
- const dataEngine = resolve('dataEngine')
305
+ const container = await createRequestContainer()
306
+ const em = container.resolve('em') as EntityManager
307
+ const dataEngine = container.resolve('dataEngine')
307
308
  const storageDriverFactory =
308
- (resolve('storageDriverFactory') as StorageDriverFactory | null) ?? new StorageDriverFactory(em)
309
+ (container.resolve('storageDriverFactory') as StorageDriverFactory | null) ?? new StorageDriverFactory(em)
310
+ const orgId = await resolveAttachmentOrganizationId(container, auth, req)
311
+ if (!orgId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
309
312
  await ensureDefaultPartitions(em)
310
313
  // Optional per-field validations
311
314
  let partitionFromField: string | null = null
@@ -436,12 +439,12 @@ export async function POST(req: Request) {
436
439
  }
437
440
  const metadata = mergeAttachmentMetadata(null, { assignments, tags })
438
441
  const attachmentId = randomUUID()
439
- assertAttachmentScopeInvariant({ tenantId: auth.tenantId, organizationId: auth.orgId })
442
+ assertAttachmentScopeInvariant({ tenantId: auth.tenantId, organizationId: orgId })
440
443
  const att = em.create(Attachment, {
441
444
  id: attachmentId,
442
445
  entityId,
443
446
  recordId,
444
- organizationId: auth.orgId!,
447
+ organizationId: orgId,
445
448
  tenantId: auth.tenantId!,
446
449
  fileName: safeName,
447
450
  mimeType: fileMimeType,
@@ -545,17 +548,19 @@ export async function DELETE(req: Request) {
545
548
  const url = new URL(req.url)
546
549
  const id = url.searchParams.get('id') || ''
547
550
  if (!id) return NextResponse.json({ error: 'Attachment id is required' }, { status: 400 })
548
- const { resolve } = await createRequestContainer()
549
- const em = resolve('em') as EntityManager
550
- const dataEngine = resolve('dataEngine')
551
+ const container = await createRequestContainer()
552
+ const em = container.resolve('em') as EntityManager
553
+ const dataEngine = container.resolve('dataEngine')
551
554
  const storageDriverFactory =
552
- (resolve('storageDriverFactory') as StorageDriverFactory | null) ?? new StorageDriverFactory(em)
553
- // Mirror the GET handler's superadmin bypass: a superadmin browsing with "All
554
- // organizations" selected has no concrete org, so scope the delete by tenant
555
- // only and let them remove any attachment in the tenant. Non-superadmins stay
556
- // scoped to their own organization (#3764).
555
+ (container.resolve('storageDriverFactory') as StorageDriverFactory | null) ?? new StorageDriverFactory(em)
556
+ // Resolve the currently selected organization (#3765) so a multi-org admin who
557
+ // switched the header org deletes within that org, not their pinned home org.
558
+ // A superadmin browsing with "All organizations" selected has no concrete org,
559
+ // so resolution returns null and the delete falls back to a tenant-only scope
560
+ // (#3764) — letting them remove any attachment in the tenant.
561
+ const orgId = await resolveAttachmentOrganizationId(container, auth, req)
557
562
  const deleteFilter: Record<string, unknown> = { id, tenantId: auth.tenantId! }
558
- if (auth.orgId) deleteFilter.organizationId = auth.orgId
563
+ if (orgId) deleteFilter.organizationId = orgId
559
564
  const record = await em.findOne(Attachment, deleteFilter)
560
565
  if (!record) return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
561
566
  await em.remove(record).flush()
@@ -565,7 +570,7 @@ export async function DELETE(req: Request) {
565
570
  if (record.storagePath) {
566
571
  const delDriver = await storageDriverFactory.resolveForPartition(record.partitionCode, {
567
572
  tenantId: record.tenantId ?? auth.tenantId!,
568
- organizationId: record.organizationId ?? auth.orgId ?? '',
573
+ organizationId: record.organizationId ?? orgId ?? '',
569
574
  })
570
575
  await delDriver.delete(record.partitionCode, record.storagePath)
571
576
  }
@@ -1,6 +1,7 @@
1
1
  import * as React from 'react'
2
2
  import { MarkdownContent } from '@open-mercato/ui/backend/markdown'
3
3
  import { Button } from '@open-mercato/ui/primitives/button'
4
+ import { Tabs, TabsList, TabsTrigger } from '@open-mercato/ui/primitives/tabs'
4
5
 
5
6
  type Props = {
6
7
  content?: string | null
@@ -25,12 +26,6 @@ export function AttachmentContentPreview({
25
26
  const [tab, setTab] = React.useState<'source' | 'preview'>('source')
26
27
  const text = (content ?? '').trim()
27
28
 
28
- // ARIA IDs for accessibility
29
- const sourceTabId = 'attachment-content-preview-tab-source'
30
- const previewTabId = 'attachment-content-preview-tab-preview'
31
- const sourcePanelId = 'attachment-content-preview-panel-source'
32
- const previewPanelId = 'attachment-content-preview-panel-preview'
33
-
34
29
  if (!text) {
35
30
  return <div className="text-xs text-muted-foreground italic">{emptyLabel}</div>
36
31
  }
@@ -41,47 +36,18 @@ export function AttachmentContentPreview({
41
36
  return (
42
37
  <div className="space-y-2">
43
38
  {/* Tab Navigation */}
44
- <div className="border-b border-border">
45
- <nav className="flex items-center gap-4 text-xs" role="tablist" aria-label="Content preview mode">
46
- <button
47
- type="button"
48
- id={sourceTabId}
49
- role="tab"
50
- aria-selected={tab === 'source'}
51
- aria-controls={sourcePanelId}
52
- className={`-mb-px border-b-2 px-0 pb-2 font-medium transition-colors ${
53
- tab === 'source'
54
- ? 'border-accent-indigo text-foreground'
55
- : 'border-transparent text-muted-foreground hover:text-foreground'
56
- }`}
57
- onClick={() => setTab('source')}
58
- >
59
- {sourceLabel}
60
- </button>
61
- <button
62
- type="button"
63
- id={previewTabId}
64
- role="tab"
65
- aria-selected={tab === 'preview'}
66
- aria-controls={previewPanelId}
67
- className={`-mb-px border-b-2 px-0 pb-2 font-medium transition-colors ${
68
- tab === 'preview'
69
- ? 'border-accent-indigo text-foreground'
70
- : 'border-transparent text-muted-foreground hover:text-foreground'
71
- }`}
72
- onClick={() => setTab('preview')}
73
- >
74
- {previewLabel}
75
- </button>
76
- </nav>
77
- </div>
39
+ <Tabs value={tab} onValueChange={(value) => setTab(value as 'source' | 'preview')} variant="underline">
40
+ <TabsList className="w-full">
41
+ <TabsTrigger value="source">{sourceLabel}</TabsTrigger>
42
+ <TabsTrigger value="preview">{previewLabel}</TabsTrigger>
43
+ </TabsList>
44
+ </Tabs>
78
45
 
79
46
  {/* Tab Panels */}
80
47
  {tab === 'source' ? (
81
48
  <div
82
49
  role="tabpanel"
83
- id={sourcePanelId}
84
- aria-labelledby={sourceTabId}
50
+ aria-label={sourceLabel}
85
51
  data-testid="attachment-content-preview"
86
52
  className="whitespace-pre-wrap text-sm text-muted-foreground"
87
53
  >
@@ -90,8 +56,7 @@ export function AttachmentContentPreview({
90
56
  ) : (
91
57
  <div
92
58
  role="tabpanel"
93
- id={previewPanelId}
94
- aria-labelledby={previewTabId}
59
+ aria-label={previewLabel}
95
60
  data-testid="markdown-preview"
96
61
  >
97
62
  <MarkdownContent
@@ -0,0 +1,188 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import { createLogger } from '@open-mercato/shared/lib/logger'
3
+ import type { QueryEngine } from '@open-mercato/shared/lib/query/types'
4
+ import { Attachment } from '../data/entities'
5
+
6
+ const logger = createLogger('attachments').child({ component: 'reconcileOrganization' })
7
+
8
+ /**
9
+ * Virtual entity id for library attachments — these are not attached to a
10
+ * parent record that owns an organization, so there is nothing to reconcile
11
+ * against. They are counted as skipped rather than unresolved.
12
+ */
13
+ const LIBRARY_ENTITY_ID = 'attachments:library'
14
+
15
+ const DEFAULT_BATCH_SIZE = 500
16
+
17
+ export type AttachmentOrgReconcileEntityStat = {
18
+ scanned: number
19
+ updated: number
20
+ unresolved: number
21
+ }
22
+
23
+ export type AttachmentOrgReconcileReport = {
24
+ scanned: number
25
+ updated: number
26
+ unresolved: number
27
+ skippedVirtual: number
28
+ byEntity: Record<string, AttachmentOrgReconcileEntityStat>
29
+ }
30
+
31
+ type AttachmentScanRow = {
32
+ id: string
33
+ entity_id: string | null
34
+ record_id: string | null
35
+ organization_id: string | null
36
+ }
37
+
38
+ function readParentOrganizationId(record: Record<string, unknown>): string | null {
39
+ const raw = record['organization_id'] ?? record['organizationId']
40
+ if (typeof raw === 'string' && raw.trim().length) return raw.trim()
41
+ return null
42
+ }
43
+
44
+ function ensureBucket(
45
+ report: AttachmentOrgReconcileReport,
46
+ entityId: string,
47
+ ): AttachmentOrgReconcileEntityStat {
48
+ const existing = report.byEntity[entityId]
49
+ if (existing) return existing
50
+ const bucket: AttachmentOrgReconcileEntityStat = { scanned: 0, updated: 0, unresolved: 0 }
51
+ report.byEntity[entityId] = bucket
52
+ return bucket
53
+ }
54
+
55
+ /**
56
+ * Reconcile the `organization_id` of existing attachments to the organization
57
+ * of the record they are attached to.
58
+ *
59
+ * Background (#3765): before the upload route became selected-organization
60
+ * aware, a multi-org admin who switched the header organization uploaded files
61
+ * that were silently stored under their *home* organization instead of the
62
+ * selected one (the organization of the parent record). Those rows are now
63
+ * invisible to org-scoped reads and stay orphaned. This heals them forward.
64
+ *
65
+ * The parent record's organization is the ground truth: an attachment is not
66
+ * distinguishable from a legitimately home-org one by looking at the attachment
67
+ * alone. Parent organizations are resolved generically through the Query Engine
68
+ * by `entityId` (works for both base and custom entities, tenant-scoped and
69
+ * organization-agnostic so the parent's real org is returned regardless of
70
+ * which org the caller sits in) — the same mechanism the attachments module
71
+ * already uses to enrich assignment details.
72
+ *
73
+ * The reconciliation is:
74
+ * - idempotent — rows already matching their parent are left untouched;
75
+ * - conservative — when the parent org cannot be resolved (unregistered
76
+ * entity id, hard-deleted parent, parent without an org column) the row is
77
+ * counted as `unresolved` and left as-is rather than guessed at;
78
+ * - tenant-scoped — it reconciles every attachment in the tenant across all
79
+ * organizations (the whole point is moving rows between orgs).
80
+ *
81
+ * Writes go through the passed `EntityManager` (which the caller runs inside a
82
+ * transaction) so the whole reconciliation commits atomically.
83
+ */
84
+ export async function reconcileAttachmentOrganizations(opts: {
85
+ em: EntityManager
86
+ queryEngine: QueryEngine
87
+ tenantId: string
88
+ batchSize?: number
89
+ }): Promise<AttachmentOrgReconcileReport> {
90
+ const { em, queryEngine, tenantId } = opts
91
+ const batchSize = opts.batchSize && opts.batchSize > 0 ? opts.batchSize : DEFAULT_BATCH_SIZE
92
+ const report: AttachmentOrgReconcileReport = {
93
+ scanned: 0,
94
+ updated: 0,
95
+ unresolved: 0,
96
+ skippedVirtual: 0,
97
+ byEntity: {},
98
+ }
99
+
100
+ const rows = (await em.getConnection().execute(
101
+ 'select id, entity_id, record_id, organization_id from attachments where tenant_id = ?',
102
+ [tenantId],
103
+ )) as AttachmentScanRow[]
104
+ report.scanned = rows.length
105
+ if (!rows.length) return report
106
+
107
+ const groups = new Map<string, AttachmentScanRow[]>()
108
+ for (const row of rows) {
109
+ if (!row.id || !row.entity_id || !row.record_id) continue
110
+ const list = groups.get(row.entity_id) ?? []
111
+ list.push(row)
112
+ groups.set(row.entity_id, list)
113
+ }
114
+
115
+ const pendingUpdates: Array<{ id: string; organizationId: string }> = []
116
+
117
+ for (const [entityId, group] of groups) {
118
+ const bucket = ensureBucket(report, entityId)
119
+ bucket.scanned += group.length
120
+
121
+ if (entityId === LIBRARY_ENTITY_ID) {
122
+ report.skippedVirtual += group.length
123
+ continue
124
+ }
125
+
126
+ const recordIds = Array.from(
127
+ new Set(group.map((row) => row.record_id).filter((value): value is string => !!value)),
128
+ )
129
+ const parentOrgByRecordId = new Map<string, string | null>()
130
+ let resolvable = true
131
+
132
+ for (let index = 0; index < recordIds.length; index += batchSize) {
133
+ const chunk = recordIds.slice(index, index + batchSize)
134
+ try {
135
+ const result = await queryEngine.query(entityId as any, {
136
+ fields: ['id', 'organization_id'],
137
+ filters: { id: chunk.length === 1 ? { $eq: chunk[0] } : { $in: chunk } },
138
+ tenantId,
139
+ withDeleted: true,
140
+ page: { pageSize: Math.max(chunk.length, 1) },
141
+ })
142
+ for (const item of result.items ?? []) {
143
+ const record = item as Record<string, unknown>
144
+ const recordId = record.id != null ? String(record.id) : null
145
+ if (!recordId) continue
146
+ parentOrgByRecordId.set(recordId, readParentOrganizationId(record))
147
+ }
148
+ } catch (error) {
149
+ resolvable = false
150
+ logger.warn('org reconcile: cannot resolve parent organization for entity', {
151
+ entityId,
152
+ error,
153
+ })
154
+ break
155
+ }
156
+ }
157
+
158
+ if (!resolvable) {
159
+ bucket.unresolved += group.length
160
+ report.unresolved += group.length
161
+ continue
162
+ }
163
+
164
+ for (const row of group) {
165
+ const target = row.record_id ? parentOrgByRecordId.get(row.record_id) : null
166
+ if (!target) {
167
+ bucket.unresolved += 1
168
+ report.unresolved += 1
169
+ continue
170
+ }
171
+ if ((row.organization_id ?? null) === target) continue
172
+ pendingUpdates.push({ id: row.id, organizationId: target })
173
+ bucket.updated += 1
174
+ report.updated += 1
175
+ }
176
+ }
177
+
178
+ for (let index = 0; index < pendingUpdates.length; index += batchSize) {
179
+ const chunk = pendingUpdates.slice(index, index + batchSize)
180
+ for (const update of chunk) {
181
+ const reference = em.getReference(Attachment, update.id)
182
+ reference.organizationId = update.organizationId
183
+ }
184
+ await em.flush()
185
+ }
186
+
187
+ return report
188
+ }
@@ -0,0 +1,27 @@
1
+ import type { AwilixContainer } from 'awilix'
2
+ import type { AuthContext } from '@open-mercato/shared/lib/auth/server'
3
+ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
4
+
5
+ /**
6
+ * Resolve the organization an attachment request should act within.
7
+ *
8
+ * `auth.orgId` alone is NOT selected-organization aware for non-superadmin
9
+ * principals: `applySuperAdminScope` only rewrites `orgId` from the
10
+ * `om_selected_org` cookie for superadmins, so a regular multi-org admin who
11
+ * switches the header organization keeps `auth.orgId` pinned to their own home
12
+ * organization. The CRUD factory and other org-scoped routes derive the active
13
+ * organization via `resolveOrganizationScopeForRequest` (cookie-driven and
14
+ * RBAC-validated for ALL users, falling back to the home org when the selection
15
+ * is absent or inaccessible). Attachments must do the same so uploaded files
16
+ * land under — and are read back from — the currently selected organization
17
+ * rather than the uploader's home organization (#3765).
18
+ */
19
+ export async function resolveAttachmentOrganizationId(
20
+ container: AwilixContainer,
21
+ auth: AuthContext,
22
+ request: Request,
23
+ ): Promise<string | null> {
24
+ if (!auth) return null
25
+ const scope = await resolveOrganizationScopeForRequest({ container, auth, request })
26
+ return scope?.selectedId ?? auth.orgId ?? null
27
+ }
@@ -4,6 +4,7 @@ import React from 'react'
4
4
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
5
5
  import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
6
6
  import { Button } from '@open-mercato/ui/primitives/button'
7
+ import { Tabs, TabsList, TabsTrigger } from '@open-mercato/ui/primitives/tabs'
7
8
  import { useT } from '@open-mercato/shared/lib/i18n/context'
8
9
  import type { ActionLogItem } from '../../components/AuditLogsActions'
9
10
  import { AuditLogsActions } from '../../components/AuditLogsActions'
@@ -182,28 +183,17 @@ export default function AuditLogsPage() {
182
183
  return (
183
184
  <Page>
184
185
  <PageBody>
185
- <div className="mb-6 border-b border-border">
186
- <nav className="flex items-center gap-6 text-sm" role="tablist" aria-label={t('audit_logs.tabs.label')}>
187
- <button
188
- type="button"
189
- role="tab"
190
- aria-selected={tab === 'actions'}
191
- className={`relative -mb-px border-b-2 px-0 pb-3 pt-2 font-medium transition-colors ${tab === 'actions' ? 'border-accent-indigo text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
192
- onClick={() => setTab('actions')}
193
- >
194
- {t('audit_logs.actions.title')}
195
- </button>
196
- <button
197
- type="button"
198
- role="tab"
199
- aria-selected={tab === 'access'}
200
- className={`relative -mb-px border-b-2 px-0 pb-3 pt-2 font-medium transition-colors ${tab === 'access' ? 'border-accent-indigo text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
201
- onClick={() => setTab('access')}
202
- >
203
- {t('audit_logs.access.title')}
204
- </button>
205
- </nav>
206
- </div>
186
+ <Tabs
187
+ value={tab}
188
+ onValueChange={(value) => setTab(value as TabOption)}
189
+ variant="underline"
190
+ className="mb-6"
191
+ >
192
+ <TabsList className="w-full flex-wrap" aria-label={t('audit_logs.tabs.label')}>
193
+ <TabsTrigger value="actions">{t('audit_logs.actions.title')}</TabsTrigger>
194
+ <TabsTrigger value="access">{t('audit_logs.access.title')}</TabsTrigger>
195
+ </TabsList>
196
+ </Tabs>
207
197
 
208
198
  {error && <div className="mb-4 rounded-md border border-status-error-border bg-status-error-bg p-3 text-sm text-status-error-text">{error}</div>}
209
199
 
@@ -0,0 +1,100 @@
1
+ import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
2
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
+ import { createLogger } from '@open-mercato/shared/lib/logger'
4
+ import { AuthService } from '@open-mercato/core/modules/auth/services/authService'
5
+ import { signJwt } from '@open-mercato/shared/lib/auth/jwt'
6
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
7
+ import { buildSafeRedirectResponse, resolveTrustedRedirectBase } from '@open-mercato/core/modules/auth/lib/requestRedirect'
8
+ import { sanitizeRedirectPath } from '@open-mercato/core/modules/auth/lib/safeRedirect'
9
+ import { resolveAutoLoginCredentials } from '@open-mercato/core/modules/auth/lib/autologin'
10
+ import { emitAuthEvent } from '@open-mercato/core/modules/auth/events'
11
+
12
+ export const metadata = { requireAuth: false }
13
+
14
+ const accessTokenMaxAgeSeconds = 60 * 60 * 8
15
+ const logger = createLogger('auth').child({ component: 'autologin' })
16
+
17
+ // Demo-only convenience: when OM_AUTOLOGIN_* credentials are configured, sign the
18
+ // visitor in with those credentials and redirect into the app. Gated entirely
19
+ // behind env vars — the homepage only routes here when they are set. On any
20
+ // failure (feature off, bad credentials) it falls back to the manual login form
21
+ // so a misconfigured demo can never loop.
22
+ export async function GET(req: Request) {
23
+ const url = new URL(req.url)
24
+ const baseUrl = resolveTrustedRedirectBase(req) ?? url.origin
25
+ const redirectTo = sanitizeRedirectPath(url.searchParams.get('redirect'), baseUrl, '/backend')
26
+
27
+ const credentials = resolveAutoLoginCredentials()
28
+ if (!credentials) {
29
+ return buildSafeRedirectResponse(req, '/login')
30
+ }
31
+
32
+ // Already signed in — go straight to the app instead of re-issuing a session.
33
+ const existing = await getAuthFromRequest(req)
34
+ if (existing) {
35
+ return buildSafeRedirectResponse(req, redirectTo)
36
+ }
37
+
38
+ const container = await createRequestContainer()
39
+ const auth = container.resolve<AuthService>('authService')
40
+
41
+ let user = null
42
+ if (credentials.tenantId) {
43
+ user = await auth.findUserByEmailAndTenant(credentials.email, credentials.tenantId)
44
+ } else {
45
+ const users = await auth.findUsersByEmail(credentials.email)
46
+ user = users.length === 1 ? users[0] : null
47
+ }
48
+
49
+ const ok = await auth.verifyPassword(user, credentials.password)
50
+ if (!user || !ok) {
51
+ // Misconfigured demo credentials — never loop; drop to the manual form.
52
+ logger.warn('OM_AUTOLOGIN credentials did not resolve to a single valid user; falling back to login')
53
+ return buildSafeRedirectResponse(req, '/login')
54
+ }
55
+
56
+ const resolvedTenantId = credentials.tenantId ?? (user.tenantId ? String(user.tenantId) : null)
57
+ const roles = await auth.getUserRoles(user, resolvedTenantId)
58
+ await auth.updateLastLoginAt(user)
59
+ const expiresAt = new Date(Date.now() + accessTokenMaxAgeSeconds * 1000)
60
+ const { session } = await auth.createSession(user, expiresAt)
61
+ const token = signJwt({
62
+ sub: String(user.id),
63
+ sid: String(session.id),
64
+ tenantId: resolvedTenantId,
65
+ orgId: user.organizationId ? String(user.organizationId) : null,
66
+ email: user.email,
67
+ roles,
68
+ })
69
+ void emitAuthEvent('auth.login.success', {
70
+ id: String(user.id),
71
+ email: user.email,
72
+ tenantId: resolvedTenantId,
73
+ organizationId: user.organizationId ? String(user.organizationId) : null,
74
+ }).catch(() => undefined)
75
+
76
+ const res = buildSafeRedirectResponse(req, redirectTo)
77
+ res.cookies.set('auth_token', token, {
78
+ httpOnly: true,
79
+ path: '/',
80
+ sameSite: 'lax',
81
+ secure: process.env.NODE_ENV === 'production',
82
+ maxAge: accessTokenMaxAgeSeconds,
83
+ })
84
+ return res
85
+ }
86
+
87
+ export const openApi: OpenApiRouteDoc = {
88
+ tag: 'Authentication & Accounts',
89
+ summary: 'Demo autologin',
90
+ methods: {
91
+ GET: {
92
+ summary: 'Auto sign-in using env-configured demo credentials',
93
+ description:
94
+ 'When OM_AUTOLOGIN_EMAIL / OM_AUTOLOGIN_PASSWORD are configured, signs the visitor in with those credentials and redirects into the app. Intended for single-tenant demo instances only. Falls back to the login page when disabled or misconfigured.',
95
+ responses: [
96
+ { status: 307, description: 'Redirect into the app (or to /login on failure)', mediaType: 'text/html' },
97
+ ],
98
+ },
99
+ },
100
+ }
@@ -22,6 +22,7 @@ import {
22
22
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
23
23
  import { buildPasswordSchema } from '@open-mercato/shared/lib/auth/passwordPolicy'
24
24
  import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
25
+ import { parseBooleanFlag } from '@open-mercato/shared/lib/boolean'
25
26
  import { resolveSearchConfig } from '@open-mercato/shared/lib/search/config'
26
27
  import { tokenizeText } from '@open-mercato/shared/lib/search/tokenize'
27
28
  import { sql } from 'kysely'
@@ -41,6 +42,7 @@ const querySchema = z.object({
41
42
  search: z.string().optional(),
42
43
  name: z.string().optional(),
43
44
  organizationId: z.string().uuid().optional(),
45
+ scopeToActiveOrganization: z.boolean().optional(),
44
46
  roleIds: z.array(z.string().uuid()).optional(),
45
47
  }).passthrough()
46
48
 
@@ -180,6 +182,7 @@ export async function GET(req: Request) {
180
182
  search: url.searchParams.get('search') || undefined,
181
183
  name: url.searchParams.get('name') || undefined,
182
184
  organizationId: url.searchParams.get('organizationId') || undefined,
185
+ scopeToActiveOrganization: parseBooleanFlag(url.searchParams.get('scopeToActiveOrganization') || undefined),
183
186
  roleIds: rawRoleIds.length ? rawRoleIds : undefined,
184
187
  })
185
188
  if (!parsed.success) return NextResponse.json({ items: [], total: 0, totalPages: 1 })
@@ -195,7 +198,7 @@ export async function GET(req: Request) {
195
198
  } catch (err) {
196
199
  logger.error('Failed to resolve rbac', { err })
197
200
  }
198
- const { id, page, pageSize, search, name, organizationId, roleIds } = parsed.data
201
+ const { id, page, pageSize, search, name, organizationId, scopeToActiveOrganization, roleIds } = parsed.data
199
202
  const filters: any[] = [{ deletedAt: null }]
200
203
  const actorTenantId = auth.tenantId ? String(auth.tenantId) : null
201
204
  let effectiveTenantId: string | null = null
@@ -244,6 +247,12 @@ export async function GET(req: Request) {
244
247
  ? effectiveSelectedOrganizationId
245
248
  : auth.orgId ?? null
246
249
  if (organizationId) filters.push({ organizationId })
250
+ // Recipient/assignee pickers scope to the caller's active organization so they never
251
+ // suggest users outside it. A message composed here is stamped with the caller's
252
+ // active org (auth.orgId), and the message detail endpoint enforces
253
+ // hasOrganizationAccess(scope.organizationId, message.organizationId); scoping the
254
+ // suggestions to the same org keeps a picked recipient able to open what they were sent.
255
+ if (scopeToActiveOrganization) filters.push({ organizationId: auth.orgId ?? null })
247
256
  const trimmedName = typeof name === 'string' ? name.trim() : ''
248
257
  if (trimmedName) {
249
258
  const searchPattern = `%${escapeLikePattern(trimmedName)}%`
@@ -620,7 +629,7 @@ export const openApi: OpenApiRouteDoc = {
620
629
  GET: {
621
630
  summary: 'List users',
622
631
  description:
623
- 'Returns users for the effective selected tenant and organization scope. Search matches email, organization name, and role name. Super administrators may scope the response via the topbar context, organization filters, or role filters.',
632
+ 'Returns users for the effective selected tenant and organization scope. Search matches email, organization name, and role name. Super administrators may scope the response via the topbar context, organization filters, or role filters. Pass scopeToActiveOrganization=1 to restrict results to the caller\'s active organization (used by recipient/assignee pickers so suggestions stay within the org that owns the resulting record).',
624
633
  query: querySchema,
625
634
  responses: [
626
635
  { status: 200, description: 'User collection', schema: userListResponseSchema },
@@ -9,6 +9,7 @@ import { Tenant, Organization } from '@open-mercato/core/modules/directory/data/
9
9
  import { rebuildHierarchyForTenant } from '@open-mercato/core/modules/directory/lib/hierarchy'
10
10
  import { ensureRoles, setupInitialTenant, ensureDefaultRoleAcls, ensureCustomRoleAcls, OrgSlugExistsError, DerivedUserPasswordRequiredError } from './lib/setup-app'
11
11
  import { normalizeTenantId } from './lib/tenantAccess'
12
+ import { parseCommaSeparatedList } from '@open-mercato/shared/lib/string'
12
13
  import { computeEmailHash, emailHashLookupValues } from './lib/emailHash'
13
14
  import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
14
15
  import { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'
@@ -71,7 +72,7 @@ const addUser: ModuleCli = {
71
72
  })
72
73
  await em.persist(u).flush()
73
74
  if (rolesCsv) {
74
- const names = rolesCsv.split(',').map(s => s.trim()).filter(Boolean)
75
+ const names = parseCommaSeparatedList(rolesCsv)
75
76
  for (const name of names) {
76
77
  const role = await resolveTenantScopedRole(em, name, normalizedTenantId)
77
78
  const link = em.create(UserRole, { user: u, role })
@@ -493,7 +494,7 @@ const setupApp: ModuleCli = {
493
494
  const container = await createRequestContainer()
494
495
  const em = container.resolve<EntityManager>('em')
495
496
  const roleNames = rolesCsv
496
- ? rolesCsv.split(',').map((s) => s.trim()).filter(Boolean)
497
+ ? parseCommaSeparatedList(rolesCsv)
497
498
  : undefined
498
499
 
499
500
  try {