@wopr-network/platform-core 1.58.1 → 1.60.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/billing/crypto/__tests__/key-server.test.js +3 -0
- package/dist/billing/crypto/key-server.js +1 -0
- package/dist/billing/crypto/oracle/coingecko.js +1 -0
- package/dist/billing/crypto/payment-method-store.d.ts +1 -0
- package/dist/billing/crypto/payment-method-store.js +3 -0
- package/dist/billing/crypto/tron/__tests__/address-convert.test.d.ts +1 -0
- package/dist/billing/crypto/tron/__tests__/address-convert.test.js +55 -0
- package/dist/billing/crypto/tron/address-convert.d.ts +14 -0
- package/dist/billing/crypto/tron/address-convert.js +83 -0
- package/dist/billing/crypto/watcher-service.js +21 -11
- package/dist/db/schema/crypto.d.ts +17 -0
- package/dist/db/schema/crypto.js +1 -0
- 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/0021_watcher_type_column.sql +3 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/scripts/seed-products.ts +268 -0
- package/src/billing/crypto/__tests__/key-server.test.ts +3 -0
- package/src/billing/crypto/key-server.ts +2 -0
- package/src/billing/crypto/oracle/coingecko.ts +1 -0
- package/src/billing/crypto/payment-method-store.ts +4 -0
- package/src/billing/crypto/tron/__tests__/address-convert.test.ts +67 -0
- package/src/billing/crypto/tron/address-convert.ts +80 -0
- package/src/billing/crypto/watcher-service.ts +24 -16
- package/src/db/schema/crypto.ts +1 -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,2260 @@
|
|
|
1
|
+
# Product Config DB Migration — Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Move ~46 product-configurable environment variables into database tables, served via tRPC, managed via admin UI.
|
|
6
|
+
|
|
7
|
+
**Architecture:** New Drizzle schema tables in platform-core, product config repository with in-memory cache, tRPC router (public + admin), admin UI pages in platform-ui-core. Existing modules migrate one at a time from `process.env` reads to `getProductConfig()` calls.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Drizzle ORM, tRPC, Zod, PGlite (tests), Next.js App Router, shadcn/ui, Biome
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/specs/2026-03-23-product-config-db-migration.md`
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Design Principle: Eliminate Code in Products
|
|
16
|
+
|
|
17
|
+
**The goal is NOT just swapping env vars for DB reads.** The goal is that platform-core does the heavy lifting so product backends shrink dramatically.
|
|
18
|
+
|
|
19
|
+
Before: each product backend has its own `config.ts` (30+ env vars), CORS setup, email config, fleet wiring.
|
|
20
|
+
After: each product backend passes `PRODUCT_SLUG` to `platformBoot()` and platform-core auto-configures everything from DB.
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
// paperclip-platform/src/index.ts — AFTER (the dream)
|
|
24
|
+
import { platformBoot } from "@wopr-network/platform-core";
|
|
25
|
+
|
|
26
|
+
const app = await platformBoot({
|
|
27
|
+
slug: "paperclip",
|
|
28
|
+
db: getDb(),
|
|
29
|
+
// product-specific route additions only:
|
|
30
|
+
extraRoutes: (hono) => {
|
|
31
|
+
// any paperclip-only routes
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## File Map
|
|
37
|
+
|
|
38
|
+
### platform-core (new files)
|
|
39
|
+
|
|
40
|
+
| File | Responsibility |
|
|
41
|
+
|------|---------------|
|
|
42
|
+
| `src/db/schema/products.ts` | Drizzle schema: products, product_nav_items, product_domains |
|
|
43
|
+
| `src/db/schema/product-config.ts` | Drizzle schema: product_features, product_fleet_config, product_billing_config |
|
|
44
|
+
| `src/product-config/repository-types.ts` | Plain TS interfaces: `IProductConfigRepository`, domain objects (no Drizzle types) |
|
|
45
|
+
| `src/product-config/drizzle-product-config-repository.ts` | Drizzle implementation of `IProductConfigRepository` |
|
|
46
|
+
| `src/product-config/drizzle-product-config-repository.test.ts` | Repository tests (PGlite) |
|
|
47
|
+
| `src/product-config/cache.ts` | In-memory cache with TTL + invalidation |
|
|
48
|
+
| `src/product-config/cache.test.ts` | Cache tests |
|
|
49
|
+
| `src/product-config/index.ts` | Public API: `getProductConfig()`, `getProductBrand()`, `deriveCorsOrigins()` |
|
|
50
|
+
| `src/trpc/product-config-router.ts` | tRPC router: public + admin endpoints |
|
|
51
|
+
| `src/trpc/product-config-router.test.ts` | Router tests |
|
|
52
|
+
| `scripts/seed-products.ts` | One-time seed script to populate tables from current env vars |
|
|
53
|
+
|
|
54
|
+
### platform-core (modified files)
|
|
55
|
+
|
|
56
|
+
| File | Change |
|
|
57
|
+
|------|--------|
|
|
58
|
+
| `src/db/schema/index.ts` | Export new schema tables |
|
|
59
|
+
| `src/trpc/index.ts` | Export product config router |
|
|
60
|
+
| `drizzle/migrations/XXXX_*.sql` | Generated migration |
|
|
61
|
+
|
|
62
|
+
### platform-ui-core (new files)
|
|
63
|
+
|
|
64
|
+
| File | Responsibility |
|
|
65
|
+
|------|---------------|
|
|
66
|
+
| `src/app/admin/products/page.tsx` | Admin product config page (tabs per domain) |
|
|
67
|
+
| `src/app/admin/products/loading.tsx` | Loading skeleton |
|
|
68
|
+
| `src/app/admin/products/error.tsx` | Error boundary |
|
|
69
|
+
| `src/components/admin/products/brand-form.tsx` | Brand identity form |
|
|
70
|
+
| `src/components/admin/products/nav-editor.tsx` | Navigation item editor (reorder, toggle, add/remove) |
|
|
71
|
+
| `src/components/admin/products/features-form.tsx` | Feature flags form |
|
|
72
|
+
| `src/components/admin/products/fleet-form.tsx` | Fleet config form |
|
|
73
|
+
| `src/components/admin/products/billing-form.tsx` | Billing config form |
|
|
74
|
+
|
|
75
|
+
### platform-ui-core (modified files)
|
|
76
|
+
|
|
77
|
+
| File | Change |
|
|
78
|
+
|------|--------|
|
|
79
|
+
| `src/lib/brand-config.ts` | Add `initBrandConfig()` that fetches from tRPC |
|
|
80
|
+
| `src/components/sidebar.tsx` | Read nav items from brand config (already does, but confirm) |
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## Phase 1: Schema + Repository
|
|
85
|
+
|
|
86
|
+
### Task 1: Product Schema Tables
|
|
87
|
+
|
|
88
|
+
**Files:**
|
|
89
|
+
- Create: `src/db/schema/products.ts`
|
|
90
|
+
- Create: `src/db/schema/product-config.ts`
|
|
91
|
+
- Modify: `src/db/schema/index.ts`
|
|
92
|
+
|
|
93
|
+
- [ ] **Step 1: Write products schema**
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
// src/db/schema/products.ts
|
|
97
|
+
import { index, pgTable, text, timestamp, unique, uuid } from "drizzle-orm/pg-core";
|
|
98
|
+
|
|
99
|
+
export const products = pgTable(
|
|
100
|
+
"products",
|
|
101
|
+
{
|
|
102
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
103
|
+
slug: text("slug").notNull(),
|
|
104
|
+
brandName: text("brand_name").notNull(),
|
|
105
|
+
productName: text("product_name").notNull(),
|
|
106
|
+
tagline: text("tagline").notNull().default(""),
|
|
107
|
+
domain: text("domain").notNull(),
|
|
108
|
+
appDomain: text("app_domain").notNull(),
|
|
109
|
+
cookieDomain: text("cookie_domain").notNull(),
|
|
110
|
+
companyLegal: text("company_legal").notNull().default(""),
|
|
111
|
+
priceLabel: text("price_label").notNull().default(""),
|
|
112
|
+
defaultImage: text("default_image").notNull().default(""),
|
|
113
|
+
emailSupport: text("email_support").notNull().default(""),
|
|
114
|
+
emailPrivacy: text("email_privacy").notNull().default(""),
|
|
115
|
+
emailLegal: text("email_legal").notNull().default(""),
|
|
116
|
+
fromEmail: text("from_email").notNull().default(""),
|
|
117
|
+
homePath: text("home_path").notNull().default("/marketplace"),
|
|
118
|
+
storagePrefix: text("storage_prefix").notNull(),
|
|
119
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
120
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
121
|
+
},
|
|
122
|
+
(t) => [unique("products_slug_uniq").on(t.slug)],
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
export const productNavItems = pgTable(
|
|
126
|
+
"product_nav_items",
|
|
127
|
+
{
|
|
128
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
129
|
+
productId: uuid("product_id")
|
|
130
|
+
.notNull()
|
|
131
|
+
.references(() => products.id, { onDelete: "cascade" }),
|
|
132
|
+
label: text("label").notNull(),
|
|
133
|
+
href: text("href").notNull(),
|
|
134
|
+
icon: text("icon"),
|
|
135
|
+
sortOrder: uuid("sort_order").notNull().$type<number>(),
|
|
136
|
+
requiresRole: text("requires_role"),
|
|
137
|
+
enabled: text("enabled").notNull().default("true").$type<"true" | "false">(),
|
|
138
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
139
|
+
},
|
|
140
|
+
(t) => [index("idx_product_nav_items_product").on(t.productId, t.sortOrder)],
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
export const productDomains = pgTable(
|
|
144
|
+
"product_domains",
|
|
145
|
+
{
|
|
146
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
147
|
+
productId: uuid("product_id")
|
|
148
|
+
.notNull()
|
|
149
|
+
.references(() => products.id, { onDelete: "cascade" }),
|
|
150
|
+
host: text("host").notNull(),
|
|
151
|
+
role: text("role").notNull().default("canonical"),
|
|
152
|
+
},
|
|
153
|
+
(t) => [unique("product_domains_product_host_uniq").on(t.productId, t.host)],
|
|
154
|
+
);
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
- [ ] **Step 2: Write product config schema**
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
// src/db/schema/product-config.ts
|
|
161
|
+
import { boolean, integer, jsonb, numeric, pgEnum, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
162
|
+
import { products } from "./products.js";
|
|
163
|
+
|
|
164
|
+
export const fleetLifecycleEnum = pgEnum("fleet_lifecycle", ["managed", "ephemeral"]);
|
|
165
|
+
export const fleetBillingModelEnum = pgEnum("fleet_billing_model", ["monthly", "per_use", "none"]);
|
|
166
|
+
|
|
167
|
+
export const productFeatures = pgTable("product_features", {
|
|
168
|
+
productId: uuid("product_id")
|
|
169
|
+
.primaryKey()
|
|
170
|
+
.references(() => products.id, { onDelete: "cascade" }),
|
|
171
|
+
chatEnabled: boolean("chat_enabled").notNull().default(true),
|
|
172
|
+
onboardingEnabled: boolean("onboarding_enabled").notNull().default(true),
|
|
173
|
+
onboardingDefaultModel: text("onboarding_default_model"),
|
|
174
|
+
onboardingSystemPrompt: text("onboarding_system_prompt"),
|
|
175
|
+
onboardingMaxCredits: integer("onboarding_max_credits").notNull().default(100),
|
|
176
|
+
onboardingWelcomeMsg: text("onboarding_welcome_msg"),
|
|
177
|
+
sharedModuleBilling: boolean("shared_module_billing").notNull().default(true),
|
|
178
|
+
sharedModuleMonitoring: boolean("shared_module_monitoring").notNull().default(true),
|
|
179
|
+
sharedModuleAnalytics: boolean("shared_module_analytics").notNull().default(true),
|
|
180
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
export const productFleetConfig = pgTable("product_fleet_config", {
|
|
184
|
+
productId: uuid("product_id")
|
|
185
|
+
.primaryKey()
|
|
186
|
+
.references(() => products.id, { onDelete: "cascade" }),
|
|
187
|
+
containerImage: text("container_image").notNull(),
|
|
188
|
+
containerPort: integer("container_port").notNull().default(3100),
|
|
189
|
+
lifecycle: fleetLifecycleEnum("lifecycle").notNull().default("managed"),
|
|
190
|
+
billingModel: fleetBillingModelEnum("billing_model").notNull().default("monthly"),
|
|
191
|
+
maxInstances: integer("max_instances").notNull().default(5),
|
|
192
|
+
imageAllowlist: text("image_allowlist").array(),
|
|
193
|
+
dockerNetwork: text("docker_network").notNull().default(""),
|
|
194
|
+
placementStrategy: text("placement_strategy").notNull().default("least-loaded"),
|
|
195
|
+
fleetDataDir: text("fleet_data_dir").notNull().default("/data/fleet"),
|
|
196
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
export const productBillingConfig = pgTable("product_billing_config", {
|
|
200
|
+
productId: uuid("product_id")
|
|
201
|
+
.primaryKey()
|
|
202
|
+
.references(() => products.id, { onDelete: "cascade" }),
|
|
203
|
+
stripePublishableKey: text("stripe_publishable_key"),
|
|
204
|
+
stripeSecretKey: text("stripe_secret_key"),
|
|
205
|
+
stripeWebhookSecret: text("stripe_webhook_secret"),
|
|
206
|
+
creditPrices: jsonb("credit_prices").notNull().default({}),
|
|
207
|
+
affiliateBaseUrl: text("affiliate_base_url"),
|
|
208
|
+
affiliateMatchRate: numeric("affiliate_match_rate").notNull().default("1.0"),
|
|
209
|
+
affiliateMaxCap: integer("affiliate_max_cap").notNull().default(20000),
|
|
210
|
+
dividendRate: numeric("dividend_rate").notNull().default("1.0"),
|
|
211
|
+
marginConfig: jsonb("margin_config"),
|
|
212
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
- [ ] **Step 3: Export from schema index**
|
|
217
|
+
|
|
218
|
+
Add to `src/db/schema/index.ts`:
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
export * from "./products.js";
|
|
222
|
+
export * from "./product-config.js";
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
- [ ] **Step 4: Generate migration**
|
|
226
|
+
|
|
227
|
+
Run: `npx drizzle-kit generate`
|
|
228
|
+
Expected: New migration file in `drizzle/migrations/`
|
|
229
|
+
|
|
230
|
+
- [ ] **Step 5: Verify migration applies**
|
|
231
|
+
|
|
232
|
+
Run: `npx vitest run src/product-config/repository.test.ts` (will create in next task — for now just verify the schema compiles)
|
|
233
|
+
Run: `npm run check`
|
|
234
|
+
Expected: No type errors
|
|
235
|
+
|
|
236
|
+
- [ ] **Step 6: Commit**
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
git add src/db/schema/products.ts src/db/schema/product-config.ts src/db/schema/index.ts drizzle/migrations/
|
|
240
|
+
git commit -m "feat: add product config Drizzle schema tables"
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
### Task 2: Repository Types (IProductConfigRepository)
|
|
246
|
+
|
|
247
|
+
**Files:**
|
|
248
|
+
- Create: `src/product-config/repository-types.ts`
|
|
249
|
+
|
|
250
|
+
- [ ] **Step 1: Write plain TS interfaces (no Drizzle types)**
|
|
251
|
+
|
|
252
|
+
Following the `fleet/repository-types.ts` pattern: plain domain objects + repository interface.
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
// src/product-config/repository-types.ts
|
|
256
|
+
//
|
|
257
|
+
// Plain TypeScript interfaces for product configuration domain.
|
|
258
|
+
// No Drizzle types. These are the contract all consumers work against.
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Product
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
/** Plain domain object representing a product — mirrors `products` table. */
|
|
265
|
+
export interface Product {
|
|
266
|
+
id: string;
|
|
267
|
+
slug: string;
|
|
268
|
+
brandName: string;
|
|
269
|
+
productName: string;
|
|
270
|
+
tagline: string;
|
|
271
|
+
domain: string;
|
|
272
|
+
appDomain: string;
|
|
273
|
+
cookieDomain: string;
|
|
274
|
+
companyLegal: string;
|
|
275
|
+
priceLabel: string;
|
|
276
|
+
defaultImage: string;
|
|
277
|
+
emailSupport: string;
|
|
278
|
+
emailPrivacy: string;
|
|
279
|
+
emailLegal: string;
|
|
280
|
+
fromEmail: string;
|
|
281
|
+
homePath: string;
|
|
282
|
+
storagePrefix: string;
|
|
283
|
+
createdAt: Date;
|
|
284
|
+
updatedAt: Date;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
// ProductNavItem
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
export interface ProductNavItem {
|
|
292
|
+
id: string;
|
|
293
|
+
productId: string;
|
|
294
|
+
label: string;
|
|
295
|
+
href: string;
|
|
296
|
+
icon: string | null;
|
|
297
|
+
sortOrder: number;
|
|
298
|
+
requiresRole: string | null;
|
|
299
|
+
enabled: boolean;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// ProductDomain
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
export interface ProductDomain {
|
|
307
|
+
id: string;
|
|
308
|
+
productId: string;
|
|
309
|
+
host: string;
|
|
310
|
+
role: "canonical" | "redirect";
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// ProductFeatures
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
export interface ProductFeatures {
|
|
318
|
+
productId: string;
|
|
319
|
+
chatEnabled: boolean;
|
|
320
|
+
onboardingEnabled: boolean;
|
|
321
|
+
onboardingDefaultModel: string | null;
|
|
322
|
+
onboardingSystemPrompt: string | null;
|
|
323
|
+
onboardingMaxCredits: number;
|
|
324
|
+
onboardingWelcomeMsg: string | null;
|
|
325
|
+
sharedModuleBilling: boolean;
|
|
326
|
+
sharedModuleMonitoring: boolean;
|
|
327
|
+
sharedModuleAnalytics: boolean;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// ProductFleetConfig
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
export type FleetLifecycle = "managed" | "ephemeral";
|
|
335
|
+
export type FleetBillingModel = "monthly" | "per_use" | "none";
|
|
336
|
+
|
|
337
|
+
export interface ProductFleetConfig {
|
|
338
|
+
productId: string;
|
|
339
|
+
containerImage: string;
|
|
340
|
+
containerPort: number;
|
|
341
|
+
lifecycle: FleetLifecycle;
|
|
342
|
+
billingModel: FleetBillingModel;
|
|
343
|
+
maxInstances: number;
|
|
344
|
+
imageAllowlist: string[] | null;
|
|
345
|
+
dockerNetwork: string;
|
|
346
|
+
placementStrategy: string;
|
|
347
|
+
fleetDataDir: string;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// ProductBillingConfig
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
export interface ProductBillingConfig {
|
|
355
|
+
productId: string;
|
|
356
|
+
stripePublishableKey: string | null;
|
|
357
|
+
stripeSecretKey: string | null;
|
|
358
|
+
stripeWebhookSecret: string | null;
|
|
359
|
+
creditPrices: Record<string, number>;
|
|
360
|
+
affiliateBaseUrl: string | null;
|
|
361
|
+
affiliateMatchRate: number;
|
|
362
|
+
affiliateMaxCap: number;
|
|
363
|
+
dividendRate: number;
|
|
364
|
+
marginConfig: unknown;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// Aggregates
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
/** Full product config resolved from all tables. */
|
|
372
|
+
export interface ProductConfig {
|
|
373
|
+
product: Product;
|
|
374
|
+
navItems: ProductNavItem[];
|
|
375
|
+
domains: ProductDomain[];
|
|
376
|
+
features: ProductFeatures | null;
|
|
377
|
+
fleet: ProductFleetConfig | null;
|
|
378
|
+
billing: ProductBillingConfig | null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Brand config shape served to UI (matches BrandConfig in platform-ui-core). */
|
|
382
|
+
export interface ProductBrandConfig {
|
|
383
|
+
productName: string;
|
|
384
|
+
brandName: string;
|
|
385
|
+
domain: string;
|
|
386
|
+
appDomain: string;
|
|
387
|
+
tagline: string;
|
|
388
|
+
emails: { privacy: string; legal: string; support: string };
|
|
389
|
+
defaultImage: string;
|
|
390
|
+
storagePrefix: string;
|
|
391
|
+
companyLegalName: string;
|
|
392
|
+
price: string;
|
|
393
|
+
homePath: string;
|
|
394
|
+
chatEnabled: boolean;
|
|
395
|
+
navItems: Array<{ label: string; href: string }>;
|
|
396
|
+
domains?: Array<{ host: string; role: string }>;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
// Repository Interface
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
/** Upsert payload for product brand fields. */
|
|
404
|
+
export type ProductBrandUpdate = Partial<Omit<Product, "id" | "slug" | "createdAt" | "updatedAt">>;
|
|
405
|
+
|
|
406
|
+
/** Upsert payload for a nav item (no id — replaced in bulk). */
|
|
407
|
+
export interface NavItemInput {
|
|
408
|
+
label: string;
|
|
409
|
+
href: string;
|
|
410
|
+
icon?: string;
|
|
411
|
+
sortOrder: number;
|
|
412
|
+
requiresRole?: string;
|
|
413
|
+
enabled?: boolean;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export interface IProductConfigRepository {
|
|
417
|
+
getBySlug(slug: string): Promise<ProductConfig | null>;
|
|
418
|
+
listAll(): Promise<ProductConfig[]>;
|
|
419
|
+
upsertProduct(slug: string, data: ProductBrandUpdate): Promise<Product>;
|
|
420
|
+
replaceNavItems(productId: string, items: NavItemInput[]): Promise<void>;
|
|
421
|
+
upsertFeatures(productId: string, data: Partial<ProductFeatures>): Promise<void>;
|
|
422
|
+
upsertFleetConfig(productId: string, data: Partial<ProductFleetConfig>): Promise<void>;
|
|
423
|
+
upsertBillingConfig(productId: string, data: Partial<ProductBillingConfig>): Promise<void>;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
// Helpers
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
/** Derive CORS origins from product config. */
|
|
431
|
+
export function deriveCorsOrigins(product: Product, domains: ProductDomain[]): string[] {
|
|
432
|
+
const origins = new Set<string>();
|
|
433
|
+
origins.add(`https://${product.domain}`);
|
|
434
|
+
origins.add(`https://${product.appDomain}`);
|
|
435
|
+
for (const d of domains) {
|
|
436
|
+
origins.add(`https://${d.host}`);
|
|
437
|
+
}
|
|
438
|
+
return [...origins];
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Derive brand config for UI from full product config. */
|
|
442
|
+
export function toBrandConfig(config: ProductConfig): ProductBrandConfig {
|
|
443
|
+
const { product, navItems, domains, features } = config;
|
|
444
|
+
return {
|
|
445
|
+
productName: product.productName,
|
|
446
|
+
brandName: product.brandName,
|
|
447
|
+
domain: product.domain,
|
|
448
|
+
appDomain: product.appDomain,
|
|
449
|
+
tagline: product.tagline,
|
|
450
|
+
emails: {
|
|
451
|
+
privacy: product.emailPrivacy,
|
|
452
|
+
legal: product.emailLegal,
|
|
453
|
+
support: product.emailSupport,
|
|
454
|
+
},
|
|
455
|
+
defaultImage: product.defaultImage,
|
|
456
|
+
storagePrefix: product.storagePrefix,
|
|
457
|
+
companyLegalName: product.companyLegal,
|
|
458
|
+
price: product.priceLabel,
|
|
459
|
+
homePath: product.homePath,
|
|
460
|
+
chatEnabled: features?.chatEnabled ?? true,
|
|
461
|
+
navItems: navItems
|
|
462
|
+
.filter((n) => n.enabled)
|
|
463
|
+
.map((n) => ({ label: n.label, href: n.href })),
|
|
464
|
+
domains: domains.length > 0 ? domains.map((d) => ({ host: d.host, role: d.role })) : undefined,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
- [ ] **Step 2: Commit**
|
|
470
|
+
|
|
471
|
+
```bash
|
|
472
|
+
git add src/product-config/repository-types.ts
|
|
473
|
+
git commit -m "feat: add product config repository types (IProductConfigRepository)"
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
---
|
|
477
|
+
|
|
478
|
+
### Task 3: Drizzle Product Config Repository
|
|
479
|
+
|
|
480
|
+
**Files:**
|
|
481
|
+
- Create: `src/product-config/drizzle-product-config-repository.ts`
|
|
482
|
+
- Create: `src/product-config/drizzle-product-config-repository.test.ts`
|
|
483
|
+
|
|
484
|
+
- [ ] **Step 1: Write failing test — getBySlug**
|
|
485
|
+
|
|
486
|
+
```typescript
|
|
487
|
+
// src/product-config/drizzle-product-config-repository.test.ts
|
|
488
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
489
|
+
import type { PGlite } from "@electric-sql/pglite";
|
|
490
|
+
import { createTestDb } from "../test/db.js";
|
|
491
|
+
import { DrizzleProductConfigRepository } from "./drizzle-product-config-repository.js";
|
|
492
|
+
import { products } from "../db/schema/products.js";
|
|
493
|
+
import type { DrizzleDb } from "../db/index.js";
|
|
494
|
+
|
|
495
|
+
describe("DrizzleProductConfigRepository", () => {
|
|
496
|
+
let db: DrizzleDb;
|
|
497
|
+
let pool: PGlite;
|
|
498
|
+
let repo: ProductConfigRepository;
|
|
499
|
+
|
|
500
|
+
beforeAll(async () => {
|
|
501
|
+
const result = await createTestDb();
|
|
502
|
+
db = result.db;
|
|
503
|
+
pool = result.pool;
|
|
504
|
+
repo = new DrizzleProductConfigRepository(db);
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
afterAll(async () => {
|
|
508
|
+
await pool.close();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("returns null for unknown slug", async () => {
|
|
512
|
+
const result = await repo.getBySlug("nonexistent");
|
|
513
|
+
expect(result).toBeNull();
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("returns full config for seeded product", async () => {
|
|
517
|
+
// Seed a product
|
|
518
|
+
const [inserted] = await db
|
|
519
|
+
.insert(products)
|
|
520
|
+
.values({
|
|
521
|
+
slug: "test-product",
|
|
522
|
+
brandName: "Test",
|
|
523
|
+
productName: "Test Product",
|
|
524
|
+
domain: "test.com",
|
|
525
|
+
appDomain: "app.test.com",
|
|
526
|
+
cookieDomain: ".test.com",
|
|
527
|
+
storagePrefix: "test",
|
|
528
|
+
})
|
|
529
|
+
.returning();
|
|
530
|
+
|
|
531
|
+
const config = await repo.getBySlug("test-product");
|
|
532
|
+
expect(config).not.toBeNull();
|
|
533
|
+
expect(config!.product.slug).toBe("test-product");
|
|
534
|
+
expect(config!.product.brandName).toBe("Test");
|
|
535
|
+
expect(config!.navItems).toEqual([]);
|
|
536
|
+
expect(config!.domains).toEqual([]);
|
|
537
|
+
expect(config!.features).toBeNull();
|
|
538
|
+
expect(config!.fleet).toBeNull();
|
|
539
|
+
expect(config!.billing).toBeNull();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("returns nav items sorted by sortOrder", async () => {
|
|
543
|
+
const [product] = await db
|
|
544
|
+
.insert(products)
|
|
545
|
+
.values({
|
|
546
|
+
slug: "nav-test",
|
|
547
|
+
brandName: "Nav",
|
|
548
|
+
productName: "Nav Test",
|
|
549
|
+
domain: "nav.com",
|
|
550
|
+
appDomain: "app.nav.com",
|
|
551
|
+
cookieDomain: ".nav.com",
|
|
552
|
+
storagePrefix: "nav",
|
|
553
|
+
})
|
|
554
|
+
.returning();
|
|
555
|
+
|
|
556
|
+
const { productNavItems } = await import("../db/schema/products.js");
|
|
557
|
+
await db.insert(productNavItems).values([
|
|
558
|
+
{ productId: product.id, label: "Second", href: "/second", sortOrder: 2 },
|
|
559
|
+
{ productId: product.id, label: "First", href: "/first", sortOrder: 1 },
|
|
560
|
+
]);
|
|
561
|
+
|
|
562
|
+
const config = await repo.getBySlug("nav-test");
|
|
563
|
+
expect(config!.navItems).toHaveLength(2);
|
|
564
|
+
expect(config!.navItems[0].label).toBe("First");
|
|
565
|
+
expect(config!.navItems[1].label).toBe("Second");
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
571
|
+
|
|
572
|
+
Run: `npx vitest run src/product-config/repository.test.ts`
|
|
573
|
+
Expected: FAIL — module `./repository.js` not found
|
|
574
|
+
|
|
575
|
+
- [ ] **Step 3: Write Drizzle repository implementation**
|
|
576
|
+
|
|
577
|
+
```typescript
|
|
578
|
+
// src/product-config/drizzle-product-config-repository.ts
|
|
579
|
+
import { eq, asc } from "drizzle-orm";
|
|
580
|
+
import type { DrizzleDb } from "../db/index.js";
|
|
581
|
+
import { products, productNavItems, productDomains } from "../db/schema/products.js";
|
|
582
|
+
import {
|
|
583
|
+
productFeatures,
|
|
584
|
+
productFleetConfig,
|
|
585
|
+
productBillingConfig,
|
|
586
|
+
} from "../db/schema/product-config.js";
|
|
587
|
+
import type { IProductConfigRepository, ProductConfig, ProductBrandUpdate, NavItemInput } from "./repository-types.js";
|
|
588
|
+
|
|
589
|
+
export class DrizzleProductConfigRepository implements IProductConfigRepository {
|
|
590
|
+
constructor(private db: DrizzleDb) {}
|
|
591
|
+
|
|
592
|
+
async getBySlug(slug: string): Promise<ProductConfig | null> {
|
|
593
|
+
const [product] = await this.db
|
|
594
|
+
.select()
|
|
595
|
+
.from(products)
|
|
596
|
+
.where(eq(products.slug, slug))
|
|
597
|
+
.limit(1);
|
|
598
|
+
|
|
599
|
+
if (!product) return null;
|
|
600
|
+
|
|
601
|
+
const [navItems, domains, features, fleet, billing] = await Promise.all([
|
|
602
|
+
this.db
|
|
603
|
+
.select()
|
|
604
|
+
.from(productNavItems)
|
|
605
|
+
.where(eq(productNavItems.productId, product.id))
|
|
606
|
+
.orderBy(asc(productNavItems.sortOrder)),
|
|
607
|
+
this.db
|
|
608
|
+
.select()
|
|
609
|
+
.from(productDomains)
|
|
610
|
+
.where(eq(productDomains.productId, product.id)),
|
|
611
|
+
this.db
|
|
612
|
+
.select()
|
|
613
|
+
.from(productFeatures)
|
|
614
|
+
.where(eq(productFeatures.productId, product.id))
|
|
615
|
+
.limit(1)
|
|
616
|
+
.then((rows) => rows[0] ?? null),
|
|
617
|
+
this.db
|
|
618
|
+
.select()
|
|
619
|
+
.from(productFleetConfig)
|
|
620
|
+
.where(eq(productFleetConfig.productId, product.id))
|
|
621
|
+
.limit(1)
|
|
622
|
+
.then((rows) => rows[0] ?? null),
|
|
623
|
+
this.db
|
|
624
|
+
.select()
|
|
625
|
+
.from(productBillingConfig)
|
|
626
|
+
.where(eq(productBillingConfig.productId, product.id))
|
|
627
|
+
.limit(1)
|
|
628
|
+
.then((rows) => rows[0] ?? null),
|
|
629
|
+
]);
|
|
630
|
+
|
|
631
|
+
return { product, navItems, domains, features, fleet, billing };
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async listAll(): Promise<ProductConfig[]> {
|
|
635
|
+
const allProducts = await this.db.select().from(products);
|
|
636
|
+
return Promise.all(allProducts.map((p) => this.getBySlug(p.slug).then((c) => c!)));
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async upsertProduct(
|
|
640
|
+
slug: string,
|
|
641
|
+
data: Partial<typeof products.$inferInsert>,
|
|
642
|
+
): Promise<typeof products.$inferSelect> {
|
|
643
|
+
const [result] = await this.db
|
|
644
|
+
.insert(products)
|
|
645
|
+
.values({ slug, ...data } as typeof products.$inferInsert)
|
|
646
|
+
.onConflictDoUpdate({
|
|
647
|
+
target: products.slug,
|
|
648
|
+
set: { ...data, updatedAt: new Date() },
|
|
649
|
+
})
|
|
650
|
+
.returning();
|
|
651
|
+
return result;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
async replaceNavItems(
|
|
655
|
+
productId: string,
|
|
656
|
+
items: Array<{ label: string; href: string; icon?: string; sortOrder: number; requiresRole?: string; enabled?: boolean }>,
|
|
657
|
+
): Promise<void> {
|
|
658
|
+
await this.db.delete(productNavItems).where(eq(productNavItems.productId, productId));
|
|
659
|
+
if (items.length > 0) {
|
|
660
|
+
await this.db.insert(productNavItems).values(
|
|
661
|
+
items.map((item) => ({
|
|
662
|
+
productId,
|
|
663
|
+
label: item.label,
|
|
664
|
+
href: item.href,
|
|
665
|
+
icon: item.icon ?? null,
|
|
666
|
+
sortOrder: item.sortOrder,
|
|
667
|
+
requiresRole: item.requiresRole ?? null,
|
|
668
|
+
enabled: item.enabled === false ? "false" : "true",
|
|
669
|
+
})),
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
async upsertFeatures(
|
|
675
|
+
productId: string,
|
|
676
|
+
data: Partial<typeof productFeatures.$inferInsert>,
|
|
677
|
+
): Promise<void> {
|
|
678
|
+
await this.db
|
|
679
|
+
.insert(productFeatures)
|
|
680
|
+
.values({ productId, ...data } as typeof productFeatures.$inferInsert)
|
|
681
|
+
.onConflictDoUpdate({
|
|
682
|
+
target: productFeatures.productId,
|
|
683
|
+
set: { ...data, updatedAt: new Date() },
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async upsertFleetConfig(
|
|
688
|
+
productId: string,
|
|
689
|
+
data: Partial<typeof productFleetConfig.$inferInsert>,
|
|
690
|
+
): Promise<void> {
|
|
691
|
+
await this.db
|
|
692
|
+
.insert(productFleetConfig)
|
|
693
|
+
.values({ productId, ...data } as typeof productFleetConfig.$inferInsert)
|
|
694
|
+
.onConflictDoUpdate({
|
|
695
|
+
target: productFleetConfig.productId,
|
|
696
|
+
set: { ...data, updatedAt: new Date() },
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async upsertBillingConfig(
|
|
701
|
+
productId: string,
|
|
702
|
+
data: Partial<typeof productBillingConfig.$inferInsert>,
|
|
703
|
+
): Promise<void> {
|
|
704
|
+
await this.db
|
|
705
|
+
.insert(productBillingConfig)
|
|
706
|
+
.values({ productId, ...data } as typeof productBillingConfig.$inferInsert)
|
|
707
|
+
.onConflictDoUpdate({
|
|
708
|
+
target: productBillingConfig.productId,
|
|
709
|
+
set: { ...data, updatedAt: new Date() },
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
- [ ] **Step 4: Run tests**
|
|
716
|
+
|
|
717
|
+
Run: `npx vitest run src/product-config/repository.test.ts`
|
|
718
|
+
Expected: PASS
|
|
719
|
+
|
|
720
|
+
- [ ] **Step 5: Commit**
|
|
721
|
+
|
|
722
|
+
```bash
|
|
723
|
+
git add src/product-config/
|
|
724
|
+
git commit -m "feat: add product config repository with tests"
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
---
|
|
728
|
+
|
|
729
|
+
### Task 4: In-Memory Cache
|
|
730
|
+
|
|
731
|
+
**Files:**
|
|
732
|
+
- Create: `src/product-config/cache.ts`
|
|
733
|
+
- Create: `src/product-config/cache.test.ts`
|
|
734
|
+
|
|
735
|
+
- [ ] **Step 1: Write failing test**
|
|
736
|
+
|
|
737
|
+
```typescript
|
|
738
|
+
// src/product-config/cache.test.ts
|
|
739
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
740
|
+
import { ProductConfigCache } from "./cache.js";
|
|
741
|
+
import type { ProductConfig } from "./types.js";
|
|
742
|
+
|
|
743
|
+
const mockConfig: ProductConfig = {
|
|
744
|
+
product: {
|
|
745
|
+
id: "test-id",
|
|
746
|
+
slug: "test",
|
|
747
|
+
brandName: "Test",
|
|
748
|
+
productName: "Test",
|
|
749
|
+
tagline: "",
|
|
750
|
+
domain: "test.com",
|
|
751
|
+
appDomain: "app.test.com",
|
|
752
|
+
cookieDomain: ".test.com",
|
|
753
|
+
companyLegal: "",
|
|
754
|
+
priceLabel: "",
|
|
755
|
+
defaultImage: "",
|
|
756
|
+
emailSupport: "",
|
|
757
|
+
emailPrivacy: "",
|
|
758
|
+
emailLegal: "",
|
|
759
|
+
fromEmail: "",
|
|
760
|
+
homePath: "/dashboard",
|
|
761
|
+
storagePrefix: "test",
|
|
762
|
+
createdAt: new Date(),
|
|
763
|
+
updatedAt: new Date(),
|
|
764
|
+
},
|
|
765
|
+
navItems: [],
|
|
766
|
+
domains: [],
|
|
767
|
+
features: null,
|
|
768
|
+
fleet: null,
|
|
769
|
+
billing: null,
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
describe("ProductConfigCache", () => {
|
|
773
|
+
let fetcher: ReturnType<typeof vi.fn>;
|
|
774
|
+
let cache: ProductConfigCache;
|
|
775
|
+
|
|
776
|
+
beforeEach(() => {
|
|
777
|
+
fetcher = vi.fn().mockResolvedValue(mockConfig);
|
|
778
|
+
cache = new ProductConfigCache(fetcher, { ttlMs: 100 });
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it("calls fetcher on first get", async () => {
|
|
782
|
+
const result = await cache.get("test");
|
|
783
|
+
expect(result).toEqual(mockConfig);
|
|
784
|
+
expect(fetcher).toHaveBeenCalledOnce();
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it("returns cached value on second get", async () => {
|
|
788
|
+
await cache.get("test");
|
|
789
|
+
await cache.get("test");
|
|
790
|
+
expect(fetcher).toHaveBeenCalledOnce();
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
it("refetches after TTL expires", async () => {
|
|
794
|
+
await cache.get("test");
|
|
795
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
796
|
+
await cache.get("test");
|
|
797
|
+
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it("invalidate forces refetch", async () => {
|
|
801
|
+
await cache.get("test");
|
|
802
|
+
cache.invalidate("test");
|
|
803
|
+
await cache.get("test");
|
|
804
|
+
expect(fetcher).toHaveBeenCalledTimes(2);
|
|
805
|
+
});
|
|
806
|
+
});
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
810
|
+
|
|
811
|
+
Run: `npx vitest run src/product-config/cache.test.ts`
|
|
812
|
+
Expected: FAIL
|
|
813
|
+
|
|
814
|
+
- [ ] **Step 3: Write cache implementation**
|
|
815
|
+
|
|
816
|
+
```typescript
|
|
817
|
+
// src/product-config/cache.ts
|
|
818
|
+
import type { ProductConfig } from "./types.js";
|
|
819
|
+
|
|
820
|
+
interface CacheEntry {
|
|
821
|
+
config: ProductConfig;
|
|
822
|
+
expiresAt: number;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
export class ProductConfigCache {
|
|
826
|
+
private cache = new Map<string, CacheEntry>();
|
|
827
|
+
private ttlMs: number;
|
|
828
|
+
private fetcher: (slug: string) => Promise<ProductConfig | null>;
|
|
829
|
+
|
|
830
|
+
constructor(
|
|
831
|
+
fetcher: (slug: string) => Promise<ProductConfig | null>,
|
|
832
|
+
opts: { ttlMs?: number } = {},
|
|
833
|
+
) {
|
|
834
|
+
this.fetcher = fetcher;
|
|
835
|
+
this.ttlMs = opts.ttlMs ?? 60_000;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
async get(slug: string): Promise<ProductConfig | null> {
|
|
839
|
+
const entry = this.cache.get(slug);
|
|
840
|
+
if (entry && Date.now() < entry.expiresAt) {
|
|
841
|
+
return entry.config;
|
|
842
|
+
}
|
|
843
|
+
const config = await this.fetcher(slug);
|
|
844
|
+
if (config) {
|
|
845
|
+
this.cache.set(slug, { config, expiresAt: Date.now() + this.ttlMs });
|
|
846
|
+
}
|
|
847
|
+
return config;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
invalidate(slug: string): void {
|
|
851
|
+
this.cache.delete(slug);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
invalidateAll(): void {
|
|
855
|
+
this.cache.clear();
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
- [ ] **Step 4: Run tests**
|
|
861
|
+
|
|
862
|
+
Run: `npx vitest run src/product-config/cache.test.ts`
|
|
863
|
+
Expected: PASS
|
|
864
|
+
|
|
865
|
+
- [ ] **Step 5: Commit**
|
|
866
|
+
|
|
867
|
+
```bash
|
|
868
|
+
git add src/product-config/cache.ts src/product-config/cache.test.ts
|
|
869
|
+
git commit -m "feat: add product config in-memory cache with TTL"
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
---
|
|
873
|
+
|
|
874
|
+
### Task 5: Public API (index.ts)
|
|
875
|
+
|
|
876
|
+
**Files:**
|
|
877
|
+
- Create: `src/product-config/index.ts`
|
|
878
|
+
|
|
879
|
+
- [ ] **Step 1: Write public API module**
|
|
880
|
+
|
|
881
|
+
```typescript
|
|
882
|
+
// src/product-config/index.ts
|
|
883
|
+
import type { DrizzleDb } from "../db/index.js";
|
|
884
|
+
import { ProductConfigCache } from "./cache.js";
|
|
885
|
+
import { DrizzleProductConfigRepository } from "./drizzle-product-config-repository.js";
|
|
886
|
+
import type { IProductConfigRepository, ProductBrandConfig, ProductConfig } from "./repository-types.js";
|
|
887
|
+
import { deriveCorsOrigins, toBrandConfig } from "./repository-types.js";
|
|
888
|
+
|
|
889
|
+
export type { ProductConfig, ProductBrandConfig, IProductConfigRepository, ProductFeatures, ProductFleetConfig, ProductBillingConfig, FleetLifecycle, FleetBillingModel } from "./repository-types.js";
|
|
890
|
+
export { DrizzleProductConfigRepository } from "./drizzle-product-config-repository.js";
|
|
891
|
+
export { ProductConfigCache } from "./cache.js";
|
|
892
|
+
export { deriveCorsOrigins, toBrandConfig } from "./repository-types.js";
|
|
893
|
+
|
|
894
|
+
let _repo: IProductConfigRepository | null = null;
|
|
895
|
+
let _cache: ProductConfigCache | null = null;
|
|
896
|
+
|
|
897
|
+
/** Initialize the product config system. Call once at startup. */
|
|
898
|
+
export function initProductConfig(db: DrizzleDb): void {
|
|
899
|
+
_repo = new DrizzleProductConfigRepository(db);
|
|
900
|
+
_cache = new ProductConfigCache((slug) => _repo!.getBySlug(slug));
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** Initialize with a custom repository (for testing or alternative backends). */
|
|
904
|
+
export function initProductConfigWithRepo(repo: IProductConfigRepository): void {
|
|
905
|
+
_repo = repo;
|
|
906
|
+
_cache = new ProductConfigCache((slug) => _repo!.getBySlug(slug));
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** Get full product config by slug. Cached. */
|
|
910
|
+
export async function getProductConfig(slug: string): Promise<ProductConfig | null> {
|
|
911
|
+
if (!_cache) throw new Error("Product config not initialized. Call initProductConfig() first.");
|
|
912
|
+
return _cache.get(slug);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** Get brand config formatted for UI consumption. */
|
|
916
|
+
export async function getProductBrand(slug: string): Promise<ProductBrandConfig | null> {
|
|
917
|
+
const config = await getProductConfig(slug);
|
|
918
|
+
if (!config) return null;
|
|
919
|
+
return toBrandConfig(config);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/** Get the repository for admin mutations. */
|
|
923
|
+
export function getProductConfigRepo(): IProductConfigRepository {
|
|
924
|
+
if (!_repo) throw new Error("Product config not initialized.");
|
|
925
|
+
return _repo;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** Invalidate cache for a product (call after admin mutations). */
|
|
929
|
+
export function invalidateProductConfig(slug: string): void {
|
|
930
|
+
_cache?.invalidate(slug);
|
|
931
|
+
}
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
- [ ] **Step 2: Commit**
|
|
935
|
+
|
|
936
|
+
```bash
|
|
937
|
+
git add src/product-config/index.ts
|
|
938
|
+
git commit -m "feat: add product config public API with cache"
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
---
|
|
942
|
+
|
|
943
|
+
## Phase 2: tRPC Router
|
|
944
|
+
|
|
945
|
+
### Task 6: Product Config tRPC Router
|
|
946
|
+
|
|
947
|
+
**Files:**
|
|
948
|
+
- Create: `src/trpc/product-config-router.ts`
|
|
949
|
+
- Modify: `src/trpc/index.ts`
|
|
950
|
+
|
|
951
|
+
- [ ] **Step 1: Write the router**
|
|
952
|
+
|
|
953
|
+
```typescript
|
|
954
|
+
// src/trpc/product-config-router.ts
|
|
955
|
+
import { z } from "zod";
|
|
956
|
+
import { adminProcedure, publicProcedure, router } from "./init.js";
|
|
957
|
+
import type { IProductConfigRepository } from "../product-config/repository-types.js";
|
|
958
|
+
import type { ProductConfigCache } from "../product-config/cache.js";
|
|
959
|
+
import { getProductBrand } from "../product-config/index.js";
|
|
960
|
+
|
|
961
|
+
export function createProductConfigRouter(
|
|
962
|
+
getRepo: () => IProductConfigRepository,
|
|
963
|
+
getCache: () => ProductConfigCache,
|
|
964
|
+
productSlug: string,
|
|
965
|
+
) {
|
|
966
|
+
return router({
|
|
967
|
+
// --- Public ---
|
|
968
|
+
getBrandConfig: publicProcedure.query(async () => {
|
|
969
|
+
return getProductBrand(productSlug);
|
|
970
|
+
}),
|
|
971
|
+
|
|
972
|
+
getNavItems: publicProcedure.query(async () => {
|
|
973
|
+
const brand = await getProductBrand(productSlug);
|
|
974
|
+
return brand?.navItems ?? [];
|
|
975
|
+
}),
|
|
976
|
+
|
|
977
|
+
// --- Admin ---
|
|
978
|
+
admin: router({
|
|
979
|
+
get: adminProcedure.query(async () => {
|
|
980
|
+
return getRepo().getBySlug(productSlug);
|
|
981
|
+
}),
|
|
982
|
+
|
|
983
|
+
listAll: adminProcedure.query(async () => {
|
|
984
|
+
return getRepo().listAll();
|
|
985
|
+
}),
|
|
986
|
+
|
|
987
|
+
updateBrand: adminProcedure
|
|
988
|
+
.input(
|
|
989
|
+
z.object({
|
|
990
|
+
brandName: z.string().min(1).optional(),
|
|
991
|
+
productName: z.string().min(1).optional(),
|
|
992
|
+
tagline: z.string().optional(),
|
|
993
|
+
domain: z.string().min(1).optional(),
|
|
994
|
+
appDomain: z.string().min(1).optional(),
|
|
995
|
+
cookieDomain: z.string().optional(),
|
|
996
|
+
companyLegal: z.string().optional(),
|
|
997
|
+
priceLabel: z.string().optional(),
|
|
998
|
+
defaultImage: z.string().optional(),
|
|
999
|
+
emailSupport: z.string().email().optional(),
|
|
1000
|
+
emailPrivacy: z.string().email().optional(),
|
|
1001
|
+
emailLegal: z.string().email().optional(),
|
|
1002
|
+
fromEmail: z.string().email().optional(),
|
|
1003
|
+
homePath: z.string().optional(),
|
|
1004
|
+
storagePrefix: z.string().min(1).optional(),
|
|
1005
|
+
}),
|
|
1006
|
+
)
|
|
1007
|
+
.mutation(async ({ input }) => {
|
|
1008
|
+
const result = await getRepo().upsertProduct(productSlug, input);
|
|
1009
|
+
getCache().invalidate(productSlug);
|
|
1010
|
+
return result;
|
|
1011
|
+
}),
|
|
1012
|
+
|
|
1013
|
+
updateNavItems: adminProcedure
|
|
1014
|
+
.input(
|
|
1015
|
+
z.array(
|
|
1016
|
+
z.object({
|
|
1017
|
+
label: z.string().min(1),
|
|
1018
|
+
href: z.string().min(1),
|
|
1019
|
+
icon: z.string().optional(),
|
|
1020
|
+
sortOrder: z.number().int().min(0),
|
|
1021
|
+
requiresRole: z.string().optional(),
|
|
1022
|
+
enabled: z.boolean().optional(),
|
|
1023
|
+
}),
|
|
1024
|
+
),
|
|
1025
|
+
)
|
|
1026
|
+
.mutation(async ({ input }) => {
|
|
1027
|
+
const config = await getRepo().getBySlug(productSlug);
|
|
1028
|
+
if (!config) throw new Error("Product not found");
|
|
1029
|
+
await getRepo().replaceNavItems(config.product.id, input);
|
|
1030
|
+
getCache().invalidate(productSlug);
|
|
1031
|
+
}),
|
|
1032
|
+
|
|
1033
|
+
updateFeatures: adminProcedure
|
|
1034
|
+
.input(
|
|
1035
|
+
z.object({
|
|
1036
|
+
chatEnabled: z.boolean().optional(),
|
|
1037
|
+
onboardingEnabled: z.boolean().optional(),
|
|
1038
|
+
onboardingDefaultModel: z.string().optional(),
|
|
1039
|
+
onboardingSystemPrompt: z.string().optional(),
|
|
1040
|
+
onboardingMaxCredits: z.number().int().min(0).optional(),
|
|
1041
|
+
onboardingWelcomeMsg: z.string().optional(),
|
|
1042
|
+
sharedModuleBilling: z.boolean().optional(),
|
|
1043
|
+
sharedModuleMonitoring: z.boolean().optional(),
|
|
1044
|
+
sharedModuleAnalytics: z.boolean().optional(),
|
|
1045
|
+
}),
|
|
1046
|
+
)
|
|
1047
|
+
.mutation(async ({ input }) => {
|
|
1048
|
+
const config = await getRepo().getBySlug(productSlug);
|
|
1049
|
+
if (!config) throw new Error("Product not found");
|
|
1050
|
+
await getRepo().upsertFeatures(config.product.id, input);
|
|
1051
|
+
getCache().invalidate(productSlug);
|
|
1052
|
+
}),
|
|
1053
|
+
|
|
1054
|
+
updateFleet: adminProcedure
|
|
1055
|
+
.input(
|
|
1056
|
+
z.object({
|
|
1057
|
+
containerImage: z.string().optional(),
|
|
1058
|
+
containerPort: z.number().int().optional(),
|
|
1059
|
+
lifecycle: z.enum(["managed", "ephemeral"]).optional(),
|
|
1060
|
+
billingModel: z.enum(["monthly", "per_use", "none"]).optional(),
|
|
1061
|
+
maxInstances: z.number().int().min(1).optional(),
|
|
1062
|
+
imageAllowlist: z.array(z.string()).optional(),
|
|
1063
|
+
dockerNetwork: z.string().optional(),
|
|
1064
|
+
placementStrategy: z.string().optional(),
|
|
1065
|
+
fleetDataDir: z.string().optional(),
|
|
1066
|
+
}),
|
|
1067
|
+
)
|
|
1068
|
+
.mutation(async ({ input }) => {
|
|
1069
|
+
const config = await getRepo().getBySlug(productSlug);
|
|
1070
|
+
if (!config) throw new Error("Product not found");
|
|
1071
|
+
await getRepo().upsertFleetConfig(config.product.id, input);
|
|
1072
|
+
getCache().invalidate(productSlug);
|
|
1073
|
+
}),
|
|
1074
|
+
|
|
1075
|
+
updateBilling: adminProcedure
|
|
1076
|
+
.input(
|
|
1077
|
+
z.object({
|
|
1078
|
+
stripePublishableKey: z.string().optional(),
|
|
1079
|
+
stripeSecretKey: z.string().optional(),
|
|
1080
|
+
stripeWebhookSecret: z.string().optional(),
|
|
1081
|
+
creditPrices: z.record(z.string(), z.number()).optional(),
|
|
1082
|
+
affiliateBaseUrl: z.string().url().optional(),
|
|
1083
|
+
affiliateMatchRate: z.number().min(0).optional(),
|
|
1084
|
+
affiliateMaxCap: z.number().int().min(0).optional(),
|
|
1085
|
+
dividendRate: z.number().min(0).optional(),
|
|
1086
|
+
marginConfig: z.unknown().optional(),
|
|
1087
|
+
}),
|
|
1088
|
+
)
|
|
1089
|
+
.mutation(async ({ input }) => {
|
|
1090
|
+
const config = await getRepo().getBySlug(productSlug);
|
|
1091
|
+
if (!config) throw new Error("Product not found");
|
|
1092
|
+
await getRepo().upsertBillingConfig(config.product.id, input);
|
|
1093
|
+
getCache().invalidate(productSlug);
|
|
1094
|
+
}),
|
|
1095
|
+
}),
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
```
|
|
1099
|
+
|
|
1100
|
+
- [ ] **Step 2: Export from tRPC index**
|
|
1101
|
+
|
|
1102
|
+
Add to `src/trpc/index.ts`:
|
|
1103
|
+
|
|
1104
|
+
```typescript
|
|
1105
|
+
export { createProductConfigRouter } from "./product-config-router.js";
|
|
1106
|
+
```
|
|
1107
|
+
|
|
1108
|
+
- [ ] **Step 3: Run type check**
|
|
1109
|
+
|
|
1110
|
+
Run: `npm run check` (in platform-core)
|
|
1111
|
+
Expected: No type errors
|
|
1112
|
+
|
|
1113
|
+
- [ ] **Step 4: Commit**
|
|
1114
|
+
|
|
1115
|
+
```bash
|
|
1116
|
+
git add src/trpc/product-config-router.ts src/trpc/index.ts
|
|
1117
|
+
git commit -m "feat: add product config tRPC router (public + admin)"
|
|
1118
|
+
```
|
|
1119
|
+
|
|
1120
|
+
---
|
|
1121
|
+
|
|
1122
|
+
### Task 7: Seed Script
|
|
1123
|
+
|
|
1124
|
+
**Files:**
|
|
1125
|
+
- Create: `scripts/seed-products.ts`
|
|
1126
|
+
|
|
1127
|
+
- [ ] **Step 1: Write seed script**
|
|
1128
|
+
|
|
1129
|
+
This script reads current env var values and populates the product tables. Run once per product deployment to migrate existing config into the database.
|
|
1130
|
+
|
|
1131
|
+
```typescript
|
|
1132
|
+
// scripts/seed-products.ts
|
|
1133
|
+
/**
|
|
1134
|
+
* Seed product config tables from current environment.
|
|
1135
|
+
*
|
|
1136
|
+
* Usage:
|
|
1137
|
+
* PRODUCT_SLUG=paperclip npx tsx scripts/seed-products.ts
|
|
1138
|
+
*
|
|
1139
|
+
* Or seed all 4 products at once:
|
|
1140
|
+
* npx tsx scripts/seed-products.ts --all
|
|
1141
|
+
*/
|
|
1142
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
1143
|
+
import pg from "pg";
|
|
1144
|
+
import * as schema from "../src/db/schema/index.js";
|
|
1145
|
+
|
|
1146
|
+
const PRODUCT_PRESETS: Record<string, {
|
|
1147
|
+
brandName: string;
|
|
1148
|
+
productName: string;
|
|
1149
|
+
tagline: string;
|
|
1150
|
+
domain: string;
|
|
1151
|
+
appDomain: string;
|
|
1152
|
+
cookieDomain: string;
|
|
1153
|
+
companyLegal: string;
|
|
1154
|
+
priceLabel: string;
|
|
1155
|
+
defaultImage: string;
|
|
1156
|
+
emailSupport: string;
|
|
1157
|
+
emailPrivacy: string;
|
|
1158
|
+
emailLegal: string;
|
|
1159
|
+
fromEmail: string;
|
|
1160
|
+
homePath: string;
|
|
1161
|
+
storagePrefix: string;
|
|
1162
|
+
navItems: Array<{ label: string; href: string; sortOrder: number }>;
|
|
1163
|
+
fleet: { containerImage: string; lifecycle: "managed" | "ephemeral"; billingModel: "monthly" | "per_use" | "none"; maxInstances: number };
|
|
1164
|
+
}> = {
|
|
1165
|
+
wopr: {
|
|
1166
|
+
brandName: "WOPR",
|
|
1167
|
+
productName: "WOPR Bot",
|
|
1168
|
+
tagline: "A $5/month supercomputer that manages your business.",
|
|
1169
|
+
domain: "wopr.bot",
|
|
1170
|
+
appDomain: "app.wopr.bot",
|
|
1171
|
+
cookieDomain: ".wopr.bot",
|
|
1172
|
+
companyLegal: "WOPR Network Inc.",
|
|
1173
|
+
priceLabel: "$5/month",
|
|
1174
|
+
defaultImage: "ghcr.io/wopr-network/wopr:latest",
|
|
1175
|
+
emailSupport: "support@wopr.bot",
|
|
1176
|
+
emailPrivacy: "privacy@wopr.bot",
|
|
1177
|
+
emailLegal: "legal@wopr.bot",
|
|
1178
|
+
fromEmail: "noreply@wopr.bot",
|
|
1179
|
+
homePath: "/marketplace",
|
|
1180
|
+
storagePrefix: "wopr",
|
|
1181
|
+
navItems: [
|
|
1182
|
+
{ label: "Dashboard", href: "/dashboard", sortOrder: 0 },
|
|
1183
|
+
{ label: "Chat", href: "/chat", sortOrder: 1 },
|
|
1184
|
+
{ label: "Marketplace", href: "/marketplace", sortOrder: 2 },
|
|
1185
|
+
{ label: "Channels", href: "/channels", sortOrder: 3 },
|
|
1186
|
+
{ label: "Plugins", href: "/plugins", sortOrder: 4 },
|
|
1187
|
+
{ label: "Instances", href: "/instances", sortOrder: 5 },
|
|
1188
|
+
{ label: "Changesets", href: "/changesets", sortOrder: 6 },
|
|
1189
|
+
{ label: "Network", href: "/dashboard/network", sortOrder: 7 },
|
|
1190
|
+
{ label: "Fleet Health", href: "/fleet/health", sortOrder: 8 },
|
|
1191
|
+
{ label: "Credits", href: "/billing/credits", sortOrder: 9 },
|
|
1192
|
+
{ label: "Billing", href: "/billing/plans", sortOrder: 10 },
|
|
1193
|
+
{ label: "Settings", href: "/settings/profile", sortOrder: 11 },
|
|
1194
|
+
{ label: "Admin", href: "/admin/tenants", sortOrder: 12 },
|
|
1195
|
+
],
|
|
1196
|
+
fleet: { containerImage: "ghcr.io/wopr-network/wopr:latest", lifecycle: "managed", billingModel: "monthly", maxInstances: 5 },
|
|
1197
|
+
},
|
|
1198
|
+
paperclip: {
|
|
1199
|
+
brandName: "Paperclip",
|
|
1200
|
+
productName: "Paperclip",
|
|
1201
|
+
tagline: "AI agents that run your business.",
|
|
1202
|
+
domain: "runpaperclip.com",
|
|
1203
|
+
appDomain: "app.runpaperclip.com",
|
|
1204
|
+
cookieDomain: ".runpaperclip.com",
|
|
1205
|
+
companyLegal: "Paperclip AI Inc.",
|
|
1206
|
+
priceLabel: "$5/month",
|
|
1207
|
+
defaultImage: "ghcr.io/wopr-network/paperclip:managed",
|
|
1208
|
+
emailSupport: "support@runpaperclip.com",
|
|
1209
|
+
emailPrivacy: "privacy@runpaperclip.com",
|
|
1210
|
+
emailLegal: "legal@runpaperclip.com",
|
|
1211
|
+
fromEmail: "noreply@runpaperclip.com",
|
|
1212
|
+
homePath: "/instances",
|
|
1213
|
+
storagePrefix: "paperclip",
|
|
1214
|
+
navItems: [
|
|
1215
|
+
{ label: "Instances", href: "/instances", sortOrder: 0 },
|
|
1216
|
+
{ label: "Credits", href: "/billing/credits", sortOrder: 1 },
|
|
1217
|
+
{ label: "Settings", href: "/settings/profile", sortOrder: 2 },
|
|
1218
|
+
{ label: "Admin", href: "/admin/tenants", sortOrder: 3 },
|
|
1219
|
+
],
|
|
1220
|
+
fleet: { containerImage: "ghcr.io/wopr-network/paperclip:managed", lifecycle: "managed", billingModel: "monthly", maxInstances: 5 },
|
|
1221
|
+
},
|
|
1222
|
+
holyship: {
|
|
1223
|
+
brandName: "Holy Ship",
|
|
1224
|
+
productName: "Holy Ship",
|
|
1225
|
+
tagline: "Ship it.",
|
|
1226
|
+
domain: "holyship.wtf",
|
|
1227
|
+
appDomain: "app.holyship.wtf",
|
|
1228
|
+
cookieDomain: ".holyship.wtf",
|
|
1229
|
+
companyLegal: "WOPR Network Inc.",
|
|
1230
|
+
priceLabel: "",
|
|
1231
|
+
defaultImage: "ghcr.io/wopr-network/holyship:latest",
|
|
1232
|
+
emailSupport: "support@holyship.wtf",
|
|
1233
|
+
emailPrivacy: "privacy@holyship.wtf",
|
|
1234
|
+
emailLegal: "legal@holyship.wtf",
|
|
1235
|
+
fromEmail: "noreply@holyship.wtf",
|
|
1236
|
+
homePath: "/dashboard",
|
|
1237
|
+
storagePrefix: "holyship",
|
|
1238
|
+
navItems: [
|
|
1239
|
+
{ label: "Dashboard", href: "/dashboard", sortOrder: 0 },
|
|
1240
|
+
{ label: "Ship", href: "/ship", sortOrder: 1 },
|
|
1241
|
+
{ label: "Approvals", href: "/approvals", sortOrder: 2 },
|
|
1242
|
+
{ label: "Connect", href: "/connect", sortOrder: 3 },
|
|
1243
|
+
{ label: "Credits", href: "/billing/credits", sortOrder: 4 },
|
|
1244
|
+
{ label: "Settings", href: "/settings/profile", sortOrder: 5 },
|
|
1245
|
+
{ label: "Admin", href: "/admin/tenants", sortOrder: 6 },
|
|
1246
|
+
],
|
|
1247
|
+
fleet: { containerImage: "ghcr.io/wopr-network/holyship:latest", lifecycle: "ephemeral", billingModel: "none", maxInstances: 50 },
|
|
1248
|
+
},
|
|
1249
|
+
nemoclaw: {
|
|
1250
|
+
brandName: "NemoPod",
|
|
1251
|
+
productName: "NemoPod",
|
|
1252
|
+
tagline: "NVIDIA NeMo, one click away",
|
|
1253
|
+
domain: "nemopod.com",
|
|
1254
|
+
appDomain: "app.nemopod.com",
|
|
1255
|
+
cookieDomain: ".nemopod.com",
|
|
1256
|
+
companyLegal: "WOPR Network Inc.",
|
|
1257
|
+
priceLabel: "$5 free credits",
|
|
1258
|
+
defaultImage: "ghcr.io/wopr-network/nemoclaw:latest",
|
|
1259
|
+
emailSupport: "support@nemopod.com",
|
|
1260
|
+
emailPrivacy: "privacy@nemopod.com",
|
|
1261
|
+
emailLegal: "legal@nemopod.com",
|
|
1262
|
+
fromEmail: "noreply@nemopod.com",
|
|
1263
|
+
homePath: "/instances",
|
|
1264
|
+
storagePrefix: "nemopod",
|
|
1265
|
+
navItems: [
|
|
1266
|
+
{ label: "NemoClaws", href: "/instances", sortOrder: 0 },
|
|
1267
|
+
{ label: "Credits", href: "/billing/credits", sortOrder: 1 },
|
|
1268
|
+
{ label: "Settings", href: "/settings/profile", sortOrder: 2 },
|
|
1269
|
+
{ label: "Admin", href: "/admin/tenants", sortOrder: 3 },
|
|
1270
|
+
],
|
|
1271
|
+
fleet: { containerImage: "ghcr.io/wopr-network/nemoclaw:latest", lifecycle: "managed", billingModel: "monthly", maxInstances: 5 },
|
|
1272
|
+
},
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
async function seed() {
|
|
1276
|
+
const dbUrl = process.env.DATABASE_URL;
|
|
1277
|
+
if (!dbUrl) {
|
|
1278
|
+
console.error("DATABASE_URL required");
|
|
1279
|
+
process.exit(1);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
const pool = new pg.Pool({ connectionString: dbUrl });
|
|
1283
|
+
const db = drizzle(pool, { schema });
|
|
1284
|
+
|
|
1285
|
+
const slugs = process.argv.includes("--all")
|
|
1286
|
+
? Object.keys(PRODUCT_PRESETS)
|
|
1287
|
+
: [process.env.PRODUCT_SLUG ?? "wopr"];
|
|
1288
|
+
|
|
1289
|
+
for (const slug of slugs) {
|
|
1290
|
+
const preset = PRODUCT_PRESETS[slug];
|
|
1291
|
+
if (!preset) {
|
|
1292
|
+
console.error(`Unknown product slug: ${slug}`);
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
console.log(`Seeding ${slug}...`);
|
|
1297
|
+
|
|
1298
|
+
const { navItems, fleet, ...productData } = preset;
|
|
1299
|
+
|
|
1300
|
+
// Upsert product
|
|
1301
|
+
const [product] = await db
|
|
1302
|
+
.insert(schema.products)
|
|
1303
|
+
.values({ slug, ...productData })
|
|
1304
|
+
.onConflictDoUpdate({ target: schema.products.slug, set: productData })
|
|
1305
|
+
.returning();
|
|
1306
|
+
|
|
1307
|
+
// Replace nav items
|
|
1308
|
+
await db.delete(schema.productNavItems).where(
|
|
1309
|
+
require("drizzle-orm").eq(schema.productNavItems.productId, product.id),
|
|
1310
|
+
);
|
|
1311
|
+
if (navItems.length > 0) {
|
|
1312
|
+
await db.insert(schema.productNavItems).values(
|
|
1313
|
+
navItems.map((item) => ({ productId: product.id, ...item })),
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// Upsert fleet config
|
|
1318
|
+
await db
|
|
1319
|
+
.insert(schema.productFleetConfig)
|
|
1320
|
+
.values({ productId: product.id, ...fleet })
|
|
1321
|
+
.onConflictDoUpdate({ target: schema.productFleetConfig.productId, set: fleet });
|
|
1322
|
+
|
|
1323
|
+
// Features with defaults
|
|
1324
|
+
await db
|
|
1325
|
+
.insert(schema.productFeatures)
|
|
1326
|
+
.values({ productId: product.id })
|
|
1327
|
+
.onConflictDoNothing();
|
|
1328
|
+
|
|
1329
|
+
console.log(` ✓ ${slug} seeded (${navItems.length} nav items)`);
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
await pool.end();
|
|
1333
|
+
console.log("Done.");
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
seed().catch(console.error);
|
|
1337
|
+
```
|
|
1338
|
+
|
|
1339
|
+
- [ ] **Step 2: Commit**
|
|
1340
|
+
|
|
1341
|
+
```bash
|
|
1342
|
+
git add scripts/seed-products.ts
|
|
1343
|
+
git commit -m "feat: add product config seed script for all 4 products"
|
|
1344
|
+
```
|
|
1345
|
+
|
|
1346
|
+
---
|
|
1347
|
+
|
|
1348
|
+
## Phase 3: Admin UI (platform-ui-core)
|
|
1349
|
+
|
|
1350
|
+
### Task 8: Admin Products Page Shell
|
|
1351
|
+
|
|
1352
|
+
**Files:**
|
|
1353
|
+
- Create: `src/app/admin/products/page.tsx`
|
|
1354
|
+
- Create: `src/app/admin/products/loading.tsx`
|
|
1355
|
+
- Create: `src/app/admin/products/error.tsx`
|
|
1356
|
+
|
|
1357
|
+
- [ ] **Step 1: Create page with tab layout**
|
|
1358
|
+
|
|
1359
|
+
The page loads the current product config via tRPC admin endpoint, then renders tabs for each config domain (Brand, Navigation, Features, Fleet, Billing). Each tab contains a form component.
|
|
1360
|
+
|
|
1361
|
+
```typescript
|
|
1362
|
+
// src/app/admin/products/page.tsx
|
|
1363
|
+
"use client";
|
|
1364
|
+
|
|
1365
|
+
import { useCallback, useEffect, useState } from "react";
|
|
1366
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
1367
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
1368
|
+
import { trpcVanilla } from "@/lib/trpc";
|
|
1369
|
+
import { BrandForm } from "@/components/admin/products/brand-form";
|
|
1370
|
+
import { NavEditor } from "@/components/admin/products/nav-editor";
|
|
1371
|
+
import { FeaturesForm } from "@/components/admin/products/features-form";
|
|
1372
|
+
import { FleetForm } from "@/components/admin/products/fleet-form";
|
|
1373
|
+
import { BillingForm } from "@/components/admin/products/billing-form";
|
|
1374
|
+
|
|
1375
|
+
export default function AdminProductsPage() {
|
|
1376
|
+
const [config, setConfig] = useState<Awaited<ReturnType<typeof trpcVanilla.product.admin.get.query>> | null>(null);
|
|
1377
|
+
const [error, setError] = useState<string | null>(null);
|
|
1378
|
+
|
|
1379
|
+
const load = useCallback(async () => {
|
|
1380
|
+
try {
|
|
1381
|
+
const result = await trpcVanilla.product.admin.get.query();
|
|
1382
|
+
setConfig(result);
|
|
1383
|
+
} catch (err) {
|
|
1384
|
+
setError(err instanceof Error ? err.message : "Failed to load config");
|
|
1385
|
+
}
|
|
1386
|
+
}, []);
|
|
1387
|
+
|
|
1388
|
+
useEffect(() => { load(); }, [load]);
|
|
1389
|
+
|
|
1390
|
+
if (error) return <div className="p-6 text-red-400">{error}</div>;
|
|
1391
|
+
if (!config) return null;
|
|
1392
|
+
|
|
1393
|
+
return (
|
|
1394
|
+
<div className="space-y-6 p-6">
|
|
1395
|
+
<h1 className="text-2xl font-bold">Product Configuration</h1>
|
|
1396
|
+
<p className="text-muted-foreground">
|
|
1397
|
+
{config.product.productName} ({config.product.slug})
|
|
1398
|
+
</p>
|
|
1399
|
+
|
|
1400
|
+
<Tabs defaultValue="brand">
|
|
1401
|
+
<TabsList>
|
|
1402
|
+
<TabsTrigger value="brand">Brand</TabsTrigger>
|
|
1403
|
+
<TabsTrigger value="navigation">Navigation</TabsTrigger>
|
|
1404
|
+
<TabsTrigger value="features">Features</TabsTrigger>
|
|
1405
|
+
<TabsTrigger value="fleet">Fleet</TabsTrigger>
|
|
1406
|
+
<TabsTrigger value="billing">Billing</TabsTrigger>
|
|
1407
|
+
</TabsList>
|
|
1408
|
+
|
|
1409
|
+
<TabsContent value="brand">
|
|
1410
|
+
<BrandForm product={config.product} onSaved={load} />
|
|
1411
|
+
</TabsContent>
|
|
1412
|
+
<TabsContent value="navigation">
|
|
1413
|
+
<NavEditor items={config.navItems} onSaved={load} />
|
|
1414
|
+
</TabsContent>
|
|
1415
|
+
<TabsContent value="features">
|
|
1416
|
+
<FeaturesForm features={config.features} onSaved={load} />
|
|
1417
|
+
</TabsContent>
|
|
1418
|
+
<TabsContent value="fleet">
|
|
1419
|
+
<FleetForm fleet={config.fleet} onSaved={load} />
|
|
1420
|
+
</TabsContent>
|
|
1421
|
+
<TabsContent value="billing">
|
|
1422
|
+
<BillingForm billing={config.billing} onSaved={load} />
|
|
1423
|
+
</TabsContent>
|
|
1424
|
+
</Tabs>
|
|
1425
|
+
</div>
|
|
1426
|
+
);
|
|
1427
|
+
}
|
|
1428
|
+
```
|
|
1429
|
+
|
|
1430
|
+
- [ ] **Step 2: Create loading and error boundaries**
|
|
1431
|
+
|
|
1432
|
+
```typescript
|
|
1433
|
+
// src/app/admin/products/loading.tsx
|
|
1434
|
+
export default function Loading() {
|
|
1435
|
+
return <div className="p-6 animate-pulse">Loading product configuration...</div>;
|
|
1436
|
+
}
|
|
1437
|
+
```
|
|
1438
|
+
|
|
1439
|
+
```typescript
|
|
1440
|
+
// src/app/admin/products/error.tsx
|
|
1441
|
+
"use client";
|
|
1442
|
+
|
|
1443
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
1444
|
+
import { Button } from "@/components/ui/button";
|
|
1445
|
+
|
|
1446
|
+
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
|
|
1447
|
+
return (
|
|
1448
|
+
<div className="p-6">
|
|
1449
|
+
<Card>
|
|
1450
|
+
<CardHeader>
|
|
1451
|
+
<CardTitle>Error loading product config</CardTitle>
|
|
1452
|
+
</CardHeader>
|
|
1453
|
+
<CardContent className="space-y-4">
|
|
1454
|
+
<p className="text-muted-foreground">{error.message}</p>
|
|
1455
|
+
<Button onClick={reset}>Retry</Button>
|
|
1456
|
+
</CardContent>
|
|
1457
|
+
</Card>
|
|
1458
|
+
</div>
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
```
|
|
1462
|
+
|
|
1463
|
+
- [ ] **Step 3: Commit**
|
|
1464
|
+
|
|
1465
|
+
```bash
|
|
1466
|
+
git add src/app/admin/products/
|
|
1467
|
+
git commit -m "feat: add admin products page shell with tabs"
|
|
1468
|
+
```
|
|
1469
|
+
|
|
1470
|
+
---
|
|
1471
|
+
|
|
1472
|
+
### Task 9: Brand Form Component
|
|
1473
|
+
|
|
1474
|
+
**Files:**
|
|
1475
|
+
- Create: `src/components/admin/products/brand-form.tsx`
|
|
1476
|
+
|
|
1477
|
+
- [ ] **Step 1: Write brand form**
|
|
1478
|
+
|
|
1479
|
+
Follow the existing promotion-form.tsx pattern: state-based, shadcn components, tRPC mutations.
|
|
1480
|
+
|
|
1481
|
+
```typescript
|
|
1482
|
+
// src/components/admin/products/brand-form.tsx
|
|
1483
|
+
"use client";
|
|
1484
|
+
|
|
1485
|
+
import { useState } from "react";
|
|
1486
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
1487
|
+
import { Input } from "@/components/ui/input";
|
|
1488
|
+
import { Label } from "@/components/ui/label";
|
|
1489
|
+
import { Button } from "@/components/ui/button";
|
|
1490
|
+
import { trpcVanilla } from "@/lib/trpc";
|
|
1491
|
+
import { toast } from "sonner";
|
|
1492
|
+
|
|
1493
|
+
interface Props {
|
|
1494
|
+
product: {
|
|
1495
|
+
brandName: string;
|
|
1496
|
+
productName: string;
|
|
1497
|
+
tagline: string;
|
|
1498
|
+
domain: string;
|
|
1499
|
+
appDomain: string;
|
|
1500
|
+
cookieDomain: string;
|
|
1501
|
+
companyLegal: string;
|
|
1502
|
+
priceLabel: string;
|
|
1503
|
+
defaultImage: string;
|
|
1504
|
+
emailSupport: string;
|
|
1505
|
+
emailPrivacy: string;
|
|
1506
|
+
emailLegal: string;
|
|
1507
|
+
fromEmail: string;
|
|
1508
|
+
homePath: string;
|
|
1509
|
+
storagePrefix: string;
|
|
1510
|
+
};
|
|
1511
|
+
onSaved: () => void;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
export function BrandForm({ product, onSaved }: Props) {
|
|
1515
|
+
const [form, setForm] = useState(product);
|
|
1516
|
+
const [saving, setSaving] = useState(false);
|
|
1517
|
+
|
|
1518
|
+
const update = (field: keyof typeof form, value: string) =>
|
|
1519
|
+
setForm((prev) => ({ ...prev, [field]: value }));
|
|
1520
|
+
|
|
1521
|
+
const save = async () => {
|
|
1522
|
+
setSaving(true);
|
|
1523
|
+
try {
|
|
1524
|
+
await trpcVanilla.product.admin.updateBrand.mutate(form);
|
|
1525
|
+
toast.success("Brand config saved");
|
|
1526
|
+
onSaved();
|
|
1527
|
+
} catch (err) {
|
|
1528
|
+
toast.error(err instanceof Error ? err.message : "Save failed");
|
|
1529
|
+
} finally {
|
|
1530
|
+
setSaving(false);
|
|
1531
|
+
}
|
|
1532
|
+
};
|
|
1533
|
+
|
|
1534
|
+
const fields: Array<{ key: keyof typeof form; label: string }> = [
|
|
1535
|
+
{ key: "brandName", label: "Brand Name" },
|
|
1536
|
+
{ key: "productName", label: "Product Name" },
|
|
1537
|
+
{ key: "tagline", label: "Tagline" },
|
|
1538
|
+
{ key: "domain", label: "Domain" },
|
|
1539
|
+
{ key: "appDomain", label: "App Domain" },
|
|
1540
|
+
{ key: "cookieDomain", label: "Cookie Domain" },
|
|
1541
|
+
{ key: "companyLegal", label: "Company Legal Name" },
|
|
1542
|
+
{ key: "priceLabel", label: "Price Label" },
|
|
1543
|
+
{ key: "defaultImage", label: "Default Container Image" },
|
|
1544
|
+
{ key: "emailSupport", label: "Support Email" },
|
|
1545
|
+
{ key: "emailPrivacy", label: "Privacy Email" },
|
|
1546
|
+
{ key: "emailLegal", label: "Legal Email" },
|
|
1547
|
+
{ key: "fromEmail", label: "From Email (notifications)" },
|
|
1548
|
+
{ key: "homePath", label: "Home Path (post-login redirect)" },
|
|
1549
|
+
{ key: "storagePrefix", label: "Storage Prefix" },
|
|
1550
|
+
];
|
|
1551
|
+
|
|
1552
|
+
return (
|
|
1553
|
+
<Card>
|
|
1554
|
+
<CardHeader>
|
|
1555
|
+
<CardTitle>Brand Identity</CardTitle>
|
|
1556
|
+
</CardHeader>
|
|
1557
|
+
<CardContent className="space-y-4">
|
|
1558
|
+
{fields.map(({ key, label }) => (
|
|
1559
|
+
<div key={key} className="space-y-1">
|
|
1560
|
+
<Label htmlFor={key}>{label}</Label>
|
|
1561
|
+
<Input
|
|
1562
|
+
id={key}
|
|
1563
|
+
value={form[key]}
|
|
1564
|
+
onChange={(e) => update(key, e.target.value)}
|
|
1565
|
+
/>
|
|
1566
|
+
</div>
|
|
1567
|
+
))}
|
|
1568
|
+
<Button onClick={save} disabled={saving}>
|
|
1569
|
+
{saving ? "Saving..." : "Save Brand Config"}
|
|
1570
|
+
</Button>
|
|
1571
|
+
</CardContent>
|
|
1572
|
+
</Card>
|
|
1573
|
+
);
|
|
1574
|
+
}
|
|
1575
|
+
```
|
|
1576
|
+
|
|
1577
|
+
- [ ] **Step 2: Commit**
|
|
1578
|
+
|
|
1579
|
+
```bash
|
|
1580
|
+
git add src/components/admin/products/brand-form.tsx
|
|
1581
|
+
git commit -m "feat: add brand identity admin form"
|
|
1582
|
+
```
|
|
1583
|
+
|
|
1584
|
+
---
|
|
1585
|
+
|
|
1586
|
+
### Task 10: Navigation Editor Component
|
|
1587
|
+
|
|
1588
|
+
**Files:**
|
|
1589
|
+
- Create: `src/components/admin/products/nav-editor.tsx`
|
|
1590
|
+
|
|
1591
|
+
- [ ] **Step 1: Write nav editor**
|
|
1592
|
+
|
|
1593
|
+
List of nav items with add/remove/reorder/toggle. No drag-and-drop library needed for v1 — use up/down buttons.
|
|
1594
|
+
|
|
1595
|
+
```typescript
|
|
1596
|
+
// src/components/admin/products/nav-editor.tsx
|
|
1597
|
+
"use client";
|
|
1598
|
+
|
|
1599
|
+
import { useState } from "react";
|
|
1600
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
1601
|
+
import { Input } from "@/components/ui/input";
|
|
1602
|
+
import { Button } from "@/components/ui/button";
|
|
1603
|
+
import { trpcVanilla } from "@/lib/trpc";
|
|
1604
|
+
import { toast } from "sonner";
|
|
1605
|
+
|
|
1606
|
+
interface NavItem {
|
|
1607
|
+
label: string;
|
|
1608
|
+
href: string;
|
|
1609
|
+
icon?: string | null;
|
|
1610
|
+
sortOrder: number;
|
|
1611
|
+
requiresRole?: string | null;
|
|
1612
|
+
enabled: string;
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
interface Props {
|
|
1616
|
+
items: NavItem[];
|
|
1617
|
+
onSaved: () => void;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
export function NavEditor({ items: initialItems, onSaved }: Props) {
|
|
1621
|
+
const [items, setItems] = useState<NavItem[]>(
|
|
1622
|
+
[...initialItems].sort((a, b) => a.sortOrder - b.sortOrder),
|
|
1623
|
+
);
|
|
1624
|
+
const [saving, setSaving] = useState(false);
|
|
1625
|
+
|
|
1626
|
+
const moveUp = (idx: number) => {
|
|
1627
|
+
if (idx === 0) return;
|
|
1628
|
+
const next = [...items];
|
|
1629
|
+
[next[idx - 1], next[idx]] = [next[idx], next[idx - 1]];
|
|
1630
|
+
setItems(next.map((item, i) => ({ ...item, sortOrder: i })));
|
|
1631
|
+
};
|
|
1632
|
+
|
|
1633
|
+
const moveDown = (idx: number) => {
|
|
1634
|
+
if (idx === items.length - 1) return;
|
|
1635
|
+
const next = [...items];
|
|
1636
|
+
[next[idx], next[idx + 1]] = [next[idx + 1], next[idx]];
|
|
1637
|
+
setItems(next.map((item, i) => ({ ...item, sortOrder: i })));
|
|
1638
|
+
};
|
|
1639
|
+
|
|
1640
|
+
const toggle = (idx: number) => {
|
|
1641
|
+
const next = [...items];
|
|
1642
|
+
next[idx] = { ...next[idx], enabled: next[idx].enabled === "true" ? "false" : "true" };
|
|
1643
|
+
setItems(next);
|
|
1644
|
+
};
|
|
1645
|
+
|
|
1646
|
+
const remove = (idx: number) => {
|
|
1647
|
+
setItems(items.filter((_, i) => i !== idx).map((item, i) => ({ ...item, sortOrder: i })));
|
|
1648
|
+
};
|
|
1649
|
+
|
|
1650
|
+
const add = () => {
|
|
1651
|
+
setItems([...items, { label: "", href: "/", sortOrder: items.length, enabled: "true" }]);
|
|
1652
|
+
};
|
|
1653
|
+
|
|
1654
|
+
const updateField = (idx: number, field: "label" | "href", value: string) => {
|
|
1655
|
+
const next = [...items];
|
|
1656
|
+
next[idx] = { ...next[idx], [field]: value };
|
|
1657
|
+
setItems(next);
|
|
1658
|
+
};
|
|
1659
|
+
|
|
1660
|
+
const save = async () => {
|
|
1661
|
+
setSaving(true);
|
|
1662
|
+
try {
|
|
1663
|
+
await trpcVanilla.product.admin.updateNavItems.mutate(
|
|
1664
|
+
items.map((item) => ({
|
|
1665
|
+
label: item.label,
|
|
1666
|
+
href: item.href,
|
|
1667
|
+
icon: item.icon ?? undefined,
|
|
1668
|
+
sortOrder: item.sortOrder,
|
|
1669
|
+
requiresRole: item.requiresRole ?? undefined,
|
|
1670
|
+
enabled: item.enabled !== "false",
|
|
1671
|
+
})),
|
|
1672
|
+
);
|
|
1673
|
+
toast.success("Navigation saved");
|
|
1674
|
+
onSaved();
|
|
1675
|
+
} catch (err) {
|
|
1676
|
+
toast.error(err instanceof Error ? err.message : "Save failed");
|
|
1677
|
+
} finally {
|
|
1678
|
+
setSaving(false);
|
|
1679
|
+
}
|
|
1680
|
+
};
|
|
1681
|
+
|
|
1682
|
+
return (
|
|
1683
|
+
<Card>
|
|
1684
|
+
<CardHeader>
|
|
1685
|
+
<CardTitle>Navigation Items</CardTitle>
|
|
1686
|
+
</CardHeader>
|
|
1687
|
+
<CardContent className="space-y-3">
|
|
1688
|
+
{items.map((item, idx) => (
|
|
1689
|
+
<div key={idx} className="flex items-center gap-2">
|
|
1690
|
+
<span className="text-xs text-muted-foreground w-6">{idx}</span>
|
|
1691
|
+
<Input
|
|
1692
|
+
className="flex-1"
|
|
1693
|
+
placeholder="Label"
|
|
1694
|
+
value={item.label}
|
|
1695
|
+
onChange={(e) => updateField(idx, "label", e.target.value)}
|
|
1696
|
+
/>
|
|
1697
|
+
<Input
|
|
1698
|
+
className="flex-1"
|
|
1699
|
+
placeholder="/path"
|
|
1700
|
+
value={item.href}
|
|
1701
|
+
onChange={(e) => updateField(idx, "href", e.target.value)}
|
|
1702
|
+
/>
|
|
1703
|
+
<Button variant="ghost" size="sm" onClick={() => moveUp(idx)} disabled={idx === 0}>
|
|
1704
|
+
↑
|
|
1705
|
+
</Button>
|
|
1706
|
+
<Button variant="ghost" size="sm" onClick={() => moveDown(idx)} disabled={idx === items.length - 1}>
|
|
1707
|
+
↓
|
|
1708
|
+
</Button>
|
|
1709
|
+
<Button variant="ghost" size="sm" onClick={() => toggle(idx)}>
|
|
1710
|
+
{item.enabled === "true" ? "On" : "Off"}
|
|
1711
|
+
</Button>
|
|
1712
|
+
<Button variant="ghost" size="sm" onClick={() => remove(idx)}>
|
|
1713
|
+
✕
|
|
1714
|
+
</Button>
|
|
1715
|
+
</div>
|
|
1716
|
+
))}
|
|
1717
|
+
<div className="flex gap-2">
|
|
1718
|
+
<Button variant="outline" onClick={add}>Add Item</Button>
|
|
1719
|
+
<Button onClick={save} disabled={saving}>
|
|
1720
|
+
{saving ? "Saving..." : "Save Navigation"}
|
|
1721
|
+
</Button>
|
|
1722
|
+
</div>
|
|
1723
|
+
</CardContent>
|
|
1724
|
+
</Card>
|
|
1725
|
+
);
|
|
1726
|
+
}
|
|
1727
|
+
```
|
|
1728
|
+
|
|
1729
|
+
- [ ] **Step 2: Commit**
|
|
1730
|
+
|
|
1731
|
+
```bash
|
|
1732
|
+
git add src/components/admin/products/nav-editor.tsx
|
|
1733
|
+
git commit -m "feat: add navigation item editor with reorder/toggle"
|
|
1734
|
+
```
|
|
1735
|
+
|
|
1736
|
+
---
|
|
1737
|
+
|
|
1738
|
+
### Task 11: Features, Fleet, and Billing Forms
|
|
1739
|
+
|
|
1740
|
+
**Files:**
|
|
1741
|
+
- Create: `src/components/admin/products/features-form.tsx`
|
|
1742
|
+
- Create: `src/components/admin/products/fleet-form.tsx`
|
|
1743
|
+
- Create: `src/components/admin/products/billing-form.tsx`
|
|
1744
|
+
|
|
1745
|
+
- [ ] **Step 1: Write features form**
|
|
1746
|
+
|
|
1747
|
+
Toggle switches for boolean flags, text inputs for string fields.
|
|
1748
|
+
|
|
1749
|
+
Pattern: same as brand-form but with `Checkbox` components for booleans.
|
|
1750
|
+
|
|
1751
|
+
```typescript
|
|
1752
|
+
// src/components/admin/products/features-form.tsx
|
|
1753
|
+
"use client";
|
|
1754
|
+
|
|
1755
|
+
import { useState } from "react";
|
|
1756
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
1757
|
+
import { Input } from "@/components/ui/input";
|
|
1758
|
+
import { Label } from "@/components/ui/label";
|
|
1759
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
1760
|
+
import { Button } from "@/components/ui/button";
|
|
1761
|
+
import { trpcVanilla } from "@/lib/trpc";
|
|
1762
|
+
import { toast } from "sonner";
|
|
1763
|
+
|
|
1764
|
+
interface Props {
|
|
1765
|
+
features: {
|
|
1766
|
+
chatEnabled: boolean;
|
|
1767
|
+
onboardingEnabled: boolean;
|
|
1768
|
+
onboardingDefaultModel: string | null;
|
|
1769
|
+
onboardingMaxCredits: number;
|
|
1770
|
+
onboardingWelcomeMsg: string | null;
|
|
1771
|
+
sharedModuleBilling: boolean;
|
|
1772
|
+
sharedModuleMonitoring: boolean;
|
|
1773
|
+
sharedModuleAnalytics: boolean;
|
|
1774
|
+
} | null;
|
|
1775
|
+
onSaved: () => void;
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
export function FeaturesForm({ features, onSaved }: Props) {
|
|
1779
|
+
const defaults = {
|
|
1780
|
+
chatEnabled: true,
|
|
1781
|
+
onboardingEnabled: true,
|
|
1782
|
+
onboardingDefaultModel: "",
|
|
1783
|
+
onboardingMaxCredits: 100,
|
|
1784
|
+
onboardingWelcomeMsg: "",
|
|
1785
|
+
sharedModuleBilling: true,
|
|
1786
|
+
sharedModuleMonitoring: true,
|
|
1787
|
+
sharedModuleAnalytics: true,
|
|
1788
|
+
};
|
|
1789
|
+
const [form, setForm] = useState({
|
|
1790
|
+
...defaults,
|
|
1791
|
+
...features,
|
|
1792
|
+
onboardingDefaultModel: features?.onboardingDefaultModel ?? "",
|
|
1793
|
+
onboardingWelcomeMsg: features?.onboardingWelcomeMsg ?? "",
|
|
1794
|
+
});
|
|
1795
|
+
const [saving, setSaving] = useState(false);
|
|
1796
|
+
|
|
1797
|
+
const save = async () => {
|
|
1798
|
+
setSaving(true);
|
|
1799
|
+
try {
|
|
1800
|
+
await trpcVanilla.product.admin.updateFeatures.mutate(form);
|
|
1801
|
+
toast.success("Features saved");
|
|
1802
|
+
onSaved();
|
|
1803
|
+
} catch (err) {
|
|
1804
|
+
toast.error(err instanceof Error ? err.message : "Save failed");
|
|
1805
|
+
} finally {
|
|
1806
|
+
setSaving(false);
|
|
1807
|
+
}
|
|
1808
|
+
};
|
|
1809
|
+
|
|
1810
|
+
const toggles: Array<{ key: keyof typeof form; label: string }> = [
|
|
1811
|
+
{ key: "chatEnabled", label: "Chat Widget" },
|
|
1812
|
+
{ key: "onboardingEnabled", label: "Onboarding Flow" },
|
|
1813
|
+
{ key: "sharedModuleBilling", label: "Billing Module" },
|
|
1814
|
+
{ key: "sharedModuleMonitoring", label: "Monitoring Module" },
|
|
1815
|
+
{ key: "sharedModuleAnalytics", label: "Analytics Module" },
|
|
1816
|
+
];
|
|
1817
|
+
|
|
1818
|
+
return (
|
|
1819
|
+
<Card>
|
|
1820
|
+
<CardHeader><CardTitle>Feature Flags</CardTitle></CardHeader>
|
|
1821
|
+
<CardContent className="space-y-4">
|
|
1822
|
+
{toggles.map(({ key, label }) => (
|
|
1823
|
+
<div key={key} className="flex items-center gap-2">
|
|
1824
|
+
<Checkbox
|
|
1825
|
+
checked={form[key] as boolean}
|
|
1826
|
+
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, [key]: !!checked }))}
|
|
1827
|
+
/>
|
|
1828
|
+
<Label>{label}</Label>
|
|
1829
|
+
</div>
|
|
1830
|
+
))}
|
|
1831
|
+
<div className="space-y-1">
|
|
1832
|
+
<Label>Onboarding Default Model</Label>
|
|
1833
|
+
<Input value={form.onboardingDefaultModel} onChange={(e) => setForm((prev) => ({ ...prev, onboardingDefaultModel: e.target.value }))} />
|
|
1834
|
+
</div>
|
|
1835
|
+
<div className="space-y-1">
|
|
1836
|
+
<Label>Onboarding Max Credits</Label>
|
|
1837
|
+
<Input type="number" value={form.onboardingMaxCredits} onChange={(e) => setForm((prev) => ({ ...prev, onboardingMaxCredits: Number(e.target.value) }))} />
|
|
1838
|
+
</div>
|
|
1839
|
+
<Button onClick={save} disabled={saving}>{saving ? "Saving..." : "Save Features"}</Button>
|
|
1840
|
+
</CardContent>
|
|
1841
|
+
</Card>
|
|
1842
|
+
);
|
|
1843
|
+
}
|
|
1844
|
+
```
|
|
1845
|
+
|
|
1846
|
+
- [ ] **Step 2: Write fleet form**
|
|
1847
|
+
|
|
1848
|
+
Dropdowns for lifecycle/billing model enums, number inputs for limits.
|
|
1849
|
+
|
|
1850
|
+
```typescript
|
|
1851
|
+
// src/components/admin/products/fleet-form.tsx
|
|
1852
|
+
"use client";
|
|
1853
|
+
|
|
1854
|
+
import { useState } from "react";
|
|
1855
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
1856
|
+
import { Input } from "@/components/ui/input";
|
|
1857
|
+
import { Label } from "@/components/ui/label";
|
|
1858
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
1859
|
+
import { Button } from "@/components/ui/button";
|
|
1860
|
+
import { trpcVanilla } from "@/lib/trpc";
|
|
1861
|
+
import { toast } from "sonner";
|
|
1862
|
+
|
|
1863
|
+
interface Props {
|
|
1864
|
+
fleet: {
|
|
1865
|
+
containerImage: string;
|
|
1866
|
+
containerPort: number;
|
|
1867
|
+
lifecycle: string;
|
|
1868
|
+
billingModel: string;
|
|
1869
|
+
maxInstances: number;
|
|
1870
|
+
dockerNetwork: string;
|
|
1871
|
+
placementStrategy: string;
|
|
1872
|
+
fleetDataDir: string;
|
|
1873
|
+
} | null;
|
|
1874
|
+
onSaved: () => void;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
export function FleetForm({ fleet, onSaved }: Props) {
|
|
1878
|
+
const defaults = {
|
|
1879
|
+
containerImage: "",
|
|
1880
|
+
containerPort: 3100,
|
|
1881
|
+
lifecycle: "managed" as const,
|
|
1882
|
+
billingModel: "monthly" as const,
|
|
1883
|
+
maxInstances: 5,
|
|
1884
|
+
dockerNetwork: "",
|
|
1885
|
+
placementStrategy: "least-loaded",
|
|
1886
|
+
fleetDataDir: "/data/fleet",
|
|
1887
|
+
};
|
|
1888
|
+
const [form, setForm] = useState({ ...defaults, ...fleet });
|
|
1889
|
+
const [saving, setSaving] = useState(false);
|
|
1890
|
+
|
|
1891
|
+
const save = async () => {
|
|
1892
|
+
setSaving(true);
|
|
1893
|
+
try {
|
|
1894
|
+
await trpcVanilla.product.admin.updateFleet.mutate(form);
|
|
1895
|
+
toast.success("Fleet config saved");
|
|
1896
|
+
onSaved();
|
|
1897
|
+
} catch (err) {
|
|
1898
|
+
toast.error(err instanceof Error ? err.message : "Save failed");
|
|
1899
|
+
} finally {
|
|
1900
|
+
setSaving(false);
|
|
1901
|
+
}
|
|
1902
|
+
};
|
|
1903
|
+
|
|
1904
|
+
return (
|
|
1905
|
+
<Card>
|
|
1906
|
+
<CardHeader><CardTitle>Fleet Configuration</CardTitle></CardHeader>
|
|
1907
|
+
<CardContent className="space-y-4">
|
|
1908
|
+
<div className="space-y-1">
|
|
1909
|
+
<Label>Container Image</Label>
|
|
1910
|
+
<Input value={form.containerImage} onChange={(e) => setForm((prev) => ({ ...prev, containerImage: e.target.value }))} />
|
|
1911
|
+
</div>
|
|
1912
|
+
<div className="space-y-1">
|
|
1913
|
+
<Label>Container Port</Label>
|
|
1914
|
+
<Input type="number" value={form.containerPort} onChange={(e) => setForm((prev) => ({ ...prev, containerPort: Number(e.target.value) }))} />
|
|
1915
|
+
</div>
|
|
1916
|
+
<div className="space-y-1">
|
|
1917
|
+
<Label>Lifecycle</Label>
|
|
1918
|
+
<Select value={form.lifecycle} onValueChange={(v) => setForm((prev) => ({ ...prev, lifecycle: v }))}>
|
|
1919
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
1920
|
+
<SelectContent>
|
|
1921
|
+
<SelectItem value="managed">Managed (persistent)</SelectItem>
|
|
1922
|
+
<SelectItem value="ephemeral">Ephemeral (teardown on completion)</SelectItem>
|
|
1923
|
+
</SelectContent>
|
|
1924
|
+
</Select>
|
|
1925
|
+
</div>
|
|
1926
|
+
<div className="space-y-1">
|
|
1927
|
+
<Label>Billing Model</Label>
|
|
1928
|
+
<Select value={form.billingModel} onValueChange={(v) => setForm((prev) => ({ ...prev, billingModel: v }))}>
|
|
1929
|
+
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
1930
|
+
<SelectContent>
|
|
1931
|
+
<SelectItem value="monthly">Monthly subscription</SelectItem>
|
|
1932
|
+
<SelectItem value="per_use">Per-use (credit gate)</SelectItem>
|
|
1933
|
+
<SelectItem value="none">None</SelectItem>
|
|
1934
|
+
</SelectContent>
|
|
1935
|
+
</Select>
|
|
1936
|
+
</div>
|
|
1937
|
+
<div className="space-y-1">
|
|
1938
|
+
<Label>Max Instances Per Tenant</Label>
|
|
1939
|
+
<Input type="number" value={form.maxInstances} onChange={(e) => setForm((prev) => ({ ...prev, maxInstances: Number(e.target.value) }))} />
|
|
1940
|
+
</div>
|
|
1941
|
+
<div className="space-y-1">
|
|
1942
|
+
<Label>Docker Network</Label>
|
|
1943
|
+
<Input value={form.dockerNetwork} onChange={(e) => setForm((prev) => ({ ...prev, dockerNetwork: e.target.value }))} />
|
|
1944
|
+
</div>
|
|
1945
|
+
<Button onClick={save} disabled={saving}>{saving ? "Saving..." : "Save Fleet Config"}</Button>
|
|
1946
|
+
</CardContent>
|
|
1947
|
+
</Card>
|
|
1948
|
+
);
|
|
1949
|
+
}
|
|
1950
|
+
```
|
|
1951
|
+
|
|
1952
|
+
- [ ] **Step 3: Write billing form**
|
|
1953
|
+
|
|
1954
|
+
Stripe keys (masked), credit price tiers, affiliate config.
|
|
1955
|
+
|
|
1956
|
+
```typescript
|
|
1957
|
+
// src/components/admin/products/billing-form.tsx
|
|
1958
|
+
"use client";
|
|
1959
|
+
|
|
1960
|
+
import { useState } from "react";
|
|
1961
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
1962
|
+
import { Input } from "@/components/ui/input";
|
|
1963
|
+
import { Label } from "@/components/ui/label";
|
|
1964
|
+
import { Button } from "@/components/ui/button";
|
|
1965
|
+
import { trpcVanilla } from "@/lib/trpc";
|
|
1966
|
+
import { toast } from "sonner";
|
|
1967
|
+
|
|
1968
|
+
interface Props {
|
|
1969
|
+
billing: {
|
|
1970
|
+
stripePublishableKey: string | null;
|
|
1971
|
+
creditPrices: Record<string, number>;
|
|
1972
|
+
affiliateBaseUrl: string | null;
|
|
1973
|
+
affiliateMatchRate: string;
|
|
1974
|
+
affiliateMaxCap: number;
|
|
1975
|
+
dividendRate: string;
|
|
1976
|
+
} | null;
|
|
1977
|
+
onSaved: () => void;
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
export function BillingForm({ billing, onSaved }: Props) {
|
|
1981
|
+
const defaults = {
|
|
1982
|
+
stripePublishableKey: "",
|
|
1983
|
+
creditPrices: { "5": 500, "20": 2000, "50": 5000, "100": 10000, "500": 50000 },
|
|
1984
|
+
affiliateBaseUrl: "",
|
|
1985
|
+
affiliateMatchRate: 1.0,
|
|
1986
|
+
affiliateMaxCap: 20000,
|
|
1987
|
+
dividendRate: 1.0,
|
|
1988
|
+
};
|
|
1989
|
+
const [form, setForm] = useState({
|
|
1990
|
+
...defaults,
|
|
1991
|
+
...billing,
|
|
1992
|
+
stripePublishableKey: billing?.stripePublishableKey ?? "",
|
|
1993
|
+
affiliateBaseUrl: billing?.affiliateBaseUrl ?? "",
|
|
1994
|
+
affiliateMatchRate: Number(billing?.affiliateMatchRate ?? 1.0),
|
|
1995
|
+
dividendRate: Number(billing?.dividendRate ?? 1.0),
|
|
1996
|
+
creditPrices: (billing?.creditPrices as Record<string, number>) ?? defaults.creditPrices,
|
|
1997
|
+
});
|
|
1998
|
+
const [saving, setSaving] = useState(false);
|
|
1999
|
+
|
|
2000
|
+
const save = async () => {
|
|
2001
|
+
setSaving(true);
|
|
2002
|
+
try {
|
|
2003
|
+
await trpcVanilla.product.admin.updateBilling.mutate(form);
|
|
2004
|
+
toast.success("Billing config saved");
|
|
2005
|
+
onSaved();
|
|
2006
|
+
} catch (err) {
|
|
2007
|
+
toast.error(err instanceof Error ? err.message : "Save failed");
|
|
2008
|
+
} finally {
|
|
2009
|
+
setSaving(false);
|
|
2010
|
+
}
|
|
2011
|
+
};
|
|
2012
|
+
|
|
2013
|
+
const priceTiers = ["5", "20", "50", "100", "500"];
|
|
2014
|
+
|
|
2015
|
+
return (
|
|
2016
|
+
<Card>
|
|
2017
|
+
<CardHeader><CardTitle>Billing Configuration</CardTitle></CardHeader>
|
|
2018
|
+
<CardContent className="space-y-4">
|
|
2019
|
+
<div className="space-y-1">
|
|
2020
|
+
<Label>Stripe Publishable Key</Label>
|
|
2021
|
+
<Input value={form.stripePublishableKey} onChange={(e) => setForm((prev) => ({ ...prev, stripePublishableKey: e.target.value }))} />
|
|
2022
|
+
</div>
|
|
2023
|
+
<div className="space-y-2">
|
|
2024
|
+
<Label>Credit Prices (cents)</Label>
|
|
2025
|
+
{priceTiers.map((tier) => (
|
|
2026
|
+
<div key={tier} className="flex items-center gap-2">
|
|
2027
|
+
<span className="w-16 text-sm text-muted-foreground">${tier}:</span>
|
|
2028
|
+
<Input
|
|
2029
|
+
type="number"
|
|
2030
|
+
value={form.creditPrices[tier] ?? 0}
|
|
2031
|
+
onChange={(e) => setForm((prev) => ({
|
|
2032
|
+
...prev,
|
|
2033
|
+
creditPrices: { ...prev.creditPrices, [tier]: Number(e.target.value) },
|
|
2034
|
+
}))}
|
|
2035
|
+
/>
|
|
2036
|
+
</div>
|
|
2037
|
+
))}
|
|
2038
|
+
</div>
|
|
2039
|
+
<div className="space-y-1">
|
|
2040
|
+
<Label>Affiliate Match Rate</Label>
|
|
2041
|
+
<Input type="number" step="0.1" value={form.affiliateMatchRate} onChange={(e) => setForm((prev) => ({ ...prev, affiliateMatchRate: Number(e.target.value) }))} />
|
|
2042
|
+
</div>
|
|
2043
|
+
<div className="space-y-1">
|
|
2044
|
+
<Label>Affiliate Max Cap (cents)</Label>
|
|
2045
|
+
<Input type="number" value={form.affiliateMaxCap} onChange={(e) => setForm((prev) => ({ ...prev, affiliateMaxCap: Number(e.target.value) }))} />
|
|
2046
|
+
</div>
|
|
2047
|
+
<div className="space-y-1">
|
|
2048
|
+
<Label>Dividend Rate</Label>
|
|
2049
|
+
<Input type="number" step="0.1" value={form.dividendRate} onChange={(e) => setForm((prev) => ({ ...prev, dividendRate: Number(e.target.value) }))} />
|
|
2050
|
+
</div>
|
|
2051
|
+
<Button onClick={save} disabled={saving}>{saving ? "Saving..." : "Save Billing Config"}</Button>
|
|
2052
|
+
</CardContent>
|
|
2053
|
+
</Card>
|
|
2054
|
+
);
|
|
2055
|
+
}
|
|
2056
|
+
```
|
|
2057
|
+
|
|
2058
|
+
- [ ] **Step 4: Commit**
|
|
2059
|
+
|
|
2060
|
+
```bash
|
|
2061
|
+
git add src/components/admin/products/
|
|
2062
|
+
git commit -m "feat: add features, fleet, and billing admin forms"
|
|
2063
|
+
```
|
|
2064
|
+
|
|
2065
|
+
---
|
|
2066
|
+
|
|
2067
|
+
## Phase 4: Frontend Brand Config Migration
|
|
2068
|
+
|
|
2069
|
+
### Task 12: initBrandConfig() via tRPC
|
|
2070
|
+
|
|
2071
|
+
**Files:**
|
|
2072
|
+
- Modify: `src/lib/brand-config.ts` (in platform-ui-core)
|
|
2073
|
+
|
|
2074
|
+
- [ ] **Step 1: Add initBrandConfig function**
|
|
2075
|
+
|
|
2076
|
+
Add after the existing `setBrandConfig` function:
|
|
2077
|
+
|
|
2078
|
+
```typescript
|
|
2079
|
+
/**
|
|
2080
|
+
* Fetch brand config from the platform API and apply it.
|
|
2081
|
+
* Call once in root layout server component.
|
|
2082
|
+
* Falls back to env var defaults if API unavailable.
|
|
2083
|
+
*/
|
|
2084
|
+
export async function initBrandConfig(apiBaseUrl: string): Promise<void> {
|
|
2085
|
+
try {
|
|
2086
|
+
const res = await fetch(`${apiBaseUrl}/trpc/product.getBrandConfig`, {
|
|
2087
|
+
next: { revalidate: 60 },
|
|
2088
|
+
});
|
|
2089
|
+
if (!res.ok) return; // fall back to env defaults
|
|
2090
|
+
const json = await res.json();
|
|
2091
|
+
const data = json?.result?.data;
|
|
2092
|
+
if (data) {
|
|
2093
|
+
setBrandConfig(data);
|
|
2094
|
+
}
|
|
2095
|
+
} catch {
|
|
2096
|
+
// API unavailable — env var defaults remain active
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
```
|
|
2100
|
+
|
|
2101
|
+
- [ ] **Step 2: Run type check**
|
|
2102
|
+
|
|
2103
|
+
Run: `npm run check` (in platform-ui-core)
|
|
2104
|
+
Expected: No type errors
|
|
2105
|
+
|
|
2106
|
+
- [ ] **Step 3: Commit**
|
|
2107
|
+
|
|
2108
|
+
```bash
|
|
2109
|
+
git add src/lib/brand-config.ts
|
|
2110
|
+
git commit -m "feat: add initBrandConfig() for tRPC-based brand config loading"
|
|
2111
|
+
```
|
|
2112
|
+
|
|
2113
|
+
---
|
|
2114
|
+
|
|
2115
|
+
## Phase 5: Backend Module Migration (platform-core)
|
|
2116
|
+
|
|
2117
|
+
> **Note:** These tasks modify how existing platform-core modules read config. Each task is a standalone migration of one module — they can be done in any order. Each product backend adopts `PRODUCT_SLUG` + `initProductConfig()` at its own pace.
|
|
2118
|
+
|
|
2119
|
+
### Task 13: Platform Boot Function (eliminate code in products)
|
|
2120
|
+
|
|
2121
|
+
**Files:**
|
|
2122
|
+
- Create: `src/boot.ts` (in platform-core)
|
|
2123
|
+
- Modify: each product backend's `src/index.ts`
|
|
2124
|
+
|
|
2125
|
+
The goal: product backends call `platformBoot()` and platform-core auto-configures CORS, email, fleet defaults, brand config endpoint — all from DB. Products only add their own custom routes.
|
|
2126
|
+
|
|
2127
|
+
- [ ] **Step 1: Write platformBoot in platform-core**
|
|
2128
|
+
|
|
2129
|
+
```typescript
|
|
2130
|
+
// src/boot.ts
|
|
2131
|
+
import type { Hono } from "hono";
|
|
2132
|
+
import type { DrizzleDb } from "./db/index.js";
|
|
2133
|
+
import { initProductConfig, getProductConfig, deriveCorsOrigins } from "./product-config/index.js";
|
|
2134
|
+
|
|
2135
|
+
export interface PlatformBootOptions {
|
|
2136
|
+
slug: string;
|
|
2137
|
+
db: DrizzleDb;
|
|
2138
|
+
app: Hono;
|
|
2139
|
+
/** Additional CORS origins (e.g. DEV_ORIGINS from env). */
|
|
2140
|
+
devOrigins?: string[];
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
/**
|
|
2144
|
+
* Initialize platform-core modules from DB-driven product config.
|
|
2145
|
+
* Call once at startup, before serve().
|
|
2146
|
+
*
|
|
2147
|
+
* This replaces: BRAND_NAME, PLATFORM_DOMAIN, UI_ORIGIN, FROM_EMAIL,
|
|
2148
|
+
* SUPPORT_EMAIL, COOKIE_DOMAIN, and all other product-specific env vars
|
|
2149
|
+
* that platform-core modules previously read from process.env.
|
|
2150
|
+
*/
|
|
2151
|
+
export async function platformBoot(opts: PlatformBootOptions): Promise<void> {
|
|
2152
|
+
const { slug, db, app, devOrigins = [] } = opts;
|
|
2153
|
+
|
|
2154
|
+
// 1. Initialize product config from DB
|
|
2155
|
+
initProductConfig(db);
|
|
2156
|
+
const config = await getProductConfig(slug);
|
|
2157
|
+
if (!config) throw new Error(`Product "${slug}" not found in DB. Run seed script.`);
|
|
2158
|
+
|
|
2159
|
+
// 2. Auto-configure CORS from product domains
|
|
2160
|
+
const origins = [...deriveCorsOrigins(config.product, config.domains), ...devOrigins];
|
|
2161
|
+
// Wire into existing CORS middleware (implementation depends on current CORS setup)
|
|
2162
|
+
|
|
2163
|
+
// 3. Auto-configure email (brand name, from email, support email)
|
|
2164
|
+
// Wire into existing notification service
|
|
2165
|
+
|
|
2166
|
+
// 4. Auto-configure fleet defaults (lifecycle, billing model, container image)
|
|
2167
|
+
// Wire into fleet manager initialization
|
|
2168
|
+
|
|
2169
|
+
// 5. Register product config tRPC endpoints
|
|
2170
|
+
// Already handled by router composition
|
|
2171
|
+
}
|
|
2172
|
+
```
|
|
2173
|
+
|
|
2174
|
+
- [ ] **Step 2: Migrate product backends one at a time**
|
|
2175
|
+
|
|
2176
|
+
Each product backend shrinks its `config.ts` to just infrastructure vars and calls `platformBoot()`:
|
|
2177
|
+
|
|
2178
|
+
```typescript
|
|
2179
|
+
// paperclip-platform/src/index.ts — AFTER
|
|
2180
|
+
import { platformBoot } from "@wopr-network/platform-core";
|
|
2181
|
+
|
|
2182
|
+
// After DB init:
|
|
2183
|
+
await platformBoot({
|
|
2184
|
+
slug: process.env.PRODUCT_SLUG ?? "paperclip",
|
|
2185
|
+
db,
|
|
2186
|
+
app,
|
|
2187
|
+
devOrigins: process.env.DEV_ORIGINS?.split(","),
|
|
2188
|
+
});
|
|
2189
|
+
```
|
|
2190
|
+
|
|
2191
|
+
- [ ] **Step 3: Migrate platform-core modules to read from product config**
|
|
2192
|
+
|
|
2193
|
+
Each module migration is a separate commit. Priority:
|
|
2194
|
+
|
|
2195
|
+
1. **Email templates** — `brand_name`, `from_email`, `support_email` read from `getProductConfig()`
|
|
2196
|
+
2. **CORS middleware** — origins derived from `product.domain` + `product.appDomain` + `product_domains`
|
|
2197
|
+
3. **Fleet manager** — `container_image`, `lifecycle`, `billing_model` from `product_fleet_config`
|
|
2198
|
+
4. **Billing module** — `credit_prices`, `affiliate_*` from `product_billing_config`
|
|
2199
|
+
5. **Onboarding** — feature flags from `product_features`
|
|
2200
|
+
6. **Auth/cookie** — `cookie_domain` from `product.cookieDomain`
|
|
2201
|
+
|
|
2202
|
+
- [ ] **Step 4: Each product backend removes absorbed env vars from its config.ts**
|
|
2203
|
+
|
|
2204
|
+
Paperclip's `config.ts` goes from ~30 env vars to ~12. The removed ones are now in DB, accessed via `getProductConfig()` inside platform-core modules.
|
|
2205
|
+
|
|
2206
|
+
- [ ] **Step 5: Commit per module**
|
|
2207
|
+
|
|
2208
|
+
```bash
|
|
2209
|
+
git commit -m "feat: add platformBoot() to auto-configure core modules from DB"
|
|
2210
|
+
git commit -m "refactor: migrate email templates to read from product config DB"
|
|
2211
|
+
git commit -m "refactor: migrate CORS to derive origins from product config DB"
|
|
2212
|
+
```
|
|
2213
|
+
|
|
2214
|
+
---
|
|
2215
|
+
|
|
2216
|
+
## Phase 6: Cleanup
|
|
2217
|
+
|
|
2218
|
+
### Task 14: Remove Absorbed Env Vars
|
|
2219
|
+
|
|
2220
|
+
- [ ] **Step 1: Remove from .env files**
|
|
2221
|
+
|
|
2222
|
+
After all modules read from DB, remove the absorbed env vars from:
|
|
2223
|
+
- `platform-ui-core/.env.wopr`
|
|
2224
|
+
- `platform-ui-core/.env.paperclip`
|
|
2225
|
+
- Each product's `.env.example`
|
|
2226
|
+
- Each product's `docker-compose.yml` build args
|
|
2227
|
+
|
|
2228
|
+
- [ ] **Step 2: Remove from Zod schemas**
|
|
2229
|
+
|
|
2230
|
+
Remove absorbed fields from each product's `src/config.ts` Zod schema.
|
|
2231
|
+
|
|
2232
|
+
- [ ] **Step 3: Remove setBrandConfig overrides from thin shells**
|
|
2233
|
+
|
|
2234
|
+
Each product UI's `src/lib/brand-config.ts` override becomes empty or deleted — `initBrandConfig()` handles everything.
|
|
2235
|
+
|
|
2236
|
+
- [ ] **Step 4: Remove Dockerfile build args**
|
|
2237
|
+
|
|
2238
|
+
Remove `ARG NEXT_PUBLIC_BRAND_*` lines from each product UI's Dockerfile.
|
|
2239
|
+
|
|
2240
|
+
- [ ] **Step 5: Final commit**
|
|
2241
|
+
|
|
2242
|
+
```bash
|
|
2243
|
+
git commit -m "chore: remove absorbed env vars, Zod fields, and Dockerfile build args"
|
|
2244
|
+
```
|
|
2245
|
+
|
|
2246
|
+
---
|
|
2247
|
+
|
|
2248
|
+
## Verification Checklist
|
|
2249
|
+
|
|
2250
|
+
After all phases:
|
|
2251
|
+
|
|
2252
|
+
- [ ] `npm run check` passes in platform-core
|
|
2253
|
+
- [ ] `npm run check` passes in platform-ui-core
|
|
2254
|
+
- [ ] `npx vitest run src/product-config/` passes in platform-core
|
|
2255
|
+
- [ ] Seed script populates all 4 products: `npx tsx scripts/seed-products.ts --all`
|
|
2256
|
+
- [ ] Admin UI at `/admin/products` shows brand/nav/features/fleet/billing tabs
|
|
2257
|
+
- [ ] Changing nav items in admin UI reflects immediately on next page load
|
|
2258
|
+
- [ ] Each product backend starts with just `PRODUCT_SLUG` + infrastructure env vars
|
|
2259
|
+
- [ ] Holy Ship fleet config shows `lifecycle: ephemeral`, `billingModel: none`
|
|
2260
|
+
- [ ] Adding a 5th product = 1 seed script entry + 1 `PRODUCT_SLUG` env var
|