@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.
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/admin/api/sales-channels-client.d.ts +69 -0
- package/dist/admin/api/sales-channels-client.d.ts.map +1 -0
- package/dist/admin/api/sales-channels-client.js +74 -0
- package/dist/admin/api/sales-channels-client.js.map +1 -0
- package/dist/admin/components/ChannelIdentityForm.d.ts +24 -0
- package/dist/admin/components/ChannelIdentityForm.d.ts.map +1 -0
- package/dist/admin/components/ChannelIdentityForm.js +147 -0
- package/dist/admin/components/ChannelIdentityForm.js.map +1 -0
- package/dist/admin/components/DefaultChannelBadge.d.ts +21 -0
- package/dist/admin/components/DefaultChannelBadge.d.ts.map +1 -0
- package/dist/admin/components/DefaultChannelBadge.js +26 -0
- package/dist/admin/components/DefaultChannelBadge.js.map +1 -0
- package/dist/admin/components/EntityChannelMembership.d.ts +52 -0
- package/dist/admin/components/EntityChannelMembership.d.ts.map +1 -0
- package/dist/admin/components/EntityChannelMembership.js +98 -0
- package/dist/admin/components/EntityChannelMembership.js.map +1 -0
- package/dist/admin/index.d.ts +44 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +148 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/pages/SalesChannelEditPage.d.ts +25 -0
- package/dist/admin/pages/SalesChannelEditPage.d.ts.map +1 -0
- package/dist/admin/pages/SalesChannelEditPage.js +185 -0
- package/dist/admin/pages/SalesChannelEditPage.js.map +1 -0
- package/dist/admin/pages/SalesChannelsListPage.d.ts +25 -0
- package/dist/admin/pages/SalesChannelsListPage.d.ts.map +1 -0
- package/dist/admin/pages/SalesChannelsListPage.js +91 -0
- package/dist/admin/pages/SalesChannelsListPage.js.map +1 -0
- package/dist/admin/zones/OrganizationChannelMembership.d.ts +34 -0
- package/dist/admin/zones/OrganizationChannelMembership.d.ts.map +1 -0
- package/dist/admin/zones/OrganizationChannelMembership.js +11 -0
- package/dist/admin/zones/OrganizationChannelMembership.js.map +1 -0
- package/dist/admin/zones/ProductChannelMembership.d.ts +23 -0
- package/dist/admin/zones/ProductChannelMembership.d.ts.map +1 -0
- package/dist/admin/zones/ProductChannelMembership.js +11 -0
- package/dist/admin/zones/ProductChannelMembership.js.map +1 -0
- package/dist/backend/commands/set-default.command.d.ts +53 -0
- package/dist/backend/commands/set-default.command.d.ts.map +1 -0
- package/dist/backend/commands/set-default.command.js +87 -0
- package/dist/backend/commands/set-default.command.js.map +1 -0
- package/dist/backend/index.d.ts +71 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +81 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/routes.admin.d.ts +12 -0
- package/dist/backend/routes.admin.d.ts.map +1 -0
- package/dist/backend/routes.admin.js +302 -0
- package/dist/backend/routes.admin.js.map +1 -0
- package/dist/backend/routes.storefront.d.ts +36 -0
- package/dist/backend/routes.storefront.d.ts.map +1 -0
- package/dist/backend/routes.storefront.js +58 -0
- package/dist/backend/routes.storefront.js.map +1 -0
- package/dist/backend/services/index.d.ts +2 -0
- package/dist/backend/services/index.d.ts.map +1 -0
- package/dist/backend/services/index.js +2 -0
- package/dist/backend/services/index.js.map +1 -0
- package/dist/backend/services/sales-channel-attribution-registry.d.ts +28 -0
- package/dist/backend/services/sales-channel-attribution-registry.d.ts.map +1 -0
- package/dist/backend/services/sales-channel-attribution-registry.js +50 -0
- package/dist/backend/services/sales-channel-attribution-registry.js.map +1 -0
- package/dist/backend/services/sales-channels.service.d.ts +247 -0
- package/dist/backend/services/sales-channels.service.d.ts.map +1 -0
- package/dist/backend/services/sales-channels.service.js +535 -0
- package/dist/backend/services/sales-channels.service.js.map +1 -0
- package/dist/manifest.d.ts +185 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +204 -0
- package/dist/manifest.js.map +1 -0
- package/docs/sales_channels/admin-usage.md +77 -0
- package/docs/sales_channels/developer-guide.md +107 -0
- package/docs/sales_channels/index.md +95 -0
- package/i18n/en.json +75 -0
- package/i18n/pl.json +75 -0
- package/package.json +88 -0
- 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"}
|
package/dist/manifest.js
ADDED
|
@@ -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":"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"}
|
|
@@ -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.
|