@wopr-network/platform-core 1.58.0 → 1.59.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 (47) hide show
  1. package/dist/billing/crypto/key-server-entry.js +1 -0
  2. package/dist/billing/crypto/watcher-service.d.ts +2 -0
  3. package/dist/billing/crypto/watcher-service.js +7 -3
  4. package/dist/db/schema/index.d.ts +2 -0
  5. package/dist/db/schema/index.js +2 -0
  6. package/dist/db/schema/product-config.d.ts +610 -0
  7. package/dist/db/schema/product-config.js +51 -0
  8. package/dist/db/schema/products.d.ts +565 -0
  9. package/dist/db/schema/products.js +43 -0
  10. package/dist/product-config/boot.d.ts +36 -0
  11. package/dist/product-config/boot.js +30 -0
  12. package/dist/product-config/drizzle-product-config-repository.d.ts +19 -0
  13. package/dist/product-config/drizzle-product-config-repository.js +200 -0
  14. package/dist/product-config/drizzle-product-config-repository.test.d.ts +1 -0
  15. package/dist/product-config/drizzle-product-config-repository.test.js +114 -0
  16. package/dist/product-config/index.d.ts +24 -0
  17. package/dist/product-config/index.js +37 -0
  18. package/dist/product-config/repository-types.d.ts +143 -0
  19. package/dist/product-config/repository-types.js +53 -0
  20. package/dist/product-config/service.d.ts +27 -0
  21. package/dist/product-config/service.js +74 -0
  22. package/dist/product-config/service.test.d.ts +1 -0
  23. package/dist/product-config/service.test.js +107 -0
  24. package/dist/trpc/index.d.ts +1 -0
  25. package/dist/trpc/index.js +1 -0
  26. package/dist/trpc/product-config-router.d.ts +117 -0
  27. package/dist/trpc/product-config-router.js +137 -0
  28. package/docs/specs/2026-03-23-product-config-db-migration-plan.md +2260 -0
  29. package/docs/specs/2026-03-23-product-config-db-migration.md +371 -0
  30. package/drizzle/migrations/0020_product_config_tables.sql +109 -0
  31. package/drizzle/migrations/meta/_journal.json +7 -0
  32. package/package.json +1 -1
  33. package/scripts/seed-products.ts +268 -0
  34. package/src/billing/crypto/key-server-entry.ts +1 -0
  35. package/src/billing/crypto/watcher-service.ts +8 -2
  36. package/src/db/schema/index.ts +2 -0
  37. package/src/db/schema/product-config.ts +56 -0
  38. package/src/db/schema/products.ts +58 -0
  39. package/src/product-config/boot.ts +57 -0
  40. package/src/product-config/drizzle-product-config-repository.test.ts +132 -0
  41. package/src/product-config/drizzle-product-config-repository.ts +229 -0
  42. package/src/product-config/index.ts +62 -0
  43. package/src/product-config/repository-types.ts +222 -0
  44. package/src/product-config/service.test.ts +127 -0
  45. package/src/product-config/service.ts +105 -0
  46. package/src/trpc/index.ts +1 -0
  47. package/src/trpc/product-config-router.ts +161 -0
@@ -0,0 +1,229 @@
1
+ import { asc, eq } from "drizzle-orm";
2
+ import type { DrizzleDb } from "../db/index.js";
3
+ import { productBillingConfig, productFeatures, productFleetConfig } from "../db/schema/product-config.js";
4
+ import { productDomains, productNavItems, products } from "../db/schema/products.js";
5
+ import type {
6
+ IProductConfigRepository,
7
+ NavItemInput,
8
+ Product,
9
+ ProductBillingConfig as ProductBillingConfigType,
10
+ ProductBrandUpdate,
11
+ ProductConfig,
12
+ ProductDomain,
13
+ ProductFeatures as ProductFeaturesType,
14
+ ProductFleetConfig as ProductFleetConfigType,
15
+ ProductNavItem,
16
+ } from "./repository-types.js";
17
+
18
+ export class DrizzleProductConfigRepository implements IProductConfigRepository {
19
+ constructor(private db: DrizzleDb) {}
20
+
21
+ async getBySlug(slug: string): Promise<ProductConfig | null> {
22
+ const [product] = await this.db.select().from(products).where(eq(products.slug, slug)).limit(1);
23
+
24
+ if (!product) return null;
25
+
26
+ const [navItems, domains, featuresRows, fleetRows, billingRows] = await Promise.all([
27
+ this.db
28
+ .select()
29
+ .from(productNavItems)
30
+ .where(eq(productNavItems.productId, product.id))
31
+ .orderBy(asc(productNavItems.sortOrder)),
32
+ this.db.select().from(productDomains).where(eq(productDomains.productId, product.id)),
33
+ this.db.select().from(productFeatures).where(eq(productFeatures.productId, product.id)).limit(1),
34
+ this.db.select().from(productFleetConfig).where(eq(productFleetConfig.productId, product.id)).limit(1),
35
+ this.db.select().from(productBillingConfig).where(eq(productBillingConfig.productId, product.id)).limit(1),
36
+ ]);
37
+
38
+ return {
39
+ product: this.mapProduct(product),
40
+ navItems: navItems.map((n) => this.mapNavItem(n)),
41
+ domains: domains.map((d) => this.mapDomain(d)),
42
+ features: featuresRows[0] ? this.mapFeatures(featuresRows[0]) : null,
43
+ fleet: fleetRows[0] ? this.mapFleet(fleetRows[0]) : null,
44
+ billing: billingRows[0] ? this.mapBilling(billingRows[0]) : null,
45
+ };
46
+ }
47
+
48
+ async listAll(): Promise<ProductConfig[]> {
49
+ const allProducts = await this.db.select().from(products);
50
+ const configs = await Promise.all(allProducts.map((p) => this.getBySlug(p.slug)));
51
+ return configs.filter((c): c is ProductConfig => c !== null);
52
+ }
53
+
54
+ async upsertProduct(slug: string, data: ProductBrandUpdate): Promise<Product> {
55
+ const base = {
56
+ slug,
57
+ storagePrefix: slug,
58
+ domain: "",
59
+ appDomain: "",
60
+ cookieDomain: "",
61
+ brandName: "",
62
+ productName: "",
63
+ };
64
+ const [result] = await this.db
65
+ .insert(products)
66
+ .values({ ...base, ...data })
67
+ .onConflictDoUpdate({
68
+ target: products.slug,
69
+ set: { ...(data as Record<string, unknown>), updatedAt: new Date() },
70
+ })
71
+ .returning();
72
+ return this.mapProduct(result);
73
+ }
74
+
75
+ async replaceNavItems(productId: string, items: NavItemInput[]): Promise<void> {
76
+ await this.db.transaction(async (tx) => {
77
+ await tx.delete(productNavItems).where(eq(productNavItems.productId, productId));
78
+ if (items.length > 0) {
79
+ await tx.insert(productNavItems).values(
80
+ items.map((item) => ({
81
+ productId,
82
+ label: item.label,
83
+ href: item.href,
84
+ icon: item.icon ?? null,
85
+ sortOrder: item.sortOrder,
86
+ requiresRole: item.requiresRole ?? null,
87
+ enabled: item.enabled !== false,
88
+ })),
89
+ );
90
+ }
91
+ });
92
+ }
93
+
94
+ async upsertFeatures(productId: string, data: Partial<ProductFeaturesType>): Promise<void> {
95
+ const { productId: _, ...rest } = data as Record<string, unknown>;
96
+ await this.db
97
+ .insert(productFeatures)
98
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle partial upsert requires unknown spread
99
+ .values({ productId, ...rest } as any)
100
+ .onConflictDoUpdate({
101
+ target: productFeatures.productId,
102
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle partial upsert requires unknown spread
103
+ set: { ...rest, updatedAt: new Date() } as any,
104
+ });
105
+ }
106
+
107
+ async upsertFleetConfig(productId: string, data: Partial<ProductFleetConfigType>): Promise<void> {
108
+ const { productId: _, ...rest } = data as Record<string, unknown>;
109
+ await this.db
110
+ .insert(productFleetConfig)
111
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle partial upsert requires unknown spread
112
+ .values({ productId, containerImage: "", ...rest } as any)
113
+ .onConflictDoUpdate({
114
+ target: productFleetConfig.productId,
115
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle partial upsert requires unknown spread
116
+ set: { ...rest, updatedAt: new Date() } as any,
117
+ });
118
+ }
119
+
120
+ async upsertBillingConfig(productId: string, data: Partial<ProductBillingConfigType>): Promise<void> {
121
+ // TODO: stripeSecretKey and stripeWebhookSecret must be encrypted via the credential vault
122
+ // (CRYPTO_SERVICE_KEY) before reaching this method. The schema stores encrypted ciphertext.
123
+ const { productId: _, ...rest } = data as Record<string, unknown>;
124
+ await this.db
125
+ .insert(productBillingConfig)
126
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle partial upsert requires unknown spread
127
+ .values({ productId, ...rest } as any)
128
+ .onConflictDoUpdate({
129
+ target: productBillingConfig.productId,
130
+ // biome-ignore lint/suspicious/noExplicitAny: Drizzle partial upsert requires unknown spread
131
+ set: { ...rest, updatedAt: new Date() } as any,
132
+ });
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Mappers
137
+ // ---------------------------------------------------------------------------
138
+
139
+ private mapProduct(row: typeof products.$inferSelect): Product {
140
+ return {
141
+ id: row.id,
142
+ slug: row.slug,
143
+ brandName: row.brandName,
144
+ productName: row.productName,
145
+ tagline: row.tagline,
146
+ domain: row.domain,
147
+ appDomain: row.appDomain,
148
+ cookieDomain: row.cookieDomain,
149
+ companyLegal: row.companyLegal,
150
+ priceLabel: row.priceLabel,
151
+ defaultImage: row.defaultImage,
152
+ emailSupport: row.emailSupport,
153
+ emailPrivacy: row.emailPrivacy,
154
+ emailLegal: row.emailLegal,
155
+ fromEmail: row.fromEmail,
156
+ homePath: row.homePath,
157
+ storagePrefix: row.storagePrefix,
158
+ createdAt: row.createdAt,
159
+ updatedAt: row.updatedAt,
160
+ };
161
+ }
162
+
163
+ private mapNavItem(row: typeof productNavItems.$inferSelect): ProductNavItem {
164
+ return {
165
+ id: row.id,
166
+ productId: row.productId,
167
+ label: row.label,
168
+ href: row.href,
169
+ icon: row.icon ?? null,
170
+ sortOrder: row.sortOrder,
171
+ requiresRole: row.requiresRole ?? null,
172
+ enabled: row.enabled,
173
+ };
174
+ }
175
+
176
+ private mapDomain(row: typeof productDomains.$inferSelect): ProductDomain {
177
+ return {
178
+ id: row.id,
179
+ productId: row.productId,
180
+ host: row.host,
181
+ role: row.role as "canonical" | "redirect",
182
+ };
183
+ }
184
+
185
+ private mapFeatures(row: typeof productFeatures.$inferSelect): ProductFeaturesType {
186
+ return {
187
+ productId: row.productId,
188
+ chatEnabled: row.chatEnabled,
189
+ onboardingEnabled: row.onboardingEnabled,
190
+ onboardingDefaultModel: row.onboardingDefaultModel ?? null,
191
+ onboardingSystemPrompt: row.onboardingSystemPrompt ?? null,
192
+ onboardingMaxCredits: row.onboardingMaxCredits,
193
+ onboardingWelcomeMsg: row.onboardingWelcomeMsg ?? null,
194
+ sharedModuleBilling: row.sharedModuleBilling,
195
+ sharedModuleMonitoring: row.sharedModuleMonitoring,
196
+ sharedModuleAnalytics: row.sharedModuleAnalytics,
197
+ };
198
+ }
199
+
200
+ private mapFleet(row: typeof productFleetConfig.$inferSelect): ProductFleetConfigType {
201
+ return {
202
+ productId: row.productId,
203
+ containerImage: row.containerImage,
204
+ containerPort: row.containerPort,
205
+ lifecycle: row.lifecycle as ProductFleetConfigType["lifecycle"],
206
+ billingModel: row.billingModel as ProductFleetConfigType["billingModel"],
207
+ maxInstances: row.maxInstances,
208
+ imageAllowlist: row.imageAllowlist ?? null,
209
+ dockerNetwork: row.dockerNetwork,
210
+ placementStrategy: row.placementStrategy,
211
+ fleetDataDir: row.fleetDataDir,
212
+ };
213
+ }
214
+
215
+ private mapBilling(row: typeof productBillingConfig.$inferSelect): ProductBillingConfigType {
216
+ return {
217
+ productId: row.productId,
218
+ stripePublishableKey: row.stripePublishableKey ?? null,
219
+ stripeSecretKey: row.stripeSecretKey ?? null,
220
+ stripeWebhookSecret: row.stripeWebhookSecret ?? null,
221
+ creditPrices: (row.creditPrices ?? {}) as Record<string, number>,
222
+ affiliateBaseUrl: row.affiliateBaseUrl ?? null,
223
+ affiliateMatchRate: Number(row.affiliateMatchRate),
224
+ affiliateMaxCap: row.affiliateMaxCap,
225
+ dividendRate: Number(row.dividendRate),
226
+ marginConfig: row.marginConfig ?? null,
227
+ };
228
+ }
229
+ }
@@ -0,0 +1,62 @@
1
+ import type { DrizzleDb } from "../db/index.js";
2
+ import type { PlatformBootOptions, PlatformBootResult } from "./boot.js";
3
+ import { platformBoot as rawPlatformBoot } from "./boot.js";
4
+ import { DrizzleProductConfigRepository } from "./drizzle-product-config-repository.js";
5
+ import type { IProductConfigRepository } from "./repository-types.js";
6
+ import { ProductConfigService } from "./service.js";
7
+
8
+ export type { PlatformBootOptions, PlatformBootResult } from "./boot.js";
9
+
10
+ /**
11
+ * Bootstrap product config from DB and register the service globally.
12
+ * Wraps the raw boot to ensure getProductConfigService() works after calling this.
13
+ */
14
+ export async function platformBoot(opts: PlatformBootOptions): Promise<PlatformBootResult> {
15
+ const result = await rawPlatformBoot(opts);
16
+ _service = result.service;
17
+ return result;
18
+ }
19
+ export { DrizzleProductConfigRepository } from "./drizzle-product-config-repository.js";
20
+ // Re-exports for consumers
21
+ export type {
22
+ FleetBillingModel,
23
+ FleetLifecycle,
24
+ IProductConfigRepository,
25
+ NavItemInput,
26
+ Product,
27
+ ProductBillingConfig,
28
+ ProductBrandConfig,
29
+ ProductBrandUpdate,
30
+ ProductConfig,
31
+ ProductDomain,
32
+ ProductFeatures,
33
+ ProductFleetConfig,
34
+ ProductNavItem,
35
+ } from "./repository-types.js";
36
+ export { deriveCorsOrigins, toBrandConfig } from "./repository-types.js";
37
+ export { ProductConfigService } from "./service.js";
38
+
39
+ let _service: ProductConfigService | null = null;
40
+
41
+ /** Initialize the product config system. Call once at startup. */
42
+ export function initProductConfig(db: DrizzleDb): ProductConfigService {
43
+ const repo = new DrizzleProductConfigRepository(db);
44
+ _service = new ProductConfigService(repo);
45
+ return _service;
46
+ }
47
+
48
+ /** Initialize with a custom repository (for testing). */
49
+ export function initProductConfigWithRepo(repo: IProductConfigRepository): ProductConfigService {
50
+ _service = new ProductConfigService(repo);
51
+ return _service;
52
+ }
53
+
54
+ /**
55
+ * Get the product config service.
56
+ * This is the ONLY way to access product config — reads are cached,
57
+ * writes auto-invalidate the cache.
58
+ */
59
+ export function getProductConfigService(): ProductConfigService {
60
+ if (!_service) throw new Error("Product config not initialized. Call initProductConfig() first.");
61
+ return _service;
62
+ }
@@ -0,0 +1,222 @@
1
+ // src/product-config/repository-types.ts
2
+ //
3
+ // Plain TypeScript interfaces for product configuration domain.
4
+ // No Drizzle types. These are the contract all consumers work against.
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Product
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /** Plain domain object representing a product — mirrors `products` table. */
11
+ export interface Product {
12
+ id: string;
13
+ slug: string;
14
+ brandName: string;
15
+ productName: string;
16
+ tagline: string;
17
+ domain: string;
18
+ appDomain: string;
19
+ cookieDomain: string;
20
+ companyLegal: string;
21
+ priceLabel: string;
22
+ defaultImage: string;
23
+ emailSupport: string;
24
+ emailPrivacy: string;
25
+ emailLegal: string;
26
+ fromEmail: string;
27
+ homePath: string;
28
+ storagePrefix: string;
29
+ createdAt: Date;
30
+ updatedAt: Date;
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // ProductNavItem
35
+ // ---------------------------------------------------------------------------
36
+
37
+ export interface ProductNavItem {
38
+ id: string;
39
+ productId: string;
40
+ label: string;
41
+ href: string;
42
+ icon: string | null;
43
+ sortOrder: number;
44
+ requiresRole: string | null;
45
+ enabled: boolean;
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // ProductDomain
50
+ // ---------------------------------------------------------------------------
51
+
52
+ export interface ProductDomain {
53
+ id: string;
54
+ productId: string;
55
+ host: string;
56
+ role: "canonical" | "redirect";
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // ProductFeatures
61
+ // ---------------------------------------------------------------------------
62
+
63
+ export interface ProductFeatures {
64
+ productId: string;
65
+ chatEnabled: boolean;
66
+ onboardingEnabled: boolean;
67
+ onboardingDefaultModel: string | null;
68
+ onboardingSystemPrompt: string | null;
69
+ onboardingMaxCredits: number;
70
+ onboardingWelcomeMsg: string | null;
71
+ sharedModuleBilling: boolean;
72
+ sharedModuleMonitoring: boolean;
73
+ sharedModuleAnalytics: boolean;
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // ProductFleetConfig
78
+ // ---------------------------------------------------------------------------
79
+
80
+ export type FleetLifecycle = "managed" | "ephemeral";
81
+ export type FleetBillingModel = "monthly" | "per_use" | "none";
82
+
83
+ export interface ProductFleetConfig {
84
+ productId: string;
85
+ containerImage: string;
86
+ containerPort: number;
87
+ lifecycle: FleetLifecycle;
88
+ billingModel: FleetBillingModel;
89
+ maxInstances: number;
90
+ imageAllowlist: string[] | null;
91
+ dockerNetwork: string;
92
+ placementStrategy: string;
93
+ fleetDataDir: string;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // ProductBillingConfig
98
+ // ---------------------------------------------------------------------------
99
+
100
+ export interface ProductBillingConfig {
101
+ productId: string;
102
+ stripePublishableKey: string | null;
103
+ stripeSecretKey: string | null;
104
+ stripeWebhookSecret: string | null;
105
+ creditPrices: Record<string, number>;
106
+ affiliateBaseUrl: string | null;
107
+ affiliateMatchRate: number;
108
+ affiliateMaxCap: number;
109
+ dividendRate: number;
110
+ marginConfig: unknown;
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Aggregates
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /** Full product config resolved from all tables. */
118
+ export interface ProductConfig {
119
+ product: Product;
120
+ navItems: ProductNavItem[];
121
+ domains: ProductDomain[];
122
+ features: ProductFeatures | null;
123
+ fleet: ProductFleetConfig | null;
124
+ billing: ProductBillingConfig | null;
125
+ }
126
+
127
+ /** Brand config shape served to UI (matches BrandConfig in platform-ui-core). */
128
+ export interface ProductBrandConfig {
129
+ productName: string;
130
+ brandName: string;
131
+ domain: string;
132
+ appDomain: string;
133
+ tagline: string;
134
+ emails: { privacy: string; legal: string; support: string };
135
+ defaultImage: string;
136
+ storagePrefix: string;
137
+ companyLegalName: string;
138
+ price: string;
139
+ homePath: string;
140
+ chatEnabled: boolean;
141
+ navItems: Array<{ label: string; href: string }>;
142
+ domains?: Array<{ host: string; role: string }>;
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // Repository Interface
147
+ // ---------------------------------------------------------------------------
148
+
149
+ /** Upsert payload for product brand fields. */
150
+ export type ProductBrandUpdate = Partial<Omit<Product, "id" | "slug" | "createdAt" | "updatedAt">>;
151
+
152
+ /** Upsert payload for a nav item (no id — replaced in bulk). */
153
+ export interface NavItemInput {
154
+ label: string;
155
+ href: string;
156
+ icon?: string;
157
+ sortOrder: number;
158
+ requiresRole?: string;
159
+ enabled?: boolean;
160
+ }
161
+
162
+ export interface IProductConfigRepository {
163
+ getBySlug(slug: string): Promise<ProductConfig | null>;
164
+ listAll(): Promise<ProductConfig[]>;
165
+ upsertProduct(slug: string, data: ProductBrandUpdate): Promise<Product>;
166
+ replaceNavItems(productId: string, items: NavItemInput[]): Promise<void>;
167
+ upsertFeatures(productId: string, data: Partial<ProductFeatures>): Promise<void>;
168
+ upsertFleetConfig(productId: string, data: Partial<ProductFleetConfig>): Promise<void>;
169
+ upsertBillingConfig(productId: string, data: Partial<ProductBillingConfig>): Promise<void>;
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Helpers
174
+ // ---------------------------------------------------------------------------
175
+
176
+ /** Derive CORS origins from product config. */
177
+ /** Derive CORS origins — excludes redirect-only domains (they 301, never make requests). */
178
+ export function deriveCorsOrigins(product: Product, domains: ProductDomain[]): string[] {
179
+ const origins = new Set<string>();
180
+ origins.add(`https://${product.domain}`);
181
+ origins.add(`https://${product.appDomain}`);
182
+ for (const d of domains) {
183
+ if (d.role !== "redirect") {
184
+ origins.add(`https://${d.host}`);
185
+ }
186
+ }
187
+ return [...origins];
188
+ }
189
+
190
+ /**
191
+ * Derive brand config for UI from full product config.
192
+ *
193
+ * @param config - Full product config.
194
+ * @param userRole - Optional role of the requesting user. When omitted (public/unauthenticated),
195
+ * nav items that require a role are excluded from the result.
196
+ */
197
+ export function toBrandConfig(config: ProductConfig, userRole?: string): ProductBrandConfig {
198
+ const { product, navItems, domains, features } = config;
199
+ return {
200
+ productName: product.productName,
201
+ brandName: product.brandName,
202
+ domain: product.domain,
203
+ appDomain: product.appDomain,
204
+ tagline: product.tagline,
205
+ emails: {
206
+ privacy: product.emailPrivacy,
207
+ legal: product.emailLegal,
208
+ support: product.emailSupport,
209
+ },
210
+ defaultImage: product.defaultImage,
211
+ storagePrefix: product.storagePrefix,
212
+ companyLegalName: product.companyLegal,
213
+ price: product.priceLabel,
214
+ homePath: product.homePath,
215
+ chatEnabled: features?.chatEnabled ?? true,
216
+ navItems: navItems
217
+ .filter((n) => n.enabled)
218
+ .filter((n) => !n.requiresRole || n.requiresRole === userRole)
219
+ .map((n) => ({ label: n.label, href: n.href })),
220
+ domains: domains.length > 0 ? domains.map((d) => ({ host: d.host, role: d.role })) : undefined,
221
+ };
222
+ }
@@ -0,0 +1,127 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { IProductConfigRepository, Product, ProductConfig } from "./repository-types.js";
3
+ import { ProductConfigService } from "./service.js";
4
+
5
+ function makeConfig(slug: string): ProductConfig {
6
+ const product: Product = {
7
+ id: "test-id",
8
+ slug,
9
+ brandName: "Test",
10
+ productName: "Test",
11
+ tagline: "",
12
+ domain: "test.com",
13
+ appDomain: "app.test.com",
14
+ cookieDomain: ".test.com",
15
+ companyLegal: "",
16
+ priceLabel: "",
17
+ defaultImage: "",
18
+ emailSupport: "",
19
+ emailPrivacy: "",
20
+ emailLegal: "",
21
+ fromEmail: "",
22
+ homePath: "/dashboard",
23
+ storagePrefix: "test",
24
+ createdAt: new Date(),
25
+ updatedAt: new Date(),
26
+ };
27
+ return { product, navItems: [], domains: [], features: null, fleet: null, billing: null };
28
+ }
29
+
30
+ function mockRepo(): IProductConfigRepository {
31
+ return {
32
+ getBySlug: vi.fn().mockResolvedValue(makeConfig("test")),
33
+ listAll: vi.fn().mockResolvedValue([makeConfig("test")]),
34
+ upsertProduct: vi.fn().mockResolvedValue(makeConfig("test").product),
35
+ replaceNavItems: vi.fn().mockResolvedValue(undefined),
36
+ upsertFeatures: vi.fn().mockResolvedValue(undefined),
37
+ upsertFleetConfig: vi.fn().mockResolvedValue(undefined),
38
+ upsertBillingConfig: vi.fn().mockResolvedValue(undefined),
39
+ };
40
+ }
41
+
42
+ describe("ProductConfigService", () => {
43
+ let repo: ReturnType<typeof mockRepo>;
44
+ let service: ProductConfigService;
45
+
46
+ beforeEach(() => {
47
+ repo = mockRepo();
48
+ service = new ProductConfigService(repo, { ttlMs: 100 });
49
+ });
50
+
51
+ // --- Cache behavior ---
52
+
53
+ it("caches getBySlug results", async () => {
54
+ await service.getBySlug("test");
55
+ await service.getBySlug("test");
56
+ expect(repo.getBySlug).toHaveBeenCalledOnce();
57
+ });
58
+
59
+ it("refetches after TTL expires", async () => {
60
+ await service.getBySlug("test");
61
+ await new Promise((r) => setTimeout(r, 150));
62
+ await service.getBySlug("test");
63
+ expect(repo.getBySlug).toHaveBeenCalledTimes(2);
64
+ });
65
+
66
+ it("does not cache null results", async () => {
67
+ (repo.getBySlug as ReturnType<typeof vi.fn>).mockResolvedValue(null);
68
+ await service.getBySlug("missing");
69
+ await service.getBySlug("missing");
70
+ expect(repo.getBySlug).toHaveBeenCalledTimes(2);
71
+ });
72
+
73
+ // --- Auto-invalidation ---
74
+
75
+ it("upsertProduct invalidates cache", async () => {
76
+ await service.getBySlug("test");
77
+ expect(repo.getBySlug).toHaveBeenCalledOnce();
78
+
79
+ await service.upsertProduct("test", { brandName: "Updated" });
80
+
81
+ await service.getBySlug("test");
82
+ expect(repo.getBySlug).toHaveBeenCalledTimes(2);
83
+ });
84
+
85
+ it("replaceNavItems invalidates cache", async () => {
86
+ await service.getBySlug("test");
87
+ await service.replaceNavItems("test", "test-id", []);
88
+ await service.getBySlug("test");
89
+ expect(repo.getBySlug).toHaveBeenCalledTimes(2);
90
+ });
91
+
92
+ it("upsertFeatures invalidates cache", async () => {
93
+ await service.getBySlug("test");
94
+ await service.upsertFeatures("test", "test-id", { chatEnabled: false });
95
+ await service.getBySlug("test");
96
+ expect(repo.getBySlug).toHaveBeenCalledTimes(2);
97
+ });
98
+
99
+ it("upsertFleetConfig invalidates cache", async () => {
100
+ await service.getBySlug("test");
101
+ await service.upsertFleetConfig("test", "test-id", { lifecycle: "ephemeral" });
102
+ await service.getBySlug("test");
103
+ expect(repo.getBySlug).toHaveBeenCalledTimes(2);
104
+ });
105
+
106
+ it("upsertBillingConfig invalidates cache", async () => {
107
+ await service.getBySlug("test");
108
+ await service.upsertBillingConfig("test", "test-id", { affiliateMaxCap: 100 });
109
+ await service.getBySlug("test");
110
+ expect(repo.getBySlug).toHaveBeenCalledTimes(2);
111
+ });
112
+
113
+ // --- Brand config derivation ---
114
+
115
+ it("getBrandConfig returns derived brand config", async () => {
116
+ const brand = await service.getBrandConfig("test");
117
+ expect(brand).not.toBeNull();
118
+ expect(brand?.brandName).toBe("Test");
119
+ expect(brand?.domain).toBe("test.com");
120
+ });
121
+
122
+ it("getBrandConfig returns null for missing product", async () => {
123
+ (repo.getBySlug as ReturnType<typeof vi.fn>).mockResolvedValue(null);
124
+ const brand = await service.getBrandConfig("missing");
125
+ expect(brand).toBeNull();
126
+ });
127
+ });