@endora-commerce/mod-sales-channels 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 (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/dist/admin/api/sales-channels-client.d.ts +69 -0
  4. package/dist/admin/api/sales-channels-client.d.ts.map +1 -0
  5. package/dist/admin/api/sales-channels-client.js +74 -0
  6. package/dist/admin/api/sales-channels-client.js.map +1 -0
  7. package/dist/admin/components/ChannelIdentityForm.d.ts +24 -0
  8. package/dist/admin/components/ChannelIdentityForm.d.ts.map +1 -0
  9. package/dist/admin/components/ChannelIdentityForm.js +147 -0
  10. package/dist/admin/components/ChannelIdentityForm.js.map +1 -0
  11. package/dist/admin/components/DefaultChannelBadge.d.ts +21 -0
  12. package/dist/admin/components/DefaultChannelBadge.d.ts.map +1 -0
  13. package/dist/admin/components/DefaultChannelBadge.js +26 -0
  14. package/dist/admin/components/DefaultChannelBadge.js.map +1 -0
  15. package/dist/admin/components/EntityChannelMembership.d.ts +52 -0
  16. package/dist/admin/components/EntityChannelMembership.d.ts.map +1 -0
  17. package/dist/admin/components/EntityChannelMembership.js +98 -0
  18. package/dist/admin/components/EntityChannelMembership.js.map +1 -0
  19. package/dist/admin/index.d.ts +44 -0
  20. package/dist/admin/index.d.ts.map +1 -0
  21. package/dist/admin/index.js +148 -0
  22. package/dist/admin/index.js.map +1 -0
  23. package/dist/admin/pages/SalesChannelEditPage.d.ts +25 -0
  24. package/dist/admin/pages/SalesChannelEditPage.d.ts.map +1 -0
  25. package/dist/admin/pages/SalesChannelEditPage.js +185 -0
  26. package/dist/admin/pages/SalesChannelEditPage.js.map +1 -0
  27. package/dist/admin/pages/SalesChannelsListPage.d.ts +25 -0
  28. package/dist/admin/pages/SalesChannelsListPage.d.ts.map +1 -0
  29. package/dist/admin/pages/SalesChannelsListPage.js +91 -0
  30. package/dist/admin/pages/SalesChannelsListPage.js.map +1 -0
  31. package/dist/admin/zones/OrganizationChannelMembership.d.ts +34 -0
  32. package/dist/admin/zones/OrganizationChannelMembership.d.ts.map +1 -0
  33. package/dist/admin/zones/OrganizationChannelMembership.js +11 -0
  34. package/dist/admin/zones/OrganizationChannelMembership.js.map +1 -0
  35. package/dist/admin/zones/ProductChannelMembership.d.ts +23 -0
  36. package/dist/admin/zones/ProductChannelMembership.d.ts.map +1 -0
  37. package/dist/admin/zones/ProductChannelMembership.js +11 -0
  38. package/dist/admin/zones/ProductChannelMembership.js.map +1 -0
  39. package/dist/backend/commands/set-default.command.d.ts +53 -0
  40. package/dist/backend/commands/set-default.command.d.ts.map +1 -0
  41. package/dist/backend/commands/set-default.command.js +87 -0
  42. package/dist/backend/commands/set-default.command.js.map +1 -0
  43. package/dist/backend/index.d.ts +71 -0
  44. package/dist/backend/index.d.ts.map +1 -0
  45. package/dist/backend/index.js +81 -0
  46. package/dist/backend/index.js.map +1 -0
  47. package/dist/backend/routes.admin.d.ts +12 -0
  48. package/dist/backend/routes.admin.d.ts.map +1 -0
  49. package/dist/backend/routes.admin.js +302 -0
  50. package/dist/backend/routes.admin.js.map +1 -0
  51. package/dist/backend/routes.storefront.d.ts +36 -0
  52. package/dist/backend/routes.storefront.d.ts.map +1 -0
  53. package/dist/backend/routes.storefront.js +58 -0
  54. package/dist/backend/routes.storefront.js.map +1 -0
  55. package/dist/backend/services/index.d.ts +2 -0
  56. package/dist/backend/services/index.d.ts.map +1 -0
  57. package/dist/backend/services/index.js +2 -0
  58. package/dist/backend/services/index.js.map +1 -0
  59. package/dist/backend/services/sales-channel-attribution-registry.d.ts +28 -0
  60. package/dist/backend/services/sales-channel-attribution-registry.d.ts.map +1 -0
  61. package/dist/backend/services/sales-channel-attribution-registry.js +50 -0
  62. package/dist/backend/services/sales-channel-attribution-registry.js.map +1 -0
  63. package/dist/backend/services/sales-channels.service.d.ts +247 -0
  64. package/dist/backend/services/sales-channels.service.d.ts.map +1 -0
  65. package/dist/backend/services/sales-channels.service.js +535 -0
  66. package/dist/backend/services/sales-channels.service.js.map +1 -0
  67. package/dist/manifest.d.ts +185 -0
  68. package/dist/manifest.d.ts.map +1 -0
  69. package/dist/manifest.js +204 -0
  70. package/dist/manifest.js.map +1 -0
  71. package/docs/sales_channels/admin-usage.md +77 -0
  72. package/docs/sales_channels/developer-guide.md +107 -0
  73. package/docs/sales_channels/index.md +95 -0
  74. package/i18n/en.json +75 -0
  75. package/i18n/pl.json +75 -0
  76. package/package.json +88 -0
  77. package/tailwind.css +14 -0
@@ -0,0 +1,185 @@
1
+ /** Module-lifecycle manifest (feature 018). */
2
+ export declare const manifest: {
3
+ id: string;
4
+ name: string;
5
+ version: string;
6
+ dependencies: string[];
7
+ description?: string | undefined;
8
+ acknowledgedDependencies?: {
9
+ moduleId: string;
10
+ port: string;
11
+ reason: string;
12
+ }[] | undefined;
13
+ nonBindingDependencies?: {
14
+ moduleId: string;
15
+ name: string;
16
+ kind: "contributes-to" | "degrades-without" | "refuses-without";
17
+ reason: string;
18
+ whenAbsent?: string | undefined;
19
+ }[] | undefined;
20
+ activation?: {
21
+ settingCode: string;
22
+ default: boolean;
23
+ } | {
24
+ nonDeactivatable: true;
25
+ reason: string;
26
+ } | undefined;
27
+ settings?: {
28
+ moduleCode: string;
29
+ groups: {
30
+ code: string;
31
+ name: string;
32
+ salesChannelCodes?: string[] | undefined;
33
+ isSystemProtected?: boolean | undefined;
34
+ }[];
35
+ settings: {
36
+ code: string;
37
+ name: string;
38
+ valueType: "string" | "number" | "boolean" | "json" | "string_list" | "secret" | "credential_ref";
39
+ defaultValue: unknown;
40
+ description?: string | undefined;
41
+ groupCode?: string | undefined;
42
+ previousDefaultValues?: unknown[] | undefined;
43
+ salesChannelCodes?: string[] | undefined;
44
+ enumOptions?: string[] | undefined;
45
+ configurationType?: string | undefined;
46
+ hidden?: boolean | undefined;
47
+ }[];
48
+ } | undefined;
49
+ i18n?: {
50
+ bundlesDir: string;
51
+ } | undefined;
52
+ docs?: false | {
53
+ dir: string;
54
+ } | undefined;
55
+ demo?: false | {
56
+ summary: string;
57
+ seed: (context: import("@endora-commerce/contracts").ModuleDemoContext<never>) => Promise<import("@endora-commerce/contracts").DemoSeedResult>;
58
+ reset: (context: import("@endora-commerce/contracts").ModuleDemoContext<never>) => Promise<import("@endora-commerce/contracts").DemoResetResult>;
59
+ after?: readonly string[] | undefined;
60
+ package?: string | undefined;
61
+ } | undefined;
62
+ actions?: {
63
+ id: string;
64
+ labelKey: string;
65
+ icon: "Plus" | "Sparkles" | "Settings" | "Search" | "Boxes" | "Layers" | "Menu" | "PlusCircle" | "PlusSquare" | "FilePlus" | "FolderPlus" | "Upload" | "FileUp" | "CloudUpload" | "Download" | "FileDown" | "FileText" | "BookOpen" | "Rss" | "Package" | "Tag" | "ShoppingCart" | "Receipt" | "CreditCard" | "Users" | "UserPlus" | "Inbox" | "ListChecks" | "ClipboardList" | "Image" | "Video" | "LayoutDashboard" | "PanelLeft" | "KeyRound" | "ShieldCheck" | "Edit" | "Archive" | "Box" | "Truck" | "CircleDollarSign" | "Activity" | "LineChart" | "Smartphone" | "Webhook" | "Scale" | "PlugZap" | "PercentDiamond" | "Newspaper" | "Languages" | "Eraser" | "Warehouse" | "TrendingDown" | "Bell" | "PackageOpen" | "Building2" | "Store" | "ClipboardCheck";
66
+ targetRoute: string;
67
+ keywords: string[];
68
+ weight: number;
69
+ descriptionKey?: string | undefined;
70
+ requiredPermission?: string | undefined;
71
+ }[] | undefined;
72
+ permissions?: {
73
+ code: string;
74
+ label: string;
75
+ module?: string | undefined;
76
+ description?: string | undefined;
77
+ requires?: string[] | undefined;
78
+ }[] | undefined;
79
+ transactionalEmails?: {
80
+ code: string;
81
+ name: string;
82
+ variables: {
83
+ key: string;
84
+ label: string;
85
+ sampleValue?: string | undefined;
86
+ description?: string | undefined;
87
+ }[];
88
+ description?: string | undefined;
89
+ group?: string | undefined;
90
+ }[] | undefined;
91
+ capabilities?: string[] | undefined;
92
+ exclusiveCapabilities?: {
93
+ key: string;
94
+ errorCode: string;
95
+ }[] | undefined;
96
+ errorCodes?: {
97
+ code: string;
98
+ tokens?: string[] | undefined;
99
+ }[] | undefined;
100
+ blocks?: {
101
+ name: string;
102
+ labelKey: string;
103
+ category: string;
104
+ contexts: ("invoice" | "email" | "cms" | "newsletter")[];
105
+ fields: Record<string, {
106
+ type: "number" | "object" | "array" | "text" | "textarea" | "select" | "radio" | "external" | "uuid" | "richtext";
107
+ label?: string | undefined;
108
+ required?: boolean | undefined;
109
+ options?: {
110
+ label: string;
111
+ value: string | number;
112
+ }[] | undefined;
113
+ refKind?: string | undefined;
114
+ }>;
115
+ descriptionKey?: string | undefined;
116
+ defaultProps?: Record<string, unknown> | undefined;
117
+ responsiveFields?: string[] | undefined;
118
+ previewIcon?: string | undefined;
119
+ weight?: number | undefined;
120
+ }[] | undefined;
121
+ blockCategories?: {
122
+ key: string;
123
+ titleKey: string;
124
+ contexts: ("invoice" | "email" | "cms" | "newsletter")[];
125
+ weight?: number | undefined;
126
+ visible?: boolean | undefined;
127
+ }[] | undefined;
128
+ env?: {
129
+ name: string;
130
+ describes: {
131
+ en: string;
132
+ pl: string;
133
+ };
134
+ requirement: {
135
+ kind: "required";
136
+ } | {
137
+ kind: "requiredWhen";
138
+ input: string;
139
+ equals: string;
140
+ } | {
141
+ kind: "optional";
142
+ without: {
143
+ en: string;
144
+ pl: string;
145
+ };
146
+ };
147
+ secret: boolean;
148
+ generable: boolean;
149
+ owner: {
150
+ kind: "platform";
151
+ } | {
152
+ kind: "application";
153
+ application: "admin" | "backend" | "storefront";
154
+ } | {
155
+ kind: "module";
156
+ moduleId: string;
157
+ };
158
+ consumers: ("admin" | "backend" | "storefront")[];
159
+ addressOf: "admin" | "backend" | "storefront" | null;
160
+ }[] | undefined;
161
+ };
162
+ /** Legacy export retained for backward compatibility. */
163
+ export declare const salesChannelsManifest: {
164
+ moduleCode: string;
165
+ groups: {
166
+ code: string;
167
+ name: string;
168
+ salesChannelCodes?: string[] | undefined;
169
+ isSystemProtected?: boolean | undefined;
170
+ }[];
171
+ settings: {
172
+ code: string;
173
+ name: string;
174
+ valueType: "string" | "number" | "boolean" | "json" | "string_list" | "secret" | "credential_ref";
175
+ defaultValue: unknown;
176
+ description?: string | undefined;
177
+ groupCode?: string | undefined;
178
+ previousDefaultValues?: unknown[] | undefined;
179
+ salesChannelCodes?: string[] | undefined;
180
+ enumOptions?: string[] | undefined;
181
+ configurationType?: string | undefined;
182
+ hidden?: boolean | undefined;
183
+ }[];
184
+ };
185
+ //# sourceMappingURL=manifest.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAgCA,+CAA+C;AAC/C,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8KnB,CAAC;AAEH,yDAAyD;AACzD,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;CAAW,CAAC"}
@@ -0,0 +1,204 @@
1
+ import { defineModuleManifest, defineModuleSettingsManifest, } from '@endora-commerce/contracts';
2
+ /**
3
+ * Built-in settings manifest for the sales-channels module — feature 005.
4
+ *
5
+ * Reserves the `sales_channels` setting group so future channel-related
6
+ * knobs (default theme, host-map default, etc.) have a stable home.
7
+ */
8
+ const settings = defineModuleSettingsManifest({
9
+ moduleCode: 'sales_channels',
10
+ groups: [
11
+ {
12
+ code: 'sales_channels',
13
+ name: 'Sales Channels',
14
+ },
15
+ ],
16
+ settings: [
17
+ {
18
+ code: 'sales_channels.storefront_url',
19
+ name: 'Storefront URL',
20
+ description: 'Public base URL of the storefront for this sales channel. Used by the SEO sitemap generator and any other module that stamps absolute URLs into outgoing payloads. Empty value falls back to STOREFRONT_BASE_URL env.',
21
+ groupCode: 'sales_channels',
22
+ valueType: 'string',
23
+ defaultValue: '',
24
+ },
25
+ ],
26
+ });
27
+ /** Module-lifecycle manifest (feature 018). */
28
+ export const manifest = defineModuleManifest({
29
+ id: 'sales_channels',
30
+ name: 'Sales Channels',
31
+ description: 'Multi-channel storefront resolver and channel registry.',
32
+ version: '1.0.0',
33
+ // Rule 2 (bridge owner) — specs/065-manifest-aware-migrations/research.md §R9.
34
+ // This module owns nine `sales_channel_*` membership bridges plus
35
+ // `sales_channels.logo_asset_id`, so its tables foreign-key nine other
36
+ // modules. None of those edges is declared: the module that cannot function
37
+ // without channel scoping is the *domain* module, and every one of them
38
+ // already declares `sales_channels`. Four of the nine reverse edges close a
39
+ // cycle outright — sales_channels → catalog → sales_channels,
40
+ // sales_channels → cms → sales_channels,
41
+ // sales_channels → promotions → sales_channels, and
42
+ // sales_channels → customer_accounts → price_lists → catalog →
43
+ // sales_channels; together they are what made the naive union a 9-node SCC.
44
+ // The remaining five (assets_library, delivery_methods, organizations,
45
+ // payment_methods, taxes) close no cycle but are dropped by the same rule: a
46
+ // bridge owner declaring what it bridges inverts the ownership direction.
47
+ // Eight are recorded in test/unit/db/acknowledged-fk-edges.ts; the ninth,
48
+ // `sales_channels.logo_asset_id → assets`, is recorded there as
49
+ // `kernel → assets_library` because feature 072 T019 moved the SalesChannel
50
+ // entity — and with it the ownership of the `sales_channels` table — into the
51
+ // kernel. The nine bridge tables stay owned by this module.
52
+ //
53
+ // Forward-looking convention: a new bridge table for module X is owned by X's
54
+ // migration, so X → sales_channels covers it and no new exception is needed.
55
+ dependencies: ['dictionaries', 'settings'],
56
+ settings,
57
+ // Feature 074 (Constitution XVII), test C2 — functional base, and named by
58
+ // ruling 1. Channel scoping is structural: Principle XII is non-negotiable,
59
+ // every scoped read resolves the request's channel through the sanctioned
60
+ // accessors, and there is no unscoped read path to fall back to. The control
61
+ // this replaces was one of the nineteen that never accepted a deactivation
62
+ // — nineteen dependents refused it — so the lock takes away a dead button
63
+ // and adds a stated reason.
64
+ //
65
+ // `sales_channels.enabled` goes with it. Left declared it would fall through
66
+ // to an ordinary editable boolean that changes nothing; the existing rows are
67
+ // removed by a core data migration (feature 074, FR-010a), because the
68
+ // settings reconciler reports orphans and never deletes them.
69
+ activation: {
70
+ nonDeactivatable: true,
71
+ reason: 'Channel scoping is structural: every scoped read resolves the request\'s channel and ' +
72
+ 'there is no unscoped path to fall back to.',
73
+ },
74
+ /**
75
+ * The twelve error codes this module owns — feature 090 Phase 3
76
+ * (`specs/090-module-owned-error-codes/contracts/error-code-declaration.md`
77
+ * §1.1). This is where the sentence for each is looked up from: `errors.<CODE>`
78
+ * in this module's own `i18n/{en,pl}.json`, which holds all twelve in both
79
+ * languages and holds no other `errors.*` key. None of them is a
80
+ * `check-error-translations.ts` `UNTRANSLATED_ERROR_CODES` entry.
81
+ *
82
+ * The list is answer-preserving, not a judgement (§6.2 and §6.5), and it was
83
+ * not written by hand: it is the verbatim output of the runbook's step-1
84
+ * derivation over the frozen capture at
85
+ * `backend/test/fixtures/error-code-routing/chain-answers.ts`, which records
86
+ * what the prefix chain in `@endora-commerce/mod-i18n` answered at
87
+ * `49f3c6817`. Re-routing a code to a better owner is
88
+ * `specs/082-error-code-ownership/rulings.md` §9's remaining work and is
89
+ * deliberately not done here.
90
+ *
91
+ * **`UNKNOWN_OPTION` is not here, and it is the one a reader will look for.**
92
+ * This module's rule in the chain is `UNKNOWN_` ∪ `SALES_CHANNEL_` ∪ a misc
93
+ * set, so reading the rule's *source* claims `UNKNOWN_OPTION` for
94
+ * `sales_channels`. The chain is an ordered `if` and `catalog`'s misc set
95
+ * names that code two branches earlier, so the chain's *answer* is `catalog`
96
+ * — which is what the capture records and what `catalog` declared in its own
97
+ * migration. Trap T1: read the answer, never the rule. The other three
98
+ * `UNKNOWN_` members of `ERROR_CODES` reach this rule and are here.
99
+ *
100
+ * **Four codes here look generic or look like another module's, and are
101
+ * this module's by a decision an earlier feature made** (trap T2).
102
+ * `CANNOT_MODIFY_SYSTEM_DEFAULT` and `ENTITY_WOULD_HAVE_ZERO_CHANNELS` name
103
+ * no channel at all and arrive through the chain's misc set;
104
+ * `UNKNOWN_CURRENCY_CODE` and `UNKNOWN_LANGUAGE_CODE` read like
105
+ * `dictionaries` codes and arrive through the `UNKNOWN_` prefix. The inverse
106
+ * holds as well: `CHANNEL_NO_WAREHOUSES`, `CHANNEL_WAREHOUSE_NOT_FOUND` and
107
+ * `WAREHOUSE_IS_DEFAULT_FOR_CHANNELS` route to `inventory`,
108
+ * `API_KEY_CHANNEL_MISMATCH` to `core`, `SETTING_OUT_OF_SCOPE_FOR_CHANNEL` to
109
+ * `settings`, `CMS_LANGUAGE_NOT_IN_CHANNEL_SCOPE` to `cms` and
110
+ * `MEGAMENU_LANGUAGE_NOT_IN_CHANNEL_SCOPE` to `megamenu`, so none of them is
111
+ * declared here.
112
+ *
113
+ * **Five of the twelve are raised outside this package**, which is D-95.2
114
+ * working as intended — routing follows the domain noun, never the thrower.
115
+ * `@endora-commerce/platform`'s channel resolver and membership service raise
116
+ * `MISSING_SALES_CHANNEL_CONTEXT`, `UNKNOWN_SALES_CHANNEL`,
117
+ * `INACTIVE_SALES_CHANNEL` and `ENTITY_WOULD_HAVE_ZERO_CHANNELS` (the
118
+ * `SalesChannel` entity moved to the kernel in feature 072 T019), and
119
+ * `search`'s public route raises `MISSING_SALES_CHANNEL_CONTEXT` too. The
120
+ * sentences stay here.
121
+ *
122
+ * **Three of the twelve are raised by nothing in the tree** —
123
+ * `UNKNOWN_LANGUAGE_CODE`, `UNKNOWN_CURRENCY_CODE` and
124
+ * `SALES_CHANNEL_ATTRIBUTION_IMMUTABLE`. The first two were superseded rather
125
+ * than never built: feature 017 moved language and currency validation onto
126
+ * the central dictionary, so an unknown code is refused as
127
+ * `DICTIONARY_ENTRY_NOT_FOUND` by `dictionaryReferenceHttpError` in this module's own
128
+ * service, and `test/contract/sales_channels/admin-crud-lifecycle.contract.test.ts`
129
+ * asserts that answer while calling these two "legacy" in its own comment. The
130
+ * third is a guard with nothing to guard: no admin route exposes
131
+ * `salesChannelId` mutation on an order or a quote, so FR-012's immutability
132
+ * is structural, and `specs/005-sales-channels/tasks.md` T044/T059 record the
133
+ * guard as vacuous and deferred to the first route that would need it. All
134
+ * three are declared anyway — ownership follows the capture and not the raise
135
+ * sites (trap T10); dropping one reds the progress test as `undeclared` and
136
+ * moves an answer this merge request is not allowed to move.
137
+ *
138
+ * No `tokens`: no code here carries a refusal discriminator. Derived from the
139
+ * raise sites per the runbook's §5 — the envelope's `refusalToken` reads
140
+ * `details.code` and nothing else, every `new HttpError` raising one of these
141
+ * twelve passes either no fourth argument or the Zod-style
142
+ * `Array<{path, issue}>`, which `refusalToken` ignores by construction; the
143
+ * §5 raise-site scan attributes the tree's token-carrying codes to `core`,
144
+ * `invoices` and `carts` and names none of these; and the bundles hold no
145
+ * `errors.<CODE>.<token>` key in the other direction.
146
+ */
147
+ errorCodes: [
148
+ { code: 'CANNOT_MODIFY_SYSTEM_DEFAULT' },
149
+ { code: 'DUPLICATE_SALES_CHANNEL_CODE' },
150
+ { code: 'ENTITY_WOULD_HAVE_ZERO_CHANNELS' },
151
+ { code: 'INACTIVE_SALES_CHANNEL' },
152
+ { code: 'MISSING_SALES_CHANNEL_CONTEXT' },
153
+ { code: 'SALES_CHANNEL_ATTRIBUTION_IMMUTABLE' },
154
+ { code: 'SALES_CHANNEL_CODE_IMMUTABLE' },
155
+ { code: 'SALES_CHANNEL_HAS_ATTRIBUTIONS' },
156
+ { code: 'STALE_SALES_CHANNEL_WRITE' },
157
+ { code: 'UNKNOWN_CURRENCY_CODE' },
158
+ { code: 'UNKNOWN_LANGUAGE_CODE' },
159
+ { code: 'UNKNOWN_SALES_CHANNEL' },
160
+ ],
161
+ i18n: { bundlesDir: 'i18n' },
162
+ docs: { dir: 'docs' },
163
+ permissions: [
164
+ { code: 'sales_channels:read', label: 'View sales channels' },
165
+ { code: 'sales_channels:write', label: 'Manage sales channels' },
166
+ ],
167
+ actions: [
168
+ /**
169
+ * The module's landing surface, declared by feature 091's Phase 4 batch 14.
170
+ *
171
+ * `AppShell.tsx` carried a hand-written *Navigate* row for
172
+ * `/sales-channels` until that batch, and this module declared only the
173
+ * *create* action beside it — so the roster was advertised by a copy the
174
+ * server was never asked about while the create form was advertised by a
175
+ * declaration it served. The destination, the code and the keywords are the
176
+ * row's; the label and description are the two strings it rendered
177
+ * (`appShell.nav.salesChannels`, `appShell.palette.sub.storefrontChannels`),
178
+ * moved into this module's own bundle.
179
+ */
180
+ {
181
+ id: 'open-sales-channels',
182
+ labelKey: 'actions.openSalesChannels.label',
183
+ descriptionKey: 'actions.openSalesChannels.description',
184
+ icon: 'Store',
185
+ targetRoute: '/sales-channels',
186
+ requiredPermission: 'sales_channels:read',
187
+ keywords: ['sales', 'channel', 'channels', 'kanał', 'sprzedaży'],
188
+ weight: 160,
189
+ },
190
+ {
191
+ id: 'new-sales-channel',
192
+ labelKey: 'actions.newSalesChannel.label',
193
+ descriptionKey: 'actions.newSalesChannel.description',
194
+ icon: 'Layers',
195
+ targetRoute: '/sales-channels/new',
196
+ requiredPermission: 'sales_channels:write',
197
+ keywords: ['channel', 'new', 'storefront', 'kanał', 'sprzedaży'],
198
+ weight: 150,
199
+ },
200
+ ],
201
+ });
202
+ /** Legacy export retained for backward compatibility. */
203
+ export const salesChannelsManifest = settings;
204
+ //# sourceMappingURL=manifest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,oBAAoB,EACpB,4BAA4B,GAC7B,MAAM,4BAA4B,CAAC;AAEpC;;;;;GAKG;AACH,MAAM,QAAQ,GAAG,4BAA4B,CAAC;IAC5C,UAAU,EAAE,gBAAgB;IAC5B,MAAM,EAAE;QACN;YACE,IAAI,EAAE,gBAAgB;YACtB,IAAI,EAAE,gBAAgB;SACvB;KACF;IACD,QAAQ,EAAE;QACR;YACE,IAAI,EAAE,+BAA+B;YACrC,IAAI,EAAE,gBAAgB;YACtB,WAAW,EACT,uNAAuN;YACzN,SAAS,EAAE,gBAAgB;YAC3B,SAAS,EAAE,QAAQ;YACnB,YAAY,EAAE,EAAE;SACjB;KACF;CACF,CAAC,CAAC;AAEH,+CAA+C;AAC/C,MAAM,CAAC,MAAM,QAAQ,GAAG,oBAAoB,CAAC;IAC3C,EAAE,EAAE,gBAAgB;IACpB,IAAI,EAAE,gBAAgB;IACtB,WAAW,EAAE,yDAAyD;IACtE,OAAO,EAAE,OAAO;IAChB,+EAA+E;IAC/E,kEAAkE;IAClE,uEAAuE;IACvE,4EAA4E;IAC5E,wEAAwE;IACxE,4EAA4E;IAC5E,8DAA8D;IAC9D,yCAAyC;IACzC,oDAAoD;IACpD,+DAA+D;IAC/D,4EAA4E;IAC5E,uEAAuE;IACvE,6EAA6E;IAC7E,0EAA0E;IAC1E,0EAA0E;IAC1E,gEAAgE;IAChE,4EAA4E;IAC5E,8EAA8E;IAC9E,4DAA4D;IAC5D,EAAE;IACF,8EAA8E;IAC9E,6EAA6E;IAC7E,YAAY,EAAE,CAAC,cAAc,EAAE,UAAU,CAAC;IAC1C,QAAQ;IACR,2EAA2E;IAC3E,4EAA4E;IAC5E,0EAA0E;IAC1E,6EAA6E;IAC7E,2EAA2E;IAC3E,0EAA0E;IAC1E,4BAA4B;IAC5B,EAAE;IACF,6EAA6E;IAC7E,8EAA8E;IAC9E,uEAAuE;IACvE,8DAA8D;IAC9D,UAAU,EAAE;QACV,gBAAgB,EAAE,IAAI;QACtB,MAAM,EACJ,uFAAuF;YACvF,4CAA4C;KAC/C;IACD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAwEG;IACH,UAAU,EAAE;QACV,EAAE,IAAI,EAAE,8BAA8B,EAAE;QACxC,EAAE,IAAI,EAAE,8BAA8B,EAAE;QACxC,EAAE,IAAI,EAAE,iCAAiC,EAAE;QAC3C,EAAE,IAAI,EAAE,wBAAwB,EAAE;QAClC,EAAE,IAAI,EAAE,+BAA+B,EAAE;QACzC,EAAE,IAAI,EAAE,qCAAqC,EAAE;QAC/C,EAAE,IAAI,EAAE,8BAA8B,EAAE;QACxC,EAAE,IAAI,EAAE,gCAAgC,EAAE;QAC1C,EAAE,IAAI,EAAE,2BAA2B,EAAE;QACrC,EAAE,IAAI,EAAE,uBAAuB,EAAE;QACjC,EAAE,IAAI,EAAE,uBAAuB,EAAE;QACjC,EAAE,IAAI,EAAE,uBAAuB,EAAE;KAClC;IACD,IAAI,EAAE,EAAE,UAAU,EAAE,MAAM,EAAE;IAC5B,IAAI,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE;IACrB,WAAW,EAAE;QACX,EAAE,IAAI,EAAE,qBAAqB,EAAE,KAAK,EAAE,qBAAqB,EAAE;QAC7D,EAAE,IAAI,EAAE,sBAAsB,EAAE,KAAK,EAAE,uBAAuB,EAAE;KACjE;IACD,OAAO,EAAE;QACP;;;;;;;;;;;WAWG;QACH;YACE,EAAE,EAAE,qBAAqB;YACzB,QAAQ,EAAE,iCAAiC;YAC3C,cAAc,EAAE,uCAAuC;YACvD,IAAI,EAAE,OAAO;YACb,WAAW,EAAE,iBAAiB;YAC9B,kBAAkB,EAAE,qBAAqB;YACzC,QAAQ,EAAE,CAAC,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,OAAO,EAAE,WAAW,CAAC;YAChE,MAAM,EAAE,GAAG;SACZ;QACD;YACE,EAAE,EAAE,mBAAmB;YACvB,QAAQ,EAAE,+BAA+B;YACzC,cAAc,EAAE,qCAAqC;YACrD,IAAI,EAAE,QAAQ;YACd,WAAW,EAAE,qBAAqB;YAClC,kBAAkB,EAAE,sBAAsB;YAC1C,QAAQ,EAAE,CAAC,SAAS,EAAE,KAAK,EAAE,YAAY,EAAE,OAAO,EAAE,WAAW,CAAC;YAChE,MAAM,EAAE,GAAG;SACZ;KACF;CACF,CAAC,CAAC;AAEH,yDAAyD;AACzD,MAAM,CAAC,MAAM,qBAAqB,GAAG,QAAQ,CAAC"}
@@ -0,0 +1,77 @@
1
+ ---
2
+ title: Admin usage
3
+ sidebar_position: 2
4
+ ---
5
+
6
+ # Admin usage — Sales Channels
7
+
8
+ How a platform administrator drives the Sales Channels module from the Admin UI day to day. Every action below is also reachable through the module's admin HTTP API.
9
+
10
+ ## Locating the area
11
+
12
+ Sidebar → **Operations → Sales channels**.
13
+
14
+ The list page shows every channel registered on the platform. The system-default channel is marked with a `System default` badge and is always present (a freshly-installed platform automatically gets a `default` channel on first boot).
15
+
16
+ ## Creating a new channel
17
+
18
+ 1. Click **+ New channel**.
19
+ 2. Fill in:
20
+ - **Code** — lowercase machine-friendly identifier; immutable after creation.
21
+ - **Display name** — currently a single `en-US` string; multi-locale support is a follow-up.
22
+ - **Theme code** *(optional)* — opaque identifier the storefront uses to pick its theme.
23
+ - **Languages** — comma- or newline-separated. Codes must already exist in the i18n module's languages registry.
24
+ - **Default language** — must be one of the languages above.
25
+ - **Currencies** / **Default currency** — same shape; codes must exist in the currencies registry.
26
+ - **Active** *(default `true`)*.
27
+ 3. **Create channel.** The system refuses if the code is already in use, or if any language / currency code is unknown.
28
+
29
+ ## Editing an existing channel
30
+
31
+ 1. Click a channel's `code` in the list.
32
+ 2. The edit page loads its identity. Make changes; click **Save changes**.
33
+ 3. If another administrator changed the same channel between your load and save, the save returns a 412 conflict banner with the current version. Refresh the page to pull the latest state and re-apply your changes.
34
+
35
+ ## Deactivating a channel
36
+
37
+ Use the **Deactivate** button on the channel detail page. Deactivation is idempotent and reversible:
38
+
39
+ - The channel disappears from the resolver's accept list (storefront / POS requests pointing at it are refused with `INACTIVE_SALES_CHANNEL`).
40
+ - It is hidden from the "Add to channel" picker on every entity edit page.
41
+ - Existing memberships and historical Orders / Quotes still reference it.
42
+
43
+ The system-default channel cannot be deactivated.
44
+
45
+ ## Hard-deleting a channel
46
+
47
+ The **Delete** button is destructive. The platform refuses the operation when:
48
+
49
+ - The channel is the system default.
50
+ - Any Order or Quote references the channel — these attributions are immutable, so the only way to free the channel is to keep it (deactivation is the right answer here).
51
+ - Removing the channel would leave one or more entities (Products, Customers, …) bound to **zero** channels — *unless* you confirm the rebind-to-Default prompt, in which case those entities are rebound to the system default in the same transaction before the channel row is dropped.
52
+
53
+ The bridge tables' `ON DELETE CASCADE` removes every remaining membership row.
54
+
55
+ ## Managing membership from the entity side
56
+
57
+ Every entity edit page that supports channel membership (Products to start; the other 8 types are a mechanical follow-up) shows a **Sales channels** card near the bottom:
58
+
59
+ - The list shows the channels the entity is currently in, with a `System default` badge where appropriate.
60
+ - The picker lists channels the entity is **not** yet in. Pick one and click **Add**.
61
+ - **Remove** triggers the at-least-one-channel invariant — if the entity has only one channel left and you confirm the rebind-to-Default prompt, the system rebinds it to the system default before completing the remove.
62
+
63
+ ## Multi-storefront set-up
64
+
65
+ To run two storefronts on the same backend (e.g. `serwisA.com` and `serwisB.com`), set the `SALES_CHANNEL_HOST_MAP` env var on the backend:
66
+
67
+ ```env
68
+ SALES_CHANNEL_HOST_MAP=serwisA.com=channel-a,serwisB.com=channel-b
69
+ ```
70
+
71
+ Each storefront request resolves to its host's channel automatically; no header is needed. Each storefront then sees only the products / customers / prices that belong to its channel.
72
+
73
+ ## What admins cannot do
74
+
75
+ - Reassign an Order or Quote Request to a different channel after creation. The attribution is immutable; this is a deliberate audit guarantee, not an oversight.
76
+ - Delete the system-default channel. The boot-time reconciler will recreate it on the next platform boot.
77
+ - Force two channels to be system-default simultaneously. The partial unique index prevents it at the database layer.
@@ -0,0 +1,107 @@
1
+ ---
2
+ title: Developer guide
3
+ sidebar_position: 3
4
+ ---
5
+
6
+ # Developer guide — Sales Channels
7
+
8
+ How a backend module integrates with the Sales Channels module: scoping queries by the resolved channel, managing memberships, and reading channel context from request handlers.
9
+
10
+ ## Reading the resolved channel inside a route
11
+
12
+ The resolver middleware decorates every request under `/api/v1/*` with `req.salesChannel` (a serialised `CachedChannel` view of the resolved row). Use the typed helper to keep the access pattern consistent:
13
+
14
+ ```ts
15
+ import { getResolvedChannel } from '../../kernel/sales-channels/sales-channel-resolver.middleware.js';
16
+
17
+ app.get('/api/v1/storefront/products', async (request) => {
18
+ const channel = getResolvedChannel(request);
19
+ return productService.list({ salesChannelId: channel.id });
20
+ });
21
+ ```
22
+
23
+ Outside the request lifecycle (background jobs, CLI scripts), call `SalesChannelResolverService.getByCode(code)` or `getSystemDefault()` directly through the module's composition handle.
24
+
25
+ ## Adding a channel-scoped entity to your module
26
+
27
+ Channel scoping has two layers:
28
+
29
+ 1. **Schema** — the entity gains a many-to-many relationship to `sales_channels` via a new bridge table `sales_channel_<entity>` (composite primary key on both ids, `ON DELETE CASCADE` on both sides). Add the table in your module's next migration.
30
+ 2. **Service** — every read path of the entity that should be filtered by channel takes a `salesChannelId` parameter and joins through the bridge table. Every create / update path that lands a new entity calls `SalesChannelMembershipService.bindToDefaultIfEmpty(entityType, entity.id)` after `persistAndFlush` so newly-created entities default to the system-default channel.
31
+
32
+ Then add the member to the contract's `ChannelMemberEntityTypeSchema` enum, and **declare the bridge from your own module**: export the `{ entityType, table, entityIdColumn }` triple from `src/backend/index.ts` and register it from a boot hook —
33
+
34
+ ```ts
35
+ ctx.onBoot(() => {
36
+ const { salesChannelBridgeRegistry } = ctx.cradle<YourCradle>();
37
+ for (const bridge of salesChannelBridges) salesChannelBridgeRegistry.register(bridge);
38
+ });
39
+ ```
40
+
41
+ The bidirectional admin routes then pick it up automatically — no per-module routes needed. The platform deliberately holds no map of the bridges: it used to, total over the enum, which meant a membership call for a member whose module an instance never installed ran SQL against a relation that is not there. A member no module registered now refuses with `503 MODULE_DISABLED` before the database is reached, and the enum stays the published *vocabulary* while the registry decides which members are live.
42
+
43
+ ## Mutating memberships
44
+
45
+ `SalesChannelMembershipService` is the single mutator for every bridge table. Direct INSERT / DELETE on `sales_channel_*` from anywhere else is forbidden — the lint rule `no-unscoped-channel-query` is the safety net (it ships disabled and gets turned on once every existing call site has been threaded).
46
+
47
+ ```ts
48
+ const result = await membershipService.addToChannel(channelId, 'product', productId);
49
+ // result.changed is false on idempotent re-add.
50
+
51
+ const removed = await membershipService.removeFromChannel(channelId, 'product', productId, {
52
+ fallbackToDefault: true,
53
+ });
54
+ // throws ENTITY_WOULD_HAVE_ZERO_CHANNELS if it would orphan the entity AND fallbackToDefault is false.
55
+ ```
56
+
57
+ ## The Default channel guarantee
58
+
59
+ The `DefaultChannelReconciler` runs at every backend boot from `composition.ts` (and from `test-server.ts` for integration tests). Three branches:
60
+
61
+ 1. **Empty `sales_channels` table** — inserts a new `default` row sourced from `DEFAULT_SALES_CHANNEL_CODE` (env, default `default`).
62
+ 2. **Rows exist but none has `system_default = true`** — promotes the lexically-first row, with a tie-break preferring the row whose `code = 'default'`.
63
+ 3. **Exactly one row already has `system_default = true`** — no-op.
64
+
65
+ The reconciler never demotes, never deletes, and never edits identity. Code outside this module assumes the default exists; if you're writing infra-level code that runs before the reconciler, call `DefaultChannelReconciler.run()` first.
66
+
67
+ ## Optimistic concurrency for identity edits
68
+
69
+ The `version` integer column on `sales_channels` increments by exactly 1 on every successful identity update. The PATCH endpoint requires the client's `expectedVersion` to match the row's current `version`, mismatch → HTTP 412 `STALE_SALES_CHANNEL_WRITE` with the current `version` in the error envelope so the client can refresh and retry.
70
+
71
+ Membership add / remove operations are idempotent by construction and do not bump the channel's `version`.
72
+
73
+ ## Audit hooks
74
+
75
+ Every identity change, lifecycle change, and membership change writes one `audit_log_entries` row synchronously inside the same transaction. Action codes live in `@endora-commerce/contracts`:
76
+
77
+ ```ts
78
+ import { SALES_CHANNEL_AUDIT_ACTIONS } from '@endora-commerce/contracts';
79
+
80
+ await auditLogService.record({
81
+ action: SALES_CHANNEL_AUDIT_ACTIONS.IDENTITY_CHANGED,
82
+ // ...
83
+ });
84
+ ```
85
+
86
+ Use the constants — never hardcode the strings — so a future enum / type rename ripples cleanly.
87
+
88
+ ## Cache invalidation
89
+
90
+ `SalesChannelsCache` (process-local LRU + Redis) holds a `CachedChannel` per `code`. The cache is invalidated on every identity / lifecycle change via the existing `EventBus`:
91
+
92
+ - `sales_channels.identity_changed` → drops one entry by code.
93
+ - `sales_channels.lifecycle_changed` → drops one entry, or all entries when `invalidateAll: true` is set (used on hard delete).
94
+
95
+ Both drop the shared Redis entry first and the local one second, with the key marked for the whole operation so a concurrent read cannot re-pin the pre-change channel — and so a failed Redis drop leaves reads falling through to PostgreSQL rather than being served the value the invalidation was meant to remove. The `EventBus` is in-process, so a second instance converges through the local layer's 30 s window instead.
96
+
97
+ Membership lookups go through MikroORM directly (no cache layer); if you need them faster, add a Redis layer keyed by `(entityType, entityId)` with a short TTL — the hooks are already in place.
98
+
99
+ ## Testing your channel-aware code
100
+
101
+ Use the existing `setupBackendServer()` test harness — it boots the full stack with a fresh `default` channel reconciled against the test seed (`en-US` / `PLN`). For raw-DB-level tests, use `setupTestDb()` and call `DefaultChannelReconciler.run()` yourself, optionally overriding the bootstrap defaults if your test seeds different language/currency codes.
102
+
103
+ The repo's existing precedent for channel-aware integration tests (transactional rollback, parameterised entity types, raw-SQL fixtures decoupled from owning-module entity classes) is in:
104
+
105
+ - `backend/test/integration/sales_channels/bidirectional-membership-every-bridge.test.ts` — table-driven over all 9 bridges.
106
+ - `backend/test/integration/sales_channels/at-least-one-channel-invariant.test.ts` — enforcement of the at-least-one-channel invariant.
107
+ - `backend/test/contract/sales_channels/admin-membership.contract.test.ts` — HTTP-side cover.