@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.
Files changed (42) hide show
  1. package/dist/db/schema/index.d.ts +2 -0
  2. package/dist/db/schema/index.js +2 -0
  3. package/dist/db/schema/product-config.d.ts +610 -0
  4. package/dist/db/schema/product-config.js +51 -0
  5. package/dist/db/schema/products.d.ts +565 -0
  6. package/dist/db/schema/products.js +43 -0
  7. package/dist/product-config/boot.d.ts +36 -0
  8. package/dist/product-config/boot.js +30 -0
  9. package/dist/product-config/drizzle-product-config-repository.d.ts +19 -0
  10. package/dist/product-config/drizzle-product-config-repository.js +200 -0
  11. package/dist/product-config/drizzle-product-config-repository.test.d.ts +1 -0
  12. package/dist/product-config/drizzle-product-config-repository.test.js +114 -0
  13. package/dist/product-config/index.d.ts +24 -0
  14. package/dist/product-config/index.js +37 -0
  15. package/dist/product-config/repository-types.d.ts +143 -0
  16. package/dist/product-config/repository-types.js +53 -0
  17. package/dist/product-config/service.d.ts +27 -0
  18. package/dist/product-config/service.js +74 -0
  19. package/dist/product-config/service.test.d.ts +1 -0
  20. package/dist/product-config/service.test.js +107 -0
  21. package/dist/trpc/index.d.ts +1 -0
  22. package/dist/trpc/index.js +1 -0
  23. package/dist/trpc/product-config-router.d.ts +117 -0
  24. package/dist/trpc/product-config-router.js +137 -0
  25. package/docs/specs/2026-03-23-product-config-db-migration-plan.md +2260 -0
  26. package/docs/specs/2026-03-23-product-config-db-migration.md +371 -0
  27. package/drizzle/migrations/0020_product_config_tables.sql +109 -0
  28. package/drizzle/migrations/meta/_journal.json +7 -0
  29. package/package.json +1 -1
  30. package/scripts/seed-products.ts +268 -0
  31. package/src/db/schema/index.ts +2 -0
  32. package/src/db/schema/product-config.ts +56 -0
  33. package/src/db/schema/products.ts +58 -0
  34. package/src/product-config/boot.ts +57 -0
  35. package/src/product-config/drizzle-product-config-repository.test.ts +132 -0
  36. package/src/product-config/drizzle-product-config-repository.ts +229 -0
  37. package/src/product-config/index.ts +62 -0
  38. package/src/product-config/repository-types.ts +222 -0
  39. package/src/product-config/service.test.ts +127 -0
  40. package/src/product-config/service.ts +105 -0
  41. package/src/trpc/index.ts +1 -0
  42. package/src/trpc/product-config-router.ts +161 -0
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Seed product config tables with data for all 4 products.
3
+ *
4
+ * Usage:
5
+ * DATABASE_URL=postgres://... npx tsx scripts/seed-products.ts
6
+ * DATABASE_URL=postgres://... npx tsx scripts/seed-products.ts --slug=paperclip
7
+ */
8
+ import { eq } from "drizzle-orm";
9
+ import { drizzle } from "drizzle-orm/node-postgres";
10
+ import pg from "pg";
11
+ import { productFeatures, productFleetConfig } from "../src/db/schema/product-config.js";
12
+ import { productNavItems, products } from "../src/db/schema/products.js";
13
+
14
+ interface NavItem {
15
+ label: string;
16
+ href: string;
17
+ sortOrder: number;
18
+ requiresRole?: string;
19
+ }
20
+
21
+ interface FleetPreset {
22
+ containerImage: string;
23
+ lifecycle: "managed" | "ephemeral";
24
+ billingModel: "monthly" | "per_use" | "none";
25
+ maxInstances: number;
26
+ }
27
+
28
+ interface ProductPreset {
29
+ brandName: string;
30
+ productName: string;
31
+ tagline: string;
32
+ domain: string;
33
+ appDomain: string;
34
+ cookieDomain: string;
35
+ companyLegal: string;
36
+ priceLabel: string;
37
+ defaultImage: string;
38
+ emailSupport: string;
39
+ emailPrivacy: string;
40
+ emailLegal: string;
41
+ fromEmail: string;
42
+ homePath: string;
43
+ storagePrefix: string;
44
+ navItems: NavItem[];
45
+ fleet: FleetPreset;
46
+ }
47
+
48
+ const PRESETS: Record<string, ProductPreset> = {
49
+ wopr: {
50
+ brandName: "WOPR",
51
+ productName: "WOPR Bot",
52
+ tagline: "A $5/month supercomputer that manages your business.",
53
+ domain: "wopr.bot",
54
+ appDomain: "app.wopr.bot",
55
+ cookieDomain: ".wopr.bot",
56
+ companyLegal: "WOPR Network Inc.",
57
+ priceLabel: "$5/month",
58
+ defaultImage: "ghcr.io/wopr-network/wopr:latest",
59
+ emailSupport: "support@wopr.bot",
60
+ emailPrivacy: "privacy@wopr.bot",
61
+ emailLegal: "legal@wopr.bot",
62
+ fromEmail: "noreply@wopr.bot",
63
+ homePath: "/marketplace",
64
+ storagePrefix: "wopr",
65
+ navItems: [
66
+ { label: "Dashboard", href: "/dashboard", sortOrder: 0 },
67
+ { label: "Chat", href: "/chat", sortOrder: 1 },
68
+ { label: "Marketplace", href: "/marketplace", sortOrder: 2 },
69
+ { label: "Channels", href: "/channels", sortOrder: 3 },
70
+ { label: "Plugins", href: "/plugins", sortOrder: 4 },
71
+ { label: "Instances", href: "/instances", sortOrder: 5 },
72
+ { label: "Changesets", href: "/changesets", sortOrder: 6 },
73
+ { label: "Network", href: "/dashboard/network", sortOrder: 7 },
74
+ { label: "Fleet Health", href: "/fleet/health", sortOrder: 8 },
75
+ { label: "Credits", href: "/billing/credits", sortOrder: 9 },
76
+ { label: "Billing", href: "/billing/plans", sortOrder: 10 },
77
+ { label: "Settings", href: "/settings/profile", sortOrder: 11 },
78
+ { label: "Admin", href: "/admin/tenants", sortOrder: 12, requiresRole: "platform_admin" },
79
+ ],
80
+ fleet: {
81
+ containerImage: "ghcr.io/wopr-network/wopr:latest",
82
+ lifecycle: "managed",
83
+ billingModel: "monthly",
84
+ maxInstances: 5,
85
+ },
86
+ },
87
+ paperclip: {
88
+ brandName: "Paperclip",
89
+ productName: "Paperclip",
90
+ tagline: "AI agents that run your business.",
91
+ domain: "runpaperclip.com",
92
+ appDomain: "app.runpaperclip.com",
93
+ cookieDomain: ".runpaperclip.com",
94
+ companyLegal: "Paperclip AI Inc.",
95
+ priceLabel: "$5/month",
96
+ defaultImage: "ghcr.io/wopr-network/paperclip:managed",
97
+ emailSupport: "support@runpaperclip.com",
98
+ emailPrivacy: "privacy@runpaperclip.com",
99
+ emailLegal: "legal@runpaperclip.com",
100
+ fromEmail: "noreply@runpaperclip.com",
101
+ homePath: "/instances",
102
+ storagePrefix: "paperclip",
103
+ navItems: [
104
+ { label: "Instances", href: "/instances", sortOrder: 0 },
105
+ { label: "Credits", href: "/billing/credits", sortOrder: 1 },
106
+ { label: "Settings", href: "/settings/profile", sortOrder: 2 },
107
+ { label: "Admin", href: "/admin/tenants", sortOrder: 3, requiresRole: "platform_admin" },
108
+ ],
109
+ fleet: {
110
+ containerImage: "ghcr.io/wopr-network/paperclip:managed",
111
+ lifecycle: "managed",
112
+ billingModel: "monthly",
113
+ maxInstances: 5,
114
+ },
115
+ },
116
+ holyship: {
117
+ brandName: "Holy Ship",
118
+ productName: "Holy Ship",
119
+ tagline: "Ship it.",
120
+ domain: "holyship.wtf",
121
+ appDomain: "app.holyship.wtf",
122
+ cookieDomain: ".holyship.wtf",
123
+ companyLegal: "WOPR Network Inc.",
124
+ priceLabel: "",
125
+ defaultImage: "ghcr.io/wopr-network/holyship:latest",
126
+ emailSupport: "support@holyship.wtf",
127
+ emailPrivacy: "privacy@holyship.wtf",
128
+ emailLegal: "legal@holyship.wtf",
129
+ fromEmail: "noreply@holyship.wtf",
130
+ homePath: "/dashboard",
131
+ storagePrefix: "holyship",
132
+ navItems: [
133
+ { label: "Dashboard", href: "/dashboard", sortOrder: 0 },
134
+ { label: "Ship", href: "/ship", sortOrder: 1 },
135
+ { label: "Approvals", href: "/approvals", sortOrder: 2 },
136
+ { label: "Connect", href: "/connect", sortOrder: 3 },
137
+ { label: "Credits", href: "/billing/credits", sortOrder: 4 },
138
+ { label: "Settings", href: "/settings/profile", sortOrder: 5 },
139
+ { label: "Admin", href: "/admin/tenants", sortOrder: 6, requiresRole: "platform_admin" },
140
+ ],
141
+ fleet: {
142
+ containerImage: "ghcr.io/wopr-network/holyship:latest",
143
+ lifecycle: "ephemeral",
144
+ billingModel: "none",
145
+ maxInstances: 50,
146
+ },
147
+ },
148
+ nemoclaw: {
149
+ brandName: "NemoPod",
150
+ productName: "NemoPod",
151
+ tagline: "NVIDIA NeMo, one click away",
152
+ domain: "nemopod.com",
153
+ appDomain: "app.nemopod.com",
154
+ cookieDomain: ".nemopod.com",
155
+ companyLegal: "WOPR Network Inc.",
156
+ priceLabel: "$5 free credits",
157
+ defaultImage: "ghcr.io/wopr-network/nemoclaw:latest",
158
+ emailSupport: "support@nemopod.com",
159
+ emailPrivacy: "privacy@nemopod.com",
160
+ emailLegal: "legal@nemopod.com",
161
+ fromEmail: "noreply@nemopod.com",
162
+ homePath: "/instances",
163
+ storagePrefix: "nemopod",
164
+ navItems: [
165
+ { label: "NemoClaws", href: "/instances", sortOrder: 0 },
166
+ { label: "Credits", href: "/billing/credits", sortOrder: 1 },
167
+ { label: "Settings", href: "/settings/profile", sortOrder: 2 },
168
+ { label: "Admin", href: "/admin/tenants", sortOrder: 3, requiresRole: "platform_admin" },
169
+ ],
170
+ fleet: {
171
+ containerImage: "ghcr.io/wopr-network/nemoclaw:latest",
172
+ lifecycle: "managed",
173
+ billingModel: "monthly",
174
+ maxInstances: 5,
175
+ },
176
+ },
177
+ };
178
+
179
+ async function seed(): Promise<void> {
180
+ const dbUrl = process.env.DATABASE_URL;
181
+ if (!dbUrl) {
182
+ console.error("DATABASE_URL is required");
183
+ process.exit(1);
184
+ }
185
+
186
+ const slugArg = process.argv.find((a) => a.startsWith("--slug="))?.split("=")[1];
187
+ const slugs = slugArg ? [slugArg] : Object.keys(PRESETS);
188
+
189
+ const pool = new pg.Pool({ connectionString: dbUrl });
190
+ const db = drizzle(pool);
191
+
192
+ for (const slug of slugs) {
193
+ const preset = PRESETS[slug];
194
+ if (!preset) {
195
+ console.error(`Unknown product: ${slug}. Valid: ${Object.keys(PRESETS).join(", ")}`);
196
+ continue;
197
+ }
198
+
199
+ console.log(`Seeding ${slug}...`);
200
+ const { navItems, fleet, ...productData } = preset;
201
+
202
+ // Upsert product
203
+ const [product] = await db
204
+ .insert(products)
205
+ .values({ slug, ...productData })
206
+ .onConflictDoUpdate({
207
+ target: products.slug,
208
+ set: { ...productData, updatedAt: new Date() },
209
+ })
210
+ .returning();
211
+
212
+ if (!product) {
213
+ throw new Error(`Failed to upsert product: ${slug}`);
214
+ }
215
+
216
+ // Replace nav items
217
+ await db.delete(productNavItems).where(eq(productNavItems.productId, product.id));
218
+ if (navItems.length > 0) {
219
+ await db.insert(productNavItems).values(
220
+ navItems.map((item) => ({
221
+ productId: product.id,
222
+ label: item.label,
223
+ href: item.href,
224
+ sortOrder: item.sortOrder,
225
+ requiresRole: item.requiresRole ?? null,
226
+ enabled: true,
227
+ })),
228
+ );
229
+ }
230
+
231
+ // Upsert fleet config
232
+ await db
233
+ .insert(productFleetConfig)
234
+ .values({
235
+ productId: product.id,
236
+ containerImage: fleet.containerImage,
237
+ lifecycle: fleet.lifecycle,
238
+ billingModel: fleet.billingModel,
239
+ maxInstances: fleet.maxInstances,
240
+ })
241
+ .onConflictDoUpdate({
242
+ target: productFleetConfig.productId,
243
+ set: {
244
+ containerImage: fleet.containerImage,
245
+ lifecycle: fleet.lifecycle,
246
+ billingModel: fleet.billingModel,
247
+ maxInstances: fleet.maxInstances,
248
+ updatedAt: new Date(),
249
+ },
250
+ });
251
+
252
+ // Upsert default features (no-op if already exists)
253
+ await db
254
+ .insert(productFeatures)
255
+ .values({ productId: product.id })
256
+ .onConflictDoNothing();
257
+
258
+ console.log(` done (${navItems.length} nav items, fleet: ${fleet.lifecycle}/${fleet.billingModel})`);
259
+ }
260
+
261
+ await pool.end();
262
+ console.log("Seed complete.");
263
+ }
264
+
265
+ seed().catch((err: unknown) => {
266
+ console.error("Seed failed:", err);
267
+ process.exit(1);
268
+ });
@@ -44,6 +44,8 @@ export * from "./page-contexts.js";
44
44
  export * from "./platform-api-keys.js";
45
45
  export * from "./plugin-configs.js";
46
46
  export * from "./plugin-marketplace-content.js";
47
+ export * from "./product-config.js";
48
+ export * from "./products.js";
47
49
  export * from "./promotion-redemptions.js";
48
50
  export * from "./promotions.js";
49
51
  export * from "./provider-credentials.js";
@@ -0,0 +1,56 @@
1
+ import { boolean, integer, jsonb, numeric, pgEnum, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
2
+
3
+ import { products } from "./products.js";
4
+
5
+ export const fleetLifecycleEnum = pgEnum("fleet_lifecycle", ["managed", "ephemeral"]);
6
+ export const fleetBillingModelEnum = pgEnum("fleet_billing_model", ["monthly", "per_use", "none"]);
7
+
8
+ export const productFeatures = pgTable("product_features", {
9
+ productId: uuid("product_id")
10
+ .primaryKey()
11
+ .references(() => products.id, { onDelete: "cascade" }),
12
+ chatEnabled: boolean("chat_enabled").notNull().default(true),
13
+ onboardingEnabled: boolean("onboarding_enabled").notNull().default(true),
14
+ onboardingDefaultModel: text("onboarding_default_model"),
15
+ onboardingSystemPrompt: text("onboarding_system_prompt"),
16
+ onboardingMaxCredits: integer("onboarding_max_credits").notNull().default(100),
17
+ onboardingWelcomeMsg: text("onboarding_welcome_msg"),
18
+ sharedModuleBilling: boolean("shared_module_billing").notNull().default(true),
19
+ sharedModuleMonitoring: boolean("shared_module_monitoring").notNull().default(true),
20
+ sharedModuleAnalytics: boolean("shared_module_analytics").notNull().default(true),
21
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
22
+ });
23
+
24
+ export const productFleetConfig = pgTable("product_fleet_config", {
25
+ productId: uuid("product_id")
26
+ .primaryKey()
27
+ .references(() => products.id, { onDelete: "cascade" }),
28
+ containerImage: text("container_image").notNull(),
29
+ containerPort: integer("container_port").notNull().default(3100),
30
+ lifecycle: fleetLifecycleEnum("lifecycle").notNull().default("managed"),
31
+ billingModel: fleetBillingModelEnum("billing_model").notNull().default("monthly"),
32
+ maxInstances: integer("max_instances").notNull().default(5),
33
+ imageAllowlist: text("image_allowlist").array(),
34
+ dockerNetwork: text("docker_network").notNull().default(""),
35
+ placementStrategy: text("placement_strategy").notNull().default("least-loaded"),
36
+ fleetDataDir: text("fleet_data_dir").notNull().default("/data/fleet"),
37
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
38
+ });
39
+
40
+ export const productBillingConfig = pgTable("product_billing_config", {
41
+ productId: uuid("product_id")
42
+ .primaryKey()
43
+ .references(() => products.id, { onDelete: "cascade" }),
44
+ stripePublishableKey: text("stripe_publishable_key"),
45
+ /** Encrypted via credential vault (CRYPTO_SERVICE_KEY) before storage. */
46
+ stripeSecretKey: text("stripe_secret_key"),
47
+ /** Encrypted via credential vault (CRYPTO_SERVICE_KEY) before storage. */
48
+ stripeWebhookSecret: text("stripe_webhook_secret"),
49
+ creditPrices: jsonb("credit_prices").notNull().default({}),
50
+ affiliateBaseUrl: text("affiliate_base_url"),
51
+ affiliateMatchRate: numeric("affiliate_match_rate").notNull().default("1.0"),
52
+ affiliateMaxCap: integer("affiliate_max_cap").notNull().default(20000),
53
+ dividendRate: numeric("dividend_rate").notNull().default("1.0"),
54
+ marginConfig: jsonb("margin_config"),
55
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
56
+ });
@@ -0,0 +1,58 @@
1
+ import { boolean, index, integer, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
2
+
3
+ export const products = pgTable(
4
+ "products",
5
+ {
6
+ id: uuid("id").primaryKey().defaultRandom(),
7
+ slug: text("slug").notNull(),
8
+ brandName: text("brand_name").notNull(),
9
+ productName: text("product_name").notNull(),
10
+ tagline: text("tagline").notNull().default(""),
11
+ domain: text("domain").notNull(),
12
+ appDomain: text("app_domain").notNull(),
13
+ cookieDomain: text("cookie_domain").notNull(),
14
+ companyLegal: text("company_legal").notNull().default(""),
15
+ priceLabel: text("price_label").notNull().default(""),
16
+ defaultImage: text("default_image").notNull().default(""),
17
+ emailSupport: text("email_support").notNull().default(""),
18
+ emailPrivacy: text("email_privacy").notNull().default(""),
19
+ emailLegal: text("email_legal").notNull().default(""),
20
+ fromEmail: text("from_email").notNull().default(""),
21
+ homePath: text("home_path").notNull().default("/marketplace"),
22
+ storagePrefix: text("storage_prefix").notNull(),
23
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
24
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
25
+ },
26
+ (t) => [uniqueIndex("products_slug_idx").on(t.slug)],
27
+ );
28
+
29
+ export const productNavItems = pgTable(
30
+ "product_nav_items",
31
+ {
32
+ id: uuid("id").primaryKey().defaultRandom(),
33
+ productId: uuid("product_id")
34
+ .notNull()
35
+ .references(() => products.id, { onDelete: "cascade" }),
36
+ label: text("label").notNull(),
37
+ href: text("href").notNull(),
38
+ icon: text("icon"),
39
+ sortOrder: integer("sort_order").notNull(),
40
+ requiresRole: text("requires_role"),
41
+ enabled: boolean("enabled").notNull().default(true),
42
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
43
+ },
44
+ (t) => [index("product_nav_items_product_sort_idx").on(t.productId, t.sortOrder)],
45
+ );
46
+
47
+ export const productDomains = pgTable(
48
+ "product_domains",
49
+ {
50
+ id: uuid("id").primaryKey().defaultRandom(),
51
+ productId: uuid("product_id")
52
+ .notNull()
53
+ .references(() => products.id, { onDelete: "cascade" }),
54
+ host: text("host").notNull(),
55
+ role: text("role").notNull().default("canonical"),
56
+ },
57
+ (t) => [uniqueIndex("product_domains_product_host_idx").on(t.productId, t.host)],
58
+ );
@@ -0,0 +1,57 @@
1
+ import type { DrizzleDb } from "../db/index.js";
2
+ import { DrizzleProductConfigRepository } from "./drizzle-product-config-repository.js";
3
+ import type { ProductConfig } from "./repository-types.js";
4
+ import { deriveCorsOrigins } from "./repository-types.js";
5
+ import { ProductConfigService } from "./service.js";
6
+
7
+ export interface PlatformBootResult {
8
+ /** The product config service — single point of access for config reads/writes. */
9
+ service: ProductConfigService;
10
+ /** The resolved product config (cached). */
11
+ config: ProductConfig;
12
+ /** CORS origins derived from product domains + optional dev origins. */
13
+ corsOrigins: string[];
14
+ }
15
+
16
+ export interface PlatformBootOptions {
17
+ /** Product slug (e.g. "paperclip", "wopr", "holyship", "nemoclaw"). */
18
+ slug: string;
19
+ /** Drizzle database instance. */
20
+ db: DrizzleDb;
21
+ /** Additional CORS origins for local dev (from DEV_ORIGINS env var). */
22
+ devOrigins?: string[];
23
+ }
24
+
25
+ /**
26
+ * Bootstrap product configuration from DB.
27
+ *
28
+ * Call once at startup, after DB + migrations, before route registration.
29
+ * Returns the service (for tRPC router wiring) and the resolved config
30
+ * (for CORS, email, fleet, auth initialization).
31
+ *
32
+ * This replaces: BRAND_NAME, PLATFORM_DOMAIN, UI_ORIGIN, FROM_EMAIL,
33
+ * SUPPORT_EMAIL, COOKIE_DOMAIN, APP_BASE_URL, and all other product-
34
+ * specific env vars that platform-core modules previously read from
35
+ * process.env.
36
+ *
37
+ * Product backends still own their specific wiring (crypto watchers,
38
+ * fleet updaters, notification pipelines). platformBoot handles the
39
+ * config-driven parts that are identical across products.
40
+ */
41
+ export async function platformBoot(opts: PlatformBootOptions): Promise<PlatformBootResult> {
42
+ const { slug, db, devOrigins = [] } = opts;
43
+
44
+ const repo = new DrizzleProductConfigRepository(db);
45
+ const service = new ProductConfigService(repo);
46
+
47
+ const config = await service.getBySlug(slug);
48
+ if (!config) {
49
+ throw new Error(
50
+ `Product "${slug}" not found in database. Run the seed script: DATABASE_URL=... npx tsx scripts/seed-products.ts`,
51
+ );
52
+ }
53
+
54
+ const corsOrigins = [...deriveCorsOrigins(config.product, config.domains), ...devOrigins];
55
+
56
+ return { service, config, corsOrigins };
57
+ }
@@ -0,0 +1,132 @@
1
+ import type { PGlite } from "@electric-sql/pglite";
2
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
3
+ import type { DrizzleDb } from "../db/index.js";
4
+ import { products } from "../db/schema/products.js";
5
+ import { createTestDb } from "../test/db.js";
6
+ import { DrizzleProductConfigRepository } from "./drizzle-product-config-repository.js";
7
+
8
+ const PRODUCT_SEED = {
9
+ slug: "test-product",
10
+ brandName: "Test Brand",
11
+ productName: "Test Product",
12
+ domain: "test.example.com",
13
+ appDomain: "app.test.example.com",
14
+ cookieDomain: ".test.example.com",
15
+ storagePrefix: "test",
16
+ };
17
+
18
+ describe("DrizzleProductConfigRepository", () => {
19
+ let db: DrizzleDb;
20
+ let pool: PGlite;
21
+ let repo: DrizzleProductConfigRepository;
22
+
23
+ beforeAll(async () => {
24
+ const result = await createTestDb();
25
+ db = result.db as unknown as DrizzleDb;
26
+ pool = result.pool;
27
+ repo = new DrizzleProductConfigRepository(db);
28
+ });
29
+
30
+ afterAll(async () => {
31
+ await pool.close();
32
+ });
33
+
34
+ it("getBySlug returns null for unknown slug", async () => {
35
+ const result = await repo.getBySlug("no-such-slug");
36
+ expect(result).toBeNull();
37
+ });
38
+
39
+ it("getBySlug returns full config for seeded product with empty related tables", async () => {
40
+ await db.insert(products).values(PRODUCT_SEED);
41
+
42
+ const config = await repo.getBySlug("test-product");
43
+
44
+ expect(config).not.toBeNull();
45
+ expect(config?.product.slug).toBe("test-product");
46
+ expect(config?.product.brandName).toBe("Test Brand");
47
+ expect(config?.navItems).toHaveLength(0);
48
+ expect(config?.domains).toHaveLength(0);
49
+ expect(config?.features).toBeNull();
50
+ expect(config?.fleet).toBeNull();
51
+ expect(config?.billing).toBeNull();
52
+ });
53
+
54
+ it("replaceNavItems then getBySlug returns sorted nav items", async () => {
55
+ const config = await repo.getBySlug("test-product");
56
+ if (!config) throw new Error("product not found");
57
+ const productId = config.product.id;
58
+
59
+ await repo.replaceNavItems(productId, [
60
+ { label: "Beta", href: "/beta", sortOrder: 2 },
61
+ { label: "Alpha", href: "/alpha", sortOrder: 1 },
62
+ { label: "Gamma", href: "/gamma", sortOrder: 3, requiresRole: "admin" },
63
+ ]);
64
+
65
+ const updated = await repo.getBySlug("test-product");
66
+ expect(updated?.navItems).toHaveLength(3);
67
+ expect(updated?.navItems[0].label).toBe("Alpha");
68
+ expect(updated?.navItems[1].label).toBe("Beta");
69
+ expect(updated?.navItems[2].label).toBe("Gamma");
70
+ expect(updated?.navItems[2].requiresRole).toBe("admin");
71
+ });
72
+
73
+ it("upsertFeatures then getBySlug returns features", async () => {
74
+ const config = await repo.getBySlug("test-product");
75
+ if (!config) throw new Error("product not found");
76
+ const productId = config.product.id;
77
+
78
+ await repo.upsertFeatures(productId, {
79
+ chatEnabled: false,
80
+ onboardingEnabled: true,
81
+ onboardingDefaultModel: "gpt-4o",
82
+ onboardingMaxCredits: 50,
83
+ });
84
+
85
+ const updated = await repo.getBySlug("test-product");
86
+ expect(updated?.features).not.toBeNull();
87
+ expect(updated?.features?.chatEnabled).toBe(false);
88
+ expect(updated?.features?.onboardingDefaultModel).toBe("gpt-4o");
89
+ expect(updated?.features?.onboardingMaxCredits).toBe(50);
90
+ });
91
+
92
+ it("upsertFleetConfig with lifecycle ephemeral and billingModel none", async () => {
93
+ const config = await repo.getBySlug("test-product");
94
+ if (!config) throw new Error("product not found");
95
+ const productId = config.product.id;
96
+
97
+ await repo.upsertFleetConfig(productId, {
98
+ containerImage: "ghcr.io/example/app:latest",
99
+ lifecycle: "ephemeral",
100
+ billingModel: "none",
101
+ containerPort: 8080,
102
+ maxInstances: 10,
103
+ });
104
+
105
+ const updated = await repo.getBySlug("test-product");
106
+ expect(updated?.fleet).not.toBeNull();
107
+ expect(updated?.fleet?.containerImage).toBe("ghcr.io/example/app:latest");
108
+ expect(updated?.fleet?.lifecycle).toBe("ephemeral");
109
+ expect(updated?.fleet?.billingModel).toBe("none");
110
+ expect(updated?.fleet?.containerPort).toBe(8080);
111
+ expect(updated?.fleet?.maxInstances).toBe(10);
112
+ });
113
+
114
+ it("listAll returns all products", async () => {
115
+ // Insert a second product
116
+ await db.insert(products).values({
117
+ slug: "second-product",
118
+ brandName: "Second Brand",
119
+ productName: "Second Product",
120
+ domain: "second.example.com",
121
+ appDomain: "app.second.example.com",
122
+ cookieDomain: ".second.example.com",
123
+ storagePrefix: "second",
124
+ });
125
+
126
+ const all = await repo.listAll();
127
+ const slugs = all.map((c) => c.product.slug);
128
+ expect(slugs).toContain("test-product");
129
+ expect(slugs).toContain("second-product");
130
+ expect(all.length).toBeGreaterThanOrEqual(2);
131
+ });
132
+ });