@endora-commerce/mod-organizations 0.100.0

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 (288) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -0
  3. package/dist/admin/components/OrganizationSalesRepsTab.d.ts +27 -0
  4. package/dist/admin/components/OrganizationSalesRepsTab.d.ts.map +1 -0
  5. package/dist/admin/components/OrganizationSalesRepsTab.js +170 -0
  6. package/dist/admin/components/OrganizationSalesRepsTab.js.map +1 -0
  7. package/dist/admin/index.d.ts +30 -0
  8. package/dist/admin/index.d.ts.map +1 -0
  9. package/dist/admin/index.js +59 -0
  10. package/dist/admin/index.js.map +1 -0
  11. package/dist/admin/pages/OrganizationDetail.d.ts +9 -0
  12. package/dist/admin/pages/OrganizationDetail.d.ts.map +1 -0
  13. package/dist/admin/pages/OrganizationDetail.js +247 -0
  14. package/dist/admin/pages/OrganizationDetail.js.map +1 -0
  15. package/dist/admin/pages/OrganizationsList.d.ts +9 -0
  16. package/dist/admin/pages/OrganizationsList.d.ts.map +1 -0
  17. package/dist/admin/pages/OrganizationsList.js +73 -0
  18. package/dist/admin/pages/OrganizationsList.js.map +1 -0
  19. package/dist/admin/panels/ApplicablePriceListsPanel.d.ts +12 -0
  20. package/dist/admin/panels/ApplicablePriceListsPanel.d.ts.map +1 -0
  21. package/dist/admin/panels/ApplicablePriceListsPanel.js +49 -0
  22. package/dist/admin/panels/ApplicablePriceListsPanel.js.map +1 -0
  23. package/dist/admin/panels/FulfilmentStrategyPanel.d.ts +16 -0
  24. package/dist/admin/panels/FulfilmentStrategyPanel.d.ts.map +1 -0
  25. package/dist/admin/panels/FulfilmentStrategyPanel.js +29 -0
  26. package/dist/admin/panels/FulfilmentStrategyPanel.js.map +1 -0
  27. package/dist/admin/panels/HierarchyPanel.d.ts +15 -0
  28. package/dist/admin/panels/HierarchyPanel.d.ts.map +1 -0
  29. package/dist/admin/panels/HierarchyPanel.js +65 -0
  30. package/dist/admin/panels/HierarchyPanel.js.map +1 -0
  31. package/dist/admin/panels/ModerationActionsPanel.d.ts +28 -0
  32. package/dist/admin/panels/ModerationActionsPanel.d.ts.map +1 -0
  33. package/dist/admin/panels/ModerationActionsPanel.js +111 -0
  34. package/dist/admin/panels/ModerationActionsPanel.js.map +1 -0
  35. package/dist/admin/panels/RestrictionsPanel.d.ts +24 -0
  36. package/dist/admin/panels/RestrictionsPanel.d.ts.map +1 -0
  37. package/dist/admin/panels/RestrictionsPanel.js +121 -0
  38. package/dist/admin/panels/RestrictionsPanel.js.map +1 -0
  39. package/dist/admin/panels/VatValidationPanel.d.ts +28 -0
  40. package/dist/admin/panels/VatValidationPanel.d.ts.map +1 -0
  41. package/dist/admin/panels/VatValidationPanel.js +79 -0
  42. package/dist/admin/panels/VatValidationPanel.js.map +1 -0
  43. package/dist/backend/commands/move.command.d.ts +11 -0
  44. package/dist/backend/commands/move.command.d.ts.map +1 -0
  45. package/dist/backend/commands/move.command.js +11 -0
  46. package/dist/backend/commands/move.command.js.map +1 -0
  47. package/dist/backend/commands/set-cart-approval-policy.command.d.ts +31 -0
  48. package/dist/backend/commands/set-cart-approval-policy.command.d.ts.map +1 -0
  49. package/dist/backend/commands/set-cart-approval-policy.command.js +56 -0
  50. package/dist/backend/commands/set-cart-approval-policy.command.js.map +1 -0
  51. package/dist/backend/commands/set-credit-mode.command.d.ts +16 -0
  52. package/dist/backend/commands/set-credit-mode.command.d.ts.map +1 -0
  53. package/dist/backend/commands/set-credit-mode.command.js +25 -0
  54. package/dist/backend/commands/set-credit-mode.command.js.map +1 -0
  55. package/dist/backend/commands/set-parent.command.d.ts +26 -0
  56. package/dist/backend/commands/set-parent.command.d.ts.map +1 -0
  57. package/dist/backend/commands/set-parent.command.js +51 -0
  58. package/dist/backend/commands/set-parent.command.js.map +1 -0
  59. package/dist/backend/demo/reset.d.ts +20 -0
  60. package/dist/backend/demo/reset.d.ts.map +1 -0
  61. package/dist/backend/demo/reset.js +11 -0
  62. package/dist/backend/demo/reset.js.map +1 -0
  63. package/dist/backend/demo/rows.d.ts +44 -0
  64. package/dist/backend/demo/rows.d.ts.map +1 -0
  65. package/dist/backend/demo/rows.js +44 -0
  66. package/dist/backend/demo/rows.js.map +1 -0
  67. package/dist/backend/demo/seed.d.ts +20 -0
  68. package/dist/backend/demo/seed.d.ts.map +1 -0
  69. package/dist/backend/demo/seed.js +28 -0
  70. package/dist/backend/demo/seed.js.map +1 -0
  71. package/dist/backend/email-templates/invitation.d.ts +23 -0
  72. package/dist/backend/email-templates/invitation.d.ts.map +1 -0
  73. package/dist/backend/email-templates/invitation.js +35 -0
  74. package/dist/backend/email-templates/invitation.js.map +1 -0
  75. package/dist/backend/email-templates/transactional-defaults.d.ts +54 -0
  76. package/dist/backend/email-templates/transactional-defaults.d.ts.map +1 -0
  77. package/dist/backend/email-templates/transactional-defaults.js +242 -0
  78. package/dist/backend/email-templates/transactional-defaults.js.map +1 -0
  79. package/dist/backend/email-templates/verification.d.ts +14 -0
  80. package/dist/backend/email-templates/verification.d.ts.map +1 -0
  81. package/dist/backend/email-templates/verification.js +34 -0
  82. package/dist/backend/email-templates/verification.js.map +1 -0
  83. package/dist/backend/entities/email-verification-token.entity.d.ts +17 -0
  84. package/dist/backend/entities/email-verification-token.entity.d.ts.map +1 -0
  85. package/dist/backend/entities/email-verification-token.entity.js +60 -0
  86. package/dist/backend/entities/email-verification-token.entity.js.map +1 -0
  87. package/dist/backend/entities/organization-delivery-method-link.entity.d.ts +14 -0
  88. package/dist/backend/entities/organization-delivery-method-link.entity.d.ts.map +1 -0
  89. package/dist/backend/entities/organization-delivery-method-link.entity.js +41 -0
  90. package/dist/backend/entities/organization-delivery-method-link.entity.js.map +1 -0
  91. package/dist/backend/entities/organization-invitation.entity.d.ts +21 -0
  92. package/dist/backend/entities/organization-invitation.entity.d.ts.map +1 -0
  93. package/dist/backend/entities/organization-invitation.entity.js +85 -0
  94. package/dist/backend/entities/organization-invitation.entity.js.map +1 -0
  95. package/dist/backend/entities/organization-payment-method-link.entity.d.ts +14 -0
  96. package/dist/backend/entities/organization-payment-method-link.entity.d.ts.map +1 -0
  97. package/dist/backend/entities/organization-payment-method-link.entity.js +41 -0
  98. package/dist/backend/entities/organization-payment-method-link.entity.js.map +1 -0
  99. package/dist/backend/entities/organization-sales-rep-assignment.entity.d.ts +17 -0
  100. package/dist/backend/entities/organization-sales-rep-assignment.entity.d.ts.map +1 -0
  101. package/dist/backend/entities/organization-sales-rep-assignment.entity.js +56 -0
  102. package/dist/backend/entities/organization-sales-rep-assignment.entity.js.map +1 -0
  103. package/dist/backend/entities/organization-tax-id-validation.entity.d.ts +29 -0
  104. package/dist/backend/entities/organization-tax-id-validation.entity.d.ts.map +1 -0
  105. package/dist/backend/entities/organization-tax-id-validation.entity.js +78 -0
  106. package/dist/backend/entities/organization-tax-id-validation.entity.js.map +1 -0
  107. package/dist/backend/entities/organization-warehouse-link.entity.d.ts +15 -0
  108. package/dist/backend/entities/organization-warehouse-link.entity.d.ts.map +1 -0
  109. package/dist/backend/entities/organization-warehouse-link.entity.js +42 -0
  110. package/dist/backend/entities/organization-warehouse-link.entity.js.map +1 -0
  111. package/dist/backend/entities/organization.entity.d.ts +125 -0
  112. package/dist/backend/entities/organization.entity.d.ts.map +1 -0
  113. package/dist/backend/entities/organization.entity.js +282 -0
  114. package/dist/backend/entities/organization.entity.js.map +1 -0
  115. package/dist/backend/index.d.ts +250 -0
  116. package/dist/backend/index.d.ts.map +1 -0
  117. package/dist/backend/index.js +506 -0
  118. package/dist/backend/index.js.map +1 -0
  119. package/dist/backend/integrations/ministerstwo-finansow-client.d.ts +22 -0
  120. package/dist/backend/integrations/ministerstwo-finansow-client.d.ts.map +1 -0
  121. package/dist/backend/integrations/ministerstwo-finansow-client.js +172 -0
  122. package/dist/backend/integrations/ministerstwo-finansow-client.js.map +1 -0
  123. package/dist/backend/integrations/vies-client.d.ts +18 -0
  124. package/dist/backend/integrations/vies-client.d.ts.map +1 -0
  125. package/dist/backend/integrations/vies-client.js +133 -0
  126. package/dist/backend/integrations/vies-client.js.map +1 -0
  127. package/dist/backend/plugin.d.ts +126 -0
  128. package/dist/backend/plugin.d.ts.map +1 -0
  129. package/dist/backend/plugin.js +139 -0
  130. package/dist/backend/plugin.js.map +1 -0
  131. package/dist/backend/routes.admin.d.ts +66 -0
  132. package/dist/backend/routes.admin.d.ts.map +1 -0
  133. package/dist/backend/routes.admin.js +711 -0
  134. package/dist/backend/routes.admin.js.map +1 -0
  135. package/dist/backend/routes.customer.d.ts +33 -0
  136. package/dist/backend/routes.customer.d.ts.map +1 -0
  137. package/dist/backend/routes.customer.js +139 -0
  138. package/dist/backend/routes.customer.js.map +1 -0
  139. package/dist/backend/routes.members.d.ts +31 -0
  140. package/dist/backend/routes.members.d.ts.map +1 -0
  141. package/dist/backend/routes.members.js +109 -0
  142. package/dist/backend/routes.members.js.map +1 -0
  143. package/dist/backend/routes.public.d.ts +49 -0
  144. package/dist/backend/routes.public.d.ts.map +1 -0
  145. package/dist/backend/routes.public.js +220 -0
  146. package/dist/backend/routes.public.js.map +1 -0
  147. package/dist/backend/routes.sales-reps.d.ts +46 -0
  148. package/dist/backend/routes.sales-reps.d.ts.map +1 -0
  149. package/dist/backend/routes.sales-reps.js +114 -0
  150. package/dist/backend/routes.sales-reps.js.map +1 -0
  151. package/dist/backend/routes.storefront.d.ts +25 -0
  152. package/dist/backend/routes.storefront.d.ts.map +1 -0
  153. package/dist/backend/routes.storefront.js +65 -0
  154. package/dist/backend/routes.storefront.js.map +1 -0
  155. package/dist/backend/schemas/organization.d.ts +59 -0
  156. package/dist/backend/schemas/organization.d.ts.map +1 -0
  157. package/dist/backend/schemas/organization.js +55 -0
  158. package/dist/backend/schemas/organization.js.map +1 -0
  159. package/dist/backend/schemas/settings.d.ts +30 -0
  160. package/dist/backend/schemas/settings.d.ts.map +1 -0
  161. package/dist/backend/schemas/settings.js +35 -0
  162. package/dist/backend/schemas/settings.js.map +1 -0
  163. package/dist/backend/services/email-verification-service.d.ts +45 -0
  164. package/dist/backend/services/email-verification-service.d.ts.map +1 -0
  165. package/dist/backend/services/email-verification-service.js +95 -0
  166. package/dist/backend/services/email-verification-service.js.map +1 -0
  167. package/dist/backend/services/invitation-service.d.ts +85 -0
  168. package/dist/backend/services/invitation-service.d.ts.map +1 -0
  169. package/dist/backend/services/invitation-service.js +266 -0
  170. package/dist/backend/services/invitation-service.js.map +1 -0
  171. package/dist/backend/services/normalize-name.d.ts +14 -0
  172. package/dist/backend/services/normalize-name.d.ts.map +1 -0
  173. package/dist/backend/services/normalize-name.js +14 -0
  174. package/dist/backend/services/normalize-name.js.map +1 -0
  175. package/dist/backend/services/org-registration-notifier.d.ts +36 -0
  176. package/dist/backend/services/org-registration-notifier.d.ts.map +1 -0
  177. package/dist/backend/services/org-registration-notifier.js +109 -0
  178. package/dist/backend/services/org-registration-notifier.js.map +1 -0
  179. package/dist/backend/services/org-template-email.d.ts +18 -0
  180. package/dist/backend/services/org-template-email.d.ts.map +1 -0
  181. package/dist/backend/services/org-template-email.js +16 -0
  182. package/dist/backend/services/org-template-email.js.map +1 -0
  183. package/dist/backend/services/organization-context-service.d.ts +52 -0
  184. package/dist/backend/services/organization-context-service.d.ts.map +1 -0
  185. package/dist/backend/services/organization-context-service.js +70 -0
  186. package/dist/backend/services/organization-context-service.js.map +1 -0
  187. package/dist/backend/services/organization-country-reference.d.ts +11 -0
  188. package/dist/backend/services/organization-country-reference.d.ts.map +1 -0
  189. package/dist/backend/services/organization-country-reference.js +24 -0
  190. package/dist/backend/services/organization-country-reference.js.map +1 -0
  191. package/dist/backend/services/organization-details-port.d.ts +33 -0
  192. package/dist/backend/services/organization-details-port.d.ts.map +1 -0
  193. package/dist/backend/services/organization-details-port.js +95 -0
  194. package/dist/backend/services/organization-details-port.js.map +1 -0
  195. package/dist/backend/services/organization-effective-pricelists-service.d.ts +50 -0
  196. package/dist/backend/services/organization-effective-pricelists-service.d.ts.map +1 -0
  197. package/dist/backend/services/organization-effective-pricelists-service.js +68 -0
  198. package/dist/backend/services/organization-effective-pricelists-service.js.map +1 -0
  199. package/dist/backend/services/organization-inheritance-service.d.ts +73 -0
  200. package/dist/backend/services/organization-inheritance-service.d.ts.map +1 -0
  201. package/dist/backend/services/organization-inheritance-service.js +63 -0
  202. package/dist/backend/services/organization-inheritance-service.js.map +1 -0
  203. package/dist/backend/services/organization-moderation-service.d.ts +124 -0
  204. package/dist/backend/services/organization-moderation-service.d.ts.map +1 -0
  205. package/dist/backend/services/organization-moderation-service.js +337 -0
  206. package/dist/backend/services/organization-moderation-service.js.map +1 -0
  207. package/dist/backend/services/organization-restriction-service.d.ts +70 -0
  208. package/dist/backend/services/organization-restriction-service.d.ts.map +1 -0
  209. package/dist/backend/services/organization-restriction-service.js +210 -0
  210. package/dist/backend/services/organization-restriction-service.js.map +1 -0
  211. package/dist/backend/services/organization-tax-id-validation-service.d.ts +59 -0
  212. package/dist/backend/services/organization-tax-id-validation-service.d.ts.map +1 -0
  213. package/dist/backend/services/organization-tax-id-validation-service.js +115 -0
  214. package/dist/backend/services/organization-tax-id-validation-service.js.map +1 -0
  215. package/dist/backend/services/organization-tree-service.d.ts +102 -0
  216. package/dist/backend/services/organization-tree-service.d.ts.map +1 -0
  217. package/dist/backend/services/organization-tree-service.js +184 -0
  218. package/dist/backend/services/organization-tree-service.js.map +1 -0
  219. package/dist/backend/services/personal-organization-service.d.ts +130 -0
  220. package/dist/backend/services/personal-organization-service.d.ts.map +1 -0
  221. package/dist/backend/services/personal-organization-service.js +207 -0
  222. package/dist/backend/services/personal-organization-service.js.map +1 -0
  223. package/dist/backend/services/registration-service.d.ts +80 -0
  224. package/dist/backend/services/registration-service.d.ts.map +1 -0
  225. package/dist/backend/services/registration-service.js +126 -0
  226. package/dist/backend/services/registration-service.js.map +1 -0
  227. package/dist/backend/services/sales-rep-assignment-service.d.ts +89 -0
  228. package/dist/backend/services/sales-rep-assignment-service.d.ts.map +1 -0
  229. package/dist/backend/services/sales-rep-assignment-service.js +176 -0
  230. package/dist/backend/services/sales-rep-assignment-service.js.map +1 -0
  231. package/dist/backend/services/vat-validator-port.d.ts +20 -0
  232. package/dist/backend/services/vat-validator-port.d.ts.map +1 -0
  233. package/dist/backend/services/vat-validator-port.js +2 -0
  234. package/dist/backend/services/vat-validator-port.js.map +1 -0
  235. package/dist/manifest.d.ts +176 -0
  236. package/dist/manifest.d.ts.map +1 -0
  237. package/dist/manifest.js +503 -0
  238. package/dist/manifest.js.map +1 -0
  239. package/dist/migrations/20260424T205317_organizations_init.d.ts +13 -0
  240. package/dist/migrations/20260424T205317_organizations_init.d.ts.map +1 -0
  241. package/dist/migrations/20260424T205317_organizations_init.js +100 -0
  242. package/dist/migrations/20260424T205317_organizations_init.js.map +1 -0
  243. package/dist/migrations/20260425T051700_organizations_invitations_init.d.ts +13 -0
  244. package/dist/migrations/20260425T051700_organizations_invitations_init.d.ts.map +1 -0
  245. package/dist/migrations/20260425T051700_organizations_invitations_init.js +39 -0
  246. package/dist/migrations/20260425T051700_organizations_invitations_init.js.map +1 -0
  247. package/dist/migrations/20260611T140349_organizations_consolidation.d.ts +35 -0
  248. package/dist/migrations/20260611T140349_organizations_consolidation.d.ts.map +1 -0
  249. package/dist/migrations/20260611T140349_organizations_consolidation.js +180 -0
  250. package/dist/migrations/20260611T140349_organizations_consolidation.js.map +1 -0
  251. package/dist/migrations/20260611T140358_organizations_org_order_confirmation_emails.d.ts +10 -0
  252. package/dist/migrations/20260611T140358_organizations_org_order_confirmation_emails.d.ts.map +1 -0
  253. package/dist/migrations/20260611T140358_organizations_org_order_confirmation_emails.js +14 -0
  254. package/dist/migrations/20260611T140358_organizations_org_order_confirmation_emails.js.map +1 -0
  255. package/dist/migrations/20260611T140402_organizations_org_fulfilment_strategy.d.ts +14 -0
  256. package/dist/migrations/20260611T140402_organizations_org_fulfilment_strategy.d.ts.map +1 -0
  257. package/dist/migrations/20260611T140402_organizations_org_fulfilment_strategy.js +20 -0
  258. package/dist/migrations/20260611T140402_organizations_org_fulfilment_strategy.js.map +1 -0
  259. package/dist/migrations/20260717T151403_organizations_personal_organizations.d.ts +24 -0
  260. package/dist/migrations/20260717T151403_organizations_personal_organizations.d.ts.map +1 -0
  261. package/dist/migrations/20260717T151403_organizations_personal_organizations.js +63 -0
  262. package/dist/migrations/20260717T151403_organizations_personal_organizations.js.map +1 -0
  263. package/dist/migrations/20260718T200340_organizations_organization_custom_field_values.d.ts +12 -0
  264. package/dist/migrations/20260718T200340_organizations_organization_custom_field_values.d.ts.map +1 -0
  265. package/dist/migrations/20260718T200340_organizations_organization_custom_field_values.js +16 -0
  266. package/dist/migrations/20260718T200340_organizations_organization_custom_field_values.js.map +1 -0
  267. package/dist/migrations/20260720T044254_organizations_org_hierarchy.d.ts +23 -0
  268. package/dist/migrations/20260720T044254_organizations_org_hierarchy.d.ts.map +1 -0
  269. package/dist/migrations/20260720T044254_organizations_org_hierarchy.js +37 -0
  270. package/dist/migrations/20260720T044254_organizations_org_hierarchy.js.map +1 -0
  271. package/dist/migrations/20260912T094646_organizations_sales_channel_organizations.d.ts +27 -0
  272. package/dist/migrations/20260912T094646_organizations_sales_channel_organizations.d.ts.map +1 -0
  273. package/dist/migrations/20260912T094646_organizations_sales_channel_organizations.js +44 -0
  274. package/dist/migrations/20260912T094646_organizations_sales_channel_organizations.js.map +1 -0
  275. package/dist/migrations/index.d.ts +35 -0
  276. package/dist/migrations/index.d.ts.map +1 -0
  277. package/dist/migrations/index.js +45 -0
  278. package/dist/migrations/index.js.map +1 -0
  279. package/dist/ports/index.d.ts +53 -0
  280. package/dist/ports/index.d.ts.map +1 -0
  281. package/dist/ports/index.js +2 -0
  282. package/dist/ports/index.js.map +1 -0
  283. package/docs/organization-hierarchy.md +117 -0
  284. package/docs/organizations.md +312 -0
  285. package/i18n/en.json +17 -0
  286. package/i18n/pl.json +17 -0
  287. package/package.json +107 -0
  288. package/tailwind.css +14 -0
@@ -0,0 +1,711 @@
1
+ import { z } from 'zod';
2
+ import { ERROR_CODES, adminPatchOrganizationRequestSchema, adminRecoverOrgAccessRequestSchema, adminDirectMemberRequestSchema, adminPatchMemberRoleRequestSchema, adminPatchMemberProfileRequestSchema, adminSetMemberRollupRequestSchema, assignOrganizationParentRequestSchema, setCreditInheritanceModeRequestSchema, inviteMemberRequestSchema, isCustomFieldValidationFailure, } from '@endora-commerce/contracts';
3
+ import { HttpError } from '@endora-commerce/platform/http';
4
+ import { getTenantContext } from '@endora-commerce/platform/tenancy';
5
+ import { hasChildrenRefusal } from './services/organization-tree-service.js';
6
+ import { makeSetParentCommand } from './commands/set-parent.command.js';
7
+ import { makeMoveCommand } from './commands/move.command.js';
8
+ import { makeSetCreditModeCommand } from './commands/set-credit-mode.command.js';
9
+ import { Organization } from './entities/organization.entity.js';
10
+ import { emitCustomerAccountCreated, } from './services/registration-service.js';
11
+ import { OrganizationNotFoundError, OrganizationStatusGuardError, OrganizationVersionMismatchError, } from './services/organization-moderation-service.js';
12
+ import { OrganizationVersionMismatchError as RestrictionVersionMismatchError, } from './services/organization-restriction-service.js';
13
+ import { approveOrganizationSchema, rejectOrganizationSchema, blockOrganizationSchema, unblockOrganizationSchema, replaceOrgRestrictionsSchema, patchOrgRestrictionsSchema, triggerVatValidationSchema, } from './schemas/organization.js';
14
+ import { normalizeOrganizationName } from './services/normalize-name.js';
15
+ /**
16
+ * Admin /admin/organizations — platform staff operations (003).
17
+ */
18
+ const listQuerySchema = z.object({
19
+ 'filter[status]': z.enum(['pending_verification', 'active', 'blocked', 'rejected']).optional(),
20
+ 'filter[vatStatus]': z.enum(['vat_payer', 'vat_exempt', 'reverse_charge']).optional(),
21
+ q: z.string().optional(),
22
+ limit: z.coerce.number().int().positive().max(200).default(50),
23
+ // Feature 051 — personal (B2C) orgs are excluded by default; pass
24
+ // `includePersonal=true` to see them (string compare — z.coerce.boolean would
25
+ // treat the string "false" as true).
26
+ includePersonal: z.string().optional(),
27
+ });
28
+ /**
29
+ * The acting admin, or `null` for any other actor.
30
+ *
31
+ * `request.actor` is not on `FastifyRequest`: it is a declaration-merging
32
+ * augmentation the `auth` module contributes (`modules/auth/plugin.ts`). Inside
33
+ * `backend/src` it arrived ambiently through the host's own `types` graph, so
34
+ * nothing in this module ever named it; a package compiles against its own
35
+ * manifest and `auth` publishes nothing a package can name, so the property is
36
+ * simply not there (TS2339). Read structurally and narrowed rather than
37
+ * asserted — an absent actor answers `null`, which is the "record nothing"
38
+ * branch this call site already had.
39
+ */
40
+ function adminActorOf(request) {
41
+ const { actor } = request;
42
+ if (actor?.kind !== 'admin' || typeof actor.adminUserId !== 'string')
43
+ return null;
44
+ return { adminUserId: actor.adminUserId };
45
+ }
46
+ export async function registerOrganizationsAdminRoutes(app, deps) {
47
+ const { requireAdmin, requireAdminAny, emFactory, invitationService, roleService, auditLogService, } = deps;
48
+ const moderationService = deps.moderationService;
49
+ const restrictionService = deps.restrictionService;
50
+ const effectivePriceListsService = deps.effectivePriceListsService;
51
+ const taxIdValidationService = deps.taxIdValidationService;
52
+ const customFieldValues = deps.customFieldValues;
53
+ const customersRead = requireAdminAny(['customers:read', 'customers:manage']);
54
+ const audit = async (request, action, objectType, objectId, stateBefore, stateAfter) => {
55
+ const actor = adminActorOf(request);
56
+ if (actor === null)
57
+ return;
58
+ const rid = request.headers['x-request-id'];
59
+ await auditLogService.record({
60
+ actorAdminUserId: actor.adminUserId,
61
+ action,
62
+ objectType,
63
+ objectId,
64
+ stateBefore,
65
+ stateAfter,
66
+ requestId: typeof rid === 'string' ? rid : null,
67
+ });
68
+ };
69
+ const assertVersion = (current, expectedIso) => {
70
+ if (!expectedIso)
71
+ return;
72
+ const expected = new Date(expectedIso);
73
+ if (Number.isNaN(expected.getTime()) || current.getTime() !== expected.getTime()) {
74
+ throw new HttpError(409, ERROR_CODES.VERSION_CONFLICT, 'Record was modified by another request. Refresh and retry.');
75
+ }
76
+ };
77
+ app.get('/api/v1/admin/organizations', { preHandler: customersRead }, async (request) => {
78
+ const query = listQuerySchema.parse(request.query);
79
+ const em = emFactory();
80
+ const where = { deletedAt: null };
81
+ // Feature 051 — exclude personal (B2C) orgs from the B2B admin list by default.
82
+ if (query.includePersonal !== 'true')
83
+ where['isPersonal'] = false;
84
+ if (query['filter[status]'])
85
+ where['status'] = query['filter[status]'];
86
+ if (query['filter[vatStatus]'])
87
+ where['vatStatus'] = query['filter[vatStatus]'];
88
+ if (query.q) {
89
+ // Feature 026 US8 — diacritic-insensitive search via the
90
+ // denormalized `nameSearch` column populated by the
91
+ // @BeforeCreate / @BeforeUpdate hooks on the Organization
92
+ // entity. Falls back to taxId ILIKE so admins can also paste a
93
+ // tax-id fragment.
94
+ const normalized = normalizeOrganizationName(query.q);
95
+ where['$or'] = [
96
+ { nameSearch: { $ilike: `%${normalized}%` } },
97
+ { taxId: { $ilike: `%${query.q}%` } },
98
+ ];
99
+ }
100
+ const orgs = await em.find(Organization, where, {
101
+ limit: query.limit,
102
+ orderBy: { createdAt: 'desc' },
103
+ });
104
+ return {
105
+ data: orgs.map(serializeOrg),
106
+ pagination: { cursor: null, hasMore: false, limit: query.limit },
107
+ };
108
+ });
109
+ app.get('/api/v1/admin/organizations/:id', { preHandler: customersRead }, async (request) => {
110
+ const em = emFactory();
111
+ const org = await em.findOne(Organization, { id: request.params.id, deletedAt: null });
112
+ if (!org) {
113
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Organization not found.');
114
+ }
115
+ const members = await deps.customerAccountRead.listByOrganization(org.id, {
116
+ activeOnly: true,
117
+ });
118
+ return {
119
+ data: {
120
+ ...serializeOrg(org),
121
+ members: members.map(serializeMemberDetail),
122
+ },
123
+ };
124
+ });
125
+ // Org-owned addresses (with ids) — feeds the default-preferences panel's
126
+ // billing/shipping address pickers on the Organization edit page.
127
+ if (deps.addressService) {
128
+ const addressService = deps.addressService;
129
+ app.get('/api/v1/admin/organizations/:id/addresses', { preHandler: customersRead }, async (request) => {
130
+ const rows = await addressService.list(request.params.id);
131
+ return {
132
+ data: rows.map((a) => ({
133
+ id: a.id,
134
+ organizationId: a.organizationId,
135
+ kind: a.kind,
136
+ recipientName: a.recipientName,
137
+ street: a.street,
138
+ city: a.city,
139
+ postalCode: a.postalCode,
140
+ country: a.country,
141
+ phone: a.phone ?? null,
142
+ isDefault: a.isDefault,
143
+ })),
144
+ };
145
+ });
146
+ }
147
+ app.patch('/api/v1/admin/organizations/:id', {
148
+ preHandler: requireAdmin('customers:manage'),
149
+ schema: { body: adminPatchOrganizationRequestSchema },
150
+ }, async (request) => {
151
+ const em = emFactory();
152
+ const body = adminPatchOrganizationRequestSchema.parse(request.body);
153
+ const org = await em.findOne(Organization, { id: request.params.id, deletedAt: null });
154
+ if (!org) {
155
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Organization not found.');
156
+ }
157
+ assertVersion(org.updatedAt, body.expectedUpdatedAt);
158
+ const before = serializeOrg(org);
159
+ if (body.name !== undefined)
160
+ org.name = body.name;
161
+ if (body.vatStatus !== undefined)
162
+ org.vatStatus = body.vatStatus;
163
+ if (body.status !== undefined)
164
+ org.status = body.status;
165
+ if (body.orderConfirmationEmails !== undefined)
166
+ org.orderConfirmationEmails = body.orderConfirmationEmails;
167
+ if (body.fulfilmentStrategy !== undefined)
168
+ org.fulfilmentStrategy = body.fulfilmentStrategy;
169
+ if (body.fulfilmentStrategyWarehouseOrder !== undefined) {
170
+ org.fulfilmentStrategyWarehouseOrder = body.fulfilmentStrategyWarehouseOrder;
171
+ }
172
+ // Feature 055 — validate + merge custom-field values; the org module owns
173
+ // the write, the value service only validates (Principle XIV).
174
+ if (body.customFieldValues !== undefined && customFieldValues) {
175
+ try {
176
+ org.customFieldValues = await customFieldValues.validateAndMerge('organization', org.customFieldValues ?? {}, body.customFieldValues);
177
+ }
178
+ catch (err) {
179
+ // Narrowed structurally rather than by `instanceof`: the constructor
180
+ // is a file in `custom_fields`' directory, and the guard is what
181
+ // `@endora-commerce/contracts` publishes in its place. `customFieldValueService`
182
+ // is a gated port, so this re-throws anything that is not the
183
+ // validation failure — a `ModuleDisabledError` included.
184
+ if (isCustomFieldValidationFailure(err)) {
185
+ throw new HttpError(422, ERROR_CODES.CUSTOM_FIELD_VALUE_INVALID, 'One or more custom fields are invalid.', err.errors.map((e) => ({ path: e.field, issue: e.message })));
186
+ }
187
+ throw err;
188
+ }
189
+ }
190
+ await em.flush();
191
+ await audit(request, 'organization.admin_patch', 'organization', org.id, before, serializeOrg(org));
192
+ return { data: serializeOrg(org) };
193
+ });
194
+ // ── Moderation lifecycle (feature 026) ─────────────────────────────────
195
+ // Approve / Reject / Block / Unblock. Mounted only when the moderation
196
+ // service was wired (composition root passes it in).
197
+ if (moderationService) {
198
+ app.post('/api/v1/admin/organizations/:id/approve', {
199
+ preHandler: requireAdmin('customers:manage'),
200
+ schema: { body: approveOrganizationSchema },
201
+ }, async (request) => {
202
+ const body = approveOrganizationSchema.parse(request.body);
203
+ const rid = request.headers['x-request-id'];
204
+ try {
205
+ const org = await moderationService.approve(request.params.id, {
206
+ expectedVersion: body.expectedVersion,
207
+ actorAdminUserId: resolveAdminUserId(request),
208
+ requestId: typeof rid === 'string' ? rid : null,
209
+ });
210
+ return { data: serializeOrg(org) };
211
+ }
212
+ catch (err) {
213
+ throw mapModerationError(err);
214
+ }
215
+ });
216
+ app.post('/api/v1/admin/organizations/:id/reject', {
217
+ preHandler: requireAdmin('customers:manage'),
218
+ schema: { body: rejectOrganizationSchema },
219
+ }, async (request) => {
220
+ const body = rejectOrganizationSchema.parse(request.body);
221
+ const rid = request.headers['x-request-id'];
222
+ try {
223
+ const org = await moderationService.reject(request.params.id, {
224
+ expectedVersion: body.expectedVersion,
225
+ reason: body.reason,
226
+ notifyCustomerEmail: body.notifyCustomerEmail,
227
+ actorAdminUserId: resolveAdminUserId(request),
228
+ requestId: typeof rid === 'string' ? rid : null,
229
+ });
230
+ return { data: serializeOrg(org) };
231
+ }
232
+ catch (err) {
233
+ throw mapModerationError(err);
234
+ }
235
+ });
236
+ app.post('/api/v1/admin/organizations/:id/block', {
237
+ preHandler: requireAdmin('customers:manage'),
238
+ schema: { body: blockOrganizationSchema },
239
+ }, async (request) => {
240
+ const body = blockOrganizationSchema.parse(request.body);
241
+ const rid = request.headers['x-request-id'];
242
+ try {
243
+ const org = await moderationService.block(request.params.id, {
244
+ expectedVersion: body.expectedVersion,
245
+ reason: body.reason ?? null,
246
+ actorAdminUserId: resolveAdminUserId(request),
247
+ requestId: typeof rid === 'string' ? rid : null,
248
+ });
249
+ return { data: serializeOrg(org) };
250
+ }
251
+ catch (err) {
252
+ throw mapModerationError(err);
253
+ }
254
+ });
255
+ app.post('/api/v1/admin/organizations/:id/unblock', {
256
+ preHandler: requireAdmin('customers:manage'),
257
+ schema: { body: unblockOrganizationSchema },
258
+ }, async (request) => {
259
+ const body = unblockOrganizationSchema.parse(request.body);
260
+ const rid = request.headers['x-request-id'];
261
+ try {
262
+ const org = await moderationService.unblock(request.params.id, {
263
+ expectedVersion: body.expectedVersion,
264
+ actorAdminUserId: resolveAdminUserId(request),
265
+ requestId: typeof rid === 'string' ? rid : null,
266
+ });
267
+ return { data: serializeOrg(org) };
268
+ }
269
+ catch (err) {
270
+ throw mapModerationError(err);
271
+ }
272
+ });
273
+ }
274
+ // ── Restrictions (feature 026 US4) ─────────────────────────────────────
275
+ // Per-Organization allow-lists for payment methods, delivery methods, and
276
+ // assigned warehouses. Empty allow-list ⇒ platform defaults apply.
277
+ if (restrictionService) {
278
+ app.get('/api/v1/admin/organizations/:id/restrictions', { preHandler: requireAdmin('customers:manage') }, async (request) => {
279
+ const data = await restrictionService.readAllowLists(request.params.id);
280
+ return { data };
281
+ });
282
+ app.put('/api/v1/admin/organizations/:id/restrictions', {
283
+ preHandler: requireAdmin('customers:manage'),
284
+ schema: { body: replaceOrgRestrictionsSchema },
285
+ }, async (request) => {
286
+ const body = replaceOrgRestrictionsSchema.parse(request.body);
287
+ try {
288
+ const data = await restrictionService.replaceAllowLists(request.params.id, body);
289
+ return { data };
290
+ }
291
+ catch (err) {
292
+ throw mapRestrictionError(err);
293
+ }
294
+ });
295
+ const patchRoute = (url, kind) => {
296
+ app.patch(url, {
297
+ preHandler: requireAdmin('customers:manage'),
298
+ schema: { body: patchOrgRestrictionsSchema },
299
+ }, async (request) => {
300
+ const body = patchOrgRestrictionsSchema.parse(request.body);
301
+ try {
302
+ const data = await restrictionService.patchAllowList(request.params.id, kind, body);
303
+ return { data };
304
+ }
305
+ catch (err) {
306
+ throw mapRestrictionError(err);
307
+ }
308
+ });
309
+ };
310
+ patchRoute('/api/v1/admin/organizations/:id/restrictions/payment-methods', 'payment_method');
311
+ patchRoute('/api/v1/admin/organizations/:id/restrictions/delivery-methods', 'delivery_method');
312
+ patchRoute('/api/v1/admin/organizations/:id/restrictions/warehouses', 'warehouse');
313
+ }
314
+ // ── Applicable Price Lists (feature 026 US5) ────────────────────────────
315
+ // Read-only panel showing every Price List currently applicable to the
316
+ // Organization, each tagged with the reasons it applies.
317
+ if (effectivePriceListsService) {
318
+ app.get('/api/v1/admin/organizations/:id/applicable-price-lists', { preHandler: requireAdmin('customers:manage') }, async (request) => {
319
+ const items = await effectivePriceListsService.listApplicable(request.params.id);
320
+ return { items };
321
+ });
322
+ }
323
+ // ── VAT validation (feature 026 US7) ────────────────────────────────────
324
+ // History list + trigger-now endpoints. Provider outage always degrades
325
+ // to `outcome: 'deferred'` rather than HTTP 5xx — the org save never
326
+ // fails because of a third-party hiccup.
327
+ if (taxIdValidationService) {
328
+ app.get('/api/v1/admin/organizations/:id/vat-validations', { preHandler: requireAdmin('customers:manage') }, async (request) => {
329
+ const items = await taxIdValidationService.listForOrganization(request.params.id);
330
+ return {
331
+ items: items.map((r) => ({
332
+ id: r.id,
333
+ provider: r.provider,
334
+ outcome: r.outcome,
335
+ taxIdValue: r.taxIdValue,
336
+ legalNameReturned: r.legalNameReturned ?? null,
337
+ addressReturned: r.addressReturned ?? null,
338
+ errorKind: r.errorKind ?? null,
339
+ requestedByAdminUserId: r.requestedByAdminUserId ?? null,
340
+ createdAt: r.createdAt.toISOString(),
341
+ })),
342
+ };
343
+ });
344
+ app.post('/api/v1/admin/organizations/:id/vat-validations', {
345
+ preHandler: requireAdmin('customers:manage'),
346
+ schema: { body: triggerVatValidationSchema },
347
+ }, async (request) => {
348
+ const body = triggerVatValidationSchema.parse(request.body ?? {});
349
+ const result = await taxIdValidationService.trigger(request.params.id, {
350
+ providerHint: body.providerHint,
351
+ applyAutoFill: body.applyAutoFill,
352
+ actorAdminUserId: resolveAdminUserId(request),
353
+ });
354
+ return {
355
+ id: result.record.id,
356
+ provider: result.record.provider,
357
+ outcome: result.record.outcome,
358
+ taxIdValue: result.record.taxIdValue,
359
+ legalNameReturned: result.record.legalNameReturned ?? null,
360
+ addressReturned: result.record.addressReturned ?? null,
361
+ errorKind: result.record.errorKind ?? null,
362
+ requestedByAdminUserId: result.record.requestedByAdminUserId ?? null,
363
+ createdAt: result.record.createdAt.toISOString(),
364
+ organization: result.organization,
365
+ };
366
+ });
367
+ }
368
+ app.post('/api/v1/admin/organizations/:id/members/invite', {
369
+ preHandler: requireAdmin('customers:manage'),
370
+ schema: { body: inviteMemberRequestSchema },
371
+ }, async (request, reply) => {
372
+ const body = inviteMemberRequestSchema.parse(request.body);
373
+ const result = await invitationService.invite({ organizationId: request.params.id, customerAccountId: null }, { email: body.email, ...(body.role ? { role: body.role } : {}) });
374
+ await audit(request, 'organization.invite_sent', 'organization_invitation', result.invitation.id, null, { invitationId: result.invitation.id, email: result.invitation.email });
375
+ reply.status(201);
376
+ return {
377
+ data: {
378
+ invitationId: result.invitation.id,
379
+ expiresAt: result.invitation.expiresAt.toISOString(),
380
+ },
381
+ };
382
+ });
383
+ app.post('/api/v1/admin/organizations/:id/members', {
384
+ preHandler: requireAdmin('customers:manage'),
385
+ schema: { body: adminDirectMemberRequestSchema },
386
+ }, async (request, reply) => {
387
+ const em = emFactory();
388
+ const body = adminDirectMemberRequestSchema.parse(request.body);
389
+ const org = await em.findOne(Organization, { id: request.params.id, deletedAt: null });
390
+ if (!org) {
391
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Organization not found.');
392
+ }
393
+ // Feature 051 — a personal (B2C) org is single-member; no direct add.
394
+ if (org.isPersonal) {
395
+ throw new HttpError(422, ERROR_CODES.VALIDATION_FAILED, 'A personal (individual) organization cannot have additional members.');
396
+ }
397
+ const role = body.role ?? 'regular_user';
398
+ // The duplicate check and the argon2 hash both moved to the owner's side
399
+ // of the port with the write; `create` raises the same 409
400
+ // `EMAIL_ALREADY_REGISTERED`.
401
+ const customer = await deps.customerAccountWrite.create({
402
+ organizationId: org.id,
403
+ // Handed over as typed: the write port folds it, so this surface
404
+ // cannot disagree with the one the buyer registers through.
405
+ email: body.email,
406
+ password: body.password,
407
+ firstName: body.firstName,
408
+ lastName: body.lastName,
409
+ role,
410
+ });
411
+ if (deps.eventBus) {
412
+ emitCustomerAccountCreated(deps.eventBus, customer.id, org.id);
413
+ }
414
+ await audit(request, 'customer_account.admin_create', 'customer_account', customer.id, null, { organizationId: org.id, email: customer.email, role: customer.role });
415
+ reply.status(201);
416
+ return { data: serializeMemberDetail(customer) };
417
+ });
418
+ app.patch('/api/v1/admin/organizations/:id/members/:customerAccountId/role', {
419
+ preHandler: requireAdmin('customers:manage'),
420
+ schema: { body: adminPatchMemberRoleRequestSchema },
421
+ }, async (request) => {
422
+ const body = adminPatchMemberRoleRequestSchema.parse(request.body);
423
+ const member = await deps.customerAccountRead.findInOrganization(request.params.customerAccountId, request.params.id, { activeOnly: true });
424
+ if (!member) {
425
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Member not found.');
426
+ }
427
+ assertVersion(member.updatedAt, body.expectedUpdatedAt);
428
+ const before = serializeMemberDetail(member);
429
+ const updated = await roleService.changeRole(request.params.id, member.id, body.role);
430
+ await audit(request, 'customer_account.admin_role_change', 'customer_account', updated.id, before, serializeMemberDetail(updated));
431
+ return { data: serializeMemberDetail(updated) };
432
+ });
433
+ // Feature 056 (T032) — toggle the customer-side roll-up capability. When
434
+ // enabled, this member's login sees/acts across its organization's subtree.
435
+ app.patch('/api/v1/admin/organizations/:id/members/:customerAccountId/rollup', {
436
+ preHandler: requireAdmin('customers:manage'),
437
+ schema: { body: adminSetMemberRollupRequestSchema },
438
+ }, async (request) => {
439
+ const body = adminSetMemberRollupRequestSchema.parse(request.body);
440
+ const member = await deps.customerAccountRead.findInOrganization(request.params.customerAccountId, request.params.id, { activeOnly: true });
441
+ if (!member) {
442
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Member not found.');
443
+ }
444
+ assertVersion(member.updatedAt, body.expectedUpdatedAt);
445
+ const before = serializeMemberDetail(member);
446
+ const updated = await deps.customerAccountWrite.setSubtreeRollup(member.id, body.subtreeRollupEnabled);
447
+ await audit(request, 'customer_account.admin_rollup_change', 'customer_account', updated.id, before, serializeMemberDetail(updated));
448
+ return { data: serializeMemberDetail(updated) };
449
+ });
450
+ app.patch('/api/v1/admin/organizations/:id/members/:customerAccountId', {
451
+ preHandler: requireAdmin('customers:manage'),
452
+ schema: { body: adminPatchMemberProfileRequestSchema },
453
+ }, async (request) => {
454
+ const body = adminPatchMemberProfileRequestSchema.parse(request.body);
455
+ const member = await deps.customerAccountRead.findInOrganization(request.params.customerAccountId, request.params.id, { activeOnly: true });
456
+ if (!member) {
457
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Member not found.');
458
+ }
459
+ assertVersion(member.updatedAt, body.expectedUpdatedAt);
460
+ const before = serializeMemberDetail(member);
461
+ // The uniqueness check, the lower-casing and the "a changed address is
462
+ // no longer verified" rule all went with the write: they are properties
463
+ // of the column, not of this screen.
464
+ const updated = await deps.customerAccountWrite.updateProfile(member.id, {
465
+ ...(body.email !== undefined ? { email: body.email } : {}),
466
+ ...(body.firstName !== undefined ? { firstName: body.firstName } : {}),
467
+ ...(body.lastName !== undefined ? { lastName: body.lastName } : {}),
468
+ });
469
+ await audit(request, 'customer_account.admin_profile_update', 'customer_account', updated.id, before, serializeMemberDetail(updated));
470
+ return { data: serializeMemberDetail(updated) };
471
+ });
472
+ app.delete('/api/v1/admin/organizations/:id/members/:customerAccountId', { preHandler: requireAdmin('customers:manage') }, async (request, reply) => {
473
+ const member = await deps.customerAccountRead.findInOrganization(request.params.customerAccountId, request.params.id, { activeOnly: true });
474
+ if (!member) {
475
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Member not found.');
476
+ }
477
+ const before = serializeMemberDetail(member);
478
+ await roleService.removeMember(request.params.id, member.id);
479
+ await audit(request, 'customer_account.admin_remove', 'customer_account', member.id, before, null);
480
+ return reply.status(204).send();
481
+ });
482
+ app.post('/api/v1/admin/organizations/:id/recover-admin-access', {
483
+ preHandler: requireAdmin('customers:manage'),
484
+ schema: { body: adminRecoverOrgAccessRequestSchema },
485
+ }, async (request) => {
486
+ const body = adminRecoverOrgAccessRequestSchema.parse(request.body);
487
+ const member = await deps.customerAccountRead.findInOrganization(body.promoteCustomerAccountId, request.params.id, { activeOnly: true });
488
+ if (!member) {
489
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Member not found.');
490
+ }
491
+ const before = serializeMemberDetail(member);
492
+ // Deliberately not `roleService.changeRole`: this is the break-glass
493
+ // path an operator reaches *because* the organisation has no admin left,
494
+ // and that method writes its own `customer_account.change_role` audit
495
+ // row, which would double-count the one below.
496
+ const updated = await deps.customerAccountWrite.promoteToOrganizationAdmin(member.id);
497
+ await audit(request, 'organization.break_glass_admin_promoted', 'customer_account', updated.id, before, serializeMemberDetail(updated));
498
+ return { data: serializeMemberDetail(updated) };
499
+ });
500
+ // ── Hierarchy (feature 056 US1) ─────────────────────────────────────────
501
+ // Assign/move parent (Command Bus), read subtree/ancestors, and the
502
+ // delete-with-children guard. Mounted only when the tree service + command
503
+ // bus are wired.
504
+ if (deps.treeService && deps.commandBus) {
505
+ const treeService = deps.treeService;
506
+ const commandBus = deps.commandBus;
507
+ // Assign / move / detach parent. `parentId = null` detaches to a root.
508
+ app.post('/api/v1/admin/organizations/:id/parent', {
509
+ preHandler: requireAdmin('customers:manage'),
510
+ schema: { body: assignOrganizationParentRequestSchema },
511
+ }, async (request) => {
512
+ // D-260 — **a write that changes the acting principal's own authority
513
+ // cannot be authorised by that authority.** Parentage is the tenancy
514
+ // graph: feature 056's roll-up expands each assigned node to its
515
+ // subtree (`sales-rep-assignment-service.ts:110-141`), so a scoped
516
+ // admin holding `customers:manage` could hang a foreign organization
517
+ // under one it already holds and widen its own
518
+ // `allowedOrganizationIds` — measured at 200, `[orgA] → [orgA, orgB]`.
519
+ // `Organization` is `@GlobalEntity` and correctly so, so no filter
520
+ // narrowed the read and no classification could. Same idiom, and the
521
+ // same reason, as the credit-inheritance-mode handler below.
522
+ const ctx = getTenantContext();
523
+ if (!ctx || ctx.mode !== 'all') {
524
+ throw new HttpError(403, ERROR_CODES.FORBIDDEN, 'Only a platform administrator can change an organization parent.');
525
+ }
526
+ const body = assignOrganizationParentRequestSchema.parse(request.body);
527
+ const em = emFactory();
528
+ const org = await em.findOne(Organization, { id: request.params.id, deletedAt: null });
529
+ if (!org) {
530
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Organization not found.');
531
+ }
532
+ // A distinct command action for a clean audit label: first-assign /
533
+ // detach ⇒ set_parent; re-parent of an existing child ⇒ move.
534
+ const input = { organizationId: request.params.id, parentId: body.parentId };
535
+ const wasChild = (org.parentId ?? null) !== null;
536
+ const command = wasChild && body.parentId !== null
537
+ ? makeMoveCommand({ tree: treeService }, input)
538
+ : makeSetParentCommand({ tree: treeService }, input);
539
+ const updated = await commandBus.run(command);
540
+ return { data: serializeOrg(updated) };
541
+ });
542
+ // Read the subtree (descendants incl. self), pre-order.
543
+ app.get('/api/v1/admin/organizations/:id/subtree', { preHandler: customersRead }, async (request) => {
544
+ const em = emFactory();
545
+ const org = await em.findOne(Organization, { id: request.params.id, deletedAt: null });
546
+ if (!org) {
547
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Organization not found.');
548
+ }
549
+ const items = await treeService.subtreeNodes(request.params.id);
550
+ return { items };
551
+ });
552
+ // Read the ancestor chain, nearest-first.
553
+ app.get('/api/v1/admin/organizations/:id/ancestors', { preHandler: customersRead }, async (request) => {
554
+ const em = emFactory();
555
+ const org = await em.findOne(Organization, { id: request.params.id, deletedAt: null });
556
+ if (!org) {
557
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Organization not found.');
558
+ }
559
+ const items = await treeService.ancestorNodes(request.params.id);
560
+ return { items };
561
+ });
562
+ // Set the per-org credit-inheritance mode (US3). Platform-admin only — a
563
+ // scoped / roll-up actor is rejected 403 (money-behavior switch, R6).
564
+ app.put('/api/v1/admin/organizations/:id/credit-inheritance-mode', {
565
+ preHandler: requireAdmin('customers:manage'),
566
+ schema: { body: setCreditInheritanceModeRequestSchema },
567
+ }, async (request) => {
568
+ const ctx = getTenantContext();
569
+ if (!ctx || ctx.mode !== 'all') {
570
+ // Only a platform admin (unrestricted scope) may change credit behavior.
571
+ throw new HttpError(403, ERROR_CODES.FORBIDDEN, 'Only a platform administrator can change an organization credit-inheritance mode.');
572
+ }
573
+ const body = setCreditInheritanceModeRequestSchema.parse(request.body);
574
+ const em = emFactory();
575
+ const org = await em.findOne(Organization, { id: request.params.id, deletedAt: null });
576
+ if (!org) {
577
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Organization not found.');
578
+ }
579
+ const updated = await commandBus.run(makeSetCreditModeCommand({ organizationId: request.params.id, mode: body.mode }));
580
+ return { data: serializeOrg(updated) };
581
+ });
582
+ // Delete an organization — blocked (409 has_children) while any child
583
+ // exists (FR-010). Backed by the DB `parent_id ON DELETE RESTRICT`.
584
+ app.delete('/api/v1/admin/organizations/:id', { preHandler: requireAdmin('customers:manage') }, async (request, reply) => {
585
+ const em = emFactory();
586
+ const org = await em.findOne(Organization, { id: request.params.id, deletedAt: null });
587
+ if (!org) {
588
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Organization not found.');
589
+ }
590
+ const childCount = await em.count(Organization, {
591
+ parentId: request.params.id,
592
+ deletedAt: null,
593
+ });
594
+ if (childCount > 0) {
595
+ throw hasChildrenRefusal();
596
+ }
597
+ const before = serializeOrg(org);
598
+ org.deletedAt = new Date();
599
+ await em.flush();
600
+ await audit(request, 'organization.delete', 'organization', org.id, before, null);
601
+ return reply.status(204).send();
602
+ });
603
+ }
604
+ }
605
+ function serializeOrg(o) {
606
+ return {
607
+ id: o.id,
608
+ name: o.name,
609
+ legalName: o.legalName ?? null,
610
+ taxId: o.taxId,
611
+ status: o.status,
612
+ vatStatus: o.vatStatus,
613
+ isPersonal: o.isPersonal,
614
+ registeredAddress: o.registeredAddress,
615
+ orderConfirmationEmails: o.orderConfirmationEmails ?? [],
616
+ fulfilmentStrategy: o.fulfilmentStrategy ?? null,
617
+ fulfilmentStrategyWarehouseOrder: o.fulfilmentStrategyWarehouseOrder ?? null,
618
+ // Feature 056 — hierarchy.
619
+ parentId: o.parentId ?? null,
620
+ path: o.path,
621
+ creditInheritanceMode: o.creditInheritanceMode ?? null,
622
+ version: o.version,
623
+ blockedReason: o.blockedReason ?? null,
624
+ blockedAt: o.blockedAt?.toISOString() ?? null,
625
+ rejectedReason: o.rejectedReason ?? null,
626
+ rejectedAt: o.rejectedAt?.toISOString() ?? null,
627
+ approvedAt: o.approvedAt?.toISOString() ?? null,
628
+ approvedByAdminUserId: o.approvedByAdminUserId ?? null,
629
+ vatValidation: {
630
+ outcome: o.vatValidationOutcome ?? null,
631
+ provider: o.vatValidationProvider ?? null,
632
+ validatedAt: o.vatValidatedAt?.toISOString() ?? null,
633
+ },
634
+ customFieldValues: o.customFieldValues ?? {},
635
+ createdAt: o.createdAt.toISOString(),
636
+ updatedAt: o.updatedAt.toISOString(),
637
+ };
638
+ }
639
+ function serializeMemberDetail(m) {
640
+ return {
641
+ id: m.id,
642
+ email: m.email,
643
+ firstName: m.firstName,
644
+ lastName: m.lastName,
645
+ role: m.role,
646
+ subtreeRollupEnabled: m.subtreeRollupEnabled,
647
+ emailVerifiedAt: m.emailVerifiedAt?.toISOString() ?? null,
648
+ twoFactorEnabled: m.twoFactorEnabled,
649
+ lastLoginAt: m.lastLoginAt?.toISOString() ?? null,
650
+ createdAt: m.createdAt.toISOString(),
651
+ updatedAt: m.updatedAt.toISOString(),
652
+ };
653
+ }
654
+ /**
655
+ * Reads the admin user id from whichever actor decoration is available.
656
+ * Production wiring decorates `request.actor` via the auth plugin; the
657
+ * test harness decorates `request.testActor` via `registerTestAuth`.
658
+ * Falls back to `null` when no admin actor is present (e.g., the auth
659
+ * gate already returned 401, or a future surface invokes this for a
660
+ * system actor).
661
+ */
662
+ function resolveAdminUserId(request) {
663
+ const testActor = request.testActor;
664
+ if (testActor && testActor.kind === 'admin' && testActor.adminUserId) {
665
+ return testActor.adminUserId;
666
+ }
667
+ const actor = request.actor;
668
+ if (actor && actor.kind === 'admin' && actor.adminUserId) {
669
+ return actor.adminUserId;
670
+ }
671
+ return null;
672
+ }
673
+ /**
674
+ * Maps the domain errors raised by OrganizationModerationService to their
675
+ * HTTP equivalents. 409 for version mismatch (with `currentVersion` in the
676
+ * body), 404 for missing org, 422 for status-guard violations.
677
+ */
678
+ /**
679
+ * Maps OrganizationRestrictionService errors to HTTP. Same 409 contract as
680
+ * the moderation endpoints (the body carries `currentVersion`).
681
+ */
682
+ function mapRestrictionError(err) {
683
+ if (err instanceof RestrictionVersionMismatchError) {
684
+ return new HttpError(409, ERROR_CODES.VERSION_CONFLICT, 'Organization was modified by another request. Refresh and retry.', {
685
+ code: 'organization_version_mismatch',
686
+ currentVersion: err.currentVersion,
687
+ });
688
+ }
689
+ if (err instanceof HttpError)
690
+ return err;
691
+ return new HttpError(500, ERROR_CODES.INTERNAL, err instanceof Error ? err.message : 'Internal error.');
692
+ }
693
+ function mapModerationError(err) {
694
+ if (err instanceof OrganizationVersionMismatchError) {
695
+ return new HttpError(409, ERROR_CODES.VERSION_CONFLICT, 'Organization was modified by another request. Refresh and retry.', {
696
+ code: 'organization_version_mismatch',
697
+ currentVersion: err.currentVersion,
698
+ });
699
+ }
700
+ if (err instanceof OrganizationStatusGuardError) {
701
+ return new HttpError(422, ERROR_CODES.VALIDATION_FAILED, `Organization is in status '${err.currentStatus}', cannot perform an action that requires '${err.requiredStatus}'.`, { currentStatus: err.currentStatus, requiredStatus: err.requiredStatus });
702
+ }
703
+ if (err instanceof OrganizationNotFoundError) {
704
+ return new HttpError(404, ERROR_CODES.NOT_FOUND, 'Organization not found.');
705
+ }
706
+ if (err instanceof HttpError)
707
+ return err;
708
+ return new HttpError(500, ERROR_CODES.INTERNAL, err instanceof Error ? err.message : 'Internal error.');
709
+ }
710
+ void null;
711
+ //# sourceMappingURL=routes.admin.js.map