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:
saravanakumardb1 2026-02-15 14:13:03 -08:00
parent 588d164ea0
commit 755c16dbfb
6 changed files with 247 additions and 3 deletions

View File

@ -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' },

View 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;
}

View 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;
}
}

View 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;
});
}

View 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>;

View File

@ -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) {