@endora-commerce/mod-languages 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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -0
  3. package/dist/backend/entities/language.entity.d.ts +23 -0
  4. package/dist/backend/entities/language.entity.d.ts.map +1 -0
  5. package/dist/backend/entities/language.entity.js +78 -0
  6. package/dist/backend/entities/language.entity.js.map +1 -0
  7. package/dist/backend/index.d.ts +63 -0
  8. package/dist/backend/index.d.ts.map +1 -0
  9. package/dist/backend/index.js +114 -0
  10. package/dist/backend/index.js.map +1 -0
  11. package/dist/backend/routes.d.ts +28 -0
  12. package/dist/backend/routes.d.ts.map +1 -0
  13. package/dist/backend/routes.js +74 -0
  14. package/dist/backend/routes.js.map +1 -0
  15. package/dist/backend/services/language-ports.d.ts +33 -0
  16. package/dist/backend/services/language-ports.d.ts.map +1 -0
  17. package/dist/backend/services/language-ports.js +70 -0
  18. package/dist/backend/services/language-ports.js.map +1 -0
  19. package/dist/backend/services/language-reference-registry.d.ts +30 -0
  20. package/dist/backend/services/language-reference-registry.d.ts.map +1 -0
  21. package/dist/backend/services/language-reference-registry.js +54 -0
  22. package/dist/backend/services/language-reference-registry.js.map +1 -0
  23. package/dist/backend/services/language-seed-service.d.ts +24 -0
  24. package/dist/backend/services/language-seed-service.d.ts.map +1 -0
  25. package/dist/backend/services/language-seed-service.js +40 -0
  26. package/dist/backend/services/language-seed-service.js.map +1 -0
  27. package/dist/backend/services/language-service.d.ts +99 -0
  28. package/dist/backend/services/language-service.d.ts.map +1 -0
  29. package/dist/backend/services/language-service.js +299 -0
  30. package/dist/backend/services/language-service.js.map +1 -0
  31. package/dist/backend/services/locale-service.d.ts +35 -0
  32. package/dist/backend/services/locale-service.d.ts.map +1 -0
  33. package/dist/backend/services/locale-service.js +88 -0
  34. package/dist/backend/services/locale-service.js.map +1 -0
  35. package/dist/manifest.d.ts +169 -0
  36. package/dist/manifest.d.ts.map +1 -0
  37. package/dist/manifest.js +40 -0
  38. package/dist/manifest.js.map +1 -0
  39. package/dist/migrations/20260425T161557_languages_currencies_init.d.ts +13 -0
  40. package/dist/migrations/20260425T161557_languages_currencies_init.d.ts.map +1 -0
  41. package/dist/migrations/20260425T161557_languages_currencies_init.js +60 -0
  42. package/dist/migrations/20260425T161557_languages_currencies_init.js.map +1 -0
  43. package/dist/migrations/index.d.ts +27 -0
  44. package/dist/migrations/index.d.ts.map +1 -0
  45. package/dist/migrations/index.js +27 -0
  46. package/dist/migrations/index.js.map +1 -0
  47. package/docs/languages.md +88 -0
  48. package/package.json +68 -0
@@ -0,0 +1,54 @@
1
+ /**
2
+ * In-process registry of "who still points at this language code" descriptors
3
+ * (feature 077, D-87 drain).
4
+ *
5
+ * Registered under `languageReferenceRegistry` and contributed to from other modules'
6
+ * boot hooks. This module consults it before deleting a language, and
7
+ * `dictionaries` consults it to build the orphan report.
8
+ *
9
+ * **Enumeration policy: honoured while the contributing module is absent.**
10
+ * D-39's default is to skip, and honouring needs a written reason: this is
11
+ * referential integrity, not a surface. A switched-off module still owns the
12
+ * rows that carry the code, so skipping its descriptor would let an operator
13
+ * delete a language that comes back as a dangling reference the moment the module
14
+ * is switched on again — data loss caused by an action Constitution XVII
15
+ * promises is non-destructive and reversible.
16
+ *
17
+ * `ownerModuleId` is recorded on every descriptor even though this registry
18
+ * does not filter on it, because the alternative is a registry that could not
19
+ * express the decision either way, and because it attributes a 409 to a module.
20
+ */
21
+ export class LanguageReferenceRegistry {
22
+ descriptors = [];
23
+ register(descriptor) {
24
+ if (!this.descriptors.includes(descriptor)) {
25
+ this.descriptors.push(descriptor);
26
+ }
27
+ }
28
+ /** The contributing module of every registered descriptor, in registration order. */
29
+ owners() {
30
+ return this.descriptors.map((d) => d.ownerModuleId);
31
+ }
32
+ async countReferences(code) {
33
+ const counts = await Promise.all(this.descriptors.map(async (d) => ({ descriptor: d, count: await d.countReferences(code) })));
34
+ return counts
35
+ .filter((row) => row.count > 0)
36
+ .map(({ descriptor, count }) => describe(descriptor, code, count));
37
+ }
38
+ async usedCodes() {
39
+ const perDescriptor = await Promise.all(this.descriptors.map(async (d) => ({ descriptor: d, rows: await d.usedCodes() })));
40
+ return perDescriptor.flatMap(({ descriptor, rows }) => rows.map((row) => describe(descriptor, row.code, row.count)));
41
+ }
42
+ }
43
+ function describe(descriptor, code, count) {
44
+ return {
45
+ ownerModuleId: descriptor.ownerModuleId,
46
+ consumer: descriptor.consumer,
47
+ tableName: descriptor.tableName,
48
+ columnName: descriptor.columnName,
49
+ code,
50
+ count,
51
+ blocking: descriptor.blocking,
52
+ };
53
+ }
54
+ //# sourceMappingURL=language-reference-registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"language-reference-registry.js","sourceRoot":"","sources":["../../../src/backend/services/language-reference-registry.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,OAAO,yBAAyB;IACnB,WAAW,GAAoC,EAAE,CAAC;IAEnE,QAAQ,CAAC,UAAyC;QAChD,IAAI,CAAC,IAAI,CAAC,WAAW,CAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,CAAC;YAC3C,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,UAAU,CAAC,CAAC;QACpC,CAAC;IACH,CAAC;IAED,qFAAqF;IACrF,MAAM;QACJ,OAAO,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC;IACtD,CAAC;IAED,KAAK,CAAC,eAAe,CAAC,IAAY;QAChC,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,GAAG,CAC9B,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,eAAe,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAC7F,CAAC;QACF,OAAO,MAAM;aACV,MAAM,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,KAAK,GAAG,CAAC,CAAC;aAC9B,GAAG,CAAC,CAAC,EAAE,UAAU,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,QAAQ,CAAC,UAAU,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC,CAAC;IACvE,CAAC;IAED,KAAK,CAAC,SAAS;QACb,MAAM,aAAa,GAAG,MAAM,OAAO,CAAC,GAAG,CACrC,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC,CAClF,CAAC;QACF,OAAO,aAAa,CAAC,OAAO,CAAC,CAAC,EAAE,UAAU,EAAE,IAAI,EAAE,EAAE,EAAE,CACpD,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,QAAQ,CAAC,UAAU,EAAE,GAAG,CAAC,IAAI,EAAE,GAAG,CAAC,KAAK,CAAC,CAAC,CAC7D,CAAC;IACJ,CAAC;CACF;AAED,SAAS,QAAQ,CACf,UAAyC,EACzC,IAAY,EACZ,KAAa;IAEb,OAAO;QACL,aAAa,EAAE,UAAU,CAAC,aAAa;QACvC,QAAQ,EAAE,UAAU,CAAC,QAAQ;QAC7B,SAAS,EAAE,UAAU,CAAC,SAAS;QAC/B,UAAU,EAAE,UAAU,CAAC,UAAU;QACjC,IAAI;QACJ,KAAK;QACL,QAAQ,EAAE,UAAU,CAAC,QAAQ;KAC9B,CAAC;AACJ,CAAC"}
@@ -0,0 +1,24 @@
1
+ import type { LanguageSeedPort } from '@endora-commerce/contracts';
2
+ import type { EntityManager } from '@mikro-orm/postgresql';
3
+ /**
4
+ * The seeding face of `languages` (feature 077, D-87 drain).
5
+ *
6
+ * One job: fill the empty `native_label` that migration 038 left behind on the
7
+ * two languages migration 012 created. `dictionaries` knows the labels — it
8
+ * ships the reference catalogue — and this module owns the table, so before
9
+ * this port the reconciler wrote it with an `update "languages" …` naming
10
+ * another module's table in a string no import-level check can see.
11
+ *
12
+ * The `and "native_label" = ''` predicate is the idempotency: once an operator
13
+ * (or an earlier boot) has set a label, a re-run matches no row and reverts
14
+ * nothing.
15
+ */
16
+ export declare class LanguageSeedService implements LanguageSeedPort {
17
+ private readonly emFactory;
18
+ constructor(emFactory: () => EntityManager);
19
+ backfillNativeLabels(rows: readonly {
20
+ code: string;
21
+ nativeLabel: string;
22
+ }[]): Promise<number>;
23
+ }
24
+ //# sourceMappingURL=language-seed-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"language-seed-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/language-seed-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAC;AACnE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAE3D;;;;;;;;;;;;GAYG;AACH,qBAAa,mBAAoB,YAAW,gBAAgB;IAC9C,OAAO,CAAC,QAAQ,CAAC,SAAS;gBAAT,SAAS,EAAE,MAAM,aAAa;IAErD,oBAAoB,CACxB,IAAI,EAAE,SAAS;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,EAAE,GACrD,OAAO,CAAC,MAAM,CAAC;CAqBnB"}
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The seeding face of `languages` (feature 077, D-87 drain).
3
+ *
4
+ * One job: fill the empty `native_label` that migration 038 left behind on the
5
+ * two languages migration 012 created. `dictionaries` knows the labels — it
6
+ * ships the reference catalogue — and this module owns the table, so before
7
+ * this port the reconciler wrote it with an `update "languages" …` naming
8
+ * another module's table in a string no import-level check can see.
9
+ *
10
+ * The `and "native_label" = ''` predicate is the idempotency: once an operator
11
+ * (or an earlier boot) has set a label, a re-run matches no row and reverts
12
+ * nothing.
13
+ */
14
+ export class LanguageSeedService {
15
+ emFactory;
16
+ constructor(emFactory) {
17
+ this.emFactory = emFactory;
18
+ }
19
+ async backfillNativeLabels(rows) {
20
+ const em = this.emFactory();
21
+ let filled = 0;
22
+ for (const row of rows) {
23
+ const before = (await em.execute('select "native_label" from "languages" where "code" = ?', [
24
+ row.code,
25
+ ]));
26
+ if (before.length === 0)
27
+ continue;
28
+ if (before[0]?.native_label !== '')
29
+ continue;
30
+ // command-coverage-ignore: idempotent lifecycle reconciler/seed — a system-
31
+ // owned backfill of a column the migration left empty, with no actor behind
32
+ // it. The `= ''` predicate means it can only ever fill a blank, never
33
+ // overwrite an operator's label, so there is nothing to audit or undo.
34
+ await em.execute(`update "languages" set "native_label" = ? where "code" = ? and "native_label" = ''`, [row.nativeLabel, row.code]);
35
+ filled += 1;
36
+ }
37
+ return filled;
38
+ }
39
+ }
40
+ //# sourceMappingURL=language-seed-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"language-seed-service.js","sourceRoot":"","sources":["../../../src/backend/services/language-seed-service.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;GAYG;AACH,MAAM,OAAO,mBAAmB;IACD;IAA7B,YAA6B,SAA8B;QAA9B,cAAS,GAAT,SAAS,CAAqB;IAAG,CAAC;IAE/D,KAAK,CAAC,oBAAoB,CACxB,IAAsD;QAEtD,MAAM,EAAE,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;QAC5B,IAAI,MAAM,GAAG,CAAC,CAAC;QACf,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACvB,MAAM,MAAM,GAAG,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,yDAAyD,EAAE;gBAC1F,GAAG,CAAC,IAAI;aACT,CAAC,CAAoC,CAAC;YACvC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;gBAAE,SAAS;YAClC,IAAI,MAAM,CAAC,CAAC,CAAC,EAAE,YAAY,KAAK,EAAE;gBAAE,SAAS;YAC7C,4EAA4E;YAC5E,4EAA4E;YAC5E,sEAAsE;YACtE,uEAAuE;YACvE,MAAM,EAAE,CAAC,OAAO,CACd,oFAAoF,EACpF,CAAC,GAAG,CAAC,WAAW,EAAE,GAAG,CAAC,IAAI,CAAC,CAC5B,CAAC;YACF,MAAM,IAAI,CAAC,CAAC;QACd,CAAC;QACD,OAAO,MAAM,CAAC;IAChB,CAAC;CACF"}
@@ -0,0 +1,99 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql';
2
+ import { type DictionaryReference, type DictionaryReferenceRegistryPort } from '@endora-commerce/contracts';
3
+ import { Language } from '../entities/language.entity.js';
4
+ import type { AuditPort } from '@endora-commerce/platform/kernel';
5
+ /**
6
+ * LanguageService — admin CRUD over the languages pool.
7
+ *
8
+ * Originally introduced by feature 004 (T238 / FR-105) for the minimal
9
+ * languages table. Extended by feature 017 to cover the new columns
10
+ * (`nativeLabel`, `isRtl`, `fallbackCode`) and the spec invariants:
11
+ *
12
+ * - exactly one default at a time (partial unique index, transactional
13
+ * promote+demote).
14
+ * - at-least-one-active (the only active row cannot be deactivated).
15
+ * - default-cannot-be-deactivated.
16
+ * - fallback-cycle guard (the `code → fallback_code → ...` chain must
17
+ * terminate without revisiting any node).
18
+ * - FK-protected hard-delete: refused while any consumer references the
19
+ * language (sales channels' default_language + languages JSONB,
20
+ * megamenu bindings, blog post / category languages, CMS page
21
+ * languages).
22
+ */
23
+ export declare class LanguageService {
24
+ #private;
25
+ private readonly emFactory;
26
+ private readonly invalidateDictionaryCache?;
27
+ private readonly auditLog?;
28
+ /**
29
+ * Resolved per call rather than captured: the registry is a singleton this
30
+ * module owns, but the accessor keeps the constructor honest for the tests
31
+ * that build the service without one.
32
+ */
33
+ private readonly referenceRegistry?;
34
+ constructor(emFactory: () => EntityManager, invalidateDictionaryCache?: (() => Promise<void>) | undefined, auditLog?: AuditPort | undefined,
35
+ /**
36
+ * Resolved per call rather than captured: the registry is a singleton this
37
+ * module owns, but the accessor keeps the constructor honest for the tests
38
+ * that build the service without one.
39
+ */
40
+ referenceRegistry?: (() => DictionaryReferenceRegistryPort) | undefined);
41
+ list(): Promise<Language[]>;
42
+ listActive(): Promise<Language[]>;
43
+ getDefault(): Promise<Language | null>;
44
+ getByCode(code: string): Promise<Language | null>;
45
+ create(input: {
46
+ code: string;
47
+ label: string;
48
+ nativeLabel: string;
49
+ isRtl?: boolean;
50
+ fallbackCode?: string | null;
51
+ isActive?: boolean;
52
+ sortOrder?: number;
53
+ }): Promise<Language>;
54
+ /**
55
+ * Legacy upsert kept for the existing `/api/v1/admin/languages/:code`
56
+ * endpoint. Sets only the legacy fields. The dictionary admin surface
57
+ * uses `update()` for the extended fields.
58
+ */
59
+ upsert(input: {
60
+ code: string;
61
+ label: string;
62
+ isActive?: boolean;
63
+ sortOrder?: number;
64
+ }): Promise<Language>;
65
+ /**
66
+ * Extended update used by the Dictionary admin surface (feature 017).
67
+ * Supports every column on the Language entity and enforces the spec
68
+ * invariants (default-cannot-be-deactivated, at-least-one-active,
69
+ * fallback-cycle).
70
+ */
71
+ update(code: string, input: {
72
+ label?: string;
73
+ nativeLabel?: string;
74
+ isRtl?: boolean;
75
+ fallbackCode?: string | null;
76
+ isActive?: boolean;
77
+ sortOrder?: number;
78
+ }): Promise<Language>;
79
+ setDefault(code: string): Promise<Language>;
80
+ /**
81
+ * Every consumer reference to `code`, across the platform (feature 077, D-87).
82
+ *
83
+ * This used to be six hand-written `count(*)` statements naming
84
+ * `sales_channels`, `megamenu_bindings`, `blog_post_languages`,
85
+ * `blog_category_languages` and `cms_pages` — five tables this module does not
86
+ * own, in strings no import-level boundary check can see. Four of them are
87
+ * contributed descriptors now (`languageReferenceRegistry`); the fifth is the
88
+ * kernel's channel table, which this module may read through the ORM because
89
+ * the kernel is not a module (D-32).
90
+ *
91
+ * Channels are counted in memory rather than in SQL: a deployment has tens of
92
+ * them, both questions are about the same rows, and `languages` is a JSON
93
+ * array whose containment test was the reason the statement existed at all.
94
+ */
95
+ countDependents(code: string): Promise<DictionaryReference[]>;
96
+ remove(code: string): Promise<void>;
97
+ private assertNoCycle;
98
+ }
99
+ //# sourceMappingURL=language-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"language-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/language-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAC3D,OAAO,EAEL,KAAK,mBAAmB,EACxB,KAAK,+BAA+B,EACrC,MAAM,4BAA4B,CAAC;AAGpC,OAAO,EAAE,QAAQ,EAAE,MAAM,gCAAgC,CAAC;AAE1D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAC;AAElE;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,eAAe;;IAExB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,yBAAyB,CAAC;IAC3C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;IAC1B;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAC;gBARlB,SAAS,EAAE,MAAM,aAAa,EAC9B,yBAAyB,CAAC,GAAE,MAAM,OAAO,CAAC,IAAI,CAAC,aAAA,EAC/C,QAAQ,CAAC,EAAE,SAAS,YAAA;IACrC;;;;OAIG;IACc,iBAAiB,CAAC,GAAE,MAAM,+BAA+B,aAAA;IAqBtE,IAAI,IAAI,OAAO,CAAC,QAAQ,EAAE,CAAC;IAK3B,UAAU,IAAI,OAAO,CAAC,QAAQ,EAAE,CAAC;IAOjC,UAAU,IAAI,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAKtC,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAKjD,MAAM,CAAC,KAAK,EAAE;QAClB,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,WAAW,EAAE,MAAM,CAAC;QACpB,KAAK,CAAC,EAAE,OAAO,CAAC;QAChB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC7B,QAAQ,CAAC,EAAE,OAAO,CAAC;QACnB,SAAS,CAAC,EAAE,MAAM,CAAC;KACpB,GAAG,OAAO,CAAC,QAAQ,CAAC;IAoCrB;;;;OAIG;IACG,MAAM,CAAC,KAAK,EAAE;QAClB,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,QAAQ,CAAC,EAAE,OAAO,CAAC;QACnB,SAAS,CAAC,EAAE,MAAM,CAAC;KACpB,GAAG,OAAO,CAAC,QAAQ,CAAC;IA8CrB;;;;;OAKG;IACG,MAAM,CACV,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE;QACL,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,KAAK,CAAC,EAAE,OAAO,CAAC;QAChB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAC7B,QAAQ,CAAC,EAAE,OAAO,CAAC;QACnB,SAAS,CAAC,EAAE,MAAM,CAAC;KACpB,GACA,OAAO,CAAC,QAAQ,CAAC;IAwDd,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IA4BjD;;;;;;;;;;;;;;OAcG;IACG,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,EAAE,CAAC;IAmC7D,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;YA+B3B,aAAa;CA0B5B"}
@@ -0,0 +1,299 @@
1
+ import { ERROR_CODES, } from '@endora-commerce/contracts';
2
+ import { SalesChannel } from '@endora-commerce/platform/kernel';
3
+ import { HttpError } from '@endora-commerce/platform/http';
4
+ import { Language } from '../entities/language.entity.js';
5
+ import { recordAuditFromContext } from '@endora-commerce/platform/commands';
6
+ /**
7
+ * LanguageService — admin CRUD over the languages pool.
8
+ *
9
+ * Originally introduced by feature 004 (T238 / FR-105) for the minimal
10
+ * languages table. Extended by feature 017 to cover the new columns
11
+ * (`nativeLabel`, `isRtl`, `fallbackCode`) and the spec invariants:
12
+ *
13
+ * - exactly one default at a time (partial unique index, transactional
14
+ * promote+demote).
15
+ * - at-least-one-active (the only active row cannot be deactivated).
16
+ * - default-cannot-be-deactivated.
17
+ * - fallback-cycle guard (the `code → fallback_code → ...` chain must
18
+ * terminate without revisiting any node).
19
+ * - FK-protected hard-delete: refused while any consumer references the
20
+ * language (sales channels' default_language + languages JSONB,
21
+ * megamenu bindings, blog post / category languages, CMS page
22
+ * languages).
23
+ */
24
+ export class LanguageService {
25
+ emFactory;
26
+ invalidateDictionaryCache;
27
+ auditLog;
28
+ referenceRegistry;
29
+ constructor(emFactory, invalidateDictionaryCache, auditLog,
30
+ /**
31
+ * Resolved per call rather than captured: the registry is a singleton this
32
+ * module owns, but the accessor keeps the constructor honest for the tests
33
+ * that build the service without one.
34
+ */
35
+ referenceRegistry) {
36
+ this.emFactory = emFactory;
37
+ this.invalidateDictionaryCache = invalidateDictionaryCache;
38
+ this.auditLog = auditLog;
39
+ this.referenceRegistry = referenceRegistry;
40
+ }
41
+ #audit(em, action, objectId, stateBefore, stateAfter) {
42
+ if (this.auditLog) {
43
+ recordAuditFromContext(this.auditLog, em, {
44
+ action,
45
+ objectType: 'language',
46
+ objectId,
47
+ stateBefore,
48
+ stateAfter,
49
+ });
50
+ }
51
+ }
52
+ async list() {
53
+ const em = this.emFactory();
54
+ return em.find(Language, {}, { orderBy: { sortOrder: 'asc', code: 'asc' } });
55
+ }
56
+ async listActive() {
57
+ const em = this.emFactory();
58
+ // `code` tiebreak keeps the order deterministic when several languages
59
+ // share a sortOrder (e.g. the seed default 0) — matches list() above.
60
+ return em.find(Language, { isActive: true }, { orderBy: { sortOrder: 'asc', code: 'asc' } });
61
+ }
62
+ async getDefault() {
63
+ const em = this.emFactory();
64
+ return em.findOne(Language, { isDefault: true });
65
+ }
66
+ async getByCode(code) {
67
+ const em = this.emFactory();
68
+ return em.findOne(Language, { code });
69
+ }
70
+ async create(input) {
71
+ const em = this.emFactory();
72
+ const existing = await em.findOne(Language, { code: input.code });
73
+ if (existing) {
74
+ throw new HttpError(409, ERROR_CODES.VALIDATION_FAILED, `Language ${input.code} already exists.`);
75
+ }
76
+ if (input.fallbackCode !== undefined && input.fallbackCode !== null) {
77
+ if (input.fallbackCode === input.code) {
78
+ throw new HttpError(409, ERROR_CODES.DICTIONARY_FALLBACK_CYCLE, 'A language cannot fall back to itself.');
79
+ }
80
+ await this.assertNoCycle(em, input.code, input.fallbackCode);
81
+ }
82
+ const row = em.create(Language, {
83
+ code: input.code,
84
+ label: input.label,
85
+ nativeLabel: input.nativeLabel,
86
+ ...(input.isRtl !== undefined ? { isRtl: input.isRtl } : {}),
87
+ ...(input.fallbackCode !== undefined ? { fallbackCode: input.fallbackCode } : {}),
88
+ ...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
89
+ ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}),
90
+ });
91
+ em.persist(row);
92
+ this.#audit(em, 'language.create', row.code, null, { label: row.label });
93
+ await em.flush();
94
+ await this.invalidateDictionaryCache?.();
95
+ return row;
96
+ }
97
+ /**
98
+ * Legacy upsert kept for the existing `/api/v1/admin/languages/:code`
99
+ * endpoint. Sets only the legacy fields. The dictionary admin surface
100
+ * uses `update()` for the extended fields.
101
+ */
102
+ async upsert(input) {
103
+ const em = this.emFactory();
104
+ const existing = await em.findOne(Language, { code: input.code });
105
+ if (existing) {
106
+ // Default-cannot-be-deactivated invariant.
107
+ if (input.isActive === false && existing.isDefault) {
108
+ throw new HttpError(409, ERROR_CODES.DICTIONARY_DEFAULT_CANNOT_BE_DEACTIVATED, 'Cannot deactivate the default language. Promote a different language first.');
109
+ }
110
+ if (input.isActive === false && existing.isActive) {
111
+ const otherActive = await em.count(Language, {
112
+ isActive: true,
113
+ code: { $ne: input.code },
114
+ });
115
+ if (otherActive === 0) {
116
+ throw new HttpError(409, ERROR_CODES.DICTIONARY_LAST_ACTIVE_ENTRY, 'At least one language must remain active.');
117
+ }
118
+ }
119
+ existing.label = input.label;
120
+ if (input.isActive !== undefined)
121
+ existing.isActive = input.isActive;
122
+ if (input.sortOrder !== undefined)
123
+ existing.sortOrder = input.sortOrder;
124
+ this.#audit(em, 'language.upsert', existing.code, null, { label: existing.label });
125
+ await em.flush();
126
+ await this.invalidateDictionaryCache?.();
127
+ return existing;
128
+ }
129
+ const row = em.create(Language, {
130
+ code: input.code,
131
+ label: input.label,
132
+ ...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
133
+ ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}),
134
+ });
135
+ em.persist(row);
136
+ this.#audit(em, 'language.upsert', row.code, null, { label: row.label });
137
+ await em.flush();
138
+ await this.invalidateDictionaryCache?.();
139
+ return row;
140
+ }
141
+ /**
142
+ * Extended update used by the Dictionary admin surface (feature 017).
143
+ * Supports every column on the Language entity and enforces the spec
144
+ * invariants (default-cannot-be-deactivated, at-least-one-active,
145
+ * fallback-cycle).
146
+ */
147
+ async update(code, input) {
148
+ const em = this.emFactory();
149
+ const existing = await em.findOne(Language, { code });
150
+ if (!existing) {
151
+ throw new HttpError(404, ERROR_CODES.DICTIONARY_ENTRY_NOT_FOUND, `Language ${code} not found.`);
152
+ }
153
+ if (input.isActive === false && existing.isDefault) {
154
+ throw new HttpError(409, ERROR_CODES.DICTIONARY_DEFAULT_CANNOT_BE_DEACTIVATED, 'Cannot deactivate the default language. Promote a different language first.');
155
+ }
156
+ if (input.isActive === false && existing.isActive) {
157
+ const otherActive = await em.count(Language, {
158
+ isActive: true,
159
+ code: { $ne: code },
160
+ });
161
+ if (otherActive === 0) {
162
+ throw new HttpError(409, ERROR_CODES.DICTIONARY_LAST_ACTIVE_ENTRY, 'At least one language must remain active.');
163
+ }
164
+ }
165
+ if (input.fallbackCode !== undefined && input.fallbackCode !== null) {
166
+ if (input.fallbackCode === code) {
167
+ throw new HttpError(409, ERROR_CODES.DICTIONARY_FALLBACK_CYCLE, 'A language cannot fall back to itself.');
168
+ }
169
+ await this.assertNoCycle(em, code, input.fallbackCode);
170
+ }
171
+ if (input.label !== undefined)
172
+ existing.label = input.label;
173
+ if (input.nativeLabel !== undefined)
174
+ existing.nativeLabel = input.nativeLabel;
175
+ if (input.isRtl !== undefined)
176
+ existing.isRtl = input.isRtl;
177
+ if (input.fallbackCode !== undefined)
178
+ existing.fallbackCode = input.fallbackCode;
179
+ if (input.isActive !== undefined)
180
+ existing.isActive = input.isActive;
181
+ if (input.sortOrder !== undefined)
182
+ existing.sortOrder = input.sortOrder;
183
+ this.#audit(em, 'language.update', existing.code, null, { label: existing.label, isActive: existing.isActive });
184
+ await em.flush();
185
+ await this.invalidateDictionaryCache?.();
186
+ return existing;
187
+ }
188
+ async setDefault(code) {
189
+ const em = this.emFactory();
190
+ const target = await em.findOne(Language, { code });
191
+ if (!target) {
192
+ throw new HttpError(404, ERROR_CODES.NOT_FOUND, `Language ${code} not found.`);
193
+ }
194
+ if (!target.isActive) {
195
+ throw new HttpError(409, ERROR_CODES.VALIDATION_FAILED, 'Cannot mark an inactive language as default.');
196
+ }
197
+ if (target.isDefault)
198
+ return target;
199
+ // Demote-then-promote inside the EM's current tx scope (em.transactional
200
+ // would fork into a separate tx that commits independently — breaks the
201
+ // test fork+rollback pattern and the cross-module composition root's
202
+ // outer-tx semantics).
203
+ await em.nativeUpdate(Language, { isDefault: true }, { isDefault: false });
204
+ await em.nativeUpdate(Language, { code }, { isDefault: true });
205
+ target.isDefault = true;
206
+ this.#audit(em, 'language.set_default', code, null, { isDefault: true });
207
+ await em.flush();
208
+ await this.invalidateDictionaryCache?.();
209
+ return target;
210
+ }
211
+ /**
212
+ * Every consumer reference to `code`, across the platform (feature 077, D-87).
213
+ *
214
+ * This used to be six hand-written `count(*)` statements naming
215
+ * `sales_channels`, `megamenu_bindings`, `blog_post_languages`,
216
+ * `blog_category_languages` and `cms_pages` — five tables this module does not
217
+ * own, in strings no import-level boundary check can see. Four of them are
218
+ * contributed descriptors now (`languageReferenceRegistry`); the fifth is the
219
+ * kernel's channel table, which this module may read through the ORM because
220
+ * the kernel is not a module (D-32).
221
+ *
222
+ * Channels are counted in memory rather than in SQL: a deployment has tens of
223
+ * them, both questions are about the same rows, and `languages` is a JSON
224
+ * array whose containment test was the reason the statement existed at all.
225
+ */
226
+ async countDependents(code) {
227
+ const channels = await this.emFactory().find(SalesChannel, {});
228
+ const references = [];
229
+ const asDefault = channels.filter((channel) => channel.defaultLanguage === code).length;
230
+ if (asDefault > 0) {
231
+ references.push({
232
+ ownerModuleId: 'sales_channels',
233
+ consumer: 'sales_channels',
234
+ tableName: 'sales_channels',
235
+ columnName: 'default_language',
236
+ code,
237
+ count: asDefault,
238
+ blocking: true,
239
+ });
240
+ }
241
+ const listed = channels.filter((channel) => channel.languages.includes(code)).length;
242
+ if (listed > 0) {
243
+ references.push({
244
+ ownerModuleId: 'sales_channels',
245
+ consumer: 'sales_channels',
246
+ tableName: 'sales_channels',
247
+ columnName: 'languages[]',
248
+ code,
249
+ count: listed,
250
+ blocking: true,
251
+ });
252
+ }
253
+ const registry = this.referenceRegistry?.();
254
+ if (registry)
255
+ references.push(...(await registry.countReferences(code)));
256
+ return references;
257
+ }
258
+ async remove(code) {
259
+ const em = this.emFactory();
260
+ const row = await em.findOne(Language, { code });
261
+ if (!row)
262
+ return;
263
+ if (row.isDefault) {
264
+ throw new HttpError(409, ERROR_CODES.DICTIONARY_DEFAULT_CANNOT_BE_DEACTIVATED, 'Cannot remove the default language.');
265
+ }
266
+ const dependents = await this.countDependents(code);
267
+ // Which references refuse the delete is the contributing module's call,
268
+ // carried on the descriptor: a column its own foreign key blanks on delete
269
+ // is reported but does not block.
270
+ const total = dependents
271
+ .filter((reference) => reference.blocking)
272
+ .reduce((sum, reference) => sum + reference.count, 0);
273
+ if (total > 0) {
274
+ throw new HttpError(409, ERROR_CODES.DICTIONARY_ENTRY_HAS_DEPENDENTS, `Language ${code} cannot be deleted because ${total} consumer reference(s) exist.`, [{ path: 'consumers', issue: JSON.stringify(dependents) }]);
275
+ }
276
+ this.#audit(em, 'language.delete', row.code, { label: row.label }, null);
277
+ await em.removeAndFlush(row);
278
+ await this.invalidateDictionaryCache?.();
279
+ }
280
+ async assertNoCycle(em, startCode, proposedFallback) {
281
+ // Walk the existing graph from `proposedFallback` and ensure we never
282
+ // arrive back at `startCode`. Bound the walk by the registry size so a
283
+ // pathological dataset cannot loop.
284
+ const visited = new Set([startCode]);
285
+ let cursor = proposedFallback;
286
+ let steps = 0;
287
+ const maxSteps = 32;
288
+ while (cursor && steps < maxSteps) {
289
+ if (visited.has(cursor)) {
290
+ throw new HttpError(409, ERROR_CODES.DICTIONARY_FALLBACK_CYCLE, `Setting fallback_code to ${proposedFallback} would create a cycle through ${cursor}.`);
291
+ }
292
+ visited.add(cursor);
293
+ const next = await em.findOne(Language, { code: cursor });
294
+ cursor = next?.fallbackCode ?? null;
295
+ steps += 1;
296
+ }
297
+ }
298
+ }
299
+ //# sourceMappingURL=language-service.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"language-service.js","sourceRoot":"","sources":["../../../src/backend/services/language-service.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,35 @@
1
+ import type { LanguageService } from './language-service.js';
2
+ /**
3
+ * LocaleService — the small chunk of behaviour FR-105 calls
4
+ * "translation-fallback middleware".
5
+ *
6
+ * Two responsibilities:
7
+ * 1. `pickLocalizedValue(record, requestedLocale, defaultLocale?)` — given a
8
+ * `Record<localeCode, string>` (the multilingual JSONB shape used by
9
+ * Product / Category / CMS), return the best available string. Order:
10
+ * requested → default → first present → empty string.
11
+ * 2. `resolveRequestLocale(acceptLanguageHeader)` — pick a supported
12
+ * locale for the current request, with the admin-configured default
13
+ * as the last-resort fallback.
14
+ *
15
+ * The default locale is read once per resolver and cached for the lifetime
16
+ * of the service instance to avoid hitting the DB on every request; it is
17
+ * refreshed on `invalidateDefault()` after admin mutations.
18
+ */
19
+ export declare class LocaleService {
20
+ private readonly languageService;
21
+ private cachedDefaultCode;
22
+ private cachedDefaultLoadedAt;
23
+ private readonly cacheTtlMs;
24
+ constructor(languageService: LanguageService);
25
+ pickLocalizedValue(record: Record<string, string> | null | undefined, requestedLocale: string, defaultLocale?: string): string;
26
+ getDefaultLocale(): Promise<string>;
27
+ invalidateDefault(): void;
28
+ /**
29
+ * Resolve a single locale string from an Accept-Language header against
30
+ * the active language pool. Returns the configured default when nothing
31
+ * matches.
32
+ */
33
+ resolveRequestLocale(acceptLanguageHeader: string | undefined, activeLocales: string[]): Promise<string>;
34
+ }
35
+ //# sourceMappingURL=locale-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"locale-service.d.ts","sourceRoot":"","sources":["../../../src/backend/services/locale-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAE7D;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,aAAa;IAKZ,OAAO,CAAC,QAAQ,CAAC,eAAe;IAJ5C,OAAO,CAAC,iBAAiB,CAAuB;IAChD,OAAO,CAAC,qBAAqB,CAAK;IAClC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAU;gBAER,eAAe,EAAE,eAAe;IAE7D,kBAAkB,CAChB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,GAAG,SAAS,EACjD,eAAe,EAAE,MAAM,EACvB,aAAa,CAAC,EAAE,MAAM,GACrB,MAAM;IAQH,gBAAgB,IAAI,OAAO,CAAC,MAAM,CAAC;IAWzC,iBAAiB,IAAI,IAAI;IAKzB;;;;OAIG;IACG,oBAAoB,CACxB,oBAAoB,EAAE,MAAM,GAAG,SAAS,EACxC,aAAa,EAAE,MAAM,EAAE,GACtB,OAAO,CAAC,MAAM,CAAC;CAiBnB"}