feat(platform-service): add products module (types, repository, cache, routes)
- New products container in Cosmos DB (partition key: /id) - ProductDoc: displayName, licensePrefix, deviceLimits, trialDays, status - In-memory cache loaded on startup via loadProductCache() - CRUD routes: GET/POST /products, GET/PUT /products/:id - Cache refreshed after admin writes (create/update) - Registered before all other modules in server.ts
This commit is contained in:
parent
588d164ea0
commit
755c16dbfb
@ -3,6 +3,7 @@ import type { ContainerConfig } from '@bytelyst/cosmos';
|
||||
import { config } from './config.js';
|
||||
|
||||
const CONTAINER_DEFS: Record<string, ContainerConfig> = {
|
||||
products: { partitionKeyPath: '/id' },
|
||||
users: { partitionKeyPath: '/id' },
|
||||
devices: { partitionKeyPath: '/userId' },
|
||||
notification_prefs: { partitionKeyPath: '/userId' },
|
||||
|
||||
39
services/platform-service/src/modules/products/cache.ts
Normal file
39
services/platform-service/src/modules/products/cache.ts
Normal file
@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Products in-memory cache.
|
||||
* Loaded on startup, refreshed after admin writes.
|
||||
*/
|
||||
|
||||
import * as repository from './repository.js';
|
||||
import type { ProductDoc } from './types.js';
|
||||
|
||||
const productCache = new Map<string, ProductDoc>();
|
||||
|
||||
/**
|
||||
* Load all products from Cosmos into memory.
|
||||
* Called on startup and after admin create/update.
|
||||
*/
|
||||
export async function loadProductCache(): Promise<void> {
|
||||
const all = await repository.getAll();
|
||||
productCache.clear();
|
||||
for (const p of all) productCache.set(p.id, p);
|
||||
}
|
||||
|
||||
/** Get a product by ID from cache. */
|
||||
export function getProduct(productId: string): ProductDoc | undefined {
|
||||
return productCache.get(productId);
|
||||
}
|
||||
|
||||
/** Check if a productId exists in cache. */
|
||||
export function isValidProduct(productId: string): boolean {
|
||||
return productCache.has(productId);
|
||||
}
|
||||
|
||||
/** Get all cached products. */
|
||||
export function getAllProducts(): ProductDoc[] {
|
||||
return Array.from(productCache.values());
|
||||
}
|
||||
|
||||
/** Current cache size (for tests/health). */
|
||||
export function cacheSize(): number {
|
||||
return productCache.size;
|
||||
}
|
||||
49
services/platform-service/src/modules/products/repository.ts
Normal file
49
services/platform-service/src/modules/products/repository.ts
Normal file
@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Products repository — Cosmos DB CRUD.
|
||||
* Products are the central registry; productId is the partition key.
|
||||
*/
|
||||
|
||||
import { getContainer } from '../../lib/cosmos.js';
|
||||
import type { ProductDoc } from './types.js';
|
||||
|
||||
function container() {
|
||||
return getContainer('products');
|
||||
}
|
||||
|
||||
export async function getAll(): Promise<ProductDoc[]> {
|
||||
const { resources } = await container()
|
||||
.items.query<ProductDoc>({
|
||||
query: 'SELECT * FROM c ORDER BY c.displayName ASC',
|
||||
})
|
||||
.fetchAll();
|
||||
return resources;
|
||||
}
|
||||
|
||||
export async function getById(productId: string): Promise<ProductDoc | null> {
|
||||
try {
|
||||
const { resource } = await container().item(productId, productId).read<ProductDoc>();
|
||||
return resource ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(doc: ProductDoc): Promise<ProductDoc> {
|
||||
const { resource } = await container().items.create(doc);
|
||||
return resource as ProductDoc;
|
||||
}
|
||||
|
||||
export async function update(
|
||||
productId: string,
|
||||
updates: Partial<ProductDoc>
|
||||
): Promise<ProductDoc | null> {
|
||||
try {
|
||||
const { resource: existing } = await container().item(productId, productId).read<ProductDoc>();
|
||||
if (!existing) return null;
|
||||
const merged = { ...existing, ...updates, updatedAt: new Date().toISOString() };
|
||||
const { resource } = await container().item(productId, productId).replace(merged);
|
||||
return resource as ProductDoc;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
79
services/platform-service/src/modules/products/routes.ts
Normal file
79
services/platform-service/src/modules/products/routes.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Products registry REST endpoints.
|
||||
*
|
||||
* GET /products — list all products (public, cached)
|
||||
* GET /products/:id — get one product (public, cached)
|
||||
* POST /products — create product (admin-only)
|
||||
* PUT /products/:id — update product (admin-only, refreshes cache)
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { BadRequestError, ConflictError, NotFoundError } from '../../lib/errors.js';
|
||||
import * as repo from './repository.js';
|
||||
import { loadProductCache, getAllProducts, getProduct } from './cache.js';
|
||||
import { CreateProductSchema, UpdateProductSchema, type ProductDoc } from './types.js';
|
||||
|
||||
export async function productRoutes(app: FastifyInstance) {
|
||||
// List all products (served from cache)
|
||||
app.get('/products', async () => {
|
||||
return { products: getAllProducts() };
|
||||
});
|
||||
|
||||
// Get single product (served from cache)
|
||||
app.get('/products/:id', async req => {
|
||||
const { id } = req.params as { id: string };
|
||||
const product = getProduct(id);
|
||||
if (!product) throw new NotFoundError('Product not found');
|
||||
return product;
|
||||
});
|
||||
|
||||
// Create product (admin-only)
|
||||
app.post('/products', async (req, reply) => {
|
||||
const parsed = CreateProductSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
const input = parsed.data;
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await repo.getById(input.productId);
|
||||
if (existing) throw new ConflictError(`Product "${input.productId}" already exists`);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const doc: ProductDoc = {
|
||||
id: input.productId,
|
||||
...input,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
const created = await repo.create(doc);
|
||||
await loadProductCache();
|
||||
reply.code(201);
|
||||
return created;
|
||||
});
|
||||
|
||||
// Update product (admin-only, refreshes cache)
|
||||
app.put('/products/:id', async req => {
|
||||
const { id } = req.params as { id: string };
|
||||
const existing = await repo.getById(id);
|
||||
if (!existing) throw new NotFoundError('Product not found');
|
||||
|
||||
const parsed = UpdateProductSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
// Merge deviceLimits if partial update provided
|
||||
const { deviceLimits, ...rest } = parsed.data;
|
||||
const updates: Partial<ProductDoc> = { ...rest };
|
||||
if (deviceLimits) {
|
||||
updates.deviceLimits = { ...existing.deviceLimits, ...deviceLimits };
|
||||
}
|
||||
|
||||
const updated = await repo.update(id, updates);
|
||||
if (!updated) throw new NotFoundError('Product update failed');
|
||||
await loadProductCache();
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
70
services/platform-service/src/modules/products/types.ts
Normal file
70
services/platform-service/src/modules/products/types.ts
Normal file
@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Products registry types — central product configuration.
|
||||
* Admin creates products, dev teams use the productId across their stack.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface ProductDoc {
|
||||
id: string;
|
||||
productId: string;
|
||||
displayName: string;
|
||||
licensePrefix: string;
|
||||
packageName: string;
|
||||
defaultPlan: 'free' | 'pro';
|
||||
trialDays: number;
|
||||
deviceLimits: {
|
||||
free: number;
|
||||
pro: number;
|
||||
enterprise: number;
|
||||
};
|
||||
websiteUrl: string;
|
||||
status: 'active' | 'disabled';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const DeviceLimitsSchema = z.object({
|
||||
free: z.number().int().min(0).default(1),
|
||||
pro: z.number().int().min(0).default(3),
|
||||
enterprise: z.number().int().min(0).default(10),
|
||||
});
|
||||
|
||||
export const CreateProductSchema = z.object({
|
||||
productId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(/^[a-z0-9_-]+$/, 'productId must be lowercase alphanumeric with hyphens/underscores'),
|
||||
displayName: z.string().min(1).max(128),
|
||||
licensePrefix: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(16)
|
||||
.regex(/^[A-Z]+$/, 'licensePrefix must be uppercase letters'),
|
||||
packageName: z.string().min(1).max(256).default(''),
|
||||
defaultPlan: z.enum(['free', 'pro']).default('free'),
|
||||
trialDays: z.number().int().min(0).max(365).default(14),
|
||||
deviceLimits: DeviceLimitsSchema.default({ free: 1, pro: 3, enterprise: 10 }),
|
||||
websiteUrl: z.string().url().or(z.literal('')).default(''),
|
||||
status: z.enum(['active', 'disabled']).default('active'),
|
||||
});
|
||||
|
||||
export const UpdateProductSchema = z.object({
|
||||
displayName: z.string().min(1).max(128).optional(),
|
||||
licensePrefix: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(16)
|
||||
.regex(/^[A-Z]+$/)
|
||||
.optional(),
|
||||
packageName: z.string().max(256).optional(),
|
||||
defaultPlan: z.enum(['free', 'pro']).optional(),
|
||||
trialDays: z.number().int().min(0).max(365).optional(),
|
||||
deviceLimits: DeviceLimitsSchema.partial().optional(),
|
||||
websiteUrl: z.string().url().or(z.literal('')).optional(),
|
||||
status: z.enum(['active', 'disabled']).optional(),
|
||||
});
|
||||
|
||||
export type CreateProductInput = z.infer<typeof CreateProductSchema>;
|
||||
export type UpdateProductInput = z.infer<typeof UpdateProductSchema>;
|
||||
@ -22,6 +22,8 @@ await resolveKeyVaultSecrets([
|
||||
]);
|
||||
|
||||
import { createServiceApp, startService } from '@bytelyst/fastify-core';
|
||||
import { productRoutes } from './modules/products/routes.js';
|
||||
import { loadProductCache } from './modules/products/cache.js';
|
||||
import { authRoutes } from './modules/auth/routes.js';
|
||||
import { auditRoutes } from './modules/audit/routes.js';
|
||||
import { notificationRoutes } from './modules/notifications/routes.js';
|
||||
@ -45,21 +47,25 @@ import { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
||||
import { config } from './lib/config.js';
|
||||
|
||||
await initCosmosIfNeeded();
|
||||
await loadProductCache();
|
||||
|
||||
const app = await createServiceApp({
|
||||
name: 'platform-service',
|
||||
version: '0.1.0',
|
||||
description: 'Auth, audit, notifications, feature flags, rate limiting, invitations, referrals, promos, subscriptions, usage, plans, licenses, stripe',
|
||||
description:
|
||||
'Auth, audit, notifications, feature flags, rate limiting, invitations, referrals, promos, subscriptions, usage, plans, licenses, stripe',
|
||||
corsOrigin: config.CORS_ORIGIN,
|
||||
swagger: {
|
||||
title: 'Platform Service',
|
||||
description: 'Auth, audit, notifications, feature flags, rate limiting, invitations, referrals, promos, subscriptions, usage, plans, licenses, stripe',
|
||||
description:
|
||||
'Auth, audit, notifications, feature flags, rate limiting, invitations, referrals, promos, subscriptions, usage, plans, licenses, stripe',
|
||||
port: config.PORT,
|
||||
},
|
||||
metrics: true,
|
||||
});
|
||||
|
||||
// Register route modules
|
||||
await app.register(productRoutes, { prefix: '/api' });
|
||||
await app.register(authRoutes, { prefix: '/api' });
|
||||
await app.register(auditRoutes, { prefix: '/api' });
|
||||
await app.register(notificationRoutes, { prefix: '/api' });
|
||||
@ -74,7 +80,7 @@ await app.register(promoRoutes, { prefix: '/api' });
|
||||
// Scoped with internal key auth guard when BILLING_INTERNAL_KEY is set (Gap 3)
|
||||
const BILLING_KEY = config.BILLING_INTERNAL_KEY;
|
||||
if (BILLING_KEY) {
|
||||
await app.register(async (billingScope) => {
|
||||
await app.register(async billingScope => {
|
||||
billingScope.addHook('onRequest', async (req, reply) => {
|
||||
const key = req.headers['x-internal-key'];
|
||||
if (key !== BILLING_KEY) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user