@endora-commerce/mod-customers 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 (115) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +60 -0
  3. package/dist/admin/index.d.ts +26 -0
  4. package/dist/admin/index.d.ts.map +1 -0
  5. package/dist/admin/index.js +68 -0
  6. package/dist/admin/index.js.map +1 -0
  7. package/dist/admin/pages/CustomerDetail.d.ts +9 -0
  8. package/dist/admin/pages/CustomerDetail.d.ts.map +1 -0
  9. package/dist/admin/pages/CustomerDetail.js +72 -0
  10. package/dist/admin/pages/CustomerDetail.js.map +1 -0
  11. package/dist/admin/pages/CustomersList.d.ts +9 -0
  12. package/dist/admin/pages/CustomersList.d.ts.map +1 -0
  13. package/dist/admin/pages/CustomersList.js +115 -0
  14. package/dist/admin/pages/CustomersList.js.map +1 -0
  15. package/dist/admin/pages/OnlineCustomers.d.ts +9 -0
  16. package/dist/admin/pages/OnlineCustomers.d.ts.map +1 -0
  17. package/dist/admin/pages/OnlineCustomers.js +77 -0
  18. package/dist/admin/pages/OnlineCustomers.js.map +1 -0
  19. package/dist/admin/panels/HistoryPanels.d.ts +11 -0
  20. package/dist/admin/panels/HistoryPanels.d.ts.map +1 -0
  21. package/dist/admin/panels/HistoryPanels.js +64 -0
  22. package/dist/admin/panels/HistoryPanels.js.map +1 -0
  23. package/dist/admin/panels/ManagementPanels.d.ts +14 -0
  24. package/dist/admin/panels/ManagementPanels.d.ts.map +1 -0
  25. package/dist/admin/panels/ManagementPanels.js +93 -0
  26. package/dist/admin/panels/ManagementPanels.js.map +1 -0
  27. package/dist/backend/entities/customer-address.entity.d.ts +25 -0
  28. package/dist/backend/entities/customer-address.entity.d.ts.map +1 -0
  29. package/dist/backend/entities/customer-address.entity.js +95 -0
  30. package/dist/backend/entities/customer-address.entity.js.map +1 -0
  31. package/dist/backend/index.d.ts +91 -0
  32. package/dist/backend/index.d.ts.map +1 -0
  33. package/dist/backend/index.js +201 -0
  34. package/dist/backend/index.js.map +1 -0
  35. package/dist/backend/plugin.d.ts +168 -0
  36. package/dist/backend/plugin.d.ts.map +1 -0
  37. package/dist/backend/plugin.js +83 -0
  38. package/dist/backend/plugin.js.map +1 -0
  39. package/dist/backend/routes.admin.d.ts +70 -0
  40. package/dist/backend/routes.admin.d.ts.map +1 -0
  41. package/dist/backend/routes.admin.js +313 -0
  42. package/dist/backend/routes.admin.js.map +1 -0
  43. package/dist/backend/routes.register.d.ts +12 -0
  44. package/dist/backend/routes.register.d.ts.map +1 -0
  45. package/dist/backend/routes.register.js +40 -0
  46. package/dist/backend/routes.register.js.map +1 -0
  47. package/dist/backend/routes.self.d.ts +46 -0
  48. package/dist/backend/routes.self.d.ts.map +1 -0
  49. package/dist/backend/routes.self.js +145 -0
  50. package/dist/backend/routes.self.js.map +1 -0
  51. package/dist/backend/serializers.d.ts +13 -0
  52. package/dist/backend/serializers.d.ts.map +1 -0
  53. package/dist/backend/serializers.js +39 -0
  54. package/dist/backend/serializers.js.map +1 -0
  55. package/dist/backend/services/customer-address-read-port.d.ts +29 -0
  56. package/dist/backend/services/customer-address-read-port.d.ts.map +1 -0
  57. package/dist/backend/services/customer-address-read-port.js +53 -0
  58. package/dist/backend/services/customer-address-read-port.js.map +1 -0
  59. package/dist/backend/services/customer-address-service.d.ts +94 -0
  60. package/dist/backend/services/customer-address-service.d.ts.map +1 -0
  61. package/dist/backend/services/customer-address-service.js +176 -0
  62. package/dist/backend/services/customer-address-service.js.map +1 -0
  63. package/dist/backend/services/customer-admin-query-service.d.ts +57 -0
  64. package/dist/backend/services/customer-admin-query-service.d.ts.map +1 -0
  65. package/dist/backend/services/customer-admin-query-service.js +97 -0
  66. package/dist/backend/services/customer-admin-query-service.js.map +1 -0
  67. package/dist/backend/services/customer-authority-service.d.ts +46 -0
  68. package/dist/backend/services/customer-authority-service.d.ts.map +1 -0
  69. package/dist/backend/services/customer-authority-service.js +42 -0
  70. package/dist/backend/services/customer-authority-service.js.map +1 -0
  71. package/dist/backend/services/customer-defaults-service.d.ts +48 -0
  72. package/dist/backend/services/customer-defaults-service.d.ts.map +1 -0
  73. package/dist/backend/services/customer-defaults-service.js +71 -0
  74. package/dist/backend/services/customer-defaults-service.js.map +1 -0
  75. package/dist/backend/services/customer-deletion-service.d.ts +39 -0
  76. package/dist/backend/services/customer-deletion-service.d.ts.map +1 -0
  77. package/dist/backend/services/customer-deletion-service.js +94 -0
  78. package/dist/backend/services/customer-deletion-service.js.map +1 -0
  79. package/dist/backend/services/customer-moderation-service.d.ts +58 -0
  80. package/dist/backend/services/customer-moderation-service.d.ts.map +1 -0
  81. package/dist/backend/services/customer-moderation-service.js +70 -0
  82. package/dist/backend/services/customer-moderation-service.js.map +1 -0
  83. package/dist/backend/services/customer-org-assignment-service.d.ts +52 -0
  84. package/dist/backend/services/customer-org-assignment-service.d.ts.map +1 -0
  85. package/dist/backend/services/customer-org-assignment-service.js +106 -0
  86. package/dist/backend/services/customer-org-assignment-service.js.map +1 -0
  87. package/dist/backend/services/customer-presence-service.d.ts +44 -0
  88. package/dist/backend/services/customer-presence-service.d.ts.map +1 -0
  89. package/dist/backend/services/customer-presence-service.js +47 -0
  90. package/dist/backend/services/customer-presence-service.js.map +1 -0
  91. package/dist/backend/services/customer-registration-service.d.ts +56 -0
  92. package/dist/backend/services/customer-registration-service.d.ts.map +1 -0
  93. package/dist/backend/services/customer-registration-service.js +43 -0
  94. package/dist/backend/services/customer-registration-service.js.map +1 -0
  95. package/dist/backend/workers/anonymization-sweep-worker.d.ts +15 -0
  96. package/dist/backend/workers/anonymization-sweep-worker.d.ts.map +1 -0
  97. package/dist/backend/workers/anonymization-sweep-worker.js +20 -0
  98. package/dist/backend/workers/anonymization-sweep-worker.js.map +1 -0
  99. package/dist/manifest.d.ts +181 -0
  100. package/dist/manifest.d.ts.map +1 -0
  101. package/dist/manifest.js +192 -0
  102. package/dist/manifest.js.map +1 -0
  103. package/dist/migrations/20260611T140404_customers_customer_addresses_init.d.ts +14 -0
  104. package/dist/migrations/20260611T140404_customers_customer_addresses_init.d.ts.map +1 -0
  105. package/dist/migrations/20260611T140404_customers_customer_addresses_init.js +40 -0
  106. package/dist/migrations/20260611T140404_customers_customer_addresses_init.js.map +1 -0
  107. package/dist/migrations/index.d.ts +27 -0
  108. package/dist/migrations/index.d.ts.map +1 -0
  109. package/dist/migrations/index.js +29 -0
  110. package/dist/migrations/index.js.map +1 -0
  111. package/docs/customers.md +108 -0
  112. package/i18n/en.json +94 -0
  113. package/i18n/pl.json +94 -0
  114. package/package.json +101 -0
  115. package/tailwind.css +14 -0
@@ -0,0 +1,42 @@
1
+ /**
2
+ * CustomerAuthorityService — encodes WHICH staff member may act on a given
3
+ * Customer (block / unblock / delete / org-assign), per the clarified rule
4
+ * (spec FR-037, research §R2):
5
+ *
6
+ * - A Platform Administrator may act on any customer. Whether the actor IS
7
+ * a platform admin is decided by the caller (route layer) and passed in —
8
+ * mirroring `SalesRepAssignmentService.canSeeOrganization`, which checks
9
+ * the assignment table only.
10
+ * - Only the Salesperson responsible for that
11
+ * customer's Organization may act — "responsible" = assigned via
12
+ * `OrganizationSalesRepAssignment`, including the unassigned-org fallback
13
+ * (an org with zero assigned reps is visible to every Salesperson).
14
+ *
15
+ * **D-178 removed a third rule** — *"for a standalone (org-less) customer, any
16
+ * Salesperson may act"* — because there is no such customer:
17
+ * `customer_accounts.organization_id` is `NOT NULL` and an individual is backed
18
+ * by a personal organisation. The outcome is unchanged rather than merely
19
+ * unreachable: `SalesRepAssignmentService` refuses to assign a rep to a personal
20
+ * organisation, so such an organisation always has zero assigned reps and the
21
+ * unassigned-org fallback answers `true` for every Salesperson — which is what
22
+ * the deleted rule said, arrived at through the assignment table instead of
23
+ * through the absence of a tenant.
24
+ *
25
+ * The route layer is responsible for the coarse permission gate
26
+ * (`requireAdmin('customers:manage')`) before this finer check runs.
27
+ */
28
+ export class CustomerAuthorityService {
29
+ salesReps;
30
+ constructor(salesReps) {
31
+ this.salesReps = salesReps;
32
+ }
33
+ /**
34
+ * True when the acting staff member is authorized to manage the customer.
35
+ */
36
+ async canManageCustomer(input) {
37
+ if (input.isPlatformAdmin)
38
+ return true;
39
+ return this.salesReps.canSeeOrganization(input.adminUserId, input.customerOrganizationId);
40
+ }
41
+ }
42
+ //# sourceMappingURL=customer-authority-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"customer-authority-service.js","sourceRoot":"","sources":["../../../src/backend/services/customer-authority-service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAcH,MAAM,OAAO,wBAAwB;IACN;IAA7B,YAA6B,SAA6B;QAA7B,cAAS,GAAT,SAAS,CAAoB;IAAG,CAAC;IAE9D;;OAEG;IACH,KAAK,CAAC,iBAAiB,CAAC,KAA6B;QACnD,IAAI,KAAK,CAAC,eAAe;YAAE,OAAO,IAAI,CAAC;QACvC,OAAO,IAAI,CAAC,SAAS,CAAC,kBAAkB,CACtC,KAAK,CAAC,WAAW,EACjB,KAAK,CAAC,sBAAsB,CAC7B,CAAC;IACJ,CAAC;CACF"}
@@ -0,0 +1,48 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql';
2
+ import type { CustomerDefaults, DefaultPreferencePort, PreferenceAuditContext } from '@endora-commerce/contracts';
3
+ import type { CustomerAddressService } from './customer-address-service.js';
4
+ /**
5
+ * CustomerDefaultsService — the customer's checkout defaults (feature 040, US2).
6
+ *
7
+ * Split source of truth (research §R5, reconciled with the existing schema):
8
+ * - default **payment / delivery method** → the quick_order
9
+ * `QuickOrderDefaultPreference` (scope `customer`), which checkout already
10
+ * consumes. Reached through `defaultPreferencePort` (issue #216). This
11
+ * module used to build a **second instance** of `quick_order`'s
12
+ * `DefaultPreferenceService` out of an import of that module's directory,
13
+ * which is what kept the port — published for exactly this consumer —
14
+ * unreached, and what blocked converting that service's own entity reads
15
+ * to ports.
16
+ * - default **billing / shipping address** → the `isDefault` flag on the
17
+ * customer's personal `customer_addresses` rows. (The preference table's
18
+ * address columns FK to the org `addresses` table, so personal addresses
19
+ * cannot be stored there.)
20
+ */
21
+ export interface CustomerDefaultsPatch {
22
+ paymentMethodId?: string | null | undefined;
23
+ deliveryMethodId?: string | null | undefined;
24
+ billingAddressId?: string | null | undefined;
25
+ shippingAddressId?: string | null | undefined;
26
+ }
27
+ export declare class CustomerDefaultsService {
28
+ private readonly emFactory;
29
+ private readonly preferences;
30
+ private readonly addresses;
31
+ constructor(emFactory: () => EntityManager, preferences: DefaultPreferencePort, addresses: CustomerAddressService);
32
+ getForCustomer(customerAccountId: string): Promise<CustomerDefaults>;
33
+ /** Self-service update — the customer manages their own customer-scoped defaults. */
34
+ setForCustomer(customerAccountId: string, patch: CustomerDefaultsPatch, audit: PreferenceAuditContext): Promise<CustomerDefaults>;
35
+ /**
36
+ * The widening is `CustomerAddressService`'s, for the same reason and on the
37
+ * same terms — see `#forAuthorisedCustomer` there. This method has two
38
+ * callers and both establish the authority before reaching it: the self route
39
+ * passes the caller's **own** account id, and the admin detail screen
40
+ * (`CustomerAdminQueryService.getDetail`) has already resolved the account
41
+ * through `customer_accounts`' `@OrgScoped` read model and returned `null` if
42
+ * it was out of scope. The statement pins `customerAccountId`, so widening
43
+ * the filter widens the authority by nothing. It goes when feature 087 gives
44
+ * `customer_addresses` an `organization_id` of its own.
45
+ */
46
+ private defaultAddressId;
47
+ }
48
+ //# sourceMappingURL=customer-defaults-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"customer-defaults-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/customer-defaults-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,KAAK,EACV,gBAAgB,EAChB,qBAAqB,EACrB,sBAAsB,EACvB,MAAM,4BAA4B,CAAC;AAEpC,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAC;AAG5E;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,WAAW,qBAAqB;IACpC,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IAC5C,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IAC7C,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IAC7C,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;CAC/C;AAED,qBAAa,uBAAuB;IAEhC,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC5B,OAAO,CAAC,QAAQ,CAAC,SAAS;gBAFT,SAAS,EAAE,MAAM,aAAa,EAC9B,WAAW,EAAE,qBAAqB,EAClC,SAAS,EAAE,sBAAsB;IAG9C,cAAc,CAAC,iBAAiB,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAc1E,qFAAqF;IAC/E,cAAc,CAClB,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,qBAAqB,EAC5B,KAAK,EAAE,sBAAsB,GAC5B,OAAO,CAAC,gBAAgB,CAAC;IA4B5B;;;;;;;;;;OAUG;YACW,gBAAgB;CAiB/B"}
@@ -0,0 +1,71 @@
1
+ import { withSystemScope } from '@endora-commerce/platform/tenancy';
2
+ import { CustomerAddress } from '../entities/customer-address.entity.js';
3
+ export class CustomerDefaultsService {
4
+ emFactory;
5
+ preferences;
6
+ addresses;
7
+ constructor(emFactory, preferences, addresses) {
8
+ this.emFactory = emFactory;
9
+ this.preferences = preferences;
10
+ this.addresses = addresses;
11
+ }
12
+ async getForCustomer(customerAccountId) {
13
+ const methods = await this.preferences.resolveForCustomer(customerAccountId);
14
+ const [billingAddressId, shippingAddressId] = await Promise.all([
15
+ this.defaultAddressId(customerAccountId, 'billing'),
16
+ this.defaultAddressId(customerAccountId, 'delivery'),
17
+ ]);
18
+ return {
19
+ paymentMethodId: methods.paymentMethodId,
20
+ deliveryMethodId: methods.deliveryMethodId,
21
+ billingAddressId,
22
+ shippingAddressId,
23
+ };
24
+ }
25
+ /** Self-service update — the customer manages their own customer-scoped defaults. */
26
+ async setForCustomer(customerAccountId, patch, audit) {
27
+ // Default payment / delivery method → preference store.
28
+ if (patch.paymentMethodId !== undefined || patch.deliveryMethodId !== undefined) {
29
+ await this.preferences.upsert({ kind: 'customer', customerAccountId }, {
30
+ scope: 'customer',
31
+ scopeId: customerAccountId,
32
+ ...(patch.paymentMethodId !== undefined
33
+ ? { defaultPaymentMethodId: patch.paymentMethodId }
34
+ : {}),
35
+ ...(patch.deliveryMethodId !== undefined
36
+ ? { defaultDeliveryMethodId: patch.deliveryMethodId }
37
+ : {}),
38
+ }, audit);
39
+ }
40
+ // Default billing / shipping address → the personal address book.
41
+ if (patch.billingAddressId != null) {
42
+ await this.addresses.setDefault(customerAccountId, patch.billingAddressId);
43
+ }
44
+ if (patch.shippingAddressId != null) {
45
+ await this.addresses.setDefault(customerAccountId, patch.shippingAddressId);
46
+ }
47
+ return this.getForCustomer(customerAccountId);
48
+ }
49
+ /**
50
+ * The widening is `CustomerAddressService`'s, for the same reason and on the
51
+ * same terms — see `#forAuthorisedCustomer` there. This method has two
52
+ * callers and both establish the authority before reaching it: the self route
53
+ * passes the caller's **own** account id, and the admin detail screen
54
+ * (`CustomerAdminQueryService.getDetail`) has already resolved the account
55
+ * through `customer_accounts`' `@OrgScoped` read model and returned `null` if
56
+ * it was out of scope. The statement pins `customerAccountId`, so widening
57
+ * the filter widens the authority by nothing. It goes when feature 087 gives
58
+ * `customer_addresses` an `organization_id` of its own.
59
+ */
60
+ async defaultAddressId(customerAccountId, kind) {
61
+ const em = this.emFactory();
62
+ const row = await withSystemScope(`customers: default ${kind} address of customer account ${customerAccountId}, authorised by its own @OrgScoped account read`, () => em.findOne(CustomerAddress, {
63
+ customerAccountId,
64
+ kind,
65
+ isDefault: true,
66
+ deletedAt: null,
67
+ }));
68
+ return row?.id ?? null;
69
+ }
70
+ }
71
+ //# sourceMappingURL=customer-defaults-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"customer-defaults-service.js","sourceRoot":"","sources":["../../../src/backend/services/customer-defaults-service.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,eAAe,EAAE,MAAM,mCAAmC,CAAC;AAEpE,OAAO,EAAE,eAAe,EAAE,MAAM,wCAAwC,CAAC;AA0BzE,MAAM,OAAO,uBAAuB;IAEf;IACA;IACA;IAHnB,YACmB,SAA8B,EAC9B,WAAkC,EAClC,SAAiC;QAFjC,cAAS,GAAT,SAAS,CAAqB;QAC9B,gBAAW,GAAX,WAAW,CAAuB;QAClC,cAAS,GAAT,SAAS,CAAwB;IACjD,CAAC;IAEJ,KAAK,CAAC,cAAc,CAAC,iBAAyB;QAC5C,MAAM,OAAO,GAAG,MAAM,IAAI,CAAC,WAAW,CAAC,kBAAkB,CAAC,iBAAiB,CAAC,CAAC;QAC7E,MAAM,CAAC,gBAAgB,EAAE,iBAAiB,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;YAC9D,IAAI,CAAC,gBAAgB,CAAC,iBAAiB,EAAE,SAAS,CAAC;YACnD,IAAI,CAAC,gBAAgB,CAAC,iBAAiB,EAAE,UAAU,CAAC;SACrD,CAAC,CAAC;QACH,OAAO;YACL,eAAe,EAAE,OAAO,CAAC,eAAe;YACxC,gBAAgB,EAAE,OAAO,CAAC,gBAAgB;YAC1C,gBAAgB;YAChB,iBAAiB;SAClB,CAAC;IACJ,CAAC;IAED,qFAAqF;IACrF,KAAK,CAAC,cAAc,CAClB,iBAAyB,EACzB,KAA4B,EAC5B,KAA6B;QAE7B,wDAAwD;QACxD,IAAI,KAAK,CAAC,eAAe,KAAK,SAAS,IAAI,KAAK,CAAC,gBAAgB,KAAK,SAAS,EAAE,CAAC;YAChF,MAAM,IAAI,CAAC,WAAW,CAAC,MAAM,CAC3B,EAAE,IAAI,EAAE,UAAU,EAAE,iBAAiB,EAAE,EACvC;gBACE,KAAK,EAAE,UAAU;gBACjB,OAAO,EAAE,iBAAiB;gBAC1B,GAAG,CAAC,KAAK,CAAC,eAAe,KAAK,SAAS;oBACrC,CAAC,CAAC,EAAE,sBAAsB,EAAE,KAAK,CAAC,eAAe,EAAE;oBACnD,CAAC,CAAC,EAAE,CAAC;gBACP,GAAG,CAAC,KAAK,CAAC,gBAAgB,KAAK,SAAS;oBACtC,CAAC,CAAC,EAAE,uBAAuB,EAAE,KAAK,CAAC,gBAAgB,EAAE;oBACrD,CAAC,CAAC,EAAE,CAAC;aACR,EACD,KAAK,CACN,CAAC;QACJ,CAAC;QACD,kEAAkE;QAClE,IAAI,KAAK,CAAC,gBAAgB,IAAI,IAAI,EAAE,CAAC;YACnC,MAAM,IAAI,CAAC,SAAS,CAAC,UAAU,CAAC,iBAAiB,EAAE,KAAK,CAAC,gBAAgB,CAAC,CAAC;QAC7E,CAAC;QACD,IAAI,KAAK,CAAC,iBAAiB,IAAI,IAAI,EAAE,CAAC;YACpC,MAAM,IAAI,CAAC,SAAS,CAAC,UAAU,CAAC,iBAAiB,EAAE,KAAK,CAAC,iBAAiB,CAAC,CAAC;QAC9E,CAAC;QACD,OAAO,IAAI,CAAC,cAAc,CAAC,iBAAiB,CAAC,CAAC;IAChD,CAAC;IAED;;;;;;;;;;OAUG;IACK,KAAK,CAAC,gBAAgB,CAC5B,iBAAyB,EACzB,IAA4B;QAE5B,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;QAC5B,MAAM,GAAG,GAAG,MAAM,eAAe,CAC/B,sBAAsB,IAAI,gCAAgC,iBAAiB,iDAAiD,EAC5H,GAAG,EAAE,CACH,EAAE,CAAC,OAAO,CAAC,eAAe,EAAE;YAC1B,iBAAiB;YACjB,IAAI;YACJ,SAAS,EAAE,IAAI;YACf,SAAS,EAAE,IAAI;SAChB,CAAC,CACL,CAAC;QACF,OAAO,GAAG,EAAE,EAAE,IAAI,IAAI,CAAC;IACzB,CAAC;CACF"}
@@ -0,0 +1,39 @@
1
+ import { type CustomerAccountLifecycleWritePort, type CustomerAccountReadPort, type CustomerAccountRecord, type PersonalOrganizationPort } from '@endora-commerce/contracts';
2
+ import type { CustomerAuthorityService } from './customer-authority-service.js';
3
+ import type { ModerationActor, SessionRevoker } from './customer-moderation-service.js';
4
+ /**
5
+ * CustomerDeletionService — soft-delete, restore, and permanent anonymization
6
+ * (feature 040, US7 / FR-039/FR-040).
7
+ *
8
+ * Deletion is a soft-disable: login is denied, the account is hidden, and
9
+ * sessions are revoked; history (orders, RFQs, audit) is retained. Within the
10
+ * retention window an authorized actor can restore. The anonymization sweep
11
+ * irreversibly scrubs PII after the window, after which restore is impossible.
12
+ *
13
+ * **Feature 075, Phase C — this service holds no other module's entity.** The
14
+ * account writes and the scrub are `customer_accounts`', through
15
+ * `customerAccountLifecycleWritePort`; the cascade onto a member-less personal
16
+ * organisation is `organizations`', through `personalOrganizationPort`. Each
17
+ * owner records the one audit row that describes its own write, with the same
18
+ * action strings as before. What is left here is the *order* of the sweep and
19
+ * the authority checks, which are this module's.
20
+ */
21
+ export declare class CustomerDeletionService {
22
+ private readonly accounts;
23
+ private readonly accountWrites;
24
+ private readonly personalOrganizations;
25
+ private readonly authority;
26
+ private readonly sessions;
27
+ constructor(accounts: CustomerAccountReadPort, accountWrites: CustomerAccountLifecycleWritePort, personalOrganizations: PersonalOrganizationPort, authority: CustomerAuthorityService, sessions: SessionRevoker);
28
+ softDelete(customerAccountId: string, actor: ModerationActor): Promise<CustomerAccountRecord>;
29
+ restore(customerAccountId: string, actor: ModerationActor): Promise<CustomerAccountRecord>;
30
+ /**
31
+ * Permanently anonymizes every soft-deleted account whose retention window
32
+ * has elapsed (`deletedAt + retentionDays < now`). Returns the number
33
+ * anonymized. Called by the repeatable sweep worker.
34
+ */
35
+ sweep(retentionDays: number, now: Date): Promise<number>;
36
+ private load;
37
+ private assertAuthorized;
38
+ }
39
+ //# sourceMappingURL=customer-deletion-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"customer-deletion-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/customer-deletion-service.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,iCAAiC,EACtC,KAAK,uBAAuB,EAC5B,KAAK,qBAAqB,EAC1B,KAAK,wBAAwB,EAC9B,MAAM,4BAA4B,CAAC;AAEpC,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,iCAAiC,CAAC;AAChF,OAAO,KAAK,EACV,eAAe,EACf,cAAc,EACf,MAAM,kCAAkC,CAAC;AAE1C;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,uBAAuB;IAEhC,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,qBAAqB;IACtC,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,QAAQ;gBAJR,QAAQ,EAAE,uBAAuB,EACjC,aAAa,EAAE,iCAAiC,EAChD,qBAAqB,EAAE,wBAAwB,EAC/C,SAAS,EAAE,wBAAwB,EACnC,QAAQ,EAAE,cAAc;IAGrC,UAAU,CACd,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC,qBAAqB,CAAC;IA8B3B,OAAO,CACX,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC,qBAAqB,CAAC;IAMjC;;;;OAIG;IACG,KAAK,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC;YAehD,IAAI;YAQJ,gBAAgB;CAa/B"}
@@ -0,0 +1,94 @@
1
+ import { ERROR_CODES, } from '@endora-commerce/contracts';
2
+ import { HttpError } from '@endora-commerce/platform/http';
3
+ /**
4
+ * CustomerDeletionService — soft-delete, restore, and permanent anonymization
5
+ * (feature 040, US7 / FR-039/FR-040).
6
+ *
7
+ * Deletion is a soft-disable: login is denied, the account is hidden, and
8
+ * sessions are revoked; history (orders, RFQs, audit) is retained. Within the
9
+ * retention window an authorized actor can restore. The anonymization sweep
10
+ * irreversibly scrubs PII after the window, after which restore is impossible.
11
+ *
12
+ * **Feature 075, Phase C — this service holds no other module's entity.** The
13
+ * account writes and the scrub are `customer_accounts`', through
14
+ * `customerAccountLifecycleWritePort`; the cascade onto a member-less personal
15
+ * organisation is `organizations`', through `personalOrganizationPort`. Each
16
+ * owner records the one audit row that describes its own write, with the same
17
+ * action strings as before. What is left here is the *order* of the sweep and
18
+ * the authority checks, which are this module's.
19
+ */
20
+ export class CustomerDeletionService {
21
+ accounts;
22
+ accountWrites;
23
+ personalOrganizations;
24
+ authority;
25
+ sessions;
26
+ constructor(accounts, accountWrites, personalOrganizations, authority, sessions) {
27
+ this.accounts = accounts;
28
+ this.accountWrites = accountWrites;
29
+ this.personalOrganizations = personalOrganizations;
30
+ this.authority = authority;
31
+ this.sessions = sessions;
32
+ }
33
+ async softDelete(customerAccountId, actor) {
34
+ const customer = await this.load(customerAccountId);
35
+ await this.assertAuthorized(actor, customer.organizationId);
36
+ if (customer.deletedAt) {
37
+ throw new HttpError(409, ERROR_CODES.CUSTOMER_ALREADY_DELETED, 'Customer is already deleted.');
38
+ }
39
+ // Refuse to strand an org without an admin.
40
+ if (customer.organizationId && customer.role === 'organization_admin') {
41
+ const remaining = await this.accounts.countByOrganizationRole(customer.organizationId, 'organization_admin', { excludeCustomerAccountId: customer.id, activeOnly: true });
42
+ if (remaining === 0) {
43
+ throw new HttpError(409, ERROR_CODES.ORG_OWNER_DEPLETION, 'Cannot delete the last organization administrator.');
44
+ }
45
+ }
46
+ const deleted = await this.accountWrites.softDelete(customer.id, {
47
+ actorAdminUserId: actor.adminUserId,
48
+ });
49
+ await this.sessions.destroyAllForCustomer(customer.id);
50
+ return deleted;
51
+ }
52
+ async restore(customerAccountId, actor) {
53
+ const customer = await this.load(customerAccountId);
54
+ await this.assertAuthorized(actor, customer.organizationId);
55
+ return this.accountWrites.restore(customer.id, { actorAdminUserId: actor.adminUserId });
56
+ }
57
+ /**
58
+ * Permanently anonymizes every soft-deleted account whose retention window
59
+ * has elapsed (`deletedAt + retentionDays < now`). Returns the number
60
+ * anonymized. Called by the repeatable sweep worker.
61
+ */
62
+ async sweep(retentionDays, now) {
63
+ const cutoff = new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000);
64
+ const due = await this.accountWrites.listDueForAnonymization(cutoff);
65
+ for (const customer of due) {
66
+ await this.accountWrites.anonymize(customer.id);
67
+ // Feature 051 — a personal (B2C) organization backs exactly one customer.
68
+ // Once that customer is anonymized the org is member-less, so the scrub
69
+ // cascades: the owner renames it, blanks its address and soft-deletes it.
70
+ // Company (multi-member) orgs are never touched, and the owner decides
71
+ // that — this sweep only says which customer it just scrubbed.
72
+ await this.personalOrganizations.anonymizeIfOrphaned(customer.id);
73
+ }
74
+ return due.length;
75
+ }
76
+ async load(id) {
77
+ const customer = await this.accounts.findById(id);
78
+ if (!customer) {
79
+ throw new HttpError(404, ERROR_CODES.CUSTOMER_NOT_FOUND, 'Customer not found.');
80
+ }
81
+ return customer;
82
+ }
83
+ async assertAuthorized(actor, organizationId) {
84
+ const allowed = await this.authority.canManageCustomer({
85
+ isPlatformAdmin: actor.isPlatformAdmin,
86
+ adminUserId: actor.adminUserId,
87
+ customerOrganizationId: organizationId,
88
+ });
89
+ if (!allowed) {
90
+ throw new HttpError(403, ERROR_CODES.FORBIDDEN, 'You are not allowed to manage this customer.');
91
+ }
92
+ }
93
+ }
94
+ //# sourceMappingURL=customer-deletion-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"customer-deletion-service.js","sourceRoot":"","sources":["../../../src/backend/services/customer-deletion-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,58 @@
1
+ import { type CustomerAccountLifecycleWritePort, type CustomerAccountReadPort, type CustomerAccountRecord } from '@endora-commerce/contracts';
2
+ import type { CustomerAuthorityService } from './customer-authority-service.js';
3
+ /**
4
+ * CustomerModerationService — block / unblock a Customer account (feature 040,
5
+ * US3). Enforces the staff-authority rules (FR-013/FR-015 via
6
+ * CustomerAuthorityService), revokes sessions on block, and guards against
7
+ * stranding an Organization with zero usable admins.
8
+ *
9
+ * **Feature 075, Phase C — the row is `customer_accounts`'.** This service used
10
+ * to load and mutate that module's entity and write the audit row for it; both
11
+ * moved behind `customerAccountLifecycleWritePort`, which records exactly the
12
+ * same `customer_account.blocked` / `.unblocked` entry (FR-017). What stays is
13
+ * everything that is a *decision*: whether this staff member may act on this
14
+ * customer, and whether the block would leave an Organization without an
15
+ * administrator. The session revocation stays too — it is `auth`'s, and it must
16
+ * happen after the block is durable rather than inside it.
17
+ */
18
+ export interface SessionRevoker {
19
+ destroyAllForCustomer(customerAccountId: string): Promise<void>;
20
+ }
21
+ export interface ModerationActor {
22
+ adminUserId: string;
23
+ isPlatformAdmin: boolean;
24
+ }
25
+ export interface ModerationAuditMeta {
26
+ ipAddress?: string | null;
27
+ userAgent?: string | null;
28
+ requestId?: string | null;
29
+ }
30
+ export interface BlockCustomerInput {
31
+ targetCustomerAccountId: string;
32
+ actor: ModerationActor;
33
+ reason?: string | null;
34
+ audit?: ModerationAuditMeta;
35
+ }
36
+ export interface UnblockCustomerInput {
37
+ targetCustomerAccountId: string;
38
+ actor: ModerationActor;
39
+ audit?: ModerationAuditMeta;
40
+ }
41
+ export declare class CustomerModerationService {
42
+ private readonly accounts;
43
+ private readonly accountWrites;
44
+ private readonly authority;
45
+ private readonly sessions;
46
+ constructor(accounts: CustomerAccountReadPort, accountWrites: CustomerAccountLifecycleWritePort, authority: CustomerAuthorityService, sessions: SessionRevoker);
47
+ block(input: BlockCustomerInput): Promise<CustomerAccountRecord>;
48
+ unblock(input: UnblockCustomerInput): Promise<CustomerAccountRecord>;
49
+ private loadActive;
50
+ private assertAuthorized;
51
+ /**
52
+ * Refuses to block the last usable (active, non-blocked) organization admin
53
+ * of an Organization — that would strand the org with nobody able to manage
54
+ * it (spec edge case "org-owner depletion").
55
+ */
56
+ private assertWouldNotStrandOrg;
57
+ }
58
+ //# sourceMappingURL=customer-moderation-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"customer-moderation-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/customer-moderation-service.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,iCAAiC,EACtC,KAAK,uBAAuB,EAC5B,KAAK,qBAAqB,EAC3B,MAAM,4BAA4B,CAAC;AAEpC,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,iCAAiC,CAAC;AAEhF;;;;;;;;;;;;;;GAcG;AACH,MAAM,WAAW,cAAc;IAC7B,qBAAqB,CAAC,iBAAiB,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjE;AAED,MAAM,WAAW,eAAe;IAC9B,WAAW,EAAE,MAAM,CAAC;IACpB,eAAe,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,mBAAmB;IAClC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,MAAM,WAAW,kBAAkB;IACjC,uBAAuB,EAAE,MAAM,CAAC;IAChC,KAAK,EAAE,eAAe,CAAC;IACvB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,KAAK,CAAC,EAAE,mBAAmB,CAAC;CAC7B;AAED,MAAM,WAAW,oBAAoB;IACnC,uBAAuB,EAAE,MAAM,CAAC;IAChC,KAAK,EAAE,eAAe,CAAC;IACvB,KAAK,CAAC,EAAE,mBAAmB,CAAC;CAC7B;AAED,qBAAa,yBAAyB;IAElC,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,QAAQ;gBAHR,QAAQ,EAAE,uBAAuB,EACjC,aAAa,EAAE,iCAAiC,EAChD,SAAS,EAAE,wBAAwB,EACnC,QAAQ,EAAE,cAAc;IAGrC,KAAK,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,qBAAqB,CAAC;IAmBhE,OAAO,CAAC,KAAK,EAAE,oBAAoB,GAAG,OAAO,CAAC,qBAAqB,CAAC;YAY5D,UAAU;YAQV,gBAAgB;IAkB9B;;;;OAIG;YACW,uBAAuB;CAetC"}
@@ -0,0 +1,70 @@
1
+ import { ERROR_CODES, } from '@endora-commerce/contracts';
2
+ import { HttpError } from '@endora-commerce/platform/http';
3
+ export class CustomerModerationService {
4
+ accounts;
5
+ accountWrites;
6
+ authority;
7
+ sessions;
8
+ constructor(accounts, accountWrites, authority, sessions) {
9
+ this.accounts = accounts;
10
+ this.accountWrites = accountWrites;
11
+ this.authority = authority;
12
+ this.sessions = sessions;
13
+ }
14
+ async block(input) {
15
+ const customer = await this.loadActive(input.targetCustomerAccountId);
16
+ await this.assertAuthorized(input.actor, customer);
17
+ if (customer.blockedAt)
18
+ return customer; // idempotent
19
+ await this.assertWouldNotStrandOrg(customer);
20
+ const blocked = await this.accountWrites.block(customer.id, {
21
+ actorAdminUserId: input.actor.adminUserId,
22
+ reason: input.reason ?? null,
23
+ ...(input.audit ? { audit: input.audit } : {}),
24
+ });
25
+ // Revoke active sessions so the block takes effect promptly (SC-002).
26
+ await this.sessions.destroyAllForCustomer(customer.id);
27
+ return blocked;
28
+ }
29
+ async unblock(input) {
30
+ const customer = await this.loadActive(input.targetCustomerAccountId);
31
+ await this.assertAuthorized(input.actor, customer);
32
+ if (!customer.blockedAt)
33
+ return customer; // idempotent
34
+ return this.accountWrites.unblock(customer.id, {
35
+ actorAdminUserId: input.actor.adminUserId,
36
+ ...(input.audit ? { audit: input.audit } : {}),
37
+ });
38
+ }
39
+ async loadActive(id) {
40
+ const customer = await this.accounts.findById(id, { activeOnly: true });
41
+ if (!customer) {
42
+ throw new HttpError(404, ERROR_CODES.CUSTOMER_NOT_FOUND, 'Customer not found.');
43
+ }
44
+ return customer;
45
+ }
46
+ async assertAuthorized(actor, customer) {
47
+ const allowed = await this.authority.canManageCustomer({
48
+ isPlatformAdmin: actor.isPlatformAdmin,
49
+ adminUserId: actor.adminUserId,
50
+ customerOrganizationId: customer.organizationId,
51
+ });
52
+ if (!allowed) {
53
+ throw new HttpError(403, ERROR_CODES.FORBIDDEN, 'You are not allowed to manage this customer.');
54
+ }
55
+ }
56
+ /**
57
+ * Refuses to block the last usable (active, non-blocked) organization admin
58
+ * of an Organization — that would strand the org with nobody able to manage
59
+ * it (spec edge case "org-owner depletion").
60
+ */
61
+ async assertWouldNotStrandOrg(customer) {
62
+ if (!customer.organizationId || customer.role !== 'organization_admin')
63
+ return;
64
+ const remaining = await this.accounts.countByOrganizationRole(customer.organizationId, 'organization_admin', { excludeCustomerAccountId: customer.id, activeOnly: true, notBlocked: true });
65
+ if (remaining === 0) {
66
+ throw new HttpError(409, ERROR_CODES.ORG_OWNER_DEPLETION, 'Cannot block the last organization administrator; the organization would be left without an admin.');
67
+ }
68
+ }
69
+ }
70
+ //# sourceMappingURL=customer-moderation-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"customer-moderation-service.js","sourceRoot":"","sources":["../../../src/backend/services/customer-moderation-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,52 @@
1
+ import { type CustomerAccountLifecycleWritePort, type CustomerAccountReadPort, type CustomerAccountRecord, type OrganizationDetailsPort, type PersonalOrganizationPort } from '@endora-commerce/contracts';
2
+ import type { CustomerAuthorityService } from './customer-authority-service.js';
3
+ import type { ModerationActor } from './customer-moderation-service.js';
4
+ /**
5
+ * CustomerOrgAssignmentService — assign a Customer to an Organization, or
6
+ * detach one from the company it belongs to (feature 040, US5 / FR-026). Both
7
+ * actions are authority-checked and audited; detaching the last usable org
8
+ * admin is refused (depletion guard, mirroring moderation).
9
+ *
10
+ * **D-178 redefined the second action.** `unassign` wrote
11
+ * `organization_id = NULL` and produced an account with no tenant: it could not
12
+ * place an order, submit an RFQ or read an address, it shared one filter bucket
13
+ * with every other tenant-less account, and nothing ever gave it an
14
+ * organisation back. The operator's intent — *this person no longer belongs to
15
+ * that company* — is served exactly by moving them to their own single-member
16
+ * personal organisation, which is what the operation does now, and what the
17
+ * platform would have given them had they registered on their own.
18
+ *
19
+ * **Feature 075, Phase C — neither row is this module's.** The membership
20
+ * column belongs to `customer_accounts` and the organisation it points at
21
+ * belongs to `organizations`; both are reached through their published ports
22
+ * now, and the audit row goes with the write. The authority checks and the
23
+ * depletion guard stay here, because they are this module's policy.
24
+ */
25
+ export declare class CustomerOrgAssignmentService {
26
+ private readonly accounts;
27
+ private readonly accountWrites;
28
+ private readonly organizations;
29
+ private readonly authority;
30
+ /** D-178 — where a detached member goes. */
31
+ private readonly personalOrganizations;
32
+ constructor(accounts: CustomerAccountReadPort, accountWrites: CustomerAccountLifecycleWritePort, organizations: OrganizationDetailsPort, authority: CustomerAuthorityService,
33
+ /** D-178 — where a detached member goes. */
34
+ personalOrganizations: PersonalOrganizationPort);
35
+ assign(customerAccountId: string, organizationId: string, actor: ModerationActor): Promise<CustomerAccountRecord>;
36
+ /**
37
+ * Detach the customer from the company they belong to, moving them to their
38
+ * own personal organisation (D-178).
39
+ *
40
+ * The destination is resolved before the depletion guard runs, so the refusal
41
+ * an operator sees is still the depletion one and not a provisioning failure
42
+ * underneath it. It is idempotent: a customer already in their own personal
43
+ * organisation is returned unchanged, and no audit row is written for a move
44
+ * that did not happen.
45
+ */
46
+ unassign(customerAccountId: string, actor: ModerationActor): Promise<CustomerAccountRecord>;
47
+ /** Sets (or clears) the customer's direct customer-group (feature 040, US6). */
48
+ setCustomerGroup(customerAccountId: string, customerGroupId: string | null, actor: ModerationActor): Promise<CustomerAccountRecord>;
49
+ private loadActive;
50
+ private assertAuthorized;
51
+ }
52
+ //# sourceMappingURL=customer-org-assignment-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"customer-org-assignment-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/customer-org-assignment-service.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,iCAAiC,EACtC,KAAK,uBAAuB,EAC5B,KAAK,qBAAqB,EAC1B,KAAK,uBAAuB,EAC5B,KAAK,wBAAwB,EAC9B,MAAM,4BAA4B,CAAC;AAEpC,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,iCAAiC,CAAC;AAChF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,kCAAkC,CAAC;AAExE;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBAAa,4BAA4B;IAErC,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,aAAa;IAC9B,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,4CAA4C;IAC5C,OAAO,CAAC,QAAQ,CAAC,qBAAqB;gBALrB,QAAQ,EAAE,uBAAuB,EACjC,aAAa,EAAE,iCAAiC,EAChD,aAAa,EAAE,uBAAuB,EACtC,SAAS,EAAE,wBAAwB;IACpD,4CAA4C;IAC3B,qBAAqB,EAAE,wBAAwB;IAG5D,MAAM,CACV,iBAAiB,EAAE,MAAM,EACzB,cAAc,EAAE,MAAM,EACtB,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC,qBAAqB,CAAC;IAkBjC;;;;;;;;;OASG;IACG,QAAQ,CACZ,iBAAiB,EAAE,MAAM,EACzB,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC,qBAAqB,CAAC;IA4BjC,gFAAgF;IAC1E,gBAAgB,CACpB,iBAAiB,EAAE,MAAM,EACzB,eAAe,EAAE,MAAM,GAAG,IAAI,EAC9B,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC,qBAAqB,CAAC;YASnB,UAAU;YAQV,gBAAgB;CAa/B"}
@@ -0,0 +1,106 @@
1
+ import { ERROR_CODES, } from '@endora-commerce/contracts';
2
+ import { HttpError } from '@endora-commerce/platform/http';
3
+ /**
4
+ * CustomerOrgAssignmentService — assign a Customer to an Organization, or
5
+ * detach one from the company it belongs to (feature 040, US5 / FR-026). Both
6
+ * actions are authority-checked and audited; detaching the last usable org
7
+ * admin is refused (depletion guard, mirroring moderation).
8
+ *
9
+ * **D-178 redefined the second action.** `unassign` wrote
10
+ * `organization_id = NULL` and produced an account with no tenant: it could not
11
+ * place an order, submit an RFQ or read an address, it shared one filter bucket
12
+ * with every other tenant-less account, and nothing ever gave it an
13
+ * organisation back. The operator's intent — *this person no longer belongs to
14
+ * that company* — is served exactly by moving them to their own single-member
15
+ * personal organisation, which is what the operation does now, and what the
16
+ * platform would have given them had they registered on their own.
17
+ *
18
+ * **Feature 075, Phase C — neither row is this module's.** The membership
19
+ * column belongs to `customer_accounts` and the organisation it points at
20
+ * belongs to `organizations`; both are reached through their published ports
21
+ * now, and the audit row goes with the write. The authority checks and the
22
+ * depletion guard stay here, because they are this module's policy.
23
+ */
24
+ export class CustomerOrgAssignmentService {
25
+ accounts;
26
+ accountWrites;
27
+ organizations;
28
+ authority;
29
+ personalOrganizations;
30
+ constructor(accounts, accountWrites, organizations, authority,
31
+ /** D-178 — where a detached member goes. */
32
+ personalOrganizations) {
33
+ this.accounts = accounts;
34
+ this.accountWrites = accountWrites;
35
+ this.organizations = organizations;
36
+ this.authority = authority;
37
+ this.personalOrganizations = personalOrganizations;
38
+ }
39
+ async assign(customerAccountId, organizationId, actor) {
40
+ const customer = await this.loadActive(customerAccountId);
41
+ // Actor must be able to manage the customer in its CURRENT state and in
42
+ // the TARGET organization.
43
+ await this.assertAuthorized(actor, customer.organizationId);
44
+ await this.assertAuthorized(actor, organizationId);
45
+ const org = await this.organizations.findById(organizationId);
46
+ if (!org) {
47
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, 'Organization not found.');
48
+ }
49
+ return this.accountWrites.setOrganization(customer.id, organizationId, {
50
+ actorAdminUserId: actor.adminUserId,
51
+ });
52
+ }
53
+ /**
54
+ * Detach the customer from the company they belong to, moving them to their
55
+ * own personal organisation (D-178).
56
+ *
57
+ * The destination is resolved before the depletion guard runs, so the refusal
58
+ * an operator sees is still the depletion one and not a provisioning failure
59
+ * underneath it. It is idempotent: a customer already in their own personal
60
+ * organisation is returned unchanged, and no audit row is written for a move
61
+ * that did not happen.
62
+ */
63
+ async unassign(customerAccountId, actor) {
64
+ const customer = await this.loadActive(customerAccountId);
65
+ await this.assertAuthorized(actor, customer.organizationId);
66
+ const personal = await this.personalOrganizations.provisionPersonalOrganization(customer.id);
67
+ if (customer.organizationId === personal.id)
68
+ return customer; // already their own
69
+ // Refuse to strand the org without an admin.
70
+ if (customer.role === 'organization_admin') {
71
+ const remaining = await this.accounts.countByOrganizationRole(customer.organizationId, 'organization_admin', { excludeCustomerAccountId: customer.id, activeOnly: true });
72
+ if (remaining === 0) {
73
+ throw new HttpError(409, ERROR_CODES.ORG_OWNER_DEPLETION, 'Cannot unassign the last organization administrator.');
74
+ }
75
+ }
76
+ return this.accountWrites.detachToPersonalOrganization(customer.id, personal.id, {
77
+ actorAdminUserId: actor.adminUserId,
78
+ });
79
+ }
80
+ /** Sets (or clears) the customer's direct customer-group (feature 040, US6). */
81
+ async setCustomerGroup(customerAccountId, customerGroupId, actor) {
82
+ const customer = await this.loadActive(customerAccountId);
83
+ await this.assertAuthorized(actor, customer.organizationId);
84
+ return this.accountWrites.setCustomerGroup(customer.id, customerGroupId, {
85
+ actorAdminUserId: actor.adminUserId,
86
+ });
87
+ }
88
+ async loadActive(id) {
89
+ const customer = await this.accounts.findById(id, { activeOnly: true });
90
+ if (!customer) {
91
+ throw new HttpError(404, ERROR_CODES.CUSTOMER_NOT_FOUND, 'Customer not found.');
92
+ }
93
+ return customer;
94
+ }
95
+ async assertAuthorized(actor, organizationId) {
96
+ const allowed = await this.authority.canManageCustomer({
97
+ isPlatformAdmin: actor.isPlatformAdmin,
98
+ adminUserId: actor.adminUserId,
99
+ customerOrganizationId: organizationId,
100
+ });
101
+ if (!allowed) {
102
+ throw new HttpError(403, ERROR_CODES.FORBIDDEN, 'Not allowed for this organization.');
103
+ }
104
+ }
105
+ }
106
+ //# sourceMappingURL=customer-org-assignment-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"customer-org-assignment-service.js","sourceRoot":"","sources":["../../../src/backend/services/customer-org-assignment-service.ts"],"names":[],"mappings":"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"}