@endora-commerce/mod-settings 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 +58 -0
- package/dist/admin/api/settings-client.d.ts +47 -0
- package/dist/admin/api/settings-client.d.ts.map +1 -0
- package/dist/admin/api/settings-client.js +41 -0
- package/dist/admin/api/settings-client.js.map +1 -0
- package/dist/admin/components/ActivationPointerRow.d.ts +21 -0
- package/dist/admin/components/ActivationPointerRow.d.ts.map +1 -0
- package/dist/admin/components/ActivationPointerRow.js +28 -0
- package/dist/admin/components/ActivationPointerRow.js.map +1 -0
- package/dist/admin/components/AssetIdSettingInput.d.ts +12 -0
- package/dist/admin/components/AssetIdSettingInput.d.ts.map +1 -0
- package/dist/admin/components/AssetIdSettingInput.js +10 -0
- package/dist/admin/components/AssetIdSettingInput.js.map +1 -0
- package/dist/admin/components/ConfigurationReferenceInput.d.ts +39 -0
- package/dist/admin/components/ConfigurationReferenceInput.d.ts.map +1 -0
- package/dist/admin/components/ConfigurationReferenceInput.js +74 -0
- package/dist/admin/components/ConfigurationReferenceInput.js.map +1 -0
- package/dist/admin/components/ConflictBanner.d.ts +13 -0
- package/dist/admin/components/ConflictBanner.d.ts.map +1 -0
- package/dist/admin/components/ConflictBanner.js +12 -0
- package/dist/admin/components/ConflictBanner.js.map +1 -0
- package/dist/admin/components/ImageSettingInput.d.ts +34 -0
- package/dist/admin/components/ImageSettingInput.d.ts.map +1 -0
- package/dist/admin/components/ImageSettingInput.js +36 -0
- package/dist/admin/components/ImageSettingInput.js.map +1 -0
- package/dist/admin/components/QuoteRequestsSettingsTab.d.ts +3 -0
- package/dist/admin/components/QuoteRequestsSettingsTab.d.ts.map +1 -0
- package/dist/admin/components/QuoteRequestsSettingsTab.js +69 -0
- package/dist/admin/components/QuoteRequestsSettingsTab.js.map +1 -0
- package/dist/admin/components/SellerCompanyDataInput.d.ts +22 -0
- package/dist/admin/components/SellerCompanyDataInput.d.ts.map +1 -0
- package/dist/admin/components/SellerCompanyDataInput.js +53 -0
- package/dist/admin/components/SellerCompanyDataInput.js.map +1 -0
- package/dist/admin/components/SettingRowEditor.d.ts +55 -0
- package/dist/admin/components/SettingRowEditor.d.ts.map +1 -0
- package/dist/admin/components/SettingRowEditor.js +160 -0
- package/dist/admin/components/SettingRowEditor.js.map +1 -0
- package/dist/admin/index.d.ts +39 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +87 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/pages/CachePage.d.ts +14 -0
- package/dist/admin/pages/CachePage.d.ts.map +1 -0
- package/dist/admin/pages/CachePage.js +124 -0
- package/dist/admin/pages/CachePage.js.map +1 -0
- package/dist/admin/pages/GroupsPage.d.ts +17 -0
- package/dist/admin/pages/GroupsPage.d.ts.map +1 -0
- package/dist/admin/pages/GroupsPage.js +84 -0
- package/dist/admin/pages/GroupsPage.js.map +1 -0
- package/dist/admin/pages/SettingsPage.d.ts +20 -0
- package/dist/admin/pages/SettingsPage.d.ts.map +1 -0
- package/dist/admin/pages/SettingsPage.js +453 -0
- package/dist/admin/pages/SettingsPage.js.map +1 -0
- package/dist/backend/cli/cache-clear.d.ts +23 -0
- package/dist/backend/cli/cache-clear.d.ts.map +1 -0
- package/dist/backend/cli/cache-clear.js +37 -0
- package/dist/backend/cli/cache-clear.js.map +1 -0
- package/dist/backend/index.d.ts +100 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +125 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/routes.admin.d.ts +308 -0
- package/dist/backend/routes.admin.d.ts.map +1 -0
- package/dist/backend/routes.admin.js +188 -0
- package/dist/backend/routes.admin.js.map +1 -0
- package/dist/backend/routes.cache.d.ts +20 -0
- package/dist/backend/routes.cache.d.ts.map +1 -0
- package/dist/backend/routes.cache.js +22 -0
- package/dist/backend/routes.cache.js.map +1 -0
- package/dist/backend/routes.homepage.d.ts +15 -0
- package/dist/backend/routes.homepage.d.ts.map +1 -0
- package/dist/backend/routes.homepage.js +20 -0
- package/dist/backend/routes.homepage.js.map +1 -0
- package/dist/backend/routes.product-card-buttons.d.ts +15 -0
- package/dist/backend/routes.product-card-buttons.d.ts.map +1 -0
- package/dist/backend/routes.product-card-buttons.js +21 -0
- package/dist/backend/routes.product-card-buttons.js.map +1 -0
- package/dist/backend/routes.speculation-rules.d.ts +16 -0
- package/dist/backend/routes.speculation-rules.d.ts.map +1 -0
- package/dist/backend/routes.speculation-rules.js +21 -0
- package/dist/backend/routes.speculation-rules.js.map +1 -0
- package/dist/backend/routes.storefront.d.ts +15 -0
- package/dist/backend/routes.storefront.d.ts.map +1 -0
- package/dist/backend/routes.storefront.js +20 -0
- package/dist/backend/routes.storefront.js.map +1 -0
- package/dist/backend/services/cache-admin.service.d.ts +83 -0
- package/dist/backend/services/cache-admin.service.d.ts.map +1 -0
- package/dist/backend/services/cache-admin.service.js +148 -0
- package/dist/backend/services/cache-admin.service.js.map +1 -0
- package/dist/backend/services/homepage-resolver.d.ts +25 -0
- package/dist/backend/services/homepage-resolver.d.ts.map +1 -0
- package/dist/backend/services/homepage-resolver.js +31 -0
- package/dist/backend/services/homepage-resolver.js.map +1 -0
- package/dist/backend/services/product-card-buttons-resolver.d.ts +25 -0
- package/dist/backend/services/product-card-buttons-resolver.d.ts.map +1 -0
- package/dist/backend/services/product-card-buttons-resolver.js +37 -0
- package/dist/backend/services/product-card-buttons-resolver.js.map +1 -0
- package/dist/backend/services/registered-settings-manifests.d.ts +45 -0
- package/dist/backend/services/registered-settings-manifests.d.ts.map +1 -0
- package/dist/backend/services/registered-settings-manifests.js +55 -0
- package/dist/backend/services/registered-settings-manifests.js.map +1 -0
- package/dist/backend/services/setting-write-validators.d.ts +43 -0
- package/dist/backend/services/setting-write-validators.d.ts.map +1 -0
- package/dist/backend/services/setting-write-validators.js +43 -0
- package/dist/backend/services/setting-write-validators.js.map +1 -0
- package/dist/backend/services/settings-admin.service.d.ts +206 -0
- package/dist/backend/services/settings-admin.service.d.ts.map +1 -0
- package/dist/backend/services/settings-admin.service.js +642 -0
- package/dist/backend/services/settings-admin.service.js.map +1 -0
- package/dist/backend/services/shop-info-resolver.d.ts +15 -0
- package/dist/backend/services/shop-info-resolver.d.ts.map +1 -0
- package/dist/backend/services/shop-info-resolver.js +44 -0
- package/dist/backend/services/shop-info-resolver.js.map +1 -0
- package/dist/backend/services/speculation-rules-resolver.d.ts +28 -0
- package/dist/backend/services/speculation-rules-resolver.d.ts.map +1 -0
- package/dist/backend/services/speculation-rules-resolver.js +39 -0
- package/dist/backend/services/speculation-rules-resolver.js.map +1 -0
- package/dist/manifest.d.ts +194 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +379 -0
- package/dist/manifest.js.map +1 -0
- package/docs/settings/index.md +171 -0
- package/i18n/en.json +167 -0
- package/i18n/pl.json +167 -0
- package/package.json +96 -0
- package/tailwind.css +14 -0
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
import { ERROR_CODES, valueSchemaForType, } from '@endora-commerce/contracts';
|
|
2
|
+
import { HttpError } from '@endora-commerce/platform/http';
|
|
3
|
+
import { SettingGroup } from '@endora-commerce/platform/kernel';
|
|
4
|
+
import { Setting } from '@endora-commerce/platform/kernel';
|
|
5
|
+
import { SettingValue } from '@endora-commerce/platform/kernel';
|
|
6
|
+
import { SalesChannel } from '@endora-commerce/platform/kernel';
|
|
7
|
+
import { SecretKeyMissing, SecretKeyInvalid, encryptSecretValue } from '@endora-commerce/platform/kernel';
|
|
8
|
+
export class SettingsAdminService {
|
|
9
|
+
emFactory;
|
|
10
|
+
eventBus;
|
|
11
|
+
cache;
|
|
12
|
+
auditLogService;
|
|
13
|
+
secretEncryptionKey;
|
|
14
|
+
presence;
|
|
15
|
+
writeValidators;
|
|
16
|
+
constructor(emFactory, eventBus,
|
|
17
|
+
/**
|
|
18
|
+
* The kernel's settings cache, contributed by a root as `settingsCache`.
|
|
19
|
+
* Required, and deliberately not optional: an optional cache is a cache
|
|
20
|
+
* that is silently absent in one composition and present in the other,
|
|
21
|
+
* which is the shape every previous defect in this area had.
|
|
22
|
+
*/
|
|
23
|
+
cache, auditLogService,
|
|
24
|
+
/** Base64 32-byte key for `secret` settings (feature 043, FR-021). */
|
|
25
|
+
secretEncryptionKey,
|
|
26
|
+
/**
|
|
27
|
+
* Feature 073. Absent in a composition with no lifecycle (unit tests):
|
|
28
|
+
* every setting then classifies as editable, which is the pre-073
|
|
29
|
+
* behaviour rather than a fall-open — there is no activation axis to
|
|
30
|
+
* resolve at all.
|
|
31
|
+
*/
|
|
32
|
+
presence,
|
|
33
|
+
/**
|
|
34
|
+
* Feature 078, D-95.2. Absent in a composition that registers no registry:
|
|
35
|
+
* every write is then accepted on shape alone, which is the pre-078
|
|
36
|
+
* behaviour. Present, it is where the module that declared the setting gets
|
|
37
|
+
* to refuse a value `settings` cannot judge.
|
|
38
|
+
*/
|
|
39
|
+
writeValidators) {
|
|
40
|
+
this.emFactory = emFactory;
|
|
41
|
+
this.eventBus = eventBus;
|
|
42
|
+
this.cache = cache;
|
|
43
|
+
this.auditLogService = auditLogService;
|
|
44
|
+
this.secretEncryptionKey = secretEncryptionKey;
|
|
45
|
+
this.presence = presence;
|
|
46
|
+
this.writeValidators = writeValidators;
|
|
47
|
+
}
|
|
48
|
+
// ------------------------------------------------------------------------
|
|
49
|
+
// Feature 073 — presence classification (Constitution XVII)
|
|
50
|
+
// ------------------------------------------------------------------------
|
|
51
|
+
/**
|
|
52
|
+
* Classify one setting against the effective state of the module that owns
|
|
53
|
+
* it. Per setting, not per group: a disabled module's activation control must
|
|
54
|
+
* stay visible and writable (Constitution XVII's single exception), so its
|
|
55
|
+
* group stays non-empty and a group-level filter would render the whole
|
|
56
|
+
* group — every knob of a module that is supposed to be gone.
|
|
57
|
+
*/
|
|
58
|
+
classify(setting) {
|
|
59
|
+
if (!this.presence)
|
|
60
|
+
return { editable: true, activationControl: false };
|
|
61
|
+
const activationControl = this.presence.activationControlOwner(setting.code) === setting.ownerModule;
|
|
62
|
+
if (activationControl) {
|
|
63
|
+
// The one way back. An operator who switched a module off must be able to
|
|
64
|
+
// switch it on again, so this row never goes read-only.
|
|
65
|
+
return { editable: true, activationControl: true };
|
|
66
|
+
}
|
|
67
|
+
// `undefined` ⇒ the owner is not a module the platform manages, so nothing
|
|
68
|
+
// about it is switched off and the row keeps its pre-073 behaviour.
|
|
69
|
+
return {
|
|
70
|
+
editable: this.presence.presenceOf(setting.ownerModule) ?? true,
|
|
71
|
+
activationControl: false,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Refuse a write the classification says is not available.
|
|
76
|
+
*
|
|
77
|
+
* Two distinct refusals, because they mean different things to an operator:
|
|
78
|
+
* an activation code has a *different* door (the audited Command), while an
|
|
79
|
+
* absent module's ordinary setting has *no* door until the module is back.
|
|
80
|
+
*/
|
|
81
|
+
assertWritable(setting) {
|
|
82
|
+
const { editable, activationControl } = this.classify(setting);
|
|
83
|
+
if (activationControl) {
|
|
84
|
+
throw new HttpError(400, ERROR_CODES.MODULE_ACTIVATION_PROTECTED, `"${setting.code}" is the activation control for module "${setting.ownerModule}". ` +
|
|
85
|
+
`Change it through POST /api/v1/admin/modules/${setting.ownerModule}/activation, ` +
|
|
86
|
+
`which audits the change.`);
|
|
87
|
+
}
|
|
88
|
+
if (!editable) {
|
|
89
|
+
throw new HttpError(400, ERROR_CODES.MODULE_SETTING_READ_ONLY, `Module "${setting.ownerModule}" is switched off, so "${setting.code}" cannot be changed. ` +
|
|
90
|
+
`Its stored value is preserved and becomes editable again when the module is switched on.`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// ------------------------------------------------------------------------
|
|
94
|
+
// Read paths
|
|
95
|
+
// ------------------------------------------------------------------------
|
|
96
|
+
async listGroups(filter) {
|
|
97
|
+
const em = this.emFactory();
|
|
98
|
+
const where = filter?.groupCode ? { code: filter.groupCode } : {};
|
|
99
|
+
const groups = await em.find(SettingGroup, where, {
|
|
100
|
+
populate: ['salesChannels'],
|
|
101
|
+
orderBy: { name: 'asc' },
|
|
102
|
+
});
|
|
103
|
+
const out = [];
|
|
104
|
+
for (const group of groups) {
|
|
105
|
+
const settings = await em.find(Setting, { group }, { populate: ['salesChannels'], orderBy: { name: 'asc' } });
|
|
106
|
+
// Hidden settings are managed only through their owning module's dedicated
|
|
107
|
+
// UI; exclude them from the generic Settings screen. A group whose every
|
|
108
|
+
// setting is hidden is dropped entirely (single-source-of-truth), but a
|
|
109
|
+
// genuinely empty group is preserved so group management still lists it.
|
|
110
|
+
const visible = settings.filter((s) => !s.hidden);
|
|
111
|
+
if (visible.length === 0 && settings.length > 0)
|
|
112
|
+
continue;
|
|
113
|
+
const settingsWithValues = [];
|
|
114
|
+
for (const setting of visible) {
|
|
115
|
+
const values = await em.find(SettingValue, { setting }, { populate: ['salesChannel'] });
|
|
116
|
+
// Feature 073: classified, never dropped. An absent module's stored
|
|
117
|
+
// configuration stays readable (off is not uninstall) and the admin
|
|
118
|
+
// renders it read-only from these flags.
|
|
119
|
+
settingsWithValues.push({ setting, values, ...this.classify(setting) });
|
|
120
|
+
}
|
|
121
|
+
out.push({ group, settings: settingsWithValues });
|
|
122
|
+
}
|
|
123
|
+
return { groups: out };
|
|
124
|
+
}
|
|
125
|
+
async getSettingByCode(code) {
|
|
126
|
+
const em = this.emFactory();
|
|
127
|
+
const setting = await em.findOne(Setting, { code }, { populate: ['salesChannels', 'group'] });
|
|
128
|
+
if (!setting) {
|
|
129
|
+
throw new HttpError(404, ERROR_CODES.SETTING_NOT_REGISTERED, `Setting "${code}" is not registered.`);
|
|
130
|
+
}
|
|
131
|
+
const values = await em.find(SettingValue, { setting }, { populate: ['salesChannel'] });
|
|
132
|
+
return {
|
|
133
|
+
setting,
|
|
134
|
+
values,
|
|
135
|
+
version: this.computeSettingVersion(setting, values),
|
|
136
|
+
...this.classify(setting),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
// ------------------------------------------------------------------------
|
|
140
|
+
// Value writes
|
|
141
|
+
// ------------------------------------------------------------------------
|
|
142
|
+
async setValueForAllChannels(code, rawValue, expectedVersion, actor) {
|
|
143
|
+
return this.setValue(code, rawValue, expectedVersion, actor, { scope: 'all' });
|
|
144
|
+
}
|
|
145
|
+
async setValueForSubset(code, channelCodes, rawValue, expectedVersion, actor) {
|
|
146
|
+
// Feature 073 / FR-009, Constitution XII. Refused here as well as in
|
|
147
|
+
// `setValue`, and refused *before* the empty-subset check: activation is
|
|
148
|
+
// platform-wide, and a per-channel override against an activation code
|
|
149
|
+
// would make module presence channel-dependent through the back door — the
|
|
150
|
+
// one thing a hot-path check that is deliberately not channel-aware cannot
|
|
151
|
+
// survive. Without this, the subset path permits a write against any
|
|
152
|
+
// registered setting.
|
|
153
|
+
const owner = this.presence?.activationControlOwner(code);
|
|
154
|
+
if (owner) {
|
|
155
|
+
throw new HttpError(400, ERROR_CODES.MODULE_ACTIVATION_PROTECTED, `"${code}" is the activation control for module "${owner}". Activation is ` +
|
|
156
|
+
`platform-wide and cannot be set per sales channel.`);
|
|
157
|
+
}
|
|
158
|
+
if (channelCodes.length === 0) {
|
|
159
|
+
throw new HttpError(400, ERROR_CODES.SETTING_EMPTY_SUBSET, 'A subset save must target at least one sales channel.');
|
|
160
|
+
}
|
|
161
|
+
return this.setValue(code, rawValue, expectedVersion, actor, {
|
|
162
|
+
scope: 'subset',
|
|
163
|
+
channelCodes,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
async setValue(code, rawValue, expectedVersion, actor, target) {
|
|
167
|
+
const em = this.emFactory();
|
|
168
|
+
const setting = await em.findOne(Setting, { code }, { populate: ['salesChannels'] });
|
|
169
|
+
if (!setting) {
|
|
170
|
+
throw new HttpError(404, ERROR_CODES.SETTING_NOT_REGISTERED, `Setting "${code}" is not registered.`);
|
|
171
|
+
}
|
|
172
|
+
// Feature 073 — the two write refusals, before any validation: an
|
|
173
|
+
// activation code has a different door, and an absent module's ordinary
|
|
174
|
+
// settings have none until it is back (FR-009, FR-033).
|
|
175
|
+
this.assertWritable(setting);
|
|
176
|
+
// Validate the incoming value against the declared type.
|
|
177
|
+
const schema = valueSchemaForType(setting.valueType);
|
|
178
|
+
const parsed = schema.safeParse(rawValue);
|
|
179
|
+
if (!parsed.success) {
|
|
180
|
+
throw new HttpError(400, ERROR_CODES.SETTING_VALUE_SHAPE_MISMATCH, `Value does not match valueType="${setting.valueType}".`, parsed.error.issues.map((issue) => ({
|
|
181
|
+
path: issue.path.join('.') || '(root)',
|
|
182
|
+
issue: issue.message,
|
|
183
|
+
})));
|
|
184
|
+
}
|
|
185
|
+
// Enum settings: the value must be one of the manifest-declared options.
|
|
186
|
+
if (setting.enumOptions && setting.enumOptions.length > 0) {
|
|
187
|
+
if (typeof parsed.data !== 'string' ||
|
|
188
|
+
!setting.enumOptions.includes(parsed.data)) {
|
|
189
|
+
throw new HttpError(400, ERROR_CODES.SETTING_VALUE_SHAPE_MISMATCH, `Value must be one of: ${setting.enumOptions.join(', ')}.`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Feature 078, D-95.2 — the owning module's own refusal, step 4.
|
|
193
|
+
//
|
|
194
|
+
// After shape and enum validation, so a validator never has to defend
|
|
195
|
+
// itself against a value of the wrong type; before persistence, so a
|
|
196
|
+
// refusal writes nothing. Before `assertVersion` is a judgement call and it
|
|
197
|
+
// is deliberate: a stale-version write that is *also* illegal should say
|
|
198
|
+
// so, because the operator will refetch and re-submit the same value.
|
|
199
|
+
await this.runWriteValidators(em, setting, parsed.data, target);
|
|
200
|
+
// Secret settings (feature 043, FR-021): persist a ciphertext envelope,
|
|
201
|
+
// never the plaintext. An empty string clears the value (stored as '' so
|
|
202
|
+
// the redacted DTO reports isSet=false). No silent plaintext fallback —
|
|
203
|
+
// a missing key is a hard configuration error.
|
|
204
|
+
const isSecret = setting.valueType === 'secret';
|
|
205
|
+
let storedValue = parsed.data;
|
|
206
|
+
if (isSecret) {
|
|
207
|
+
const plaintext = parsed.data;
|
|
208
|
+
try {
|
|
209
|
+
storedValue = plaintext === '' ? '' : encryptSecretValue(plaintext, this.secretEncryptionKey);
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
if (err instanceof SecretKeyMissing || err instanceof SecretKeyInvalid) {
|
|
213
|
+
throw new HttpError(500, ERROR_CODES.SETTING_SECRET_KEY_MISSING, err.message);
|
|
214
|
+
}
|
|
215
|
+
throw err;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const existingValues = await em.find(SettingValue, { setting }, { populate: ['salesChannel'] });
|
|
219
|
+
this.assertVersion(this.computeSettingVersion(setting, existingValues), expectedVersion);
|
|
220
|
+
const affectedChannelIds = [];
|
|
221
|
+
let globalValueUpdated = false;
|
|
222
|
+
if (target.scope === 'all') {
|
|
223
|
+
// Platform-wide global override: writes setting.globalValue only. Channels
|
|
224
|
+
// that already carry their own per-channel `SettingValue` row keep it
|
|
225
|
+
// (their override wins over global). Channels without an explicit
|
|
226
|
+
// override now inherit this new global value via the resolver.
|
|
227
|
+
setting.globalValue = storedValue;
|
|
228
|
+
globalValueUpdated = true;
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
// Per-channel overrides. Validate the requested codes are real channels
|
|
232
|
+
// and within the setting's scope.
|
|
233
|
+
const allChannels = await em.find(SalesChannel, {});
|
|
234
|
+
const inScopeChannels = setting.salesChannels.length === 0
|
|
235
|
+
? allChannels
|
|
236
|
+
: setting.salesChannels.getItems();
|
|
237
|
+
const codeToChannel = new Map(allChannels.map((c) => [c.code, c]));
|
|
238
|
+
const inScopeIds = new Set(inScopeChannels.map((c) => c.id));
|
|
239
|
+
const targetChannels = [];
|
|
240
|
+
for (const c of target.channelCodes) {
|
|
241
|
+
const channel = codeToChannel.get(c);
|
|
242
|
+
if (!channel) {
|
|
243
|
+
throw new HttpError(400, ERROR_CODES.SETTING_OUT_OF_SCOPE_FOR_CHANNEL, `Sales channel "${c}" does not exist.`);
|
|
244
|
+
}
|
|
245
|
+
if (!inScopeIds.has(channel.id)) {
|
|
246
|
+
throw new HttpError(400, ERROR_CODES.SETTING_OUT_OF_SCOPE_FOR_CHANNEL, `Setting "${code}" is not in scope for sales channel "${c}".`);
|
|
247
|
+
}
|
|
248
|
+
targetChannels.push(channel);
|
|
249
|
+
}
|
|
250
|
+
if (targetChannels.length === 0) {
|
|
251
|
+
throw new HttpError(400, ERROR_CODES.SETTING_EMPTY_SUBSET, 'No applicable sales channels — setting cannot be left bound to zero channels.');
|
|
252
|
+
}
|
|
253
|
+
const valuesByChannelId = new Map(existingValues.map((v) => [v.salesChannel.id, v]));
|
|
254
|
+
for (const channel of targetChannels) {
|
|
255
|
+
const existing = valuesByChannelId.get(channel.id);
|
|
256
|
+
if (existing) {
|
|
257
|
+
existing.value = storedValue;
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
em.create(SettingValue, {
|
|
261
|
+
setting,
|
|
262
|
+
salesChannel: channel,
|
|
263
|
+
value: storedValue,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
affectedChannelIds.push(channel.id);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
await em.flush();
|
|
270
|
+
// Refresh values for an accurate post-save version.
|
|
271
|
+
const refreshed = await em.find(SettingValue, { setting }, { populate: ['salesChannel'] });
|
|
272
|
+
const newVersion = this.computeSettingVersion(setting, refreshed);
|
|
273
|
+
// Audit + event after commit.
|
|
274
|
+
if (this.auditLogService) {
|
|
275
|
+
if (globalValueUpdated) {
|
|
276
|
+
await this.auditLogService.record({
|
|
277
|
+
actorAdminUserId: actor.actorAdminUserId,
|
|
278
|
+
action: 'setting.global_value_set',
|
|
279
|
+
objectType: 'setting',
|
|
280
|
+
objectId: setting.id,
|
|
281
|
+
stateAfter: {
|
|
282
|
+
settingCode: setting.code,
|
|
283
|
+
// Secret plaintext never lands in the audit log (FR-021).
|
|
284
|
+
value: isSecret ? '[redacted]' : parsed.data,
|
|
285
|
+
valueType: setting.valueType,
|
|
286
|
+
},
|
|
287
|
+
...(actor.requestId !== undefined ? { requestId: actor.requestId } : {}),
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
for (const channelId of affectedChannelIds) {
|
|
291
|
+
await this.auditLogService.record({
|
|
292
|
+
actorAdminUserId: actor.actorAdminUserId,
|
|
293
|
+
action: 'setting.value_set',
|
|
294
|
+
objectType: 'setting',
|
|
295
|
+
objectId: setting.id,
|
|
296
|
+
stateAfter: {
|
|
297
|
+
settingCode: setting.code,
|
|
298
|
+
salesChannelId: channelId,
|
|
299
|
+
value: isSecret ? '[redacted]' : parsed.data,
|
|
300
|
+
valueType: setting.valueType,
|
|
301
|
+
},
|
|
302
|
+
...(actor.requestId !== undefined ? { requestId: actor.requestId } : {}),
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
// The write seam drops the cache (issue #45) — after the flush, so a
|
|
307
|
+
// concurrent read cannot re-pin the pre-commit value, and before the emit,
|
|
308
|
+
// so every subscriber re-reads post-invalidation state.
|
|
309
|
+
//
|
|
310
|
+
// The count is deliberately unread: `invalidateAfterWrite` answers `null`
|
|
311
|
+
// for an unreachable shared layer and the row is already written and
|
|
312
|
+
// audited, so there is nothing this method could truthfully do with it.
|
|
313
|
+
// Reads stay correct meanwhile — the cache bypasses the marked prefix until
|
|
314
|
+
// a later drop succeeds.
|
|
315
|
+
await this.cache.invalidateAfterWrite(setting.code);
|
|
316
|
+
this.eventBus.emit('settings.value_changed', {
|
|
317
|
+
eventId: `settings.value_changed:${setting.id}:${Date.now()}`,
|
|
318
|
+
occurredAt: new Date().toISOString(),
|
|
319
|
+
settingCode: setting.code,
|
|
320
|
+
salesChannelIds: affectedChannelIds,
|
|
321
|
+
globalValueUpdated,
|
|
322
|
+
valueType: setting.valueType,
|
|
323
|
+
});
|
|
324
|
+
return { setting, affectedChannelIds, newVersion };
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Hand every channel's **post-write** value to the validators the owning
|
|
328
|
+
* module contributed, and let their throw reach the caller unchanged.
|
|
329
|
+
*
|
|
330
|
+
* `settings` computes the projection because it owns three-tier resolution
|
|
331
|
+
* (per-channel row → globalValue → manifest defaultValue); a validator that
|
|
332
|
+
* re-derived it would drift from it. The extra `SalesChannel` read only
|
|
333
|
+
* happens when a validator is actually registered for the code.
|
|
334
|
+
*/
|
|
335
|
+
async runWriteValidators(em, setting, proposed, target) {
|
|
336
|
+
const validators = this.writeValidators?.forCode(setting.code) ?? [];
|
|
337
|
+
if (validators.length === 0)
|
|
338
|
+
return;
|
|
339
|
+
const channels = await em.find(SalesChannel, {});
|
|
340
|
+
const currentValues = await em.find(SettingValue, { setting }, { populate: ['salesChannel'] });
|
|
341
|
+
const currentByChannelId = new Map(currentValues.map((value) => [value.salesChannel.id, value.value]));
|
|
342
|
+
const targetedCodes = new Set(target.scope === 'subset' ? target.channelCodes : []);
|
|
343
|
+
const targetedChannelIds = channels
|
|
344
|
+
.filter((channel) => targetedCodes.has(channel.code))
|
|
345
|
+
.map((channel) => channel.id);
|
|
346
|
+
const targetedIds = new Set(targetedChannelIds);
|
|
347
|
+
const inherited = (channelId) => currentByChannelId.has(channelId)
|
|
348
|
+
? currentByChannelId.get(channelId)
|
|
349
|
+
: setting.globalValue ?? setting.defaultValue;
|
|
350
|
+
const projection = channels.map((channel) => ({
|
|
351
|
+
salesChannelId: channel.id,
|
|
352
|
+
salesChannelCode: channel.code,
|
|
353
|
+
salesChannelName: channelDisplayName(channel),
|
|
354
|
+
// A per-channel row always beats a platform-wide write, so an `all` write
|
|
355
|
+
// does not reach a channel that carries its own value — which is exactly
|
|
356
|
+
// the fact a collision predicate has to see.
|
|
357
|
+
value: target.scope === 'subset'
|
|
358
|
+
? targetedIds.has(channel.id)
|
|
359
|
+
? proposed
|
|
360
|
+
: inherited(channel.id)
|
|
361
|
+
: currentByChannelId.has(channel.id)
|
|
362
|
+
? currentByChannelId.get(channel.id)
|
|
363
|
+
: proposed,
|
|
364
|
+
}));
|
|
365
|
+
for (const validator of validators) {
|
|
366
|
+
await validator.validate({
|
|
367
|
+
code: setting.code,
|
|
368
|
+
ownerModuleId: setting.ownerModule,
|
|
369
|
+
projection,
|
|
370
|
+
targetedChannelIds,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
async resetValues(code, channelCodes, actor) {
|
|
375
|
+
const em = this.emFactory();
|
|
376
|
+
const setting = await em.findOne(Setting, { code });
|
|
377
|
+
if (!setting) {
|
|
378
|
+
throw new HttpError(404, ERROR_CODES.SETTING_NOT_REGISTERED, `Setting "${code}" is not registered.`);
|
|
379
|
+
}
|
|
380
|
+
// A reset is a write. FR-033 says an absent module's configuration is
|
|
381
|
+
// non-editable, and clearing an operator's stored value is the most
|
|
382
|
+
// destructive edit of all.
|
|
383
|
+
this.assertWritable(setting);
|
|
384
|
+
let resetChannelIds = [];
|
|
385
|
+
let globalValueCleared = false;
|
|
386
|
+
if (channelCodes && channelCodes.length > 0) {
|
|
387
|
+
// Channel-scoped reset: delete only the specified channels' override rows.
|
|
388
|
+
// The global override (if any) is left intact; affected channels now
|
|
389
|
+
// inherit it (or fall through to the manifest default).
|
|
390
|
+
const channels = await em.find(SalesChannel, { code: { $in: channelCodes } });
|
|
391
|
+
const ids = channels.map((c) => c.id);
|
|
392
|
+
if (ids.length === 0) {
|
|
393
|
+
return {
|
|
394
|
+
setting,
|
|
395
|
+
resetChannelIds: [],
|
|
396
|
+
globalValueCleared: false,
|
|
397
|
+
newVersion: this.computeSettingVersion(setting, []),
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
const values = await em.find(SettingValue, { setting, salesChannel: { $in: ids } }, { populate: ['salesChannel'] });
|
|
401
|
+
resetChannelIds = values.map((v) => v.salesChannel.id);
|
|
402
|
+
for (const v of values)
|
|
403
|
+
em.remove(v);
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
// Platform-wide reset: clear the global override only. Per-channel
|
|
407
|
+
// override rows are intentionally left untouched (per the agreed
|
|
408
|
+
// semantics; admins can still reset individual channels separately).
|
|
409
|
+
if (setting.globalValue !== null && setting.globalValue !== undefined) {
|
|
410
|
+
setting.globalValue = null;
|
|
411
|
+
globalValueCleared = true;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
await em.flush();
|
|
415
|
+
const remaining = await em.find(SettingValue, { setting }, { populate: ['salesChannel'] });
|
|
416
|
+
const newVersion = this.computeSettingVersion(setting, remaining);
|
|
417
|
+
if (this.auditLogService) {
|
|
418
|
+
for (const channelId of resetChannelIds) {
|
|
419
|
+
await this.auditLogService.record({
|
|
420
|
+
actorAdminUserId: actor.actorAdminUserId,
|
|
421
|
+
action: 'setting.value_reset',
|
|
422
|
+
objectType: 'setting',
|
|
423
|
+
objectId: setting.id,
|
|
424
|
+
stateAfter: { settingCode: setting.code, salesChannelId: channelId },
|
|
425
|
+
...(actor.requestId !== undefined ? { requestId: actor.requestId } : {}),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
if (globalValueCleared) {
|
|
429
|
+
await this.auditLogService.record({
|
|
430
|
+
actorAdminUserId: actor.actorAdminUserId,
|
|
431
|
+
action: 'setting.global_value_reset',
|
|
432
|
+
objectType: 'setting',
|
|
433
|
+
objectId: setting.id,
|
|
434
|
+
stateAfter: { settingCode: setting.code },
|
|
435
|
+
...(actor.requestId !== undefined ? { requestId: actor.requestId } : {}),
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (resetChannelIds.length > 0 || globalValueCleared) {
|
|
440
|
+
await this.cache.invalidateAfterWrite(setting.code);
|
|
441
|
+
this.eventBus.emit('settings.value_changed', {
|
|
442
|
+
eventId: `settings.value_reset:${setting.id}:${Date.now()}`,
|
|
443
|
+
occurredAt: new Date().toISOString(),
|
|
444
|
+
settingCode: setting.code,
|
|
445
|
+
salesChannelIds: resetChannelIds,
|
|
446
|
+
globalValueUpdated: globalValueCleared,
|
|
447
|
+
valueType: setting.valueType,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
return { setting, resetChannelIds, globalValueCleared, newVersion };
|
|
451
|
+
}
|
|
452
|
+
// ------------------------------------------------------------------------
|
|
453
|
+
// Group CRUD
|
|
454
|
+
// ------------------------------------------------------------------------
|
|
455
|
+
async createGroup(input, actor) {
|
|
456
|
+
const em = this.emFactory();
|
|
457
|
+
const existing = await em.findOne(SettingGroup, { code: input.code });
|
|
458
|
+
if (existing) {
|
|
459
|
+
throw new HttpError(409, ERROR_CODES.SETTING_GROUP_CODE_CONFLICT, `Setting group "${input.code}" already exists.`);
|
|
460
|
+
}
|
|
461
|
+
const group = em.create(SettingGroup, {
|
|
462
|
+
code: input.code,
|
|
463
|
+
name: input.name,
|
|
464
|
+
ownerModule: 'manual',
|
|
465
|
+
isSystemProtected: false,
|
|
466
|
+
});
|
|
467
|
+
if (input.salesChannelCodes && input.salesChannelCodes.length > 0) {
|
|
468
|
+
const channels = await em.find(SalesChannel, {
|
|
469
|
+
code: { $in: input.salesChannelCodes },
|
|
470
|
+
});
|
|
471
|
+
for (const c of channels)
|
|
472
|
+
group.salesChannels.add(c);
|
|
473
|
+
}
|
|
474
|
+
await em.persistAndFlush(group);
|
|
475
|
+
if (this.auditLogService) {
|
|
476
|
+
await this.auditLogService.record({
|
|
477
|
+
actorAdminUserId: actor.actorAdminUserId,
|
|
478
|
+
action: 'setting_group.created',
|
|
479
|
+
objectType: 'setting_group',
|
|
480
|
+
objectId: group.id,
|
|
481
|
+
stateAfter: {
|
|
482
|
+
code: group.code,
|
|
483
|
+
name: group.name,
|
|
484
|
+
salesChannelCodes: input.salesChannelCodes ?? [],
|
|
485
|
+
},
|
|
486
|
+
...(actor.requestId !== undefined ? { requestId: actor.requestId } : {}),
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
// A group change can move settings between groups or rescope channels, so
|
|
490
|
+
// it can change resolution for any number of codes; the whole namespace is
|
|
491
|
+
// the cheapest correct drop.
|
|
492
|
+
await this.cache.invalidateAllAfterWrite();
|
|
493
|
+
this.eventBus.emit('settings.group_changed', {
|
|
494
|
+
eventId: `settings.group_changed:${group.id}:${Date.now()}`,
|
|
495
|
+
occurredAt: new Date().toISOString(),
|
|
496
|
+
groupCode: group.code,
|
|
497
|
+
change: 'created',
|
|
498
|
+
});
|
|
499
|
+
return group;
|
|
500
|
+
}
|
|
501
|
+
async updateGroup(code, patch, expectedVersion, actor) {
|
|
502
|
+
const em = this.emFactory();
|
|
503
|
+
const group = await em.findOne(SettingGroup, { code }, { populate: ['salesChannels'] });
|
|
504
|
+
if (!group) {
|
|
505
|
+
throw new HttpError(404, ERROR_CODES.SETTING_GROUP_NOT_FOUND, `Setting group "${code}" not found.`);
|
|
506
|
+
}
|
|
507
|
+
this.assertVersion(group.updatedAt.toISOString(), expectedVersion);
|
|
508
|
+
const stateBefore = {
|
|
509
|
+
name: group.name,
|
|
510
|
+
salesChannelCodes: group.salesChannels.getItems().map((c) => c.code),
|
|
511
|
+
};
|
|
512
|
+
let renamed = false;
|
|
513
|
+
let rescoped = false;
|
|
514
|
+
if (patch.name && patch.name !== group.name) {
|
|
515
|
+
group.name = patch.name;
|
|
516
|
+
renamed = true;
|
|
517
|
+
}
|
|
518
|
+
if (patch.salesChannelCodes !== undefined) {
|
|
519
|
+
const allChannels = await em.find(SalesChannel, {});
|
|
520
|
+
const codeToChannel = new Map(allChannels.map((c) => [c.code, c]));
|
|
521
|
+
group.salesChannels.removeAll();
|
|
522
|
+
for (const c of patch.salesChannelCodes) {
|
|
523
|
+
const channel = codeToChannel.get(c);
|
|
524
|
+
if (channel)
|
|
525
|
+
group.salesChannels.add(channel);
|
|
526
|
+
}
|
|
527
|
+
rescoped = true;
|
|
528
|
+
}
|
|
529
|
+
await em.flush();
|
|
530
|
+
const stateAfter = {
|
|
531
|
+
name: group.name,
|
|
532
|
+
salesChannelCodes: group.salesChannels.getItems().map((c) => c.code),
|
|
533
|
+
};
|
|
534
|
+
if (this.auditLogService) {
|
|
535
|
+
if (renamed) {
|
|
536
|
+
await this.auditLogService.record({
|
|
537
|
+
actorAdminUserId: actor.actorAdminUserId,
|
|
538
|
+
action: 'setting_group.renamed',
|
|
539
|
+
objectType: 'setting_group',
|
|
540
|
+
objectId: group.id,
|
|
541
|
+
stateBefore: { name: stateBefore.name },
|
|
542
|
+
stateAfter: { name: stateAfter.name },
|
|
543
|
+
...(actor.requestId !== undefined ? { requestId: actor.requestId } : {}),
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
if (rescoped) {
|
|
547
|
+
await this.auditLogService.record({
|
|
548
|
+
actorAdminUserId: actor.actorAdminUserId,
|
|
549
|
+
action: 'setting_group.rescoped',
|
|
550
|
+
objectType: 'setting_group',
|
|
551
|
+
objectId: group.id,
|
|
552
|
+
stateBefore: { salesChannelCodes: stateBefore.salesChannelCodes },
|
|
553
|
+
stateAfter: { salesChannelCodes: stateAfter.salesChannelCodes },
|
|
554
|
+
...(actor.requestId !== undefined ? { requestId: actor.requestId } : {}),
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (renamed || rescoped) {
|
|
559
|
+
await this.cache.invalidateAllAfterWrite();
|
|
560
|
+
this.eventBus.emit('settings.group_changed', {
|
|
561
|
+
eventId: `settings.group_changed:${group.id}:${Date.now()}`,
|
|
562
|
+
occurredAt: new Date().toISOString(),
|
|
563
|
+
groupCode: group.code,
|
|
564
|
+
change: renamed && rescoped ? 'rescoped' : renamed ? 'renamed' : 'rescoped',
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
return group;
|
|
568
|
+
}
|
|
569
|
+
async deleteGroup(code, actor) {
|
|
570
|
+
const em = this.emFactory();
|
|
571
|
+
const group = await em.findOne(SettingGroup, { code });
|
|
572
|
+
if (!group) {
|
|
573
|
+
throw new HttpError(404, ERROR_CODES.SETTING_GROUP_NOT_FOUND, `Setting group "${code}" not found.`);
|
|
574
|
+
}
|
|
575
|
+
if (group.isSystemProtected) {
|
|
576
|
+
throw new HttpError(400, ERROR_CODES.SETTING_GROUP_PROTECTED, `Setting group "${code}" is system-protected and cannot be deleted.`);
|
|
577
|
+
}
|
|
578
|
+
const general = await em.findOne(SettingGroup, { code: 'general' });
|
|
579
|
+
if (!general) {
|
|
580
|
+
throw new HttpError(500, ERROR_CODES.INTERNAL, 'Built-in "general" setting group is missing — boot-time sync did not run.');
|
|
581
|
+
}
|
|
582
|
+
const ownedSettings = await em.find(Setting, { group });
|
|
583
|
+
const reassignedSettingIds = [];
|
|
584
|
+
for (const s of ownedSettings) {
|
|
585
|
+
s.group = general;
|
|
586
|
+
reassignedSettingIds.push(s.id);
|
|
587
|
+
}
|
|
588
|
+
em.remove(group);
|
|
589
|
+
await em.flush();
|
|
590
|
+
if (this.auditLogService) {
|
|
591
|
+
await this.auditLogService.record({
|
|
592
|
+
actorAdminUserId: actor.actorAdminUserId,
|
|
593
|
+
action: 'setting_group.deleted',
|
|
594
|
+
objectType: 'setting_group',
|
|
595
|
+
objectId: group.id,
|
|
596
|
+
stateBefore: { code: group.code, name: group.name },
|
|
597
|
+
stateAfter: {
|
|
598
|
+
reassignedSettings: reassignedSettingIds.length,
|
|
599
|
+
reassignedToGroupCode: 'general',
|
|
600
|
+
},
|
|
601
|
+
...(actor.requestId !== undefined ? { requestId: actor.requestId } : {}),
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
await this.cache.invalidateAllAfterWrite();
|
|
605
|
+
this.eventBus.emit('settings.group_changed', {
|
|
606
|
+
eventId: `settings.group_changed:${group.id}:${Date.now()}`,
|
|
607
|
+
occurredAt: new Date().toISOString(),
|
|
608
|
+
groupCode: code,
|
|
609
|
+
change: 'deleted',
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
// ------------------------------------------------------------------------
|
|
613
|
+
// Helpers
|
|
614
|
+
// ------------------------------------------------------------------------
|
|
615
|
+
/** Compute the setting's effective version = max(setting.updated_at, max(values.updated_at)). */
|
|
616
|
+
computeSettingVersion(setting, values) {
|
|
617
|
+
let max = setting.updatedAt.getTime();
|
|
618
|
+
for (const v of values) {
|
|
619
|
+
const t = v.updatedAt.getTime();
|
|
620
|
+
if (t > max)
|
|
621
|
+
max = t;
|
|
622
|
+
}
|
|
623
|
+
return new Date(max).toISOString();
|
|
624
|
+
}
|
|
625
|
+
assertVersion(currentVersion, expectedVersion) {
|
|
626
|
+
if (!expectedVersion)
|
|
627
|
+
return;
|
|
628
|
+
if (currentVersion !== expectedVersion) {
|
|
629
|
+
throw new HttpError(409, ERROR_CODES.VERSION_CONFLICT, 'Setting was modified by another request. Refresh and retry.');
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* A channel's display name for a message. `SalesChannel.name` is a per-language
|
|
635
|
+
* map, so a caller that needs one string picks English, then whatever is there,
|
|
636
|
+
* then the code — which is never empty.
|
|
637
|
+
*/
|
|
638
|
+
function channelDisplayName(channel) {
|
|
639
|
+
const names = channel.name ?? {};
|
|
640
|
+
return names['en'] ?? Object.values(names)[0] ?? channel.code;
|
|
641
|
+
}
|
|
642
|
+
//# sourceMappingURL=settings-admin.service.js.map
|