@wopr-network/platform-core 1.58.1 → 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.
- package/dist/db/schema/index.d.ts +2 -0
- package/dist/db/schema/index.js +2 -0
- package/dist/db/schema/product-config.d.ts +610 -0
- package/dist/db/schema/product-config.js +51 -0
- package/dist/db/schema/products.d.ts +565 -0
- package/dist/db/schema/products.js +43 -0
- package/dist/product-config/boot.d.ts +36 -0
- package/dist/product-config/boot.js +30 -0
- package/dist/product-config/drizzle-product-config-repository.d.ts +19 -0
- package/dist/product-config/drizzle-product-config-repository.js +200 -0
- package/dist/product-config/drizzle-product-config-repository.test.d.ts +1 -0
- package/dist/product-config/drizzle-product-config-repository.test.js +114 -0
- package/dist/product-config/index.d.ts +24 -0
- package/dist/product-config/index.js +37 -0
- package/dist/product-config/repository-types.d.ts +143 -0
- package/dist/product-config/repository-types.js +53 -0
- package/dist/product-config/service.d.ts +27 -0
- package/dist/product-config/service.js +74 -0
- package/dist/product-config/service.test.d.ts +1 -0
- package/dist/product-config/service.test.js +107 -0
- package/dist/trpc/index.d.ts +1 -0
- package/dist/trpc/index.js +1 -0
- package/dist/trpc/product-config-router.d.ts +117 -0
- package/dist/trpc/product-config-router.js +137 -0
- package/docs/specs/2026-03-23-product-config-db-migration-plan.md +2260 -0
- package/docs/specs/2026-03-23-product-config-db-migration.md +371 -0
- package/drizzle/migrations/0020_product_config_tables.sql +109 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/scripts/seed-products.ts +268 -0
- package/src/db/schema/index.ts +2 -0
- package/src/db/schema/product-config.ts +56 -0
- package/src/db/schema/products.ts +58 -0
- package/src/product-config/boot.ts +57 -0
- package/src/product-config/drizzle-product-config-repository.test.ts +132 -0
- package/src/product-config/drizzle-product-config-repository.ts +229 -0
- package/src/product-config/index.ts +62 -0
- package/src/product-config/repository-types.ts +222 -0
- package/src/product-config/service.test.ts +127 -0
- package/src/product-config/service.ts +105 -0
- package/src/trpc/index.ts +1 -0
- 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
|
+
});
|