feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View File

@@ -0,0 +1,95 @@
import type { FastifyInstance } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import { requireRole, type Authenticate } from '../../../shared/auth.js';
import { parseJson } from '../../../shared/http-input.js';
interface AdminStatsDeps {
pool: pg.Pool;
authenticate: Authenticate;
}
export async function registerAdminStatsRoutes(
app: FastifyInstance,
deps: AdminStatsDeps,
): Promise<void> {
app.get('/admin/stats', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const pool = deps.pool;
// All queries run in parallel for speed
const [
ordersToday,
revenueToday,
ordersByState,
outOfStockVariants,
totalProducts,
newCustomersThisMonth,
] = await Promise.all([
// Orders today
pool
.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM orders_orders
WHERE created_at >= CURRENT_DATE`,
)
.then((r) => parseInt(r.rows[0]?.count ?? '0', 10)),
// Revenue today (sum of PAID, PROCESSING, SHIPPED, DELIVERED orders)
pool
.query<{ total: string }>(
`SELECT COALESCE(SUM(total_cents), 0)::text AS total FROM orders_orders
WHERE created_at >= CURRENT_DATE
AND state IN ('PAID','PROCESSING','SHIPPED','DELIVERED')`,
)
.then((r) => parseInt(r.rows[0]?.total ?? '0', 10)),
// Orders by state
pool
.query<{ state: string; count: string }>(
`SELECT state, COUNT(*)::text AS count FROM orders_orders
GROUP BY state ORDER BY count DESC`,
)
.then((r) =>
Object.fromEntries(r.rows.map((row) => [row.state, parseInt(row.count, 10)])),
),
// Out-of-stock variants
pool
.query<{ count: string }>(
`SELECT COUNT(*)::text AS count
FROM inventory_stock s
WHERE s.available <= 0`,
)
.then((r) => parseInt(r.rows[0]?.count ?? '0', 10)),
// Total active products
pool
.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM catalog_products WHERE state = 'active'`,
)
.then((r) => parseInt(r.rows[0]?.count ?? '0', 10)),
// New customers this month
pool
.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM identity_users
WHERE role = 'customer'
AND created_at >= DATE_TRUNC('month', CURRENT_DATE)`,
)
.then((r) => parseInt(r.rows[0]?.count ?? '0', 10)),
]);
return reply.send({
ordersToday,
revenueTodayCents: revenueToday,
revenueTodayFormatted: `${(revenueToday / 100).toFixed(2)}`,
ordersByState,
outOfStockVariants,
totalActiveProducts: totalProducts,
newCustomersThisMonth,
generatedAt: new Date().toISOString(),
});
});
}

View File

@@ -0,0 +1,127 @@
import type { FastifyInstance } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import { requireRole, type Authenticate } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
import { parseJson } from '../../../shared/http-input.js';
import {
CreateBrand,
GetBrandBySlug,
ListBrands,
UpdateBrand,
} from '../application/brand-use-cases.js';
import type { Brand } from '../domain/brand.js';
import { BrandSlugAlreadyExistsError } from '../domain/errors.js';
import { PgBrandRepository } from '../infrastructure/pg-brand-repository.js';
export interface BrandsRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
}
const slugSchema = z
.string()
.min(1)
.max(160)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
const slugParamSchema = z.object({ slug: slugSchema });
const idParamSchema = z.object({ id: z.uuid() });
const newBrandSchema = z.object({
name: z.string().min(1).max(200),
slug: slugSchema,
seoTitle: z.string().min(1).max(200).optional().nullable(),
seoDescription: z.string().min(1).max(500).optional().nullable(),
});
const brandPatchSchema = newBrandSchema
.partial()
.refine((value) => Object.values(value).some((field) => field !== undefined), {
message: 'At least one brand field is required',
});
export async function registerBrandsRoutes(
app: FastifyInstance,
deps: BrandsRoutesDeps,
): Promise<void> {
const repository = new PgBrandRepository(deps.pool);
const getBySlug = new GetBrandBySlug(repository);
const listBrands = new ListBrands(repository);
const createBrand = new CreateBrand(repository);
const updateBrand = new UpdateBrand(repository);
app.get('/brands', async (_request, reply) => {
const items = await listBrands.execute();
return reply.send({ items: items.map(serializeBrand) });
});
app.get('/marca/:slug', async (request, reply) => {
const { slug } = parseJson(slugParamSchema, request.params);
const brand = await getBySlug.execute(slug);
if (!brand) {
throw new AppError(404, 'NOT_FOUND', 'Brand not found');
}
return reply.send(serializeBrand(brand));
});
app.post('/brands', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const input = parseJson(newBrandSchema, request.body);
try {
const brand = await createBrand.execute(input);
return reply.code(201).send(serializeBrand(brand));
} catch (error) {
throw mapBrandError(error);
}
});
app.patch('/brands/:id', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const patch = parseJson(brandPatchSchema, request.body);
try {
const brand = await updateBrand.execute(id, patch);
if (!brand) {
throw new AppError(404, 'NOT_FOUND', 'Brand not found');
}
return reply.send(serializeBrand(brand));
} catch (error) {
throw mapBrandError(error);
}
});
// DELETE /brands/:id
app.delete('/brands/:id', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
try {
await repository.delete(id);
} catch {
throw new AppError(404, 'NOT_FOUND', 'Brand not found');
}
return reply.code(204).send();
});
}
function mapBrandError(error: unknown): Error {
if (error instanceof BrandSlugAlreadyExistsError) {
return new AppError(409, 'BRAND_SLUG_EXISTS', error.message);
}
return error instanceof Error ? error : new Error('Unknown brand error');
}
function serializeBrand(brand: Brand) {
return {
id: brand.id,
name: brand.name,
slug: brand.slug,
url: `/marca/${brand.slug}`,
seoTitle: brand.seoTitle,
seoDescription: brand.seoDescription,
createdAt: brand.createdAt.toISOString(),
updatedAt: brand.updatedAt.toISOString(),
};
}

View File

@@ -0,0 +1,34 @@
import type { Brand, BrandPatch, NewBrand } from '../domain/brand.js';
import type { BrandRepository } from '../domain/ports.js';
export class GetBrandBySlug {
constructor(private readonly brands: BrandRepository) {}
async execute(slug: string): Promise<Brand | undefined> {
return this.brands.findBySlug(slug);
}
}
export class ListBrands {
constructor(private readonly brands: BrandRepository) {}
async execute(): Promise<Brand[]> {
return this.brands.list();
}
}
export class CreateBrand {
constructor(private readonly brands: BrandRepository) {}
async execute(input: NewBrand): Promise<Brand> {
return this.brands.create(input);
}
}
export class UpdateBrand {
constructor(private readonly brands: BrandRepository) {}
async execute(id: string, patch: BrandPatch): Promise<Brand | undefined> {
return this.brands.update(id, patch);
}
}

View File

@@ -0,0 +1,22 @@
/**
* Brand domain model. Public storefront URLs use slug; id is internal.
*/
export interface Brand {
id: string;
name: string;
slug: string;
seoTitle: string | null;
seoDescription: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface NewBrand {
name: string;
slug: string;
seoTitle?: string | null;
seoDescription?: string | null;
}
/** Fields a brand update may set. Undefined = leave unchanged. */
export type BrandPatch = Partial<NewBrand>;

View File

@@ -0,0 +1,6 @@
export class BrandSlugAlreadyExistsError extends Error {
constructor() {
super('Brand slug already exists');
this.name = 'BrandSlugAlreadyExistsError';
}
}

View File

@@ -0,0 +1,10 @@
import type { Brand, BrandPatch, NewBrand } from './brand.js';
export interface BrandRepository {
findById(id: string): Promise<Brand | undefined>;
findBySlug(slug: string): Promise<Brand | undefined>;
list(): Promise<Brand[]>;
create(input: NewBrand): Promise<Brand>;
update(id: string, patch: BrandPatch): Promise<Brand | undefined>;
delete(id: string): Promise<void>;
}

View File

@@ -0,0 +1,2 @@
/** Public API of the brands module. */
export { registerBrandsRoutes, type BrandsRoutesDeps } from './api/brands.routes.js';

View File

@@ -0,0 +1,122 @@
import type pg from 'pg';
import { BrandSlugAlreadyExistsError } from '../domain/errors.js';
import type { Brand, BrandPatch, NewBrand } from '../domain/brand.js';
import type { BrandRepository } from '../domain/ports.js';
interface BrandRow {
id: string;
name: string;
slug: string;
seo_title: string | null;
seo_description: string | null;
created_at: Date;
updated_at: Date;
}
const UNIQUE_VIOLATION = '23505';
const UPDATABLE: ReadonlyArray<[keyof BrandPatch, string]> = [
['name', 'name'],
['slug', 'slug'],
['seoTitle', 'seo_title'],
['seoDescription', 'seo_description'],
];
export class PgBrandRepository implements BrandRepository {
constructor(private readonly pool: pg.Pool) {}
async findById(id: string): Promise<Brand | undefined> {
const result = await this.pool.query<BrandRow>('SELECT * FROM brands_brands WHERE id = $1', [
id,
]);
const row = result.rows[0];
return row ? toBrand(row) : undefined;
}
async findBySlug(slug: string): Promise<Brand | undefined> {
const result = await this.pool.query<BrandRow>('SELECT * FROM brands_brands WHERE slug = $1', [
slug,
]);
const row = result.rows[0];
return row ? toBrand(row) : undefined;
}
async list(): Promise<Brand[]> {
const result = await this.pool.query<BrandRow>(
'SELECT * FROM brands_brands ORDER BY name ASC, id ASC',
);
return result.rows.map(toBrand);
}
async create(input: NewBrand): Promise<Brand> {
try {
const result = await this.pool.query<BrandRow>(
`INSERT INTO brands_brands (name, slug, seo_title, seo_description)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[input.name, input.slug, input.seoTitle ?? null, input.seoDescription ?? null],
);
const row = result.rows[0];
if (!row) {
throw new Error('brands_brands INSERT returned no row');
}
return toBrand(row);
} catch (error) {
if (isPgError(error, UNIQUE_VIOLATION)) {
throw new BrandSlugAlreadyExistsError();
}
throw error;
}
}
async update(id: string, patch: BrandPatch): Promise<Brand | undefined> {
const setClauses: string[] = [];
const values: unknown[] = [];
for (const [key, column] of UPDATABLE) {
if (key in patch) {
values.push(patch[key]);
setClauses.push(`${column} = $${values.length}`);
}
}
if (setClauses.length === 0) {
return this.findById(id);
}
values.push(id);
try {
const result = await this.pool.query<BrandRow>(
`UPDATE brands_brands SET ${setClauses.join(', ')}, updated_at = now()
WHERE id = $${values.length}
RETURNING *`,
values,
);
const row = result.rows[0];
return row ? toBrand(row) : undefined;
} catch (error) {
if (isPgError(error, UNIQUE_VIOLATION)) {
throw new BrandSlugAlreadyExistsError();
}
throw error;
}
}
async delete(id: string): Promise<void> {
await this.pool.query('DELETE FROM brands_brands WHERE id = $1', [id]);
}
}
function toBrand(row: BrandRow): Brand {
return {
id: row.id,
name: row.name,
slug: row.slug,
seoTitle: row.seo_title,
seoDescription: row.seo_description,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function isPgError(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}

View File

@@ -0,0 +1,25 @@
import type { FastifyInstance } from 'fastify';
import type { Authenticate } from '../../../shared/auth.js';
import { requireRole } from '../../../shared/auth.js';
import { CacheService } from '../application/cache-service.js';
export interface CacheRoutesDeps {
cache: CacheService;
authenticate: Authenticate;
}
export async function registerCacheRoutes(
app: FastifyInstance,
deps: CacheRoutesDeps,
): Promise<void> {
app.get('/cache/contracts', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
return reply.send({ items: deps.cache.listContracts() });
});
app.get('/cache/metrics', async (request, reply) => {
await deps.authenticate(request);
return reply.send(deps.cache.metrics());
});
}

View File

@@ -0,0 +1,61 @@
import type { CacheAdapter } from '../domain/ports.js';
import type { CacheContract } from '../domain/cache.js';
export class CacheService {
private readonly contracts = new Map<string, CacheContract>();
private hits = 0;
private misses = 0;
private invalidations = 0;
constructor(private readonly adapter: CacheAdapter) {}
registerContract(contract: CacheContract): void {
this.contracts.set(contract.name, contract);
}
listContracts(): CacheContract[] {
return [...this.contracts.values()];
}
async read<T>(contractName: string, key: string, loader: () => Promise<T>): Promise<T> {
const contract = this.contracts.get(contractName);
if (!contract) {
// Unknown contracts still load from source.
return loader();
}
const cached = await this.adapter.get<T>(key);
if (cached !== undefined) {
this.hits += 1;
return cached;
}
this.misses += 1;
const value = await loader();
await this.adapter.set(key, value, contract.ttlSeconds).catch(() => undefined);
return value;
}
async invalidate(key: string): Promise<void> {
this.invalidations += 1;
await this.adapter.invalidate(key);
}
async invalidateByName(name: string): Promise<number> {
const contract = this.contracts.get(name);
if (!contract) return 0;
this.invalidations += 1;
// Without an index, we invalidate the key pattern prefix; the in-memory adapter
// handles this. Real Redis adapter would use SCAN.
await this.adapter.invalidate(contract.keyPattern.replace(/[:*]/g, ''));
return 1;
}
metrics(): { hits: number; misses: number; invalidations: number; hitRatio: number } {
const total = this.hits + this.misses;
return {
hits: this.hits,
misses: this.misses,
invalidations: this.invalidations,
hitRatio: total === 0 ? 0 : this.hits / total,
};
}
}

View File

@@ -0,0 +1,20 @@
export interface CacheEntry<T> {
key: string;
value: T;
expiresAt: number;
}
export interface CacheContract {
name: string;
keyPattern: string;
ttlSeconds: number;
sourceOfTruth: string;
invalidation: string;
}
export interface CacheMetrics {
hits: number;
misses: number;
invalidations: number;
hitRatio(): number;
}

View File

@@ -0,0 +1,21 @@
export interface CacheAdapter {
get<T>(key: string): Promise<T | undefined>;
set<T>(key: string, value: T, ttlSeconds: number): Promise<void>;
invalidate(key: string): Promise<void>;
}
export interface CacheReadThrough {
read<T>(contractName: string, key: string, loader: () => Promise<T>): Promise<T>;
}
export interface CacheService extends CacheReadThrough {
listContracts(): Array<{
name: string;
keyPattern: string;
ttlSeconds: number;
sourceOfTruth: string;
invalidation: string;
}>;
invalidateByName(name: string): Promise<number>;
metrics(): { hits: number; misses: number; invalidations: number; hitRatio: number };
}

10
project/src/modules/cache/index.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/** Public API of the cache module. */
export { CacheService } from './application/cache-service.js';
export { InMemoryCacheAdapter } from './infrastructure/in-memory-cache-adapter.js';
export { registerCacheRoutes, type CacheRoutesDeps } from './api/cache.routes.js';
export type {
CacheAdapter,
CacheReadThrough,
CacheService as CacheServicePort,
} from './domain/ports.js';
export type { CacheContract, CacheEntry, CacheMetrics } from './domain/cache.js';

View File

@@ -0,0 +1,31 @@
import type { CacheAdapter } from '../domain/ports.js';
/** In-memory cache adapter for v1. Swap with Redis adapter later. */
export class InMemoryCacheAdapter implements CacheAdapter {
private readonly store = new Map<string, { value: unknown; expiresAt: number }>();
async get<T>(key: string): Promise<T | undefined> {
const entry = this.store.get(key);
if (!entry) return undefined;
if (entry.expiresAt < Date.now()) {
this.store.delete(key);
return undefined;
}
return entry.value as T;
}
async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> {
this.store.set(key, { value, expiresAt: Date.now() + ttlSeconds * 1000 });
}
async invalidate(key: string): Promise<void> {
if (this.store.has(key)) {
this.store.delete(key);
return;
}
// Pattern-like invalidation: drop any key containing this fragment.
for (const existing of [...this.store.keys()]) {
if (existing.includes(key)) this.store.delete(existing);
}
}
}

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { CacheService } from '../application/cache-service.js';
import { InMemoryCacheAdapter } from '../infrastructure/in-memory-cache-adapter.js';
describe('CacheService', () => {
it('records key, TTL, invalidation and source of truth per entry', () => {
const service = new CacheService(new InMemoryCacheAdapter());
service.registerContract({
name: 'product',
keyPattern: 'product:{slug}',
ttlSeconds: 300,
sourceOfTruth: 'catalog_products',
invalidation: 'ProductUpdated',
});
const contracts = service.listContracts();
expect(contracts).toEqual([
{
name: 'product',
keyPattern: 'product:{slug}',
ttlSeconds: 300,
sourceOfTruth: 'catalog_products',
invalidation: 'ProductUpdated',
},
]);
});
it('returns from cache on second read and increments hits', async () => {
const service = new CacheService(new InMemoryCacheAdapter());
service.registerContract({
name: 'product',
keyPattern: 'product:{slug}',
ttlSeconds: 60,
sourceOfTruth: 'catalog_products',
invalidation: 'ProductUpdated',
});
let loaderCalls = 0;
const loader = async () => {
loaderCalls += 1;
return { name: 'X' };
};
const a = await service.read<{ name: string }>('product', 'product:x', loader);
const b = await service.read<{ name: string }>('product', 'product:x', loader);
expect(a).toEqual({ name: 'X' });
expect(b).toEqual({ name: 'X' });
expect(loaderCalls).toBe(1);
expect(service.metrics()).toMatchObject({ hits: 1, misses: 1 });
});
it('falls back to loader when adapter is down and still records metrics', async () => {
const adapter: import('../domain/ports.js').CacheAdapter = {
get: async () => undefined,
set: async () => {
throw new Error('redis down');
},
invalidate: async () => {
throw new Error('redis down');
},
};
const service = new CacheService(adapter);
service.registerContract({
name: 'product',
keyPattern: 'product:{slug}',
ttlSeconds: 60,
sourceOfTruth: 'catalog_products',
invalidation: 'ProductUpdated',
});
const value = await service.read<{ name: string }>('product', 'product:x', async () => ({
name: 'X',
}));
expect(value).toEqual({ name: 'X' });
expect(service.metrics().misses).toBe(1);
});
});

View File

@@ -0,0 +1,120 @@
import type { FastifyInstance } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import type { Authenticate } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
import { parseJson } from '../../../shared/http-input.js';
import type { InventoryServicePort } from '../../inventory/index.js';
import type { PricingServicePort } from '../../pricing/index.js';
import type { PromotionServicePort } from '../../promotions/index.js';
import { CartService } from '../application/cart-service.js';
import type { CartItemView, CartView } from '../domain/cart.js';
import { InvalidCartQuantityError } from '../domain/errors.js';
import { PgCartRepository } from '../infrastructure/pg-cart-repository.js';
export interface CartRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
pricing: PricingServicePort;
inventory: InventoryServicePort;
promotions?: PromotionServicePort;
}
const variantParamSchema = z.object({ variantId: z.uuid() });
const itemBodySchema = z
.object({ productId: z.uuid(), variantId: z.uuid(), quantity: z.number().int().positive() })
.strip();
const quantityBodySchema = z.object({ quantity: z.number().int().positive() }).strip();
const promoCodeBodySchema = z.object({ code: z.string().min(1).max(64) }).strip();
export async function registerCartRoutes(
app: FastifyInstance,
deps: CartRoutesDeps,
): Promise<void> {
const service = new CartService(
new PgCartRepository(deps.pool),
deps.pricing,
deps.inventory,
deps.promotions,
);
app.get('/cart', async (request, reply) => {
const user = await deps.authenticate(request);
return reply.send(serializeCart(await service.getCart(user.id)));
});
app.post('/cart/items', async (request, reply) => {
const user = await deps.authenticate(request);
const input = parseJson(itemBodySchema, request.body);
try {
return reply.code(201).send(serializeCart(await service.addItem(user.id, input)));
} catch (error) {
throw mapCartError(error);
}
});
app.patch('/cart/items/:variantId', async (request, reply) => {
const user = await deps.authenticate(request);
const { variantId } = parseJson(variantParamSchema, request.params);
const { quantity } = parseJson(quantityBodySchema, request.body);
try {
return reply.send(serializeCart(await service.changeQuantity(user.id, variantId, quantity)));
} catch (error) {
throw mapCartError(error);
}
});
app.delete('/cart/items/:variantId', async (request, reply) => {
const user = await deps.authenticate(request);
const { variantId } = parseJson(variantParamSchema, request.params);
return reply.send(serializeCart(await service.removeItem(user.id, variantId)));
});
app.post('/cart/promo-code', async (request, reply) => {
const user = await deps.authenticate(request);
const { code } = parseJson(promoCodeBodySchema, request.body);
try {
return reply.send(serializeCart(await service.applyPromoCode(user.id, code)));
} catch (error) {
throw mapCartError(error);
}
});
}
function mapCartError(error: unknown): Error {
if (error instanceof InvalidCartQuantityError)
return new AppError(422, 'INVALID_CART_QUANTITY', error.message);
if (error instanceof Error && error.name.includes('Promotion'))
return new AppError(422, 'PROMOTION_INVALID', error.message);
return error instanceof Error ? error : new Error('Unknown cart error');
}
function serializeCart(cart: CartView) {
return {
id: cart.id,
userId: cart.userId,
items: cart.items.map(serializeItem),
currency: cart.currency,
netSubtotalCents: cart.netSubtotalCents,
vatAmountCents: cart.vatAmountCents,
discount: cart.discount,
totalBeforeDiscountCents: cart.totalBeforeDiscountCents,
totalCents: cart.totalCents,
createdAt: cart.createdAt.toISOString(),
updatedAt: cart.updatedAt.toISOString(),
};
}
function serializeItem(item: CartItemView) {
return {
id: item.id,
productId: item.productId,
variantId: item.variantId,
quantity: item.quantity,
available: item.available,
availability: item.availability,
pricing: item.pricing,
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
};
}

View File

@@ -0,0 +1,90 @@
import type { InventoryServicePort } from '../../inventory/index.js';
import type { PricingServicePort } from '../../pricing/index.js';
import type { PromotionServicePort } from '../../promotions/index.js';
import { InvalidCartQuantityError } from '../domain/errors.js';
import type { CartItemInput, CartView } from '../domain/cart.js';
import type { CartRepository } from '../domain/ports.js';
export class CartService {
constructor(
private readonly carts: CartRepository,
private readonly pricing: PricingServicePort,
private readonly inventory: InventoryServicePort,
private readonly promotions?: PromotionServicePort,
) {}
async getCart(userId: string): Promise<CartView> {
return this.toView(await this.carts.getOrCreate(userId));
}
async addItem(userId: string, input: CartItemInput): Promise<CartView> {
ensurePositiveQuantity(input.quantity);
return this.toView(await this.carts.addItem(userId, input));
}
async changeQuantity(userId: string, variantId: string, quantity: number): Promise<CartView> {
ensurePositiveQuantity(quantity);
return this.toView(await this.carts.changeQuantity(userId, variantId, quantity));
}
async removeItem(userId: string, variantId: string): Promise<CartView> {
return this.toView(await this.carts.removeItem(userId, variantId));
}
async applyPromoCode(userId: string, code: string): Promise<CartView> {
if (!this.promotions) return this.toView(await this.carts.setPromoCode(userId, code));
await this.promotions.validateCode(code);
return this.toView(await this.carts.setPromoCode(userId, code.trim().toUpperCase()));
}
private async toView(
cart: Awaited<ReturnType<CartRepository['getOrCreate']>>,
): Promise<CartView> {
const items = await Promise.all(
cart.items.map(async (item) => {
const [pricing, availability] = await Promise.all([
this.pricing
.calculate({ variantId: item.variantId, quantity: item.quantity })
.catch((error) => {
if (error instanceof Error && error.name === 'PriceNotFoundError') return null;
throw error;
}),
this.inventory.checkAvailability(item.variantId, item.quantity),
]);
return {
...item,
pricing,
availability,
available: pricing !== null && availability.available,
};
}),
);
const totalBeforeDiscountCents = items.reduce(
(sum, item) => sum + (item.pricing?.totalCents ?? 0),
0,
);
const discount =
cart.promoCode && this.promotions
? await this.promotions.calculateDiscount(cart.promoCode, totalBeforeDiscountCents)
: null;
return {
id: cart.id,
userId: cart.userId,
items,
currency: 'EUR',
netSubtotalCents: items.reduce((sum, item) => sum + (item.pricing?.netSubtotalCents ?? 0), 0),
vatAmountCents: items.reduce((sum, item) => sum + (item.pricing?.vatAmountCents ?? 0), 0),
discount,
totalBeforeDiscountCents,
totalCents: totalBeforeDiscountCents - (discount?.discountCents ?? 0),
createdAt: cart.createdAt,
updatedAt: cart.updatedAt,
};
}
}
function ensurePositiveQuantity(quantity: number): void {
if (!Number.isInteger(quantity) || quantity <= 0) throw new InvalidCartQuantityError();
}

View File

@@ -0,0 +1,48 @@
import type { PriceCalculation } from '../../pricing/index.js';
import type { PromotionDiscount } from '../../promotions/index.js';
import type { Availability } from '../../inventory/index.js';
export interface CartItem {
id: string;
cartId: string;
productId: string;
variantId: string;
quantity: number;
createdAt: Date;
updatedAt: Date;
}
export interface Cart {
id: string;
userId: string;
promoCode: string | null;
items: CartItem[];
createdAt: Date;
updatedAt: Date;
}
export interface CartItemInput {
productId: string;
variantId: string;
quantity: number;
}
export interface CartItemView extends CartItem {
pricing: PriceCalculation | null;
availability: Availability;
available: boolean;
}
export interface CartView {
id: string;
userId: string;
items: CartItemView[];
currency: 'EUR';
netSubtotalCents: number;
vatAmountCents: number;
discount: PromotionDiscount | null;
totalBeforeDiscountCents: number;
totalCents: number;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -0,0 +1,6 @@
export class InvalidCartQuantityError extends Error {
constructor() {
super('Cart quantity must be a positive integer');
this.name = 'InvalidCartQuantityError';
}
}

View File

@@ -0,0 +1,9 @@
import type { Cart, CartItemInput } from './cart.js';
export interface CartRepository {
getOrCreate(userId: string): Promise<Cart>;
addItem(userId: string, input: CartItemInput): Promise<Cart>;
changeQuantity(userId: string, variantId: string, quantity: number): Promise<Cart>;
removeItem(userId: string, variantId: string): Promise<Cart>;
setPromoCode(userId: string, code: string | null): Promise<Cart>;
}

View File

@@ -0,0 +1,5 @@
/** Public API of the cart module. */
export { registerCartRoutes, type CartRoutesDeps } from './api/cart.routes.js';
export { CartService } from './application/cart-service.js';
export type { Cart, CartItem, CartItemInput, CartItemView, CartView } from './domain/cart.js';
export type { CartRepository } from './domain/ports.js';

View File

@@ -0,0 +1,111 @@
import type pg from 'pg';
import type { Cart, CartItem } from '../domain/cart.js';
import type { CartRepository } from '../domain/ports.js';
import type { CartItemInput } from '../domain/cart.js';
interface CartRow {
id: string;
user_id: string;
promo_code: string | null;
created_at: Date;
updated_at: Date;
}
interface ItemRow {
id: string;
cart_id: string;
product_id: string;
variant_id: string;
quantity: number;
created_at: Date;
updated_at: Date;
}
export class PgCartRepository implements CartRepository {
constructor(private readonly pool: pg.Pool) {}
async getOrCreate(userId: string): Promise<Cart> {
const cart = await this.ensureCart(userId);
return this.loadCart(cart);
}
async addItem(userId: string, input: CartItemInput): Promise<Cart> {
const cart = await this.ensureCart(userId);
await this.pool.query(
`INSERT INTO cart_items (cart_id, product_id, variant_id, quantity)
VALUES ($1, $2, $3, $4)
ON CONFLICT (cart_id, variant_id) DO UPDATE
SET quantity = cart_items.quantity + EXCLUDED.quantity, updated_at = now()`,
[cart.id, input.productId, input.variantId, input.quantity],
);
return this.loadCart(cart);
}
async changeQuantity(userId: string, variantId: string, quantity: number): Promise<Cart> {
const cart = await this.ensureCart(userId);
await this.pool.query(
`UPDATE cart_items SET quantity = $3, updated_at = now()
WHERE cart_id = $1 AND variant_id = $2`,
[cart.id, variantId, quantity],
);
return this.loadCart(cart);
}
async removeItem(userId: string, variantId: string): Promise<Cart> {
const cart = await this.ensureCart(userId);
await this.pool.query('DELETE FROM cart_items WHERE cart_id = $1 AND variant_id = $2', [
cart.id,
variantId,
]);
return this.loadCart(cart);
}
async setPromoCode(userId: string, code: string | null): Promise<Cart> {
const cart = await this.ensureCart(userId);
const result = await this.pool.query<CartRow>(
'UPDATE cart_carts SET promo_code = $2, updated_at = now() WHERE id = $1 RETURNING *',
[cart.id, code],
);
const row = result.rows[0];
if (!row) throw new Error('cart_carts promo update returned no row');
return this.loadCart(row);
}
private async ensureCart(userId: string): Promise<CartRow> {
const result = await this.pool.query<CartRow>(
`INSERT INTO cart_carts (user_id) VALUES ($1)
ON CONFLICT (user_id) DO UPDATE SET updated_at = cart_carts.updated_at
RETURNING *`,
[userId],
);
const row = result.rows[0];
if (!row) throw new Error('cart_carts upsert returned no row');
return row;
}
private async loadCart(row: CartRow): Promise<Cart> {
const result = await this.pool.query<ItemRow>(
'SELECT * FROM cart_items WHERE cart_id = $1 ORDER BY created_at, id',
[row.id],
);
return {
id: row.id,
userId: row.user_id,
promoCode: row.promo_code,
items: result.rows.map(toItem),
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
}
function toItem(row: ItemRow): CartItem {
return {
id: row.id,
cartId: row.cart_id,
productId: row.product_id,
variantId: row.variant_id,
quantity: row.quantity,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}

View File

@@ -0,0 +1,30 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((entry) => {
const path = join(dir, entry);
return statSync(path).isDirectory() ? sourceFiles(path) : path.endsWith('.ts') ? [path] : [];
});
}
describe('cart persistence boundary', () => {
it('stores no price, tax, discount or stock columns', () => {
const migration = readFileSync(
new URL('../../../../migrations/013_cart.js', import.meta.url),
'utf8',
);
expect(migration).not.toMatch(/price|tax|vat|discount|stock/i);
});
it('does not import pricing or inventory internals', () => {
const cartDir = new URL('..', import.meta.url);
for (const file of sourceFiles(cartDir.pathname)) {
const source = readFileSync(file, 'utf8');
expect(source).not.toMatch(
/modules\/(pricing|inventory)\/(api|application|domain|infrastructure|tests)/,
);
}
});
});

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest';
import { CartService } from '../application/cart-service.js';
import type { CartRepository } from '../domain/ports.js';
import type { Cart } from '../domain/cart.js';
import type { PricingServicePort } from '../../pricing/index.js';
import type { InventoryServicePort } from '../../inventory/index.js';
const CART: Cart = {
id: 'cart-1',
userId: 'user-1',
promoCode: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
items: [
{
id: 'item-1',
cartId: 'cart-1',
productId: 'product-1',
variantId: 'variant-1',
quantity: 2,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
},
],
};
const repo: CartRepository = {
getOrCreate: async () => CART,
addItem: async () => CART,
changeQuantity: async () => CART,
removeItem: async () => ({ ...CART, items: [] }),
setPromoCode: async (_userId, code) => ({ ...CART, promoCode: code }),
};
const pricing: PricingServicePort = {
getVariantPrice: async () => undefined,
setVariantPrice: async () => {
throw new Error('not needed');
},
calculate: async ({ variantId, quantity }) => ({
variantId,
quantity,
currency: 'EUR',
vatRate: 'general',
vatBasisPoints: 2100,
netUnitAmountCents: 1000,
netSubtotalCents: quantity * 1000,
vatAmountCents: quantity * 210,
totalCents: quantity * 1210,
}),
};
const inventory: InventoryServicePort = {
checkAvailability: async () => ({ available: true, availableQuantity: 5 }),
reserve: async () => {
throw new Error('not needed');
},
release: async () => {
throw new Error('not needed');
},
confirm: async () => {
throw new Error('not needed');
},
setAvailable: async () => {
throw new Error('not needed');
},
};
describe('CartService', () => {
it('recalculates totals through PricingService and availability through InventoryService', async () => {
const view = await new CartService(repo, pricing, inventory).getCart('user-1');
expect(view.totalCents).toBe(2420);
expect(view.items[0]).toMatchObject({ available: true, pricing: { netUnitAmountCents: 1000 } });
});
});

View File

@@ -0,0 +1,479 @@
import { performance } from 'node:perf_hooks';
import type { FastifyInstance } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import { requireRole, type Authenticate } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
import { parseJson } from '../../../shared/http-input.js';
import {
AttachProductImage,
DetachProductImage,
ListProductImages,
ReorderProductImages,
} from '../application/image-use-cases.js';
import {
CreateProduct,
GetActiveProductBySlug,
SearchProducts,
SuggestCorrections,
UpdateProduct,
} from '../application/product-use-cases.js';
import {
CreateProductVariant,
ListProductVariants,
UpdateProductVariant,
UpsertProductRichData,
} from '../application/variant-use-cases.js';
import {
ProductBrandNotFoundError,
ProductCategoryNotFoundError,
ProductImageMainAlreadyExistsError,
ProductImageVariantMismatchError,
ProductSlugAlreadyExistsError,
ProductVariantCodeAlreadyExistsError,
} from '../domain/errors.js';
import type { ProductImage } from '../domain/image.js';
import { PRODUCT_IMAGE_ROLES } from '../domain/image.js';
import type { Product } from '../domain/product.js';
import { PRODUCT_ATTRIBUTES, PRODUCT_STATES } from '../domain/product.js';
import type { ProductRichData, ProductVariant } from '../domain/variant.js';
import { NUTRITION_SOURCES } from '../domain/variant.js';
import { LocalProductImageStorage } from '../infrastructure/local-product-image-storage.js';
import { PgProductImageRepository } from '../infrastructure/pg-product-image-repository.js';
import { PgProductRepository } from '../infrastructure/pg-product-repository.js';
import { PgProductSearchRepository } from '../infrastructure/pg-product-search-repository.js';
import { PgProductRichDataRepository } from '../infrastructure/pg-rich-data-repository.js';
import { PgProductVariantRepository } from '../infrastructure/pg-variant-repository.js';
interface CatalogSearchLogger {
info(payload: Record<string, unknown>, message: string): void;
}
export interface CatalogRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
logger?: CatalogSearchLogger;
}
const slugSchema = z
.string()
.min(1)
.max(160)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
const slugParamSchema = z.object({ slug: slugSchema });
const idParamSchema = z.object({ id: z.uuid() });
const variantParamSchema = z.object({ id: z.uuid(), variantId: z.uuid() });
const imageParamSchema = z.object({ id: z.uuid(), imageId: z.uuid() });
const searchQuerySchema = z.object({
q: z.string().min(1).max(200).optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
offset: z.coerce.number().int().min(0).max(10_000).optional(),
brandSlug: slugSchema.optional(),
categorySlug: slugSchema.optional(),
});
const jsonRecordSchema = z
.record(z.string(), z.unknown())
.refine((value) => JSON.stringify(value).length <= 10_000, {
message: 'JSON payload is too large',
});
const newProductSchema = z.object({
name: z.string().min(1).max(200),
slug: slugSchema,
description: z.string().min(1).max(2_000).optional().nullable(),
state: z.enum(PRODUCT_STATES).optional(),
channels: z.enum(['online', 'offline', 'all']).optional(),
featured: z.boolean().optional(),
attributes: z.array(z.enum(PRODUCT_ATTRIBUTES)).optional(),
seoTitle: z.string().min(1).max(200).optional().nullable(),
seoDescription: z.string().min(1).max(500).optional().nullable(),
categoryIds: z.array(z.uuid()).max(50).optional(),
brandId: z.uuid().optional().nullable(),
});
const productPatchSchema = newProductSchema
.partial()
.refine((value) => Object.values(value).some((field) => field !== undefined), {
message: 'At least one product field is required',
});
const newVariantSchema = z.object({
sku: z.string().min(1).max(100),
ean: z.string().min(1).max(32).optional().nullable(),
attributes: jsonRecordSchema.optional(),
});
const variantPatchSchema = newVariantSchema
.partial()
.refine((value) => Object.values(value).some((field) => field !== undefined), {
message: 'At least one variant field is required',
});
const imageUrlSchema = z
.string()
.trim()
.min(1)
.max(2_000)
.refine((value) => value.startsWith('/') || URL.canParse(value), {
message: 'Image URL must be an absolute local path or a valid URL',
});
const newImageSchema = z.object({
url: imageUrlSchema,
altText: z.string().trim().min(1).max(300),
role: z.enum(PRODUCT_IMAGE_ROLES),
variantId: z.uuid().optional().nullable(),
position: z.number().int().min(0).optional(),
});
const reorderImagesSchema = z.object({
items: z
.array(z.object({ imageId: z.uuid(), position: z.number().int().min(0) }))
.min(1)
.max(100),
});
const richDataSchema = z
.object({
ingredients: z.string().min(1).max(5_000).optional().nullable(),
allergens: z.array(z.string().min(1).max(100)).max(100).optional(),
nutrition: jsonRecordSchema.optional().nullable(),
nutritionSource: z.enum(NUTRITION_SOURCES).optional(),
isOrganic: z.boolean().optional(),
organicCertification: z.string().min(1).max(300).optional().nullable(),
})
.refine(
(value) =>
value.nutrition === undefined ||
value.nutrition === null ||
value.nutritionSource !== undefined,
{
message: 'nutritionSource is required when nutrition is provided',
},
)
.refine((value) => Object.values(value).some((field) => field !== undefined), {
message: 'At least one rich data field is required',
});
export async function registerCatalogRoutes(
app: FastifyInstance,
deps: CatalogRoutesDeps,
): Promise<void> {
const repository = new PgProductRepository(deps.pool);
const searchRepository = new PgProductSearchRepository(deps.pool);
const variants = new PgProductVariantRepository(deps.pool);
const richData = new PgProductRichDataRepository(deps.pool);
const images = new PgProductImageRepository(deps.pool, new LocalProductImageStorage());
const getBySlug = new GetActiveProductBySlug(repository);
const searchProducts = new SearchProducts(searchRepository);
const suggestCorrections = new SuggestCorrections(searchRepository);
const createProduct = new CreateProduct(repository);
const updateProduct = new UpdateProduct(repository);
const listVariants = new ListProductVariants(variants);
const createVariant = new CreateProductVariant(repository, variants);
const updateVariant = new UpdateProductVariant(variants);
const upsertRichData = new UpsertProductRichData(repository, richData);
const listImages = new ListProductImages(images);
const attachImage = new AttachProductImage(repository, images);
const detachImage = new DetachProductImage(images);
const reorderImages = new ReorderProductImages(images);
// Admin: list all products (any state)
app.get('/catalog/products', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const q = (request.query as { q?: string }).q;
const limit = parseInt((request.query as { limit?: string }).limit ?? '20', 10);
const offset = parseInt((request.query as { offset?: string }).offset ?? '0', 10);
const result = await repository.listAll({ limit, offset, q });
return reply.send({ items: result.items.map((p) => serializeProduct(p)), total: result.total });
});
app.get('/productos/:slug', async (request, reply) => {
const { slug } = parseJson(slugParamSchema, request.params);
const product = await getBySlug.execute(slug);
if (!product) {
throw new AppError(404, 'NOT_FOUND', 'Product not found');
}
const productImages = await listImages.execute(product.id);
return reply.send(serializeProduct(product, productImages));
});
// Admin: get product by ID (for editor)
app.get('/products/:id', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const product = await repository.findById(id);
if (!product) {
throw new AppError(404, 'NOT_FOUND', 'Product not found');
}
const productImages = await listImages.execute(product.id);
return reply.send(serializeProduct(product, productImages));
});
app.get('/products/search', async (request, reply) => {
const input = parseJson(searchQuerySchema, request.query);
const startedAt = performance.now();
const items = await searchProducts.execute(input);
deps.logger?.info(
{
event: 'catalog_search',
queryPresent: input.q !== undefined,
query: sanitizeSearchTelemetryQuery(input.q),
brandSlug: input.brandSlug,
categorySlug: input.categorySlug,
limit: input.limit ?? 20,
offset: input.offset ?? 0,
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
resultCount: items.length,
},
'catalog search completed',
);
return reply.send({ items: items.map((product) => serializeProduct(product)) });
});
app.get('/products/suggest', async (request, reply) => {
const { q } = parseJson(
z.object({ q: z.string().min(2).max(200) }),
request.query,
);
const suggestions = await suggestCorrections.execute(q);
return reply.send({ suggestions });
});
app.post('/products', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const input = parseJson(newProductSchema, request.body);
try {
const product = await createProduct.execute(input);
return reply.code(201).send(serializeProduct(product));
} catch (error) {
throw mapProductError(error);
}
});
app.get('/products/:id/variants', async (request, reply) => {
const { id } = parseJson(idParamSchema, request.params);
const items = await listVariants.execute(id);
return reply.send({ items: items.map(serializeVariant) });
});
app.get('/products/:id/images', async (request, reply) => {
const { id } = parseJson(idParamSchema, request.params);
const items = await listImages.execute(id);
return reply.send({ items: items.map(serializeImage) });
});
app.post('/products/:id/images', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const input = parseJson(newImageSchema, request.body);
try {
const image = await attachImage.execute(id, input);
if (!image) {
throw new AppError(404, 'NOT_FOUND', 'Product not found');
}
return reply.code(201).send(serializeImage(image));
} catch (error) {
throw mapProductError(error);
}
});
app.delete('/products/:id/images/:imageId', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id, imageId } = parseJson(imageParamSchema, request.params);
const deleted = await detachImage.execute(id, imageId);
if (!deleted) {
throw new AppError(404, 'NOT_FOUND', 'Image not found');
}
return reply.code(204).send();
});
app.patch('/products/:id/images/reorder', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const { items } = parseJson(reorderImagesSchema, request.body);
const ordered = await reorderImages.execute(id, items);
return reply.send({ items: ordered.map(serializeImage) });
});
app.post('/products/:id/variants', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const input = parseJson(newVariantSchema, request.body);
try {
const variant = await createVariant.execute(id, input);
if (!variant) {
throw new AppError(404, 'NOT_FOUND', 'Product not found');
}
return reply.code(201).send(serializeVariant(variant));
} catch (error) {
throw mapProductError(error);
}
});
app.patch('/products/:id/variants/:variantId', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id, variantId } = parseJson(variantParamSchema, request.params);
const patch = parseJson(variantPatchSchema, request.body);
try {
const variant = await updateVariant.execute(id, variantId, patch);
if (!variant) {
throw new AppError(404, 'NOT_FOUND', 'Variant not found');
}
return reply.send(serializeVariant(variant));
} catch (error) {
throw mapProductError(error);
}
});
app.patch('/products/:id/rich-data', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const patch = parseJson(richDataSchema, request.body);
const data = await upsertRichData.execute(id, patch);
if (!data) {
throw new AppError(404, 'NOT_FOUND', 'Product not found');
}
return reply.send(serializeRichData(data));
});
app.patch('/products/:id', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const patch = parseJson(productPatchSchema, request.body);
try {
const product = await updateProduct.execute(id, patch);
if (!product) {
throw new AppError(404, 'NOT_FOUND', 'Product not found');
}
return reply.send(serializeProduct(product));
} catch (error) {
throw mapProductError(error);
}
});
// PATCH /products/:id/state — change product state
app.patch('/products/:id/state', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const { state } = parseJson(z.object({ state: z.enum(PRODUCT_STATES) }), request.body);
const product = await updateProduct.execute(id, { state });
if (!product) {
throw new AppError(404, 'NOT_FOUND', 'Product not found');
}
return reply.send(serializeProduct(product));
});
// DELETE /products/:id
app.delete('/products/:id', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
await repository.delete(id);
return reply.code(204).send();
});
}
function mapProductError(error: unknown): Error {
if (error instanceof ProductSlugAlreadyExistsError) {
return new AppError(409, 'PRODUCT_SLUG_EXISTS', error.message);
}
if (error instanceof ProductCategoryNotFoundError) {
return new AppError(422, 'PRODUCT_CATEGORY_NOT_FOUND', error.message);
}
if (error instanceof ProductBrandNotFoundError) {
return new AppError(422, 'PRODUCT_BRAND_NOT_FOUND', error.message);
}
if (error instanceof ProductVariantCodeAlreadyExistsError) {
return new AppError(409, 'PRODUCT_VARIANT_CODE_EXISTS', error.message);
}
if (error instanceof ProductImageVariantMismatchError) {
return new AppError(422, 'PRODUCT_IMAGE_VARIANT_MISMATCH', error.message);
}
if (error instanceof ProductImageMainAlreadyExistsError) {
return new AppError(409, 'PRODUCT_IMAGE_MAIN_EXISTS', error.message);
}
return error instanceof Error ? error : new Error('Unknown product error');
}
function sanitizeSearchTelemetryQuery(query: string | undefined): string | undefined {
if (query === undefined) {
return undefined;
}
return query
.trim()
.slice(0, 200)
.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, '[redacted-email]')
.replace(/\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9_]+\b/g, '[redacted-key]')
.replace(/\b[A-Za-z0-9_-]{32,}\b/g, '[redacted-token]');
}
function serializeProduct(product: Product, images: ProductImage[] = []) {
return {
id: product.id,
name: product.name,
slug: product.slug,
url: `/productos/${product.slug}`,
images: images.map(serializeImage),
description: product.description,
state: product.state,
seoTitle: product.seoTitle,
seoDescription: product.seoDescription,
categoryIds: product.categoryIds,
brandId: product.brandId,
createdAt: product.createdAt.toISOString(),
updatedAt: product.updatedAt.toISOString(),
};
}
function serializeImage(image: ProductImage) {
return {
id: image.id,
productId: image.productId,
variantId: image.variantId,
url: image.url,
altText: image.altText,
position: image.position,
role: image.role,
createdAt: image.createdAt.toISOString(),
updatedAt: image.updatedAt.toISOString(),
};
}
function serializeVariant(variant: ProductVariant) {
return {
id: variant.id,
productId: variant.productId,
sku: variant.sku,
ean: variant.ean,
attributes: variant.attributes,
createdAt: variant.createdAt.toISOString(),
updatedAt: variant.updatedAt.toISOString(),
};
}
function serializeRichData(data: ProductRichData) {
return {
productId: data.productId,
ingredients: data.ingredients,
allergens: data.allergens,
nutrition: data.nutrition,
nutritionSource: data.nutritionSource,
isOrganic: data.isOrganic,
organicCertification: data.organicCertification,
createdAt: data.createdAt.toISOString(),
updatedAt: data.updatedAt.toISOString(),
};
}

View File

@@ -0,0 +1,51 @@
import { ProductImageVariantMismatchError } from '../domain/errors.js';
import type { NewProductImage, ProductImage, ProductImageOrderItem } from '../domain/image.js';
import type { ProductImageRepository, ProductRepository } from '../domain/ports.js';
export class ListProductImages {
constructor(private readonly images: ProductImageRepository) {}
async execute(productId: string, variantId?: string | null): Promise<ProductImage[]> {
return this.images.listByProductId(productId, variantId ?? null);
}
}
export class AttachProductImage {
constructor(
private readonly products: ProductRepository,
private readonly images: ProductImageRepository,
) {}
async execute(productId: string, input: NewProductImage): Promise<ProductImage | undefined> {
if (!(await this.products.findById(productId))) {
return undefined;
}
if (
input.variantId !== undefined &&
input.variantId !== null &&
!(await this.images.variantBelongsToProduct(productId, input.variantId))
) {
throw new ProductImageVariantMismatchError();
}
return this.images.attach(productId, input);
}
}
export class DetachProductImage {
constructor(private readonly images: ProductImageRepository) {}
async execute(productId: string, imageId: string): Promise<boolean> {
return this.images.detach(productId, imageId);
}
}
export class ReorderProductImages {
constructor(private readonly images: ProductImageRepository) {}
async execute(
productId: string,
items: readonly ProductImageOrderItem[],
): Promise<ProductImage[]> {
return this.images.reorder(productId, items);
}
}

View File

@@ -0,0 +1,102 @@
import { ProductBrandNotFoundError, ProductCategoryNotFoundError } from '../domain/errors.js';
import type { NewProduct, Product, ProductPatch } from '../domain/product.js';
import type { ProductRepository, ProductSearchRepository } from '../domain/ports.js';
export class GetActiveProductBySlug {
constructor(private readonly products: ProductRepository) {}
async execute(slug: string): Promise<Product | undefined> {
return this.products.findActiveBySlug(slug);
}
}
export interface SearchProductsInput {
q?: string;
limit?: number;
offset?: number;
brandSlug?: string;
categorySlug?: string;
}
export class SearchProducts {
constructor(private readonly searchRepository: ProductSearchRepository) {}
async execute(input: SearchProductsInput = {}): Promise<Product[]> {
return this.searchRepository.search({
q: input.q,
limit: input.limit ?? 20,
offset: input.offset ?? 0,
activeOnly: true,
brandSlug: input.brandSlug,
categorySlug: input.categorySlug,
});
}
}
export class SuggestCorrections {
constructor(private readonly searchRepository: ProductSearchRepository) {}
async execute(term: string): Promise<string[]> {
return this.searchRepository.suggestCorrections(term);
}
}
export class CreateProduct {
constructor(private readonly products: ProductRepository) {}
async execute(input: NewProduct): Promise<Product> {
await this.assertCategoriesExist(input.categoryIds ?? []);
await this.assertBrandExists(input.brandId);
return this.products.create({ ...input, state: input.state ?? 'draft' });
}
private async assertCategoriesExist(categoryIds: readonly string[]): Promise<void> {
if (categoryIds.length === 0) {
return;
}
if (!(await this.products.categoriesExist(categoryIds))) {
throw new ProductCategoryNotFoundError();
}
}
private async assertBrandExists(brandId: string | null | undefined): Promise<void> {
if (brandId === undefined || brandId === null) {
return;
}
if (!(await this.products.brandExists(brandId))) {
throw new ProductBrandNotFoundError();
}
}
}
export class UpdateProduct {
constructor(private readonly products: ProductRepository) {}
async execute(id: string, patch: ProductPatch): Promise<Product | undefined> {
if (patch.categoryIds !== undefined) {
await this.assertCategoriesExist(patch.categoryIds);
}
if (patch.brandId !== undefined) {
await this.assertBrandExists(patch.brandId);
}
return this.products.update(id, patch);
}
private async assertCategoriesExist(categoryIds: readonly string[]): Promise<void> {
if (categoryIds.length === 0) {
return;
}
if (!(await this.products.categoriesExist(categoryIds))) {
throw new ProductCategoryNotFoundError();
}
}
private async assertBrandExists(brandId: string | null): Promise<void> {
if (brandId === null) {
return;
}
if (!(await this.products.brandExists(brandId))) {
throw new ProductBrandNotFoundError();
}
}
}

View File

@@ -0,0 +1,78 @@
import type {
ProductRepository,
ProductRichDataRepository,
ProductVariantRepository,
} from '../domain/ports.js';
import type {
NewProductVariant,
ProductRichData,
ProductVariant,
ProductVariantPatch,
RichDataPatch,
} from '../domain/variant.js';
export class ListProductVariants {
constructor(private readonly variants: ProductVariantRepository) {}
async execute(productId: string): Promise<ProductVariant[]> {
return this.variants.listByProductId(productId);
}
}
export class CreateProductVariant {
constructor(
private readonly products: ProductRepository,
private readonly variants: ProductVariantRepository,
) {}
async execute(productId: string, input: NewProductVariant): Promise<ProductVariant | undefined> {
if (!(await this.products.findById(productId))) {
return undefined;
}
return this.variants.create(productId, input);
}
}
export class UpdateProductVariant {
constructor(private readonly variants: ProductVariantRepository) {}
async execute(
productId: string,
variantId: string,
patch: ProductVariantPatch,
): Promise<ProductVariant | undefined> {
return this.variants.update(productId, variantId, patch);
}
}
export class UpsertProductRichData {
constructor(
private readonly products: ProductRepository,
private readonly richData: ProductRichDataRepository,
) {}
async execute(productId: string, patch: RichDataPatch): Promise<ProductRichData | undefined> {
if (!(await this.products.findById(productId))) {
return undefined;
}
const current = await this.richData.findByProductId(productId);
const protectedPatch = protectManualNutrition(current, patch);
return this.richData.upsert(productId, protectedPatch);
}
}
export function protectManualNutrition(
current: ProductRichData | undefined,
patch: RichDataPatch,
): RichDataPatch {
if (
current?.nutritionSource === 'manual' &&
patch.nutrition !== undefined &&
patch.nutritionSource !== undefined &&
patch.nutritionSource !== 'manual'
) {
const { nutrition: _nutrition, nutritionSource: _nutritionSource, ...rest } = patch;
return rest;
}
return patch;
}

View File

@@ -0,0 +1,41 @@
export class ProductSlugAlreadyExistsError extends Error {
constructor() {
super('Product slug already exists');
this.name = 'ProductSlugAlreadyExistsError';
}
}
export class ProductCategoryNotFoundError extends Error {
constructor() {
super('Product category not found');
this.name = 'ProductCategoryNotFoundError';
}
}
export class ProductBrandNotFoundError extends Error {
constructor() {
super('Product brand not found');
this.name = 'ProductBrandNotFoundError';
}
}
export class ProductVariantCodeAlreadyExistsError extends Error {
constructor() {
super('Product variant SKU or EAN already exists');
this.name = 'ProductVariantCodeAlreadyExistsError';
}
}
export class ProductImageVariantMismatchError extends Error {
constructor() {
super('Product image variant does not belong to product');
this.name = 'ProductImageVariantMismatchError';
}
}
export class ProductImageMainAlreadyExistsError extends Error {
constructor() {
super('Main product image already exists for this scope');
this.name = 'ProductImageMainAlreadyExistsError';
}
}

View File

@@ -0,0 +1,33 @@
/** Product image domain model. */
export type ProductImageRole = 'main' | 'gallery';
export const PRODUCT_IMAGE_ROLES: readonly ProductImageRole[] = ['main', 'gallery'];
export interface ProductImage {
id: string;
productId: string;
variantId: string | null;
url: string;
altText: string;
position: number;
role: ProductImageRole;
createdAt: Date;
updatedAt: Date;
}
export interface NewProductImage {
url: string;
altText: string;
role: ProductImageRole;
variantId?: string | null;
position?: number;
}
export interface ProductImageOrderItem {
imageId: string;
position: number;
}
export interface ProductImageStorage {
normalizeUrl(url: string): string;
}

View File

@@ -0,0 +1,52 @@
import type { NewProductImage, ProductImage, ProductImageOrderItem } from './image.js';
import type { NewProduct, Product, ProductPatch, ProductSearchCriteria } from './product.js';
import type {
NewProductVariant,
ProductRichData,
ProductVariant,
ProductVariantPatch,
RichDataPatch,
} from './variant.js';
export interface ProductRepository {
findById(id: string): Promise<Product | undefined>;
findActiveBySlug(slug: string): Promise<Product | undefined>;
create(input: NewProduct): Promise<Product>;
update(id: string, patch: ProductPatch): Promise<Product | undefined>;
delete(id: string): Promise<void>;
listAll(options?: { limit?: number; offset?: number; q?: string }): Promise<{ items: Product[]; total: number }>;
categoriesExist(categoryIds: readonly string[]): Promise<boolean>;
brandExists(brandId: string): Promise<boolean>;
}
export interface ProductSearchRepository {
search(input: ProductSearchCriteria): Promise<Product[]>;
/**
* Returns suggested term corrections using trigram similarity.
* Falls back to brand/category names when no products match.
*/
suggestCorrections(term: string): Promise<string[]>;
}
export interface ProductVariantRepository {
listByProductId(productId: string): Promise<ProductVariant[]>;
create(productId: string, input: NewProductVariant): Promise<ProductVariant>;
update(
productId: string,
variantId: string,
patch: ProductVariantPatch,
): Promise<ProductVariant | undefined>;
}
export interface ProductRichDataRepository {
findByProductId(productId: string): Promise<ProductRichData | undefined>;
upsert(productId: string, patch: RichDataPatch): Promise<ProductRichData>;
}
export interface ProductImageRepository {
listByProductId(productId: string, variantId?: string | null): Promise<ProductImage[]>;
attach(productId: string, input: NewProductImage): Promise<ProductImage>;
detach(productId: string, imageId: string): Promise<boolean>;
reorder(productId: string, items: readonly ProductImageOrderItem[]): Promise<ProductImage[]>;
variantBelongsToProduct(productId: string, variantId: string): Promise<boolean>;
}

View File

@@ -0,0 +1,74 @@
/**
* Product domain model. Public storefront URLs use slug; id is internal.
*/
export type ProductState = 'draft' | 'active' | 'archived';
export const PRODUCT_STATES: readonly ProductState[] = ['draft', 'active', 'archived'];
/** Sales channels. */
export type SalesChannel = 'online' | 'offline' | 'all';
/** Product attribute tags (simple checkboxes). */
export const PRODUCT_ATTRIBUTES = [
'bio',
'comercio-justo',
'congelado',
'cruelty-free',
'de-temporada',
'demeter',
'fruta-verdura',
'keto',
'kosher',
'low-carb',
'raw-food',
'sin-azucar',
'sin-gluten',
'sin-lactosa',
'vegano',
'zero-waste',
] as const;
export type ProductAttribute = typeof PRODUCT_ATTRIBUTES[number];
export interface Product {
id: string;
name: string;
slug: string;
description: string | null;
state: ProductState;
channels: SalesChannel;
featured: boolean;
attributes: ProductAttribute[];
seoTitle: string | null;
seoDescription: string | null;
categoryIds: string[];
brandId: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface NewProduct {
name: string;
slug: string;
description?: string | null;
state?: ProductState;
channels?: SalesChannel;
featured?: boolean;
attributes?: ProductAttribute[];
seoTitle?: string | null;
seoDescription?: string | null;
categoryIds?: string[];
brandId?: string | null;
}
/** Fields a product update may set. Undefined = leave unchanged. */
export type ProductPatch = Partial<NewProduct>;
export interface ProductSearchCriteria {
q?: string;
limit: number;
offset: number;
activeOnly: boolean;
brandSlug?: string;
categorySlug?: string;
}

View File

@@ -0,0 +1,48 @@
/** Product variant and rich-data domain models. */
export type JsonRecord = Record<string, unknown>;
export type NutritionSource = 'manual' | 'manufacturer' | 'openfoodfacts';
export const NUTRITION_SOURCES: readonly NutritionSource[] = [
'manual',
'manufacturer',
'openfoodfacts',
];
export interface ProductVariant {
id: string;
productId: string;
sku: string;
ean: string | null;
attributes: JsonRecord;
createdAt: Date;
updatedAt: Date;
}
export interface NewProductVariant {
sku: string;
ean?: string | null;
attributes?: JsonRecord;
}
export type ProductVariantPatch = Partial<NewProductVariant>;
export interface ProductRichData {
productId: string;
ingredients: string | null;
allergens: string[];
nutrition: JsonRecord | null;
nutritionSource: NutritionSource | null;
isOrganic: boolean;
organicCertification: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface RichDataPatch {
ingredients?: string | null;
allergens?: string[];
nutrition?: JsonRecord | null;
nutritionSource?: NutritionSource;
isOrganic?: boolean;
organicCertification?: string | null;
}

View File

@@ -0,0 +1,2 @@
/** Public API of the catalog module. */
export { registerCatalogRoutes, type CatalogRoutesDeps } from './api/catalog.routes.js';

View File

@@ -0,0 +1,11 @@
import type { ProductImageStorage } from '../domain/image.js';
export class LocalProductImageStorage implements ProductImageStorage {
normalizeUrl(url: string): string {
const trimmed = url.trim();
if (trimmed.startsWith('/')) {
return trimmed;
}
return new URL(trimmed).toString();
}
}

View File

@@ -0,0 +1,151 @@
import type pg from 'pg';
import { ProductImageMainAlreadyExistsError } from '../domain/errors.js';
import type {
NewProductImage,
ProductImage,
ProductImageOrderItem,
ProductImageStorage,
} from '../domain/image.js';
import type { ProductImageRepository } from '../domain/ports.js';
interface ImageRow {
id: string;
product_id: string;
variant_id: string | null;
url: string;
alt_text: string;
position: number;
role: 'main' | 'gallery';
created_at: Date;
updated_at: Date;
}
const UNIQUE_VIOLATION = '23505';
export class PgProductImageRepository implements ProductImageRepository {
constructor(
private readonly pool: pg.Pool,
private readonly storage: ProductImageStorage,
) {}
async listByProductId(
productId: string,
variantId: string | null = null,
): Promise<ProductImage[]> {
const result = await this.pool.query<ImageRow>(
`SELECT * FROM catalog_product_images
WHERE product_id = $1 AND variant_id IS NOT DISTINCT FROM $2
ORDER BY position ASC, created_at ASC, id ASC`,
[productId, variantId],
);
return result.rows.map(toImage);
}
async attach(productId: string, input: NewProductImage): Promise<ProductImage> {
try {
const result = await this.pool.query<ImageRow>(
`INSERT INTO catalog_product_images (product_id, variant_id, url, alt_text, position, role)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[
productId,
input.variantId ?? null,
this.storage.normalizeUrl(input.url),
input.altText,
input.position ?? 0,
input.role,
],
);
const row = result.rows[0];
if (!row) {
throw new Error('catalog_product_images INSERT returned no row');
}
return toImage(row);
} catch (error) {
if (isPgError(error, UNIQUE_VIOLATION)) {
throw new ProductImageMainAlreadyExistsError();
}
throw error;
}
}
async detach(productId: string, imageId: string): Promise<boolean> {
const result = await this.pool.query(
'DELETE FROM catalog_product_images WHERE product_id = $1 AND id = $2',
[productId, imageId],
);
return (result.rowCount ?? 0) > 0;
}
async reorder(
productId: string,
items: readonly ProductImageOrderItem[],
): Promise<ProductImage[]> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const scope = await this.assertSingleScope(client, productId, items);
for (const item of items) {
await client.query(
`UPDATE catalog_product_images SET position = $1, updated_at = now()
WHERE product_id = $2 AND id = $3`,
[item.position, productId, item.imageId],
);
}
await client.query('COMMIT');
return this.listByProductId(productId, scope);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async variantBelongsToProduct(productId: string, variantId: string): Promise<boolean> {
const result = await this.pool.query<{ exists: boolean }>(
'SELECT EXISTS (SELECT 1 FROM catalog_product_variants WHERE id = $1 AND product_id = $2)',
[variantId, productId],
);
return result.rows[0]?.exists ?? false;
}
private async assertSingleScope(
client: pg.PoolClient,
productId: string,
items: readonly ProductImageOrderItem[],
): Promise<string | null> {
const imageIds = items.map((item) => item.imageId);
const result = await client.query<{ variant_id: string | null }>(
'SELECT variant_id FROM catalog_product_images WHERE product_id = $1 AND id = ANY($2::uuid[])',
[productId, imageIds],
);
if (result.rows.length !== imageIds.length) {
throw new Error('Product image reorder item not found');
}
const [first] = result.rows;
const scope = first?.variant_id ?? null;
if (!result.rows.every((row) => row.variant_id === scope)) {
throw new Error('Product image reorder items must belong to the same scope');
}
return scope;
}
}
function toImage(row: ImageRow): ProductImage {
return {
id: row.id,
productId: row.product_id,
variantId: row.variant_id,
url: row.url,
altText: row.alt_text,
position: row.position,
role: row.role,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function isPgError(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}

View File

@@ -0,0 +1,271 @@
import type pg from 'pg';
import { ProductBrandNotFoundError, ProductSlugAlreadyExistsError } from '../domain/errors.js';
import type { NewProduct, Product, ProductAttribute, ProductPatch, ProductState } from '../domain/product.js';
import type { ProductRepository } from '../domain/ports.js';
export interface ProductRow {
id: string;
name: string;
slug: string;
description: string | null;
state: ProductState;
channels: 'online' | 'offline' | 'all';
featured: boolean;
// JSONB from PostgreSQL; parsed as string[] by pg driver
attributes: unknown;
seo_title: string | null;
seo_description: string | null;
brand_id: string | null;
category_ids: string[] | null;
created_at: Date;
updated_at: Date;
}
const UNIQUE_VIOLATION = '23505';
const FOREIGN_KEY_VIOLATION = '23503';
export const PRODUCT_COLUMNS = `
p.*,
COALESCE(
array_agg(pc.category_id ORDER BY pc.category_id) FILTER (WHERE pc.category_id IS NOT NULL),
ARRAY[]::uuid[]
) AS category_ids
`;
const UPDATABLE: ReadonlyArray<[keyof ProductPatch, string]> = [
['name', 'name'],
['slug', 'slug'],
['description', 'description'],
['state', 'state'],
['channels', 'channels'],
['featured', 'featured'],
['attributes', 'attributes'],
['seoTitle', 'seo_title'],
['seoDescription', 'seo_description'],
['brandId', 'brand_id'],
];
export class PgProductRepository implements ProductRepository {
constructor(private readonly pool: pg.Pool) {}
async findById(id: string): Promise<Product | undefined> {
const result = await this.pool.query<ProductRow>(
`SELECT ${PRODUCT_COLUMNS}
FROM catalog_products p
LEFT JOIN catalog_product_categories pc ON pc.product_id = p.id
WHERE p.id = $1
GROUP BY p.id`,
[id],
);
const row = result.rows[0];
return row ? toProduct(row) : undefined;
}
async findActiveBySlug(slug: string): Promise<Product | undefined> {
const result = await this.pool.query<ProductRow>(
`SELECT ${PRODUCT_COLUMNS}
FROM catalog_products p
LEFT JOIN catalog_product_categories pc ON pc.product_id = p.id
WHERE p.slug = $1 AND p.state = 'active'
GROUP BY p.id`,
[slug],
);
const row = result.rows[0];
return row ? toProduct(row) : undefined;
}
async create(input: NewProduct): Promise<Product> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const result = await client.query<ProductRow>(
`INSERT INTO catalog_products (name, slug, description, state, seo_title, seo_description, brand_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *, ARRAY[]::uuid[] AS category_ids`,
[
input.name,
input.slug,
input.description ?? null,
input.state ?? 'draft',
input.seoTitle ?? null,
input.seoDescription ?? null,
input.brandId ?? null,
],
);
const row = result.rows[0];
if (!row) {
throw new Error('catalog_products INSERT returned no row');
}
await this.replaceCategories(client, row.id, input.categoryIds ?? []);
await client.query('COMMIT');
return (await this.findById(row.id)) ?? toProduct(row);
} catch (error) {
await client.query('ROLLBACK');
throw mapPgError(error);
} finally {
client.release();
}
}
async update(id: string, patch: ProductPatch): Promise<Product | undefined> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const setClauses: string[] = [];
const values: unknown[] = [];
for (const [key, column] of UPDATABLE) {
if (key in patch) {
values.push(patch[key]);
setClauses.push(`${column} = $${values.length}`);
}
}
let exists = true;
if (setClauses.length > 0) {
values.push(id);
const result = await client.query(
`UPDATE catalog_products SET ${setClauses.join(', ')}, updated_at = now()
WHERE id = $${values.length}`,
values,
);
exists = (result.rowCount ?? 0) > 0;
} else {
const found = await client.query('SELECT 1 FROM catalog_products WHERE id = $1', [id]);
exists = (found.rowCount ?? 0) > 0;
}
if (!exists) {
await client.query('COMMIT');
return undefined;
}
if (patch.categoryIds !== undefined) {
await this.replaceCategories(client, id, patch.categoryIds);
}
await client.query('COMMIT');
return this.findById(id);
} catch (error) {
await client.query('ROLLBACK');
throw mapPgError(error);
} finally {
client.release();
}
}
async delete(id: string): Promise<void> {
await this.pool.query('DELETE FROM catalog_products WHERE id = $1', [id]);
}
async listAll(options?: {
limit?: number;
offset?: number;
q?: string;
}): Promise<{ items: Product[]; total: number }> {
const limit = options?.limit ?? 20;
const offset = options?.offset ?? 0;
const q = options?.q?.trim();
const countResult = await this.pool.query<{ count: string }>(
q
? `SELECT COUNT(*) FROM catalog_products WHERE name ILIKE $1`
: 'SELECT COUNT(*) FROM catalog_products',
q ? [`%${q}%`] : [],
);
const total = parseInt(countResult.rows[0]?.count ?? '0', 10);
const rows = await this.pool.query<ProductRow>(
q
? `SELECT ${PRODUCT_COLUMNS}
FROM catalog_products p
LEFT JOIN catalog_product_categories pc ON pc.product_id = p.id
WHERE p.name ILIKE $1
GROUP BY p.id
ORDER BY p.created_at DESC
LIMIT $2 OFFSET $3`
: `SELECT ${PRODUCT_COLUMNS}
FROM catalog_products p
LEFT JOIN catalog_product_categories pc ON pc.product_id = p.id
GROUP BY p.id
ORDER BY p.created_at DESC
LIMIT $1 OFFSET $2`,
q ? [`%${q}%`, limit, offset] : [limit, offset],
);
return { items: rows.rows.map(toProduct), total };
}
async brandExists(brandId: string): Promise<boolean> {
const result = await this.pool.query<{ exists: boolean }>(
'SELECT EXISTS (SELECT 1 FROM brands_brands WHERE id = $1)',
[brandId],
);
return result.rows[0]?.exists ?? false;
}
async categoriesExist(categoryIds: readonly string[]): Promise<boolean> {
const uniqueIds = [...new Set(categoryIds)];
if (uniqueIds.length === 0) {
return true;
}
const result = await this.pool.query<{ count: string }>(
'SELECT count(*)::int AS count FROM categories_categories WHERE id = ANY($1::uuid[])',
[uniqueIds],
);
return Number(result.rows[0]?.count ?? 0) === uniqueIds.length;
}
private async replaceCategories(
client: pg.PoolClient,
productId: string,
categoryIds: readonly string[],
): Promise<void> {
await client.query('DELETE FROM catalog_product_categories WHERE product_id = $1', [productId]);
const uniqueIds = [...new Set(categoryIds)];
for (const categoryId of uniqueIds) {
await client.query(
'INSERT INTO catalog_product_categories (product_id, category_id) VALUES ($1, $2)',
[productId, categoryId],
);
}
}
}
export function toProduct(row: ProductRow): Product {
return {
id: row.id,
name: row.name,
slug: row.slug,
description: row.description,
state: row.state,
channels: row.channels,
featured: row.featured,
attributes: Array.isArray(row.attributes)
? (row.attributes as unknown[]).filter((a): a is ProductAttribute =>
typeof a === 'string' && [
'bio', 'comercio-justo', 'congelado', 'cruelty-free', 'de-temporada',
'demeter', 'fruta-verdura', 'keto', 'kosher', 'low-carb', 'raw-food',
'sin-azucar', 'sin-gluten', 'sin-lactosa', 'vegano', 'zero-waste',
].includes(a),
)
: [],
seoTitle: row.seo_title,
seoDescription: row.seo_description,
brandId: row.brand_id,
categoryIds: row.category_ids ?? [],
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function mapPgError(error: unknown): Error {
if (isPgError(error, UNIQUE_VIOLATION)) {
return new ProductSlugAlreadyExistsError();
}
if (isPgError(error, FOREIGN_KEY_VIOLATION)) {
return new ProductBrandNotFoundError();
}
return error instanceof Error ? error : new Error('Unknown product repository error');
}
function isPgError(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}

View File

@@ -0,0 +1,124 @@
import type pg from 'pg';
import type { Product, ProductSearchCriteria } from '../domain/product.js';
import type { ProductSearchRepository } from '../domain/ports.js';
import { PRODUCT_COLUMNS, type ProductRow, toProduct } from './pg-product-repository.js';
export class PgProductSearchRepository implements ProductSearchRepository {
constructor(private readonly pool: pg.Pool) {}
async search(input: ProductSearchCriteria): Promise<Product[]> {
const clauses: string[] = [];
const values: unknown[] = [];
const q = input.q?.trim();
if (input.activeOnly) {
clauses.push(`p.state = 'active'`);
}
if (input.brandSlug !== undefined && input.brandSlug.trim() !== '') {
values.push(input.brandSlug.trim());
clauses.push(`b.slug = $${values.length}`);
}
if (input.categorySlug !== undefined && input.categorySlug.trim() !== '') {
values.push(input.categorySlug.trim());
clauses.push(
`EXISTS (
SELECT 1 FROM catalog_product_categories filter_pc
JOIN categories_categories filter_c ON filter_c.id = filter_pc.category_id
WHERE filter_pc.product_id = p.id AND filter_c.slug = $${values.length}
)`,
);
}
const baseWhere = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
const searchWhere: string[] = [];
let rankSelect = '0::real AS search_rank';
let orderBy = 'created_at DESC, name ASC, id ASC';
if (q !== undefined && q !== '') {
// ILIKE on name: handles partial stems (e.g. 'almen' vs 'almendra').
// tsquery adds full-word relevance ranking.
// Word-by-word ILIKE fallback for fuzzy partial matching (FE-048).
values.push(`%${q}%`);
const ilikeParam = values.length;
values.push(q);
const tsqueryParam = values.length;
const tsquery = `websearch_to_tsquery('spanish', $${tsqueryParam})`;
// Word-level fuzzy: split query into words, match each against name/description.
const words = q.split(/\s+/).filter((w) => w.length >= 2);
const wordConditions = words
.map((w) => `(name ILIKE $${ilikeParam} OR description ILIKE $${ilikeParam})`)
.join(' AND ');
const wordFallback = words.length > 0 ? `(${wordConditions})` : 'TRUE';
searchWhere.push(
`(name ILIKE $${ilikeParam} OR description ILIKE $${ilikeParam} OR search_doc @@ ${tsquery} OR ${wordFallback})`,
);
// Composite ranking: text relevance + starts-with bonus
rankSelect = `
ts_rank_cd(search_doc, ${tsquery}) +
(CASE WHEN name ILIKE $${ilikeParam} THEN 1.0 ELSE 0 END) +
(CASE WHEN name ILIKE $${ilikeParam} THEN 0.5 ELSE 0 END)
AS search_rank`;
orderBy = 'search_rank DESC, created_at DESC, name ASC, id ASC';
}
values.push(input.limit, input.offset);
const where = searchWhere.length > 0 ? `WHERE ${searchWhere.join(' AND ')}` : '';
const result = await this.pool.query<ProductRow>(
`WITH search_basis AS (
SELECT
${PRODUCT_COLUMNS},
setweight(to_tsvector('spanish', COALESCE(p.name, '')), 'A') ||
setweight(to_tsvector('spanish', COALESCE(p.description, '')), 'B') ||
setweight(to_tsvector('spanish', COALESCE(p.seo_title, '')), 'B') ||
setweight(to_tsvector('spanish', COALESCE(p.seo_description, '')), 'C') ||
setweight(to_tsvector('spanish', COALESCE(b.name, '')), 'A') ||
setweight(to_tsvector('spanish', COALESCE(b.slug, '')), 'B') ||
setweight(to_tsvector('spanish', COALESCE(b.seo_title, '')), 'B') ||
setweight(to_tsvector('spanish', COALESCE(b.seo_description, '')), 'C') ||
setweight(to_tsvector('spanish', COALESCE(string_agg(DISTINCT c.name, ' '), '')), 'A') ||
setweight(to_tsvector('spanish', COALESCE(string_agg(DISTINCT c.slug, ' '), '')), 'B') ||
setweight(to_tsvector('spanish', COALESCE(string_agg(DISTINCT c.seo_title, ' '), '')), 'B') ||
setweight(to_tsvector('spanish', COALESCE(string_agg(DISTINCT c.seo_description, ' '), '')), 'C')
AS search_doc
FROM catalog_products p
LEFT JOIN brands_brands b ON b.id = p.brand_id
LEFT JOIN catalog_product_categories pc ON pc.product_id = p.id
LEFT JOIN categories_categories c ON c.id = pc.category_id
${baseWhere}
GROUP BY p.id, b.id
)
SELECT *, ${rankSelect}
FROM search_basis
${where}
ORDER BY ${orderBy}
LIMIT $${values.length - 1} OFFSET $${values.length}`,
values,
);
return result.rows.map(toProduct);
}
/** FE-048: Suggest term corrections via word-level ILIKE fallback. */
async suggestCorrections(term: string): Promise<string[]> {
const q = term.trim();
if (q.length < 2) return [];
// Find distinct words from products, brands, categories that share prefix with term
const result = await this.pool.query<{ suggestion: string }>(
`SELECT DISTINCT suggestion FROM (
SELECT DISTINCT LOWER(unnest(string_to_array(name, ' '))) AS suggestion
FROM catalog_products WHERE state = 'active'
UNION ALL
SELECT DISTINCT LOWER(unnest(string_to_array(name, ' '))) AS suggestion
FROM brands_brands
UNION ALL
SELECT DISTINCT LOWER(unnest(string_to_array(name, ' '))) AS suggestion
FROM categories_categories
) t
WHERE suggestion LIKE $1 AND suggestion <> $2
ORDER BY suggestion
LIMIT 5`,
[`${q}%`, q],
);
return result.rows.map((r) => r.suggestion);
}
}

View File

@@ -0,0 +1,90 @@
import type pg from 'pg';
import type { ProductRichDataRepository } from '../domain/ports.js';
import type { NutritionSource, ProductRichData, RichDataPatch } from '../domain/variant.js';
interface RichDataRow {
product_id: string;
ingredients: string | null;
allergens: string[];
nutrition: Record<string, unknown> | null;
nutrition_source: NutritionSource | null;
is_organic: boolean;
organic_certification: string | null;
created_at: Date;
updated_at: Date;
}
export class PgProductRichDataRepository implements ProductRichDataRepository {
constructor(private readonly pool: pg.Pool) {}
async findByProductId(productId: string): Promise<ProductRichData | undefined> {
const result = await this.pool.query<RichDataRow>(
'SELECT * FROM catalog_product_rich_data WHERE product_id = $1',
[productId],
);
const row = result.rows[0];
return row ? toRichData(row) : undefined;
}
async upsert(productId: string, patch: RichDataPatch): Promise<ProductRichData> {
const current = await this.findByProductId(productId);
const next = {
ingredients:
patch.ingredients !== undefined ? patch.ingredients : (current?.ingredients ?? null),
allergens: patch.allergens !== undefined ? patch.allergens : (current?.allergens ?? []),
nutrition: patch.nutrition !== undefined ? patch.nutrition : (current?.nutrition ?? null),
nutritionSource:
patch.nutritionSource !== undefined
? patch.nutritionSource
: (current?.nutritionSource ?? null),
isOrganic: patch.isOrganic !== undefined ? patch.isOrganic : (current?.isOrganic ?? false),
organicCertification:
patch.organicCertification !== undefined
? patch.organicCertification
: (current?.organicCertification ?? null),
};
const result = await this.pool.query<RichDataRow>(
`INSERT INTO catalog_product_rich_data
(product_id, ingredients, allergens, nutrition, nutrition_source, is_organic, organic_certification)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (product_id) DO UPDATE SET
ingredients = EXCLUDED.ingredients,
allergens = EXCLUDED.allergens,
nutrition = EXCLUDED.nutrition,
nutrition_source = EXCLUDED.nutrition_source,
is_organic = EXCLUDED.is_organic,
organic_certification = EXCLUDED.organic_certification,
updated_at = now()
RETURNING *`,
[
productId,
next.ingredients,
next.allergens,
next.nutrition,
next.nutritionSource,
next.isOrganic,
next.organicCertification,
],
);
const row = result.rows[0];
if (!row) {
throw new Error('catalog_product_rich_data UPSERT returned no row');
}
return toRichData(row);
}
}
function toRichData(row: RichDataRow): ProductRichData {
return {
productId: row.product_id,
ingredients: row.ingredients,
allergens: row.allergens,
nutrition: row.nutrition,
nutritionSource: row.nutrition_source,
isOrganic: row.is_organic,
organicCertification: row.organic_certification,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}

View File

@@ -0,0 +1,118 @@
import type pg from 'pg';
import { ProductVariantCodeAlreadyExistsError } from '../domain/errors.js';
import type { ProductVariantRepository } from '../domain/ports.js';
import type { NewProductVariant, ProductVariant, ProductVariantPatch } from '../domain/variant.js';
interface VariantRow {
id: string;
product_id: string;
sku: string;
ean: string | null;
attributes: Record<string, unknown>;
created_at: Date;
updated_at: Date;
}
const UNIQUE_VIOLATION = '23505';
const UPDATABLE: ReadonlyArray<[keyof ProductVariantPatch, string]> = [
['sku', 'sku'],
['ean', 'ean'],
['attributes', 'attributes'],
];
export class PgProductVariantRepository implements ProductVariantRepository {
constructor(private readonly pool: pg.Pool) {}
async listByProductId(productId: string): Promise<ProductVariant[]> {
const result = await this.pool.query<VariantRow>(
'SELECT * FROM catalog_product_variants WHERE product_id = $1 ORDER BY created_at, sku',
[productId],
);
return result.rows.map(toVariant);
}
async create(productId: string, input: NewProductVariant): Promise<ProductVariant> {
try {
const result = await this.pool.query<VariantRow>(
`INSERT INTO catalog_product_variants (product_id, sku, ean, attributes)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[productId, input.sku, input.ean ?? null, input.attributes ?? {}],
);
const row = result.rows[0];
if (!row) {
throw new Error('catalog_product_variants INSERT returned no row');
}
return toVariant(row);
} catch (error) {
if (isPgError(error, UNIQUE_VIOLATION)) {
throw new ProductVariantCodeAlreadyExistsError();
}
throw error;
}
}
async update(
productId: string,
variantId: string,
patch: ProductVariantPatch,
): Promise<ProductVariant | undefined> {
const setClauses: string[] = [];
const values: unknown[] = [];
for (const [key, column] of UPDATABLE) {
if (key in patch) {
values.push(patch[key]);
setClauses.push(`${column} = $${values.length}`);
}
}
if (setClauses.length === 0) {
return this.findScoped(productId, variantId);
}
values.push(productId, variantId);
try {
const result = await this.pool.query<VariantRow>(
`UPDATE catalog_product_variants SET ${setClauses.join(', ')}, updated_at = now()
WHERE product_id = $${values.length - 1} AND id = $${values.length}
RETURNING *`,
values,
);
const row = result.rows[0];
return row ? toVariant(row) : undefined;
} catch (error) {
if (isPgError(error, UNIQUE_VIOLATION)) {
throw new ProductVariantCodeAlreadyExistsError();
}
throw error;
}
}
private async findScoped(
productId: string,
variantId: string,
): Promise<ProductVariant | undefined> {
const result = await this.pool.query<VariantRow>(
'SELECT * FROM catalog_product_variants WHERE product_id = $1 AND id = $2',
[productId, variantId],
);
const row = result.rows[0];
return row ? toVariant(row) : undefined;
}
}
function toVariant(row: VariantRow): ProductVariant {
return {
id: row.id,
productId: row.product_id,
sku: row.sku,
ean: row.ean,
attributes: row.attributes,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function isPgError(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}

View File

@@ -0,0 +1,16 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('catalog domain purity', () => {
it('has zero database or HTTP imports (AC1)', () => {
const domainDir = new URL('../domain', import.meta.url);
const files = readdirSync(domainDir).filter((file) => file.endsWith('.ts'));
for (const file of files) {
const source = readFileSync(join(domainDir.pathname, file), 'utf8');
expect(source).not.toMatch(/from ['"](?:pg|fastify|node:http)/);
expect(source).not.toMatch(/from ['"].*(?:api|infrastructure)/);
}
});
});

View File

@@ -0,0 +1,187 @@
import { describe, expect, it } from 'vitest';
import { AttachProductImage, ListProductImages } from '../application/image-use-cases.js';
import { ProductImageVariantMismatchError } from '../domain/errors.js';
import type { NewProductImage, ProductImage, ProductImageOrderItem } from '../domain/image.js';
import type { NewProduct, Product, ProductPatch } from '../domain/product.js';
import type { ProductImageRepository, ProductRepository } from '../domain/ports.js';
function product(input: Partial<Product> & Pick<Product, 'id' | 'name' | 'slug'>): Product {
return {
description: null,
state: 'draft',
channels: 'all',
featured: false,
attributes: [],
seoTitle: null,
seoDescription: null,
categoryIds: [],
brandId: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...input,
};
}
function image(
input: Partial<ProductImage> & Pick<ProductImage, 'id' | 'productId' | 'url'>,
): ProductImage {
return {
variantId: null,
altText: 'Alt text',
position: 0,
role: 'gallery',
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...input,
};
}
class FakeProductRepository implements ProductRepository {
constructor(private readonly products: Product[]) {}
async findById(id: string): Promise<Product | undefined> {
return this.products.find((item) => item.id === id);
}
async findActiveBySlug(slug: string): Promise<Product | undefined> {
return this.products.find((item) => item.slug === slug && item.state === 'active');
}
async create(input: NewProduct): Promise<Product> {
const created = product({
id: `prod-${this.products.length + 1}`,
name: input.name,
slug: input.slug,
});
this.products.push(created);
return created;
}
async update(id: string, patch: ProductPatch): Promise<Product | undefined> {
const current = await this.findById(id);
if (!current) return undefined;
Object.assign(current, patch);
return current;
}
async categoriesExist(_categoryIds: readonly string[]): Promise<boolean> {
return true;
}
async brandExists(_brandId: string): Promise<boolean> {
return true;
}
async delete(_id: string): Promise<void> {
// noop for tests
}
async listAll(options?: { limit?: number; offset?: number; q?: string }): Promise<{ items: Product[]; total: number }> {
const limit = options?.limit ?? 20;
const offset = options?.offset ?? 0;
let items = [...this.products];
if (options?.q) items = items.filter((p) => p.name.toLowerCase().includes(options.q!.toLowerCase()));
return { items: items.slice(offset, offset + limit), total: items.length };
}
}
class FakeImageRepository implements ProductImageRepository {
constructor(
private readonly images: ProductImage[],
private readonly validVariants: readonly string[] = [],
) {}
async listByProductId(
productId: string,
variantId: string | null = null,
): Promise<ProductImage[]> {
return this.images
.filter((item) => item.productId === productId && item.variantId === variantId)
.sort((a, b) => a.position - b.position || a.id.localeCompare(b.id));
}
async attach(productId: string, input: NewProductImage): Promise<ProductImage> {
const created = image({
id: `img-${this.images.length + 1}`,
productId,
variantId: input.variantId ?? null,
url: input.url,
altText: input.altText,
position: input.position ?? 0,
role: input.role,
});
this.images.push(created);
return created;
}
async detach(productId: string, imageId: string): Promise<boolean> {
const index = this.images.findIndex(
(item) => item.productId === productId && item.id === imageId,
);
if (index < 0) return false;
this.images.splice(index, 1);
return true;
}
async reorder(
productId: string,
items: readonly ProductImageOrderItem[],
): Promise<ProductImage[]> {
for (const item of items) {
const current = this.images.find(
(imageItem) => imageItem.productId === productId && imageItem.id === item.imageId,
);
if (current) current.position = item.position;
}
return this.listByProductId(productId);
}
async variantBelongsToProduct(_productId: string, variantId: string): Promise<boolean> {
return this.validVariants.includes(variantId);
}
}
describe('product image use cases', () => {
it('lists product images in stable order with alt text', async () => {
const images = new FakeImageRepository([
image({ id: 'b', productId: 'prod', url: '/b.jpg', altText: 'Second', position: 2 }),
image({ id: 'a', productId: 'prod', url: '/a.jpg', altText: 'First', position: 1 }),
]);
const result = await new ListProductImages(images).execute('prod');
expect(result.map((item) => ({ url: item.url, altText: item.altText }))).toEqual([
{ url: '/a.jpg', altText: 'First' },
{ url: '/b.jpg', altText: 'Second' },
]);
});
it('returns undefined when attaching an image to a missing product', async () => {
const products = new FakeProductRepository([]);
const images = new FakeImageRepository([]);
const result = await new AttachProductImage(products, images).execute('missing', {
url: '/image.jpg',
altText: 'Image',
role: 'main',
});
expect(result).toBeUndefined();
});
it('rejects a variant image when the variant does not belong to the product', async () => {
const products = new FakeProductRepository([
product({ id: 'prod', name: 'Aceite', slug: 'aceite' }),
]);
const images = new FakeImageRepository([], ['variant-owned-by-prod']);
await expect(
new AttachProductImage(products, images).execute('prod', {
url: '/variant.jpg',
altText: 'Variant',
role: 'gallery',
variantId: 'other-variant',
}),
).rejects.toBeInstanceOf(ProductImageVariantMismatchError);
});
});

View File

@@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest';
import { CreateProduct, SearchProducts, UpdateProduct } from '../application/product-use-cases.js';
import { ProductCategoryNotFoundError } from '../domain/errors.js';
import type {
NewProduct,
Product,
ProductPatch,
ProductSearchCriteria,
} from '../domain/product.js';
import type { ProductRepository, ProductSearchRepository } from '../domain/ports.js';
function product(input: Partial<Product> & Pick<Product, 'id' | 'name' | 'slug'>): Product {
return {
description: null,
state: 'draft',
channels: 'all',
featured: false,
attributes: [],
seoTitle: null,
seoDescription: null,
categoryIds: [],
brandId: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...input,
};
}
class FakeProductRepository implements ProductRepository {
constructor(
private readonly products: Product[],
private readonly knownCategoryIds: readonly string[] = [],
) {}
async findById(id: string): Promise<Product | undefined> {
return this.products.find((item) => item.id === id);
}
async findActiveBySlug(slug: string): Promise<Product | undefined> {
return this.products.find((item) => item.slug === slug && item.state === 'active');
}
async create(input: NewProduct): Promise<Product> {
const created = product({
id: `prod-${this.products.length + 1}`,
name: input.name,
slug: input.slug,
description: input.description ?? null,
state: input.state ?? 'draft',
seoTitle: input.seoTitle ?? null,
seoDescription: input.seoDescription ?? null,
categoryIds: input.categoryIds ?? [],
brandId: input.brandId ?? null,
});
this.products.push(created);
return created;
}
async update(id: string, patch: ProductPatch): Promise<Product | undefined> {
const current = await this.findById(id);
if (!current) {
return undefined;
}
Object.assign(current, patch);
return current;
}
async categoriesExist(categoryIds: readonly string[]): Promise<boolean> {
return categoryIds.every((id) => this.knownCategoryIds.includes(id));
}
async brandExists(brandId: string): Promise<boolean> {
return brandId === 'known-brand';
}
async delete(_id: string): Promise<void> {
// noop for tests
}
async listAll(options?: { limit?: number; offset?: number; q?: string }): Promise<{ items: Product[]; total: number }> {
const limit = options?.limit ?? 20;
const offset = options?.offset ?? 0;
let items = [...this.products];
if (options?.q) items = items.filter((p) => p.name.toLowerCase().includes(options.q!.toLowerCase()));
return { items: items.slice(offset, offset + limit), total: items.length };
}
}
class FakeProductSearchRepository implements ProductSearchRepository {
public lastInput: ProductSearchCriteria | undefined;
constructor(private readonly products: Product[]) {}
async search(input: ProductSearchCriteria): Promise<Product[]> {
this.lastInput = input;
return this.products.filter((item) => !input.activeOnly || item.state === 'active');
}
async suggestCorrections(_term: string): Promise<string[]> {
return [];
}
}
describe('product use cases', () => {
it('search returns only active products', async () => {
const repo = new FakeProductSearchRepository([
product({ id: 'draft', name: 'Draft', slug: 'draft', state: 'draft' }),
product({ id: 'active', name: 'Active', slug: 'active', state: 'active' }),
product({ id: 'archived', name: 'Archived', slug: 'archived', state: 'archived' }),
]);
const result = await new SearchProducts(repo).execute({
q: 'active',
limit: 10,
offset: 5,
brandSlug: 'marca',
categorySlug: 'categoria',
});
expect(result.map((item) => item.slug)).toEqual(['active']);
expect(repo.lastInput).toEqual({
q: 'active',
limit: 10,
offset: 5,
activeOnly: true,
brandSlug: 'marca',
categorySlug: 'categoria',
});
});
it('create defaults product state to draft', async () => {
const repo = new FakeProductRepository([]);
const created = await new CreateProduct(repo).execute({ name: 'Aceite', slug: 'aceite' });
expect(created.state).toBe('draft');
});
it('create and update reject unknown category ids', async () => {
const repo = new FakeProductRepository(
[product({ id: 'prod', name: 'Aceite', slug: 'aceite' })],
['known-category'],
);
await expect(
new CreateProduct(repo).execute({ name: 'Pan', slug: 'pan', categoryIds: ['missing'] }),
).rejects.toBeInstanceOf(ProductCategoryNotFoundError);
await expect(
new UpdateProduct(repo).execute('prod', { categoryIds: ['missing'] }),
).rejects.toBeInstanceOf(ProductCategoryNotFoundError);
});
});

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { protectManualNutrition } from '../application/variant-use-cases.js';
import type { ProductRichData } from '../domain/variant.js';
function richData(input: Partial<ProductRichData> = {}): ProductRichData {
return {
productId: 'product-id',
ingredients: null,
allergens: [],
nutrition: null,
nutritionSource: null,
isOrganic: false,
organicCertification: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...input,
};
}
describe('rich data use cases', () => {
it('keeps manual nutrition when an external source tries to overwrite it', () => {
const current = richData({
nutrition: { calories: 100 },
nutritionSource: 'manual',
});
const patch = protectManualNutrition(current, {
nutrition: { calories: 200 },
nutritionSource: 'openfoodfacts',
ingredients: 'Updated ingredients',
});
expect(patch).toEqual({ ingredients: 'Updated ingredients' });
});
it('allows manual nutrition to overwrite external nutrition', () => {
const current = richData({
nutrition: { calories: 100 },
nutritionSource: 'openfoodfacts',
});
const patch = protectManualNutrition(current, {
nutrition: { calories: 200 },
nutritionSource: 'manual',
});
expect(patch).toEqual({ nutrition: { calories: 200 }, nutritionSource: 'manual' });
});
});

View File

@@ -0,0 +1,151 @@
import type { FastifyInstance } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import { requireRole, type Authenticate } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
import { parseJson } from '../../../shared/http-input.js';
import {
CreateCategory,
DeleteCategory,
GetCategoryBySlug,
ListCategoryTree,
UpdateCategory,
} from '../application/category-use-cases.js';
import type { Category, CategoryTreeNode } from '../domain/category.js';
import {
CategoryParentNotFoundError,
CategorySlugAlreadyExistsError,
CategoryTreeCycleError,
} from '../domain/errors.js';
import { PgCategoryRepository } from '../infrastructure/pg-category-repository.js';
export interface CategoriesRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
}
const slugSchema = z
.string()
.min(1)
.max(160)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
const slugParamSchema = z.object({ slug: slugSchema });
const idParamSchema = z.object({ id: z.uuid() });
const newCategorySchema = z.object({
parentId: z.uuid().optional().nullable(),
name: z.string().min(1).max(200),
slug: slugSchema,
seoTitle: z.string().min(1).max(200).optional().nullable(),
seoDescription: z.string().min(1).max(500).optional().nullable(),
});
const categoryPatchSchema = newCategorySchema
.partial()
.refine((value) => Object.values(value).some((field) => field !== undefined), {
message: 'At least one category field is required',
});
export async function registerCategoriesRoutes(
app: FastifyInstance,
deps: CategoriesRoutesDeps,
): Promise<void> {
const repository = new PgCategoryRepository(deps.pool);
const getBySlug = new GetCategoryBySlug(repository);
const listTree = new ListCategoryTree(repository);
const createCategory = new CreateCategory(repository);
const updateCategory = new UpdateCategory(repository);
const deleteCategory = new DeleteCategory(repository);
app.get('/categories/tree', async (_request, reply) => {
const items = await listTree.execute();
return reply.send({ items: items.map(serializeTreeNode) });
});
app.get('/categoria/:slug', async (request, reply) => {
const { slug } = parseJson(slugParamSchema, request.params);
const category = await getBySlug.execute(slug);
if (!category) {
throw new AppError(404, 'NOT_FOUND', 'Category not found');
}
return reply.send(serializeCategory(category));
});
app.post('/categories', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const input = parseJson(newCategorySchema, request.body);
try {
const category = await createCategory.execute(input);
return reply.code(201).send(serializeCategory(category));
} catch (error) {
throw mapCategoryError(error);
}
});
app.patch('/categories/:id', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const patch = parseJson(categoryPatchSchema, request.body);
try {
const category = await updateCategory.execute(id, patch);
if (!category) {
throw new AppError(404, 'NOT_FOUND', 'Category not found');
}
return reply.send(serializeCategory(category));
} catch (error) {
throw mapCategoryError(error);
}
});
app.delete('/categories/:id', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(idParamSchema, request.params);
const result = await deleteCategory.execute(id);
if (result === 'not_found') {
throw new AppError(404, 'NOT_FOUND', 'Category not found');
}
if (result === 'has_children') {
throw new AppError(409, 'CATEGORY_HAS_CHILDREN', 'Category has child categories');
}
return reply.code(204).send();
});
}
function mapCategoryError(error: unknown): Error {
if (error instanceof CategorySlugAlreadyExistsError) {
return new AppError(409, 'CATEGORY_SLUG_EXISTS', error.message);
}
if (error instanceof CategoryParentNotFoundError) {
return new AppError(422, 'CATEGORY_PARENT_NOT_FOUND', error.message);
}
if (error instanceof CategoryTreeCycleError) {
return new AppError(422, 'CATEGORY_TREE_CYCLE', error.message);
}
return error instanceof Error ? error : new Error('Unknown category error');
}
function serializeCategory(category: Category) {
return {
id: category.id,
parentId: category.parentId,
name: category.name,
slug: category.slug,
url: `/categoria/${category.slug}`,
seoTitle: category.seoTitle,
seoDescription: category.seoDescription,
createdAt: category.createdAt.toISOString(),
updatedAt: category.updatedAt.toISOString(),
};
}
function serializeTreeNode(category: CategoryTreeNode): ReturnType<typeof serializeCategory> & {
children: ReturnType<typeof serializeTreeNode>[];
} {
return {
...serializeCategory(category),
children: category.children.map(serializeTreeNode),
};
}

View File

@@ -0,0 +1,105 @@
import type { Category, CategoryPatch, CategoryTreeNode, NewCategory } from '../domain/category.js';
import { CategoryParentNotFoundError, CategoryTreeCycleError } from '../domain/errors.js';
import type { CategoryRepository } from '../domain/ports.js';
export class GetCategoryBySlug {
constructor(private readonly categories: CategoryRepository) {}
async execute(slug: string): Promise<Category | undefined> {
return this.categories.findBySlug(slug);
}
}
export class ListCategoryTree {
constructor(private readonly categories: CategoryRepository) {}
async execute(): Promise<CategoryTreeNode[]> {
return buildTree(await this.categories.list());
}
}
export class CreateCategory {
constructor(private readonly categories: CategoryRepository) {}
async execute(input: NewCategory): Promise<Category> {
await this.assertParentExists(input.parentId);
return this.categories.create(input);
}
private async assertParentExists(parentId: string | null | undefined): Promise<void> {
if (parentId === undefined || parentId === null) {
return;
}
const parent = await this.categories.findById(parentId);
if (!parent) {
throw new CategoryParentNotFoundError();
}
}
}
export class UpdateCategory {
constructor(private readonly categories: CategoryRepository) {}
async execute(id: string, patch: CategoryPatch): Promise<Category | undefined> {
if (patch.parentId !== undefined) {
await this.assertValidParent(id, patch.parentId);
}
return this.categories.update(id, patch);
}
private async assertValidParent(id: string, parentId: string | null): Promise<void> {
if (parentId === null) {
return;
}
if (parentId === id) {
throw new CategoryTreeCycleError();
}
const parent = await this.categories.findById(parentId);
if (!parent) {
throw new CategoryParentNotFoundError();
}
const parentIsDescendant = await this.categories.isDescendant(id, parentId);
if (parentIsDescendant) {
throw new CategoryTreeCycleError();
}
}
}
export type DeleteCategoryResult = 'deleted' | 'not_found' | 'has_children';
export class DeleteCategory {
constructor(private readonly categories: CategoryRepository) {}
async execute(id: string): Promise<DeleteCategoryResult> {
const category = await this.categories.findById(id);
if (!category) {
return 'not_found';
}
if (await this.categories.hasChildren(id)) {
return 'has_children';
}
return (await this.categories.delete(id)) ? 'deleted' : 'not_found';
}
}
function buildTree(categories: Category[]): CategoryTreeNode[] {
const nodes = new Map<string, CategoryTreeNode>();
for (const category of categories) {
nodes.set(category.id, { ...category, children: [] });
}
const roots: CategoryTreeNode[] = [];
for (const node of nodes.values()) {
if (node.parentId === null) {
roots.push(node);
continue;
}
const parent = nodes.get(node.parentId);
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}

View File

@@ -0,0 +1,28 @@
/**
* Category domain model. Public storefront URLs use slug; id is internal.
*/
export interface Category {
id: string;
parentId: string | null;
name: string;
slug: string;
seoTitle: string | null;
seoDescription: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface NewCategory {
parentId?: string | null;
name: string;
slug: string;
seoTitle?: string | null;
seoDescription?: string | null;
}
/** Fields a category update may set. Undefined = leave unchanged; parentId null = move to root. */
export type CategoryPatch = Partial<NewCategory>;
export interface CategoryTreeNode extends Category {
children: CategoryTreeNode[];
}

View File

@@ -0,0 +1,20 @@
export class CategorySlugAlreadyExistsError extends Error {
constructor() {
super('Category slug already exists');
this.name = 'CategorySlugAlreadyExistsError';
}
}
export class CategoryParentNotFoundError extends Error {
constructor() {
super('Category parent not found');
this.name = 'CategoryParentNotFoundError';
}
}
export class CategoryTreeCycleError extends Error {
constructor() {
super('Category parent would create a cycle');
this.name = 'CategoryTreeCycleError';
}
}

View File

@@ -0,0 +1,12 @@
import type { Category, CategoryPatch, NewCategory } from './category.js';
export interface CategoryRepository {
list(): Promise<Category[]>;
findById(id: string): Promise<Category | undefined>;
findBySlug(slug: string): Promise<Category | undefined>;
create(input: NewCategory): Promise<Category>;
update(id: string, patch: CategoryPatch): Promise<Category | undefined>;
delete(id: string): Promise<boolean>;
hasChildren(id: string): Promise<boolean>;
isDescendant(candidateAncestorId: string, candidateDescendantId: string): Promise<boolean>;
}

View File

@@ -0,0 +1,2 @@
/** Public API of the categories module. */
export { registerCategoriesRoutes, type CategoriesRoutesDeps } from './api/categories.routes.js';

View File

@@ -0,0 +1,156 @@
import type pg from 'pg';
import type { Category, CategoryPatch, NewCategory } from '../domain/category.js';
import { CategorySlugAlreadyExistsError } from '../domain/errors.js';
import type { CategoryRepository } from '../domain/ports.js';
interface CategoryRow {
id: string;
parent_id: string | null;
name: string;
slug: string;
seo_title: string | null;
seo_description: string | null;
created_at: Date;
updated_at: Date;
}
const UNIQUE_VIOLATION = '23505';
const UPDATABLE: ReadonlyArray<[keyof CategoryPatch, string]> = [
['parentId', 'parent_id'],
['name', 'name'],
['slug', 'slug'],
['seoTitle', 'seo_title'],
['seoDescription', 'seo_description'],
];
export class PgCategoryRepository implements CategoryRepository {
constructor(private readonly pool: pg.Pool) {}
async list(): Promise<Category[]> {
const result = await this.pool.query<CategoryRow>(
`SELECT * FROM categories_categories ORDER BY parent_id NULLS FIRST, name, created_at`,
);
return result.rows.map(toCategory);
}
async findById(id: string): Promise<Category | undefined> {
const result = await this.pool.query<CategoryRow>(
'SELECT * FROM categories_categories WHERE id = $1',
[id],
);
const row = result.rows[0];
return row ? toCategory(row) : undefined;
}
async findBySlug(slug: string): Promise<Category | undefined> {
const result = await this.pool.query<CategoryRow>(
'SELECT * FROM categories_categories WHERE slug = $1',
[slug],
);
const row = result.rows[0];
return row ? toCategory(row) : undefined;
}
async create(input: NewCategory): Promise<Category> {
try {
const result = await this.pool.query<CategoryRow>(
`INSERT INTO categories_categories (parent_id, name, slug, seo_title, seo_description)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[
input.parentId ?? null,
input.name,
input.slug,
input.seoTitle ?? null,
input.seoDescription ?? null,
],
);
const row = result.rows[0];
if (!row) {
throw new Error('categories_categories INSERT returned no row');
}
return toCategory(row);
} catch (error) {
if (isPgError(error, UNIQUE_VIOLATION)) {
throw new CategorySlugAlreadyExistsError();
}
throw error;
}
}
async update(id: string, patch: CategoryPatch): Promise<Category | undefined> {
const setClauses: string[] = [];
const values: unknown[] = [];
for (const [key, column] of UPDATABLE) {
if (key in patch) {
values.push(patch[key]);
setClauses.push(`${column} = $${values.length}`);
}
}
if (setClauses.length === 0) {
return this.findById(id);
}
values.push(id);
try {
const result = await this.pool.query<CategoryRow>(
`UPDATE categories_categories SET ${setClauses.join(', ')}, updated_at = now()
WHERE id = $${values.length}
RETURNING *`,
values,
);
const row = result.rows[0];
return row ? toCategory(row) : undefined;
} catch (error) {
if (isPgError(error, UNIQUE_VIOLATION)) {
throw new CategorySlugAlreadyExistsError();
}
throw error;
}
}
async delete(id: string): Promise<boolean> {
const result = await this.pool.query('DELETE FROM categories_categories WHERE id = $1', [id]);
return (result.rowCount ?? 0) > 0;
}
async hasChildren(id: string): Promise<boolean> {
const result = await this.pool.query<{ exists: boolean }>(
'SELECT EXISTS (SELECT 1 FROM categories_categories WHERE parent_id = $1)',
[id],
);
return result.rows[0]?.exists ?? false;
}
async isDescendant(candidateAncestorId: string, candidateDescendantId: string): Promise<boolean> {
const result = await this.pool.query<{ exists: boolean }>(
`WITH RECURSIVE descendants AS (
SELECT id FROM categories_categories WHERE parent_id = $1
UNION ALL
SELECT c.id FROM categories_categories c
INNER JOIN descendants d ON c.parent_id = d.id
)
SELECT EXISTS (SELECT 1 FROM descendants WHERE id = $2)`,
[candidateAncestorId, candidateDescendantId],
);
return result.rows[0]?.exists ?? false;
}
}
function toCategory(row: CategoryRow): Category {
return {
id: row.id,
parentId: row.parent_id,
name: row.name,
slug: row.slug,
seoTitle: row.seo_title,
seoDescription: row.seo_description,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
function isPgError(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
}

View File

@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest';
import {
CreateCategory,
ListCategoryTree,
UpdateCategory,
} from '../application/category-use-cases.js';
import type { Category, CategoryPatch, NewCategory } from '../domain/category.js';
import { CategoryParentNotFoundError, CategoryTreeCycleError } from '../domain/errors.js';
import type { CategoryRepository } from '../domain/ports.js';
function category(input: Partial<Category> & Pick<Category, 'id' | 'name' | 'slug'>): Category {
return {
parentId: null,
seoTitle: null,
seoDescription: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
...input,
};
}
class FakeCategoryRepository implements CategoryRepository {
constructor(private readonly categories: Category[]) {}
async list(): Promise<Category[]> {
return this.categories;
}
async findById(id: string): Promise<Category | undefined> {
return this.categories.find((item) => item.id === id);
}
async findBySlug(slug: string): Promise<Category | undefined> {
return this.categories.find((item) => item.slug === slug);
}
async create(input: NewCategory): Promise<Category> {
const created = category({
id: `cat-${this.categories.length + 1}`,
parentId: input.parentId ?? null,
name: input.name,
slug: input.slug,
seoTitle: input.seoTitle ?? null,
seoDescription: input.seoDescription ?? null,
});
this.categories.push(created);
return created;
}
async update(id: string, patch: CategoryPatch): Promise<Category | undefined> {
const current = await this.findById(id);
if (!current) {
return undefined;
}
Object.assign(current, patch);
return current;
}
async delete(id: string): Promise<boolean> {
const index = this.categories.findIndex((item) => item.id === id);
if (index === -1) {
return false;
}
this.categories.splice(index, 1);
return true;
}
async hasChildren(id: string): Promise<boolean> {
return this.categories.some((item) => item.parentId === id);
}
async isDescendant(candidateAncestorId: string, candidateDescendantId: string): Promise<boolean> {
let current = await this.findById(candidateDescendantId);
while (current?.parentId) {
if (current.parentId === candidateAncestorId) {
return true;
}
current = await this.findById(current.parentId);
}
return false;
}
}
describe('category use cases', () => {
it('builds a parent/child tree', async () => {
const repo = new FakeCategoryRepository([
category({ id: 'root', name: 'Alimentación', slug: 'alimentacion' }),
category({ id: 'child', parentId: 'root', name: 'Aceites', slug: 'aceites' }),
]);
const tree = await new ListCategoryTree(repo).execute();
expect(tree).toHaveLength(1);
expect(tree[0]?.slug).toBe('alimentacion');
expect(tree[0]?.children[0]?.slug).toBe('aceites');
});
it('rejects unknown parent on create', async () => {
const repo = new FakeCategoryRepository([]);
await expect(
new CreateCategory(repo).execute({ parentId: 'missing', name: 'Aceites', slug: 'aceites' }),
).rejects.toBeInstanceOf(CategoryParentNotFoundError);
});
it('rejects self-parent and descendant-as-parent updates', async () => {
const repo = new FakeCategoryRepository([
category({ id: 'root', name: 'Alimentación', slug: 'alimentacion' }),
category({ id: 'child', parentId: 'root', name: 'Aceites', slug: 'aceites' }),
category({ id: 'grandchild', parentId: 'child', name: 'Oliva', slug: 'oliva' }),
]);
const update = new UpdateCategory(repo);
await expect(update.execute('root', { parentId: 'root' })).rejects.toBeInstanceOf(
CategoryTreeCycleError,
);
await expect(update.execute('root', { parentId: 'grandchild' })).rejects.toBeInstanceOf(
CategoryTreeCycleError,
);
});
});

View File

@@ -0,0 +1,118 @@
import type { FastifyInstance } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import type { Authenticate } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
import { parseJson } from '../../../shared/http-input.js';
import { CartService } from '../../cart/index.js';
import { PgCartRepository } from '../../cart/infrastructure/pg-cart-repository.js';
import { createInventoryService } from '../../inventory/index.js';
import { OrderService } from '../../orders/index.js';
import { PgOrderRepository } from '../../orders/infrastructure/pg-order-repository.js';
import { NoOpOrderEventPublisher } from '../../orders/infrastructure/no-op-event-publisher.js';
import { createPricingService } from '../../pricing/index.js';
import { createShippingService } from '../../shipping/index.js';
import { CheckoutService } from '../application/checkout-service.js';
import { CheckoutError, type CheckoutResult } from '../domain/checkout.js';
import { InMemoryCheckoutMetrics } from '../infrastructure/metrics.js';
import { createNoOpTelemetry, type Tracer } from '../../observability/index.js';
import { StubPaymentProvider } from '../infrastructure/payment-provider.js';
export interface CheckoutRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
tracer?: Tracer;
}
const checkoutBodySchema = z
.object({
address: z.object({
country: z.string().min(2).max(80),
postalCode: z.string().min(1).max(20),
}),
promoCode: z.string().min(1).max(64).optional().nullable(),
idempotencyKey: z.string().min(1).max(120),
})
.strip();
export async function registerCheckoutRoutes(
app: FastifyInstance,
deps: CheckoutRoutesDeps,
): Promise<void> {
const pricing = createPricingService(deps.pool);
const inventory = createInventoryService(deps.pool);
const shipping = createShippingService(deps.pool);
const orders = new OrderService(new PgOrderRepository(deps.pool), new NoOpOrderEventPublisher());
const cart = new CartService(new PgCartRepository(deps.pool), pricing, inventory);
const metrics = new InMemoryCheckoutMetrics();
const tracer = deps.tracer ?? createNoOpTelemetry().tracer;
const orderLookup = new PgIdempotencyLookup(deps.pool);
const service = new CheckoutService({
cart,
pricing,
inventory,
shipping,
orders,
payments: new StubPaymentProvider(),
orderLookup,
metrics,
tracer,
});
app.post('/checkout', async (request, reply) => {
const user = await deps.authenticate(request);
const input = parseJson(checkoutBodySchema, request.body);
try {
const result = await service.execute({
userId: user.id,
address: input.address,
promoCode: input.promoCode ?? null,
idempotencyKey: input.idempotencyKey,
});
return reply.send(serializeResult(result));
} catch (error) {
throw mapCheckoutError(error);
}
});
}
class PgIdempotencyLookup {
constructor(private readonly pool: pg.Pool) {}
async findByIdempotency(
userId: string,
idempotencyKey: string,
): Promise<{ orderId: string } | undefined> {
const result = await this.pool.query<{ id: string }>(
'SELECT id FROM orders_orders WHERE user_id = $1 AND idempotency_key = $2',
[userId, idempotencyKey],
);
const row = result.rows[0];
return row ? { orderId: row.id } : undefined;
}
}
function mapCheckoutError(error: unknown): Error {
if (error instanceof CheckoutError)
return new AppError(error.httpStatus, error.code, error.message);
return error instanceof Error ? error : new Error('Unknown checkout error');
}
function serializeResult(result: CheckoutResult) {
return {
order: {
id: result.order.id,
state: result.order.state,
totalCents: result.order.totalCents,
items: result.order.items.map((item) => ({
productId: item.productId,
variantId: item.variantId,
quantity: item.quantity,
unitPriceCents: item.unitPriceCents,
taxCents: item.taxCents,
})),
},
paymentIntent: result.paymentIntent,
reservedVariantIds: result.reservedVariantIds,
};
}

View File

@@ -0,0 +1,199 @@
import type { InventoryServicePort } from '../../inventory/index.js';
import type { OrderItemInput, OrderServicePort } from '../../orders/index.js';
import type { PricingServicePort } from '../../pricing/index.js';
import type { PromotionServicePort } from '../../promotions/index.js';
import type { ShippingAddress, ShippingServicePort } from '../../shipping/index.js';
import { CheckoutError, type CheckoutResult, type PaymentIntent } from '../domain/checkout.js';
import type { Tracer } from '../../observability/index.js';
import type { CheckoutMetrics, PaymentProvider, CheckoutOrderLookup } from '../domain/ports.js';
export interface CartLike {
userId: string;
items: Array<{ productId: string; variantId: string; quantity: number }>;
}
export interface CartServicePort {
getCart(userId: string): Promise<CartLike>;
}
export interface CheckoutServiceDeps {
cart: CartServicePort;
pricing: PricingServicePort;
promotions?: PromotionServicePort;
inventory: InventoryServicePort;
shipping: ShippingServicePort;
orders: OrderServicePort;
payments: PaymentProvider;
orderLookup: CheckoutOrderLookup;
metrics: CheckoutMetrics;
tracer?: Tracer;
}
export interface CheckoutCommand {
userId: string;
address: ShippingAddress;
promoCode?: string | null;
idempotencyKey: string;
}
export class CheckoutService {
constructor(private readonly deps: CheckoutServiceDeps) {}
async execute(command: CheckoutCommand): Promise<CheckoutResult> {
const span = this.deps.tracer?.startSpan('checkout.execute') ?? {
end: () => undefined,
setError: () => undefined,
};
try {
const idempotentOrderId = (
await this.deps.orderLookup.findByIdempotency(command.userId, command.idempotencyKey)
)?.orderId;
if (idempotentOrderId) {
const existing = await this.deps.orders.getOrder(idempotentOrderId, command.userId);
if (!existing)
throw new CheckoutError(
'CHECKOUT_IDEMPOTENCY_CONFLICT',
'Idempotency key already used',
409,
);
return {
order: existing,
paymentIntent: {
id: `pi_${idempotentOrderId}`,
reference: `ref_${idempotentOrderId}`,
status: 'requires_payment',
},
reservedVariantIds: existing.items.map((item) => item.variantId),
};
}
const cart = await this.deps.cart.getCart(command.userId);
if (cart.items.length === 0) {
this.deps.metrics.incFailure();
throw new CheckoutError('CHECKOUT_CART_EMPTY', 'Cart is empty', 409);
}
let netSubtotalCents = 0;
let taxCents = 0;
const itemInputs: OrderItemInput[] = [];
for (const cartItem of cart.items) {
const calculation = await this.deps.pricing
.calculate({ variantId: cartItem.variantId, quantity: cartItem.quantity })
.catch((error: unknown) => {
if (error instanceof Error && error.name === 'PriceNotFoundError') return null;
throw error;
});
if (!calculation) {
this.deps.metrics.incFailure();
throw new CheckoutError(
'CHECKOUT_PRICE_MISSING',
`Price missing for variant ${cartItem.variantId}`,
409,
);
}
const availability = await this.deps.inventory.checkAvailability(
cartItem.variantId,
cartItem.quantity,
);
if (!availability.available) {
this.deps.metrics.incFailure();
throw new CheckoutError(
'CHECKOUT_STOCK_UNAVAILABLE',
`Variant ${cartItem.variantId} is out of stock`,
409,
);
}
netSubtotalCents += calculation.netSubtotalCents;
taxCents += calculation.vatAmountCents;
itemInputs.push({
productId: cartItem.productId,
variantId: cartItem.variantId,
sku: cartItem.variantId,
ean: null,
name: `Variant ${cartItem.variantId.slice(0, 8)}`,
unitPriceCents: calculation.netUnitAmountCents,
discountCents: 0,
taxCents: calculation.vatAmountCents,
quantity: cartItem.quantity,
});
}
let discountCents = 0;
if (command.promoCode && this.deps.promotions) {
try {
const applied = await this.deps.promotions.calculateDiscount(
command.promoCode,
netSubtotalCents + taxCents,
);
discountCents = applied.discountCents;
} catch (error) {
this.deps.metrics.incFailure();
throw new CheckoutError('CHECKOUT_PROMO_INVALID', (error as Error).message, 422);
}
}
const shipping = await this.deps.shipping
.calculate(Math.max(0, netSubtotalCents + taxCents - discountCents), command.address)
.catch((error: unknown) => {
if (error instanceof Error && error.name === 'ShippingZoneNotFoundError') return null;
throw error;
});
if (!shipping) {
this.deps.metrics.incFailure();
throw new CheckoutError(
'CHECKOUT_SHIPPING_ZONE_NOT_FOUND',
'Shipping zone not found for address',
422,
);
}
const subtotalCents = netSubtotalCents + taxCents;
const totalCents = Math.max(0, subtotalCents - discountCents) + shipping.costCents;
const orderView = await this.deps.orders.create({
userId: command.userId,
idempotencyKey: command.idempotencyKey,
items: itemInputs,
totals: { subtotalCents, discountCents, taxCents, totalCents },
});
const reserved: string[] = [];
try {
for (const item of cart.items) {
await this.deps.inventory.reserve({ variantId: item.variantId, quantity: item.quantity });
reserved.push(item.variantId);
}
} catch (error) {
for (const variantId of reserved) {
await this.deps.inventory.release({ variantId, quantity: 1 }).catch(() => undefined);
}
await this.deps.orders
.transition(orderView.id, 'CANCELLED', command.userId)
.catch(() => undefined);
this.deps.metrics.incFailure();
throw new CheckoutError('CHECKOUT_RESERVATION_FAILED', (error as Error).message, 409);
}
await this.deps.orders
.transition(orderView.id, 'AWAITING_PAYMENT', command.userId)
.catch(() => undefined);
const refreshed =
(await this.deps.orders.getOrder(orderView.id, command.userId)) ?? orderView;
const paymentIntent: PaymentIntent = await this.deps.payments.createIntent({
orderId: refreshed.id,
amountCents: refreshed.totalCents,
currency: 'EUR',
});
this.deps.metrics.incSuccess();
this.deps.metrics.checkoutCompleted(totalCents);
return { order: refreshed, paymentIntent, reservedVariantIds: reserved };
} catch (error) {
span.setError(error);
throw error;
} finally {
span.end();
}
}
}

View File

@@ -0,0 +1,38 @@
import type { ShippingAddress } from '../../shipping/index.js';
import type { OrderView } from '../../orders/index.js';
export interface CheckoutItem {
variantId: string;
quantity: number;
}
export interface CheckoutRequest {
items?: never;
itemsByVariant?: never;
address: ShippingAddress;
promoCode?: string | null;
idempotencyKey: string;
}
export interface PaymentIntent {
id: string;
reference: string;
status: 'requires_payment' | 'cancelled' | 'succeeded';
}
export interface CheckoutResult {
order: OrderView;
paymentIntent: PaymentIntent;
reservedVariantIds: string[];
}
export class CheckoutError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly httpStatus: number = 409,
) {
super(message);
this.name = code;
}
}

View File

@@ -0,0 +1,22 @@
import type { PaymentIntent } from './checkout.js';
export interface CheckoutMetrics {
incSuccess(): void;
incFailure(): void;
checkoutCompleted(amountCents: number): void;
}
export interface PaymentProvider {
createIntent(input: {
orderId: string;
amountCents: number;
currency: 'EUR';
}): Promise<PaymentIntent>;
}
export interface CheckoutOrderLookup {
findByIdempotency(
userId: string,
idempotencyKey: string,
): Promise<{ orderId: string } | undefined>;
}

View File

@@ -0,0 +1,12 @@
/** Public API of the checkout module. */
export { registerCheckoutRoutes, type CheckoutRoutesDeps } from './api/checkout.routes.js';
export { CheckoutService } from './application/checkout-service.js';
export {
CheckoutError,
type CheckoutRequest,
type CheckoutResult,
type PaymentIntent,
} from './domain/checkout.js';
export type { CheckoutMetrics, PaymentProvider, CheckoutOrderLookup } from './domain/ports.js';
export { InMemoryCheckoutMetrics } from './infrastructure/metrics.js';
export { StubPaymentProvider } from './infrastructure/payment-provider.js';

View File

@@ -0,0 +1,16 @@
import type { CheckoutMetrics } from '../domain/ports.js';
/** Simple in-process counter. */
export class InMemoryCheckoutMetrics implements CheckoutMetrics {
success = 0;
failure = 0;
incSuccess(): void {
this.success += 1;
}
incFailure(): void {
this.failure += 1;
}
checkoutCompleted(_amountCents: number): void {
this.success += 0;
}
}

View File

@@ -0,0 +1,17 @@
import type { PaymentProvider } from '../domain/ports.js';
import type { PaymentIntent } from '../domain/checkout.js';
/** Stub payment provider for v1. F-023 will replace with Stripe adapter. */
export class StubPaymentProvider implements PaymentProvider {
async createIntent(input: {
orderId: string;
amountCents: number;
currency: 'EUR';
}): Promise<PaymentIntent> {
return {
id: `pi_${input.orderId}`,
reference: `ref_${input.orderId}`,
status: 'requires_payment',
};
}
}

View File

@@ -0,0 +1,34 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((entry) => {
const path = join(dir, entry);
return statSync(path).isDirectory() ? sourceFiles(path) : path.endsWith('.ts') ? [path] : [];
});
}
const SQL_TABLES = /\bcart_|\binventory_|\bpricing_|\bshipping_|\bpromotions_|\busers_/;
describe('checkout persistence boundary', () => {
it('does not reference other module tables in code or SQL', () => {
const dir = new URL('..', import.meta.url);
for (const file of sourceFiles(dir.pathname)) {
const source = readFileSync(file, 'utf8');
// Allow imports of public interfaces from sibling modules but not
// hardcoded SQL table references.
expect(source).not.toMatch(SQL_TABLES);
}
});
it('does not import internals of sibling modules', () => {
const dir = new URL('..', import.meta.url);
for (const file of sourceFiles(dir.pathname)) {
const source = readFileSync(file, 'utf8');
expect(source).not.toMatch(
/modules\/(cart|inventory|pricing|promotions|shipping|orders)\/(api|application|infrastructure|tests)/,
);
}
});
});

View File

@@ -0,0 +1,304 @@
import { describe, expect, it } from 'vitest';
import { CheckoutService } from '../application/checkout-service.js';
import { CheckoutError } from '../domain/checkout.js';
import type { CheckoutOrderLookup, PaymentProvider } from '../domain/ports.js';
import { InMemoryCheckoutMetrics } from '../infrastructure/metrics.js';
import { StubPaymentProvider } from '../infrastructure/payment-provider.js';
import type { InventoryServicePort } from '../../inventory/index.js';
import type {
CreateOrderCommand,
OrderItemInput,
OrderServicePort,
OrderState,
OrderView,
} from '../../orders/index.js';
import type { PricingServicePort } from '../../pricing/index.js';
import type { ShippingServicePort } from '../../shipping/index.js';
const PRICE = {
variantId: 'v-1',
quantity: 1,
currency: 'EUR' as const,
vatRate: 'general' as const,
vatBasisPoints: 2100,
netUnitAmountCents: 1000,
netSubtotalCents: 1000,
vatAmountCents: 210,
totalCents: 1210,
};
interface Deps {
service: CheckoutService;
metrics: InMemoryCheckoutMetrics;
reserved: { calls: Array<{ variantId: string; quantity: number }> };
released: { calls: Array<{ variantId: string; quantity: number }> };
cancelCalls: Array<{ id: string; state: OrderState }>;
}
function buildDeps(
overrides: Partial<{
stock: boolean;
promo: boolean;
shipping: boolean;
orderId: string;
idempotency?: { orderId: string };
}> = {},
): Deps {
const cart = {
getCart: async () => ({
userId: 'u-1',
items: [{ productId: 'p-1', variantId: 'v-1', quantity: 1 }],
}),
};
const pricing: PricingServicePort = {
getVariantPrice: async () => undefined,
setVariantPrice: async () => ({}) as never,
calculate: async () => PRICE,
};
const reserved = { calls: [] as Array<{ variantId: string; quantity: number }> };
const released = { calls: [] as Array<{ variantId: string; quantity: number }> };
const cancelCalls: Array<{ id: string; state: OrderState }> = [];
const inventory: InventoryServicePort = {
checkAvailability: async () => ({ available: overrides.stock ?? true, availableQuantity: 10 }),
reserve: async (input) => {
reserved.calls.push(input);
return { ...PRICE } as never;
},
release: async (input) => {
released.calls.push(input);
return { ...PRICE } as never;
},
confirm: async () => ({}) as never,
setAvailable: async () => ({}) as never,
};
const shipping: ShippingServicePort = {
calculate: async () =>
overrides.shipping === false
? Promise.reject(
Object.assign(new Error('not found'), { name: 'ShippingZoneNotFoundError' }),
)
: Promise.resolve({
zoneId: 'z-1',
methodId: 'm-1',
methodName: 'Standard',
costCents: 500,
freeApplied: false,
}),
};
const orderId = overrides.orderId ?? 'order-1';
const orders: OrderServicePort = {
create: async (input: CreateOrderCommand) => ({
id: orderId,
userId: input.userId,
idempotencyKey: input.idempotencyKey ?? null,
state: 'AWAITING_PAYMENT',
currency: 'EUR',
subtotalCents: input.totals.subtotalCents,
discountCents: input.totals.discountCents,
taxCents: input.totals.taxCents,
totalCents: input.totals.totalCents,
createdAt: new Date(),
updatedAt: new Date(),
items: input.items.map((item: OrderItemInput, index: number) => ({
id: `i-${index}`,
orderId,
productId: item.productId,
variantId: item.variantId,
sku: item.sku,
ean: null,
name: item.name,
unitPriceCents: item.unitPriceCents,
discountCents: item.discountCents,
taxCents: item.taxCents,
quantity: item.quantity,
createdAt: new Date(),
})),
}),
listOrders: async () => [],
transition: async (id, state) => {
cancelCalls.push({ id, state });
return {
id,
userId: 'u-1',
state,
currency: 'EUR',
idempotencyKey: 'k-1',
subtotalCents: 1210,
discountCents: 0,
taxCents: 210,
totalCents: 1710,
createdAt: new Date(),
updatedAt: new Date(),
items: [],
} satisfies OrderView;
},
getOrder: async (id) => ({
id,
userId: 'u-1',
state: 'AWAITING_PAYMENT',
currency: 'EUR',
idempotencyKey: 'k-1',
subtotalCents: 1210,
discountCents: 0,
taxCents: 210,
totalCents: 1710,
createdAt: new Date(),
updatedAt: new Date(),
items: [],
}),
getOrderAdmin: async (id) => ({
id,
userId: 'u-1',
state: 'AWAITING_PAYMENT',
currency: 'EUR',
idempotencyKey: 'k-1',
subtotalCents: 1210,
discountCents: 0,
taxCents: 210,
totalCents: 1710,
createdAt: new Date(),
updatedAt: new Date(),
items: [],
}),
transitionAdmin: async (id, state) => ({
id,
userId: 'u-1',
state,
currency: 'EUR',
idempotencyKey: 'k-1',
subtotalCents: 1210,
discountCents: 0,
taxCents: 210,
totalCents: 1710,
createdAt: new Date(),
updatedAt: new Date(),
items: [],
}),
};
const orderLookup: CheckoutOrderLookup = { findByIdempotency: async () => overrides.idempotency };
const payments: PaymentProvider = new StubPaymentProvider();
const metrics = new InMemoryCheckoutMetrics();
const service = new CheckoutService({
cart: cart as never,
pricing,
inventory,
shipping,
orders,
payments,
orderLookup,
metrics,
});
return { service, metrics, reserved, released, cancelCalls };
}
describe('CheckoutService', () => {
it('returns 409 when stock is unavailable (AC1)', async () => {
const { service } = buildDeps({ stock: false });
await expect(
service.execute({
userId: 'u-1',
address: { country: 'ES', postalCode: '28001' },
idempotencyKey: 'k-1',
}),
).rejects.toBeInstanceOf(CheckoutError);
});
it('creates an AWAITING_PAYMENT order with reserved stock on success', async () => {
const deps = buildDeps();
const result = await deps.service.execute({
userId: 'u-1',
address: { country: 'ES', postalCode: '28001' },
idempotencyKey: 'k-1',
});
expect(result.order.state).toBe('AWAITING_PAYMENT');
expect(deps.reserved.calls).toEqual([{ variantId: 'v-1', quantity: 1 }]);
expect(deps.metrics.success).toBe(1);
});
it('returns the same order on idempotent retry without reserving twice (AC2)', async () => {
const dep2 = buildDeps();
const lookup: CheckoutOrderLookup = { findByIdempotency: async () => ({ orderId: 'order-x' }) };
const service2 = new CheckoutService({
cart: {
getCart: async () => ({
userId: 'u-1',
items: [{ productId: 'p-1', variantId: 'v-1', quantity: 1 }],
}),
} as never,
pricing: {
getVariantPrice: async () => undefined,
setVariantPrice: async () => ({}) as never,
calculate: async () => PRICE,
},
inventory: {
checkAvailability: async () => ({ available: true, availableQuantity: 10 }),
reserve: async (i) => {
dep2.reserved.calls.push(i);
return {} as never;
},
release: async () => ({}) as never,
confirm: async () => ({}) as never,
setAvailable: async () => ({}) as never,
},
shipping: {
calculate: async () => ({
zoneId: 'z',
methodId: 'm',
methodName: 'Standard',
costCents: 0,
freeApplied: true,
}),
},
orders: {
create: async () => {
throw new Error('should not create on retry');
},
transition: async () => {
throw new Error('should not transition on retry');
},
listOrders: async () => [],
getOrderAdmin: async () => { throw new Error('not used'); },
transitionAdmin: async () => { throw new Error('not used'); },
getOrder: async (id) => ({
id,
userId: 'u-1',
state: 'AWAITING_PAYMENT',
currency: 'EUR',
idempotencyKey: 'k-1',
subtotalCents: 1210,
discountCents: 0,
taxCents: 210,
totalCents: 1710,
createdAt: new Date(),
updatedAt: new Date(),
items: [
{
id: 'i-1',
orderId: id,
productId: 'p-1',
variantId: 'v-1',
sku: 'v-1',
ean: null,
name: 'x',
unitPriceCents: 1000,
discountCents: 0,
taxCents: 210,
quantity: 1,
createdAt: new Date(),
},
],
}),
} as OrderServicePort,
payments: new StubPaymentProvider(),
orderLookup: lookup,
metrics: dep2.metrics,
});
const second = await service2.execute({
userId: 'u-1',
address: { country: 'ES', postalCode: '28001' },
idempotencyKey: 'k-1',
});
expect(second.order.id).toBe('order-x');
expect(dep2.reserved.calls).toHaveLength(0);
});
});

View File

@@ -0,0 +1,135 @@
import type { FastifyInstance } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import type { Authenticate } from '../../../shared/auth.js';
import { requireRole } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
import { parseJson } from '../../../shared/http-input.js';
import { CmsService } from '../application/cms-service.js';
import { DuplicateSlugError, PageNotFoundError, PageNotPublishedError } from '../domain/errors.js';
import type { Page } from '../domain/page.js';
import { PgCmsRepository } from '../infrastructure/pg-cms-repository.js';
export interface CmsRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
}
const createSchema = z
.object({
slug: z
.string()
.min(1)
.max(160)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'slug must be kebab-case'),
title: z.string().min(1).max(200),
body: z.string().min(1).max(50000),
})
.strip();
const updateSchema = z
.object({
slug: z
.string()
.min(1)
.max(160)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
.optional(),
title: z.string().min(1).max(200).optional(),
body: z.string().min(1).max(50000).optional(),
})
.strip();
const idParamSchema = z.object({ id: z.uuid() });
const slugParamSchema = z.object({ slug: z.string().min(1).max(160) });
export async function registerCmsRoutes(app: FastifyInstance, deps: CmsRoutesDeps): Promise<void> {
const service = new CmsService(new PgCmsRepository(deps.pool));
app.get('/cms/pages', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const pages = await service.listAll();
return reply.send({ items: pages.map(serialize) });
});
app.post('/cms/pages', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const input = parseJson(createSchema, request.body);
try {
const page = await service.create(input);
return reply.code(201).send(serialize(page));
} catch (error) {
throw mapCmsError(error);
}
});
app.patch('/cms/pages/:id', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = idParamSchema.parse(request.params);
const patch = parseJson(updateSchema, request.body);
try {
const page = await service.update(id, patch);
return reply.send(serialize(page));
} catch (error) {
throw mapCmsError(error);
}
});
app.post('/cms/pages/:id/publish', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = idParamSchema.parse(request.params);
try {
const page = await service.publish(id);
return reply.send(serialize(page));
} catch (error) {
throw mapCmsError(error);
}
});
app.post('/cms/pages/:id/unpublish', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = idParamSchema.parse(request.params);
try {
const page = await service.unpublish(id);
return reply.send(serialize(page));
} catch (error) {
throw mapCmsError(error);
}
});
app.get('/cms/pages/:slug', async (request, reply) => {
const { slug } = slugParamSchema.parse(request.params);
try {
const page = await service.getPublicPageBySlug(slug);
return reply.send(serialize(page));
} catch (error) {
throw mapCmsError(error);
}
});
}
function mapCmsError(error: unknown): Error {
if (error instanceof DuplicateSlugError)
return new AppError(409, 'CMS_DUPLICATE_SLUG', error.message);
if (error instanceof PageNotPublishedError)
return new AppError(404, 'CMS_NOT_FOUND', error.message);
if (error instanceof PageNotFoundError) return new AppError(404, 'CMS_NOT_FOUND', error.message);
return error instanceof Error ? error : new Error('Unknown cms error');
}
function serialize(page: Page) {
return {
id: page.id,
slug: page.slug,
title: page.title,
body: page.body,
status: page.status,
createdAt: page.createdAt.toISOString(),
updatedAt: page.updatedAt.toISOString(),
};
}

View File

@@ -0,0 +1,42 @@
import { DuplicateSlugError, PageNotFoundError, PageNotPublishedError } from '../domain/errors.js';
import type { CmsRepository } from '../domain/ports.js';
import type { CreatePageInput, Page, UpdatePageInput } from '../domain/page.js';
export class CmsService {
constructor(private readonly repo: CmsRepository) {}
async create(input: CreatePageInput): Promise<Page> {
const created = await this.repo.create(input);
if (!created) throw new DuplicateSlugError();
return created;
}
async update(id: string, patch: UpdatePageInput): Promise<Page> {
const updated = await this.repo.update(id, patch);
if (!updated) throw new PageNotFoundError();
return updated;
}
async publish(id: string): Promise<Page> {
const updated = await this.repo.setStatus(id, 'published');
if (!updated) throw new PageNotFoundError();
return updated;
}
async unpublish(id: string): Promise<Page> {
const updated = await this.repo.setStatus(id, 'draft');
if (!updated) throw new PageNotFoundError();
return updated;
}
async getPublicPageBySlug(slug: string): Promise<Page> {
const page = await this.repo.findBySlug(slug);
if (!page) throw new PageNotFoundError();
if (page.status !== 'published') throw new PageNotPublishedError();
return page;
}
async listAll(): Promise<Page[]> {
return this.repo.listAll();
}
}

View File

@@ -0,0 +1,20 @@
export class DuplicateSlugError extends Error {
constructor() {
super('A page with this slug already exists');
this.name = 'DuplicateSlugError';
}
}
export class PageNotFoundError extends Error {
constructor() {
super('Page not found');
this.name = 'PageNotFoundError';
}
}
export class PageNotPublishedError extends Error {
constructor() {
super('Page is not published');
this.name = 'PageNotPublishedError';
}
}

View File

@@ -0,0 +1,23 @@
export type PageStatus = 'draft' | 'published';
export interface Page {
id: string;
slug: string;
title: string;
body: string;
status: PageStatus;
createdAt: Date;
updatedAt: Date;
}
export interface CreatePageInput {
slug: string;
title: string;
body: string;
}
export interface UpdatePageInput {
slug?: string;
title?: string;
body?: string;
}

View File

@@ -0,0 +1,10 @@
import type { CreatePageInput, Page, UpdatePageInput } from './page.js';
export interface CmsRepository {
create(input: CreatePageInput): Promise<Page | undefined>;
update(id: string, patch: UpdatePageInput): Promise<Page | undefined>;
setStatus(id: string, status: 'draft' | 'published'): Promise<Page | undefined>;
findBySlug(slug: string): Promise<Page | undefined>;
findById(id: string): Promise<Page | undefined>;
listAll(): Promise<Page[]>;
}

View File

@@ -0,0 +1,7 @@
/** Public API of the CMS module. */
export { registerCmsRoutes, type CmsRoutesDeps } from './api/cms.routes.js';
export { CmsService } from './application/cms-service.js';
export { PgCmsRepository } from './infrastructure/pg-cms-repository.js';
export { DuplicateSlugError, PageNotFoundError, PageNotPublishedError } from './domain/errors.js';
export type { CmsRepository } from './domain/ports.js';
export type { Page, PageStatus, CreatePageInput, UpdatePageInput } from './domain/page.js';

View File

@@ -0,0 +1,96 @@
import type pg from 'pg';
import type { CmsRepository } from '../domain/ports.js';
import type { CreatePageInput, Page, PageStatus, UpdatePageInput } from '../domain/page.js';
interface PageRow {
id: string;
slug: string;
title: string;
body: string;
status: PageStatus;
created_at: Date;
updated_at: Date;
}
export class PgCmsRepository implements CmsRepository {
constructor(private readonly pool: pg.Pool) {}
async create(input: CreatePageInput): Promise<Page | undefined> {
const result = await this.pool.query<PageRow>(
`INSERT INTO cms_pages (slug, title, body)
VALUES ($1, $2, $3)
ON CONFLICT (slug) DO NOTHING
RETURNING *`,
[input.slug, input.title, input.body],
);
const row = result.rows[0];
return row ? toPage(row) : undefined;
}
async update(id: string, patch: UpdatePageInput): Promise<Page | undefined> {
const set: string[] = [];
const values: unknown[] = [];
if (patch.slug !== undefined) {
values.push(patch.slug);
set.push(`slug = $${values.length}`);
}
if (patch.title !== undefined) {
values.push(patch.title);
set.push(`title = $${values.length}`);
}
if (patch.body !== undefined) {
values.push(patch.body);
set.push(`body = $${values.length}`);
}
if (set.length === 0) return this.findById(id);
values.push(id);
const result = await this.pool.query<PageRow>(
`UPDATE cms_pages SET ${set.join(', ')}, updated_at = now() WHERE id = $${values.length} RETURNING *`,
values,
);
const row = result.rows[0];
return row ? toPage(row) : undefined;
}
async setStatus(id: string, status: PageStatus): Promise<Page | undefined> {
const result = await this.pool.query<PageRow>(
`UPDATE cms_pages SET status = $2, updated_at = now() WHERE id = $1 RETURNING *`,
[id, status],
);
const row = result.rows[0];
return row ? toPage(row) : undefined;
}
async findBySlug(slug: string): Promise<Page | undefined> {
const result = await this.pool.query<PageRow>('SELECT * FROM cms_pages WHERE slug = $1', [
slug,
]);
const row = result.rows[0];
return row ? toPage(row) : undefined;
}
async findById(id: string): Promise<Page | undefined> {
const result = await this.pool.query<PageRow>('SELECT * FROM cms_pages WHERE id = $1', [id]);
const row = result.rows[0];
return row ? toPage(row) : undefined;
}
async listAll(): Promise<Page[]> {
const result = await this.pool.query<PageRow>(
'SELECT * FROM cms_pages ORDER BY created_at DESC',
);
return result.rows.map(toPage);
}
}
function toPage(row: PageRow): Page {
return {
id: row.id,
slug: row.slug,
title: row.title,
body: row.body,
status: row.status,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { CmsService } from '../application/cms-service.js';
import { DuplicateSlugError, PageNotFoundError, PageNotPublishedError } from '../domain/errors.js';
import type { CmsRepository } from '../domain/ports.js';
import type { Page } from '../domain/page.js';
const PAGE: Page = {
id: 'page-1',
slug: 'about',
title: 'About',
body: 'Content',
status: 'draft',
createdAt: new Date(),
updatedAt: new Date(),
};
function repo(overrides: Partial<CmsRepository> = {}): CmsRepository {
return {
create: async () => PAGE,
update: async () => PAGE,
setStatus: async (id, status) => ({ ...PAGE, id, status }),
findBySlug: async () => PAGE,
findById: async () => PAGE,
listAll: async () => [PAGE],
...overrides,
};
}
describe('CmsService', () => {
it('creates a page (200), duplicate slug -> 409', async () => {
const service = new CmsService(repo());
const page = await service.create({ slug: 'about', title: 'About', body: 'Content' });
expect(page.id).toBe('page-1');
const dupService = new CmsService(repo({ create: async () => undefined }));
await expect(
dupService.create({ slug: 'about', title: 'About', body: 'Content' }),
).rejects.toBeInstanceOf(DuplicateSlugError);
});
it('returns 200 on published page and 404 on draft', async () => {
const published = new CmsService(
repo({ findBySlug: async () => ({ ...PAGE, status: 'published' }) }),
);
const draft = new CmsService(repo());
const page = await published.getPublicPageBySlug('about');
expect(page.status).toBe('published');
await expect(published.getPublicPageBySlug('about')).resolves.toMatchObject({
status: 'published',
});
await expect(draft.getPublicPageBySlug('about')).rejects.toBeInstanceOf(PageNotPublishedError);
});
it('returns 404 when page does not exist', async () => {
const service = new CmsService(repo({ findBySlug: async () => undefined }));
await expect(service.getPublicPageBySlug('missing')).rejects.toBeInstanceOf(PageNotFoundError);
});
it('publish/unpublish change status', async () => {
const service = new CmsService(repo());
expect((await service.publish('page-1')).status).toBe('published');
expect((await service.unpublish('page-1')).status).toBe('draft');
});
});

View File

@@ -4,6 +4,7 @@
*/
import type { FastifyInstance, FastifyReply } from 'fastify';
import { z } from 'zod';
import type { Authenticate } from '../../../shared/auth.js';
import { parseJson } from '../../../shared/http-input.js';
import { AppError } from '../../../shared/errors.js';
import type pg from 'pg';
@@ -35,6 +36,8 @@ export interface IdentityRoutesDeps {
/** Test seams; production uses defaults. */
hasher?: PasswordHasher;
rateLimiter?: LoginRateLimiter;
/** Session authenticator. Created by createSessionAuthenticator in build-app.ts. */
authenticate?: Authenticate;
}
const credentialsSchema = z.object({
@@ -104,6 +107,18 @@ export async function registerIdentityRoutes(
clearSessionCookie(reply, cookieSecure);
return reply.code(204).send();
});
app.get('/auth/me', async (request, reply) => {
try {
const user = await deps.authenticate!(request);
return reply.send({ id: user.id, email: user.email, role: user.role });
} catch (error) {
if (error instanceof AppError && error.statusCode === 401) {
return reply.send({ user: null });
}
throw error;
}
});
}
function setSessionCookie(reply: FastifyReply, token: string, secure: boolean): void {

View File

@@ -12,6 +12,10 @@ export interface PasswordHasher {
export interface UserRepository {
create(user: NewUser): Promise<User>;
findByEmail(email: string): Promise<(User & { passwordHash: string }) | undefined>;
findById(id: string): Promise<User | undefined>;
listUsers(params?: { limit?: number; offset?: number; role?: string; q?: string }): Promise<{ items: User[]; total: number }>;
updateUser(id: string, patch: { role?: string; passwordHash?: string }): Promise<User>;
deleteUser(id: string): Promise<void>;
}
export interface SessionRepository {

View File

@@ -61,6 +61,66 @@ export class PgUserRepository implements UserRepository {
passwordHash: row.password_hash,
};
}
async findById(id: string): Promise<User | undefined> {
const result = await this.pool.query<UserRow>(
`SELECT id, email, role, created_at
FROM identity_users WHERE id = $1`,
[id],
);
const row = result.rows[0];
if (!row) return undefined;
return { id: row.id, email: row.email, role: row.role, createdAt: row.created_at };
}
async listUsers(params?: { limit?: number; offset?: number; role?: string; q?: string }): Promise<{ items: User[]; total: number }> {
const limit = params?.limit ?? 20;
const offset = params?.offset ?? 0;
const conditions: string[] = [];
const values: unknown[] = [];
let i = 1;
if (params?.role) { conditions.push(`role = $${i++}`); values.push(params.role); }
if (params?.q) { conditions.push(`(email ILIKE $${i++} OR role ILIKE $${i++})`); values.push(`%${params.q}%`); values.push(`%${params.q}%`); }
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const countResult = await this.pool.query<{ count: string }>(
`SELECT COUNT(*) FROM identity_users ${where}`,
values,
);
const total = parseInt(countResult.rows[0]?.count ?? '0', 10);
const rows = await this.pool.query<UserRow>(
`SELECT id, email, role, created_at FROM identity_users ${where} ORDER BY created_at DESC LIMIT $${i++} OFFSET $${i}`,
[...values, limit, offset],
);
return {
items: rows.rows.map((r) => ({ id: r.id, email: r.email, role: r.role, createdAt: r.created_at })),
total,
};
}
async updateUser(id: string, patch: { role?: string; passwordHash?: string }): Promise<User> {
const sets: string[] = [];
const values: unknown[] = [];
let i = 1;
if (patch.role) { sets.push(`role = $${i++}`); values.push(patch.role); }
if (patch.passwordHash) { sets.push(`password_hash = $${i++}`); values.push(patch.passwordHash); }
if (!sets.length) {
const existing = await this.findById(id);
if (!existing) throw new Error('User not found');
return existing;
}
values.push(id);
const result = await this.pool.query<UserRow>(
`UPDATE identity_users SET ${sets.join(', ')} WHERE id = $${i} RETURNING id, email, role, created_at`,
values,
);
const row = result.rows[0];
if (!row) throw new Error('User not found');
return { id: row.id, email: row.email, role: row.role, createdAt: row.created_at };
}
async deleteUser(id: string): Promise<void> {
await this.pool.query(`DELETE FROM identity_users WHERE id = $1`, [id]);
}
}
function isPgError(error: unknown): error is { code: string } {

View File

@@ -0,0 +1,118 @@
import type { FastifyInstance } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import { requireRole, type Authenticate } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
import { parseJson } from '../../../shared/http-input.js';
import { InventoryService } from '../application/inventory-service.js';
import {
InsufficientReservedStockError,
InsufficientStockError,
InvalidStockQuantityError,
} from '../domain/errors.js';
import type { StockItem } from '../domain/stock.js';
import { PgInventoryRepository } from '../infrastructure/pg-inventory-repository.js';
export interface InventoryRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
}
const variantParamSchema = z.object({ variantId: z.uuid() });
const availabilityQuerySchema = z.object({
quantity: z.coerce.number().int().positive().default(1),
});
const stockBodySchema = z.object({ quantity: z.number().int().min(0) });
const stockCommandBodySchema = z.object({ quantity: z.number().int().positive() });
export async function registerInventoryRoutes(
app: FastifyInstance,
deps: InventoryRoutesDeps,
): Promise<void> {
const inventory = new InventoryService(new PgInventoryRepository(deps.pool));
app.get('/inventory/:variantId/availability', async (request, reply) => {
const { variantId } = parseJson(variantParamSchema, request.params);
const { quantity } = parseJson(availabilityQuerySchema, request.query);
const availability = await inventory.checkAvailability(variantId, quantity);
return reply.send(availability);
});
app.put('/inventory/:variantId/stock', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { variantId } = parseJson(variantParamSchema, request.params);
const { quantity } = parseJson(stockBodySchema, request.body);
try {
const item = await inventory.setAvailable({ variantId, quantity });
return reply.send(serializeStockItem(item));
} catch (error) {
throw mapInventoryError(error);
}
});
app.post('/inventory/:variantId/reservations', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { variantId } = parseJson(variantParamSchema, request.params);
const { quantity } = parseJson(stockCommandBodySchema, request.body);
try {
const item = await inventory.reserve({ variantId, quantity });
return reply.code(201).send(serializeStockItem(item));
} catch (error) {
throw mapInventoryError(error);
}
});
app.post('/inventory/:variantId/reservations/release', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { variantId } = parseJson(variantParamSchema, request.params);
const { quantity } = parseJson(stockCommandBodySchema, request.body);
try {
const item = await inventory.release({ variantId, quantity });
return reply.send(serializeStockItem(item));
} catch (error) {
throw mapInventoryError(error);
}
});
app.post('/inventory/:variantId/reservations/confirm', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { variantId } = parseJson(variantParamSchema, request.params);
const { quantity } = parseJson(stockCommandBodySchema, request.body);
try {
const item = await inventory.confirm({ variantId, quantity });
return reply.send(serializeStockItem(item));
} catch (error) {
throw mapInventoryError(error);
}
});
}
function mapInventoryError(error: unknown): Error {
if (error instanceof InsufficientStockError) {
return new AppError(409, 'INSUFFICIENT_STOCK', error.message);
}
if (error instanceof InsufficientReservedStockError) {
return new AppError(409, 'INSUFFICIENT_RESERVED_STOCK', error.message);
}
if (error instanceof InvalidStockQuantityError) {
return new AppError(422, 'INVALID_STOCK_QUANTITY', error.message);
}
return error instanceof Error ? error : new Error('Unknown inventory error');
}
function serializeStockItem(item: StockItem) {
return {
id: item.id,
variantId: item.variantId,
available: item.available,
reserved: item.reserved,
sold: item.sold,
incoming: item.incoming,
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
};
}

View File

@@ -0,0 +1,70 @@
import {
InsufficientReservedStockError,
InsufficientStockError,
InvalidStockQuantityError,
} from '../domain/errors.js';
import type {
InventoryRepository,
InventoryService as InventoryServicePort,
} from '../domain/ports.js';
import type {
Availability,
SetAvailableStockCommand,
StockCommand,
StockItem,
} from '../domain/stock.js';
export class InventoryService implements InventoryServicePort {
constructor(private readonly repository: InventoryRepository) {}
async checkAvailability(variantId: string, quantity: number): Promise<Availability> {
ensurePositiveQuantity(quantity);
const item = await this.repository.findByVariantId(variantId);
const availableQuantity = item?.available ?? 0;
return { available: availableQuantity >= quantity, availableQuantity };
}
async reserve(input: StockCommand): Promise<StockItem> {
ensurePositiveQuantity(input.quantity);
const item = await this.repository.reserve(input);
if (!item) {
throw new InsufficientStockError();
}
return item;
}
async release(input: StockCommand): Promise<StockItem> {
ensurePositiveQuantity(input.quantity);
const item = await this.repository.release(input);
if (!item) {
throw new InsufficientReservedStockError();
}
return item;
}
async confirm(input: StockCommand): Promise<StockItem> {
ensurePositiveQuantity(input.quantity);
const item = await this.repository.confirm(input);
if (!item) {
throw new InsufficientReservedStockError();
}
return item;
}
async setAvailable(input: SetAvailableStockCommand): Promise<StockItem> {
ensureNonNegativeInteger(input.quantity);
return this.repository.setAvailable(input);
}
}
function ensurePositiveQuantity(quantity: number): void {
if (!Number.isInteger(quantity) || quantity <= 0) {
throw new InvalidStockQuantityError();
}
}
function ensureNonNegativeInteger(quantity: number): void {
if (!Number.isInteger(quantity) || quantity < 0) {
throw new InvalidStockQuantityError();
}
}

View File

@@ -0,0 +1,20 @@
export class InvalidStockQuantityError extends Error {
constructor() {
super('Stock quantity must be a positive integer');
this.name = 'InvalidStockQuantityError';
}
}
export class InsufficientStockError extends Error {
constructor() {
super('Insufficient stock available');
this.name = 'InsufficientStockError';
}
}
export class InsufficientReservedStockError extends Error {
constructor() {
super('Insufficient reserved stock');
this.name = 'InsufficientReservedStockError';
}
}

View File

@@ -0,0 +1,17 @@
import type { Availability, SetAvailableStockCommand, StockCommand, StockItem } from './stock.js';
export interface InventoryService {
checkAvailability(variantId: string, quantity: number): Promise<Availability>;
reserve(input: StockCommand): Promise<StockItem>;
release(input: StockCommand): Promise<StockItem>;
confirm(input: StockCommand): Promise<StockItem>;
setAvailable(input: SetAvailableStockCommand): Promise<StockItem>;
}
export interface InventoryRepository {
findByVariantId(variantId: string): Promise<StockItem | undefined>;
setAvailable(input: SetAvailableStockCommand): Promise<StockItem>;
reserve(input: StockCommand): Promise<StockItem | undefined>;
release(input: StockCommand): Promise<StockItem | undefined>;
confirm(input: StockCommand): Promise<StockItem | undefined>;
}

View File

@@ -0,0 +1,30 @@
export type StockState = 'available' | 'reserved' | 'sold' | 'incoming';
export interface StockItem {
id: string;
variantId: string;
available: number;
reserved: number;
sold: number;
incoming: number;
createdAt: Date;
updatedAt: Date;
}
export interface StockCommand {
variantId: string;
quantity: number;
}
export type SetAvailableStockCommand = StockCommand;
export interface Availability {
available: boolean;
availableQuantity: number;
}
export function assertPositiveQuantity(quantity: number): void {
if (!Number.isInteger(quantity) || quantity <= 0) {
throw new Error('Stock quantity must be a positive integer');
}
}

View File

@@ -0,0 +1,16 @@
/** Public API of the inventory module. */
import type pg from 'pg';
import { InventoryService } from './application/inventory-service.js';
import { PgInventoryRepository } from './infrastructure/pg-inventory-repository.js';
export { registerInventoryRoutes, type InventoryRoutesDeps } from './api/inventory.routes.js';
export { InventoryService } from './application/inventory-service.js';
export type {
InventoryRepository,
InventoryService as InventoryServicePort,
} from './domain/ports.js';
export type { Availability, StockCommand, StockItem, StockState } from './domain/stock.js';
export function createInventoryService(pool: pg.Pool): InventoryService {
return new InventoryService(new PgInventoryRepository(pool));
}

View File

@@ -0,0 +1,147 @@
import type pg from 'pg';
import type { InventoryRepository } from '../domain/ports.js';
import type { SetAvailableStockCommand, StockCommand, StockItem } from '../domain/stock.js';
interface StockRow {
id: string;
variant_id: string;
available: number;
reserved: number;
sold: number;
incoming: number;
created_at: Date;
updated_at: Date;
}
type InventoryOperation = 'reserve' | 'release' | 'confirm' | 'set_available';
export class PgInventoryRepository implements InventoryRepository {
constructor(private readonly pool: pg.Pool) {}
async findByVariantId(variantId: string): Promise<StockItem | undefined> {
const result = await this.pool.query<StockRow>(
'SELECT * FROM inventory_stock WHERE variant_id = $1',
[variantId],
);
const row = result.rows[0];
return row ? toStockItem(row) : undefined;
}
async setAvailable(input: SetAvailableStockCommand): Promise<StockItem> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const result = await client.query<StockRow>(
`INSERT INTO inventory_stock (variant_id, available)
VALUES ($1, $2)
ON CONFLICT (variant_id) DO UPDATE
SET available = EXCLUDED.available, updated_at = now()
RETURNING *`,
[input.variantId, input.quantity],
);
const item = rowOrThrow(result.rows[0], 'inventory_stock upsert returned no row');
await insertMovement(client, input.variantId, 'set_available', input.quantity);
await client.query('COMMIT');
return item;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async reserve(input: StockCommand): Promise<StockItem | undefined> {
return this.applyAtomicOperation(input, 'reserve', (client, command) =>
client.query<StockRow>(
`UPDATE inventory_stock
SET available = available - $2, reserved = reserved + $2, updated_at = now()
WHERE variant_id = $1 AND available >= $2
RETURNING *`,
[command.variantId, command.quantity],
),
);
}
async release(input: StockCommand): Promise<StockItem | undefined> {
return this.applyAtomicOperation(input, 'release', (client, command) =>
client.query<StockRow>(
`UPDATE inventory_stock
SET reserved = reserved - $2, available = available + $2, updated_at = now()
WHERE variant_id = $1 AND reserved >= $2
RETURNING *`,
[command.variantId, command.quantity],
),
);
}
async confirm(input: StockCommand): Promise<StockItem | undefined> {
return this.applyAtomicOperation(input, 'confirm', (client, command) =>
client.query<StockRow>(
`UPDATE inventory_stock
SET reserved = reserved - $2, sold = sold + $2, updated_at = now()
WHERE variant_id = $1 AND reserved >= $2
RETURNING *`,
[command.variantId, command.quantity],
),
);
}
private async applyAtomicOperation(
input: StockCommand,
operation: InventoryOperation,
update: (client: pg.PoolClient, input: StockCommand) => Promise<pg.QueryResult<StockRow>>,
): Promise<StockItem | undefined> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const result = await update(client, input);
const row = result.rows[0];
if (!row) {
await client.query('ROLLBACK');
return undefined;
}
await insertMovement(client, input.variantId, operation, input.quantity);
await client.query('COMMIT');
return toStockItem(row);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
}
async function insertMovement(
client: pg.PoolClient,
variantId: string,
operation: InventoryOperation,
quantity: number,
): Promise<void> {
await client.query(
`INSERT INTO inventory_movements (variant_id, operation, quantity)
VALUES ($1, $2, $3)`,
[variantId, operation, quantity],
);
}
function rowOrThrow(row: StockRow | undefined, message: string): StockItem {
if (!row) {
throw new Error(message);
}
return toStockItem(row);
}
function toStockItem(row: StockRow): StockItem {
return {
id: row.id,
variantId: row.variant_id,
available: row.available,
reserved: row.reserved,
sold: row.sold,
incoming: row.incoming,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}

View File

@@ -0,0 +1,29 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
function sourceFiles(dir: string): string[] {
const entries = readdirSync(dir);
return entries.flatMap((entry) => {
const path = join(dir, entry);
const stat = statSync(path);
if (stat.isDirectory()) {
return sourceFiles(path);
}
return path.endsWith('.ts') ? [path] : [];
});
}
describe('inventory module boundary', () => {
it('keeps catalog isolated from inventory internals and tables (AC3)', () => {
const catalogDir = new URL('../../catalog', import.meta.url);
for (const file of sourceFiles(catalogDir.pathname)) {
const source = readFileSync(file, 'utf8');
expect(source).not.toContain('inventory_');
expect(source).not.toMatch(
/modules\/inventory\/(?:api|application|domain|infrastructure|tests)/,
);
}
});
});

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import { InventoryService } from '../application/inventory-service.js';
import {
InsufficientReservedStockError,
InsufficientStockError,
InvalidStockQuantityError,
} from '../domain/errors.js';
import type { InventoryRepository } from '../domain/ports.js';
import type { SetAvailableStockCommand, StockCommand, StockItem } from '../domain/stock.js';
const STOCK: StockItem = {
id: 'stock-1',
variantId: 'variant-1',
available: 1,
reserved: 0,
sold: 0,
incoming: 0,
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
};
function repository(overrides: Partial<InventoryRepository> = {}): InventoryRepository {
return {
findByVariantId: async () => STOCK,
setAvailable: async (_input: SetAvailableStockCommand) => STOCK,
reserve: async (_input: StockCommand) => STOCK,
release: async (_input: StockCommand) => STOCK,
confirm: async (_input: StockCommand) => STOCK,
...overrides,
};
}
describe('InventoryService', () => {
it('rejects non-positive reserve quantities before persistence', async () => {
const service = new InventoryService(repository());
await expect(service.reserve({ variantId: 'variant-1', quantity: 0 })).rejects.toBeInstanceOf(
InvalidStockQuantityError,
);
});
it('maps failed reservation to insufficient stock', async () => {
const service = new InventoryService(repository({ reserve: async () => undefined }));
await expect(service.reserve({ variantId: 'variant-1', quantity: 1 })).rejects.toBeInstanceOf(
InsufficientStockError,
);
});
it('maps failed release to insufficient reserved stock', async () => {
const service = new InventoryService(repository({ release: async () => undefined }));
await expect(service.release({ variantId: 'variant-1', quantity: 1 })).rejects.toBeInstanceOf(
InsufficientReservedStockError,
);
});
it('checks availability without mutating stock', async () => {
const service = new InventoryService(repository());
await expect(service.checkAvailability('variant-1', 1)).resolves.toEqual({
available: true,
availableQuantity: 1,
});
await expect(service.checkAvailability('variant-1', 2)).resolves.toEqual({
available: false,
availableQuantity: 1,
});
});
});

View File

@@ -0,0 +1,57 @@
import type { FastifyInstance } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import type { Authenticate } from '../../../shared/auth.js';
import { requireRole } from '../../../shared/auth.js';
import { parseJson } from '../../../shared/http-input.js';
import { NotificationsService } from '../application/notifications-service.js';
import { PgNotificationsRepository } from '../infrastructure/pg-notifications-repository.js';
export interface NotificationsRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
emailProvider: import('../domain/ports.js').EmailProvider;
}
const dispatchSchema = z
.object({
eventId: z.string().min(1).max(120),
template: z.enum(['order_confirmation', 'payment_failed', 'order_shipped']),
recipient: z.string().email(),
subject: z.string().min(1).max(200),
body: z.string().min(1).max(5000),
})
.strip();
export async function registerNotificationsRoutes(
app: FastifyInstance,
deps: NotificationsRoutesDeps,
): Promise<void> {
const service = new NotificationsService(
new PgNotificationsRepository(deps.pool),
deps.emailProvider,
);
app.post('/notifications/dispatch', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const input = parseJson(dispatchSchema, request.body);
const outcome = await service.dispatch(input);
return reply.send({ outcome });
});
app.get('/notifications/messages/:eventId', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const params = z.object({ eventId: z.string().min(1).max(120) }).parse(request.params);
const repo = new PgNotificationsRepository(deps.pool);
const found = await repo.findByEventId(params.eventId);
if (!found)
throw new (await import('../../../shared/errors.js')).AppError(
404,
'NOT_FOUND',
'Message not found',
);
return reply.send(found);
});
}

View File

@@ -0,0 +1,20 @@
import type { NotificationsRepository, EmailProvider } from '../domain/ports.js';
import type { EmailMessage } from '../domain/notification.js';
export type DispatchOutcome = 'sent' | 'duplicate';
export class NotificationsService {
constructor(
private readonly repo: NotificationsRepository,
private readonly provider: EmailProvider,
) {}
async dispatch(message: EmailMessage): Promise<DispatchOutcome> {
const existing = await this.repo.findByEventId(message.eventId);
if (existing) return 'duplicate';
const result = await this.repo.record(message);
if (!result.created) return 'duplicate';
await this.provider.send(message);
return 'sent';
}
}

View File

@@ -0,0 +1,9 @@
export type EmailTemplate = 'order_confirmation' | 'payment_failed' | 'order_shipped';
export interface EmailMessage {
eventId: string;
template: EmailTemplate;
recipient: string;
subject: string;
body: string;
}

View File

@@ -0,0 +1,12 @@
import type { EmailMessage } from './notification.js';
export interface EmailProvider {
send(message: EmailMessage): Promise<void>;
}
export interface NotificationsRepository {
findByEventId(
eventId: string,
): Promise<{ template: string; recipient: string; status: string } | undefined>;
record(message: EmailMessage): Promise<{ created: boolean }>;
}

View File

@@ -0,0 +1,10 @@
/** Public API of the notifications module. */
export {
registerNotificationsRoutes,
type NotificationsRoutesDeps,
} from './api/notifications.routes.js';
export { NotificationsService } from './application/notifications-service.js';
export { LoggingEmailProvider } from './infrastructure/log-email-provider.js';
export { PgNotificationsRepository } from './infrastructure/pg-notifications-repository.js';
export type { NotificationsRepository, EmailProvider } from './domain/ports.js';
export type { EmailMessage, EmailTemplate } from './domain/notification.js';

View File

@@ -0,0 +1,31 @@
import type { EmailMessage, EmailTemplate } from '../domain/notification.js';
import type { EmailProvider } from '../domain/ports.js';
const SUBJECTS: Record<EmailTemplate, string> = {
order_confirmation: 'Tu pedido ha sido confirmado',
payment_failed: 'Hubo un problema con tu pago',
order_shipped: 'Tu pedido ha sido enviado',
};
const BODIES: Record<EmailTemplate, string> = {
order_confirmation: 'Hemos recibido tu pago y estamos preparando tu pedido.',
payment_failed: 'No pudimos procesar tu pago. Por favor, intenta de nuevo.',
order_shipped: 'Tu pedido está en camino. Recibirás los datos de seguimiento pronto.',
};
/** Logging email provider for v1: writes to destination stream; swap with real adapter later. */
export class LoggingEmailProvider implements EmailProvider {
constructor(private readonly log: (line: string) => void = () => undefined) {}
async send(message: EmailMessage): Promise<void> {
const line = JSON.stringify({
provider: 'log',
template: message.template,
recipient: message.recipient,
subject: message.subject || SUBJECTS[message.template],
body: message.body || BODIES[message.template],
eventId: message.eventId,
});
this.log(line);
}
}

View File

@@ -0,0 +1,38 @@
import type pg from 'pg';
import type { NotificationsRepository } from '../domain/ports.js';
import type { EmailMessage } from '../domain/notification.js';
interface MessageRow {
event_id: string;
template: string;
recipient: string;
status: string;
}
export class PgNotificationsRepository implements NotificationsRepository {
constructor(private readonly pool: pg.Pool) {}
async findByEventId(
eventId: string,
): Promise<{ template: string; recipient: string; status: string } | undefined> {
const result = await this.pool.query<MessageRow>(
'SELECT event_id, template, recipient, status FROM notifications_messages WHERE event_id = $1',
[eventId],
);
const row = result.rows[0];
return row
? { template: row.template, recipient: row.recipient, status: row.status }
: undefined;
}
async record(message: EmailMessage): Promise<{ created: boolean }> {
const result = await this.pool.query(
`INSERT INTO notifications_messages (event_id, template, recipient, subject, body)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (event_id) DO NOTHING
RETURNING id`,
[message.eventId, message.template, message.recipient, message.subject, message.body],
);
return { created: (result.rowCount ?? 0) > 0 };
}
}

View File

@@ -0,0 +1,23 @@
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((entry) => {
const path = join(dir, entry);
return statSync(path).isDirectory() ? sourceFiles(path) : path.endsWith('.ts') ? [path] : [];
});
}
describe('notifications swappability', () => {
it('only infrastructure touches the email provider implementation', () => {
const dir = new URL('..', import.meta.url);
for (const file of sourceFiles(dir.pathname)) {
if (!file.endsWith('.ts') || file.endsWith('boundary.test.ts')) continue;
if (file.includes('/infrastructure/') || file.includes('/infrastructure')) continue;
if (file.endsWith('/index.ts')) continue;
const source = readFileSync(file, 'utf8');
expect(source).not.toMatch(/LoggingEmailProvider|emailProvider\.send/);
}
});
});

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import { NotificationsService } from '../application/notifications-service.js';
import type { EmailProvider, NotificationsRepository } from '../domain/ports.js';
import type { EmailMessage } from '../domain/notification.js';
function repo(overrides: Partial<NotificationsRepository> = {}): NotificationsRepository {
return {
findByEventId: async () => undefined,
record: async () => ({ created: true }),
...overrides,
};
}
function provider(sent: EmailMessage[]): EmailProvider {
return {
send: async (message) => {
sent.push(message);
},
};
}
describe('NotificationsService', () => {
it('sends the email on first event and duplicate on replay (AC1/AC2)', async () => {
const sent: EmailMessage[] = [];
const repository: NotificationsRepository = {
findByEventId: async () => undefined,
record: async () => ({ created: true }),
};
const service = new NotificationsService(repository, provider(sent));
const message: EmailMessage = {
eventId: 'evt-1',
template: 'order_confirmation',
recipient: 'a@example.com',
subject: 'subject',
body: 'body',
};
const first = await service.dispatch(message);
expect(first).toBe('sent');
// Now simulate replay: findByEventId returns a record.
const replayService = new NotificationsService(
{
findByEventId: async () => ({
template: message.template,
recipient: message.recipient,
status: 'sent',
}),
record: async () => ({ created: true }),
},
provider(sent),
);
const second = await replayService.dispatch(message);
expect(second).toBe('duplicate');
expect(sent).toHaveLength(1);
});
it('returns duplicate when repository record returns created=false', async () => {
const sent: EmailMessage[] = [];
const service = new NotificationsService(
repo({ record: async () => ({ created: false }) }),
provider(sent),
);
const outcome = await service.dispatch({
eventId: 'evt-2',
template: 'payment_failed',
recipient: 'b@example.com',
subject: 's',
body: 'b',
});
expect(outcome).toBe('duplicate');
expect(sent).toHaveLength(0);
});
});

View File

@@ -0,0 +1,20 @@
import type { FastifyInstance } from 'fastify';
import type { Authenticate } from '../../../shared/auth.js';
import { requireRole } from '../../../shared/auth.js';
import type { InMemoryMeter } from '../infrastructure/no-op-telemetry.js';
export interface MetricsRoutesDeps {
meter: InMemoryMeter;
authenticate: Authenticate;
}
export async function registerMetricsRoutes(
app: FastifyInstance,
deps: MetricsRoutesDeps,
): Promise<void> {
app.get('/ops/metrics', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
return reply.send({ metrics: deps.meter.snapshot() });
});
}

View File

@@ -0,0 +1,29 @@
/** Minimal interfaces so observability is swappable without touching callers. */
export interface Span {
end(): void;
setError(err: unknown): void;
}
export interface Tracer {
startSpan(name: string): Span;
trace<T>(name: string, fn: () => Promise<T>): Promise<T>;
}
export interface Counter {
add(value: number, attributes?: Record<string, string>): void;
}
export interface Histogram {
record(value: number, attributes?: Record<string, string>): void;
}
export interface Meter {
counter(name: string): Counter;
histogram(name: string): Histogram;
}
export interface Telemetry {
tracer: Tracer;
meter: Meter;
}

View File

@@ -0,0 +1,8 @@
/** Public API of the observability module. */
export { registerMetricsRoutes, type MetricsRoutesDeps } from './api/metrics.routes.js';
export {
InMemoryMeter,
createInMemoryTelemetry,
createNoOpTelemetry,
} from './infrastructure/no-op-telemetry.js';
export type { Telemetry, Tracer, Meter, Span, Counter, Histogram } from './domain/telemetry.js';

View File

@@ -0,0 +1,58 @@
import type { Histogram, Counter, Meter, Span, Tracer, Telemetry } from '../domain/telemetry.js';
class NoopSpan implements Span {
end(): void {}
setError(_err: unknown): void {}
}
export class NoopTracer implements Tracer {
startSpan(_name: string): Span {
return new NoopSpan();
}
async trace<T>(name: string, fn: () => Promise<T>): Promise<T> {
return fn();
}
}
class NoopCounter implements Counter {
add(_value: number, _attributes?: Record<string, string>): void {}
}
class NoopHistogram implements Histogram {
record(_value: number, _attributes?: Record<string, string>): void {}
}
class NoopMeter implements Meter {
counter(_name: string): Counter {
return new NoopCounter();
}
histogram(_name: string): Histogram {
return new NoopHistogram();
}
}
export class InMemoryMeter implements Meter {
readonly data = new Map<string, number>();
counter(name: string): Counter {
return {
add: (value, _attributes) => {
this.data.set(name, (this.data.get(name) ?? 0) + value);
},
};
}
histogram(_name: string): Histogram {
return { record: () => undefined };
}
snapshot(): Record<string, number> {
return Object.fromEntries(this.data);
}
}
export function createNoOpTelemetry(): Telemetry {
return { tracer: new NoopTracer(), meter: new NoopMeter() };
}
export function createInMemoryTelemetry(): { telemetry: Telemetry; meter: InMemoryMeter } {
const meter = new InMemoryMeter();
return { telemetry: { tracer: new NoopTracer(), meter }, meter };
}

View File

@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { InMemoryMeter } from '../infrastructure/no-op-telemetry.js';
describe('InMemoryMeter', () => {
it('accumulates counter values', () => {
const meter = new InMemoryMeter();
meter.counter('checkout.completed').add(1);
meter.counter('checkout.completed').add(3);
expect(meter.snapshot()).toEqual({ 'checkout.completed': 4 });
});
});

View File

@@ -0,0 +1,193 @@
import type { FastifyInstance } from 'fastify';
import type pg from 'pg';
import { z } from 'zod';
import type { Authenticate, CurrentUser } from '../../../shared/auth.js';
import { requireRole } from '../../../shared/auth.js';
import { AppError } from '../../../shared/errors.js';
import { parseJson } from '../../../shared/http-input.js';
import { OrderService } from '../application/order-service.js';
import { OrderNotFoundError, OrderStateTransitionError } from '../domain/errors.js';
import type { OrderState } from '../domain/order.js';
import { PgOrderRepository } from '../infrastructure/pg-order-repository.js';
import { NoOpOrderEventPublisher } from '../infrastructure/no-op-event-publisher.js';
export interface OrdersRoutesDeps {
pool: pg.Pool;
authenticate: Authenticate;
}
const orderItemSchema = z.object({
productId: z.uuid(),
variantId: z.uuid(),
sku: z.string().min(1).max(120),
ean: z.string().min(1).max(40).nullable().optional(),
name: z.string().min(1).max(200),
unitPriceCents: z.number().int().min(0),
discountCents: z.number().int().min(0),
taxCents: z.number().int().min(0),
quantity: z.number().int().positive(),
});
const createOrderSchema = z
.object({
idempotencyKey: z.string().min(1).max(120).optional().nullable(),
items: z.array(orderItemSchema).min(1),
totals: z.object({
subtotalCents: z.number().int().min(0),
discountCents: z.number().int().min(0),
taxCents: z.number().int().min(0),
totalCents: z.number().int().min(0),
}),
})
.strip();
const transitionSchema = z.object({
state: z.enum([
'PENDING',
'AWAITING_PAYMENT',
'PAID',
'PROCESSING',
'SHIPPED',
'DELIVERED',
'CANCELLED',
'REFUNDED',
'PARTIALLY_REFUNDED',
]),
});
const orderIdParamSchema = z.object({ id: z.uuid() });
export async function registerOrdersRoutes(
app: FastifyInstance,
deps: OrdersRoutesDeps,
): Promise<void> {
const service = new OrderService(new PgOrderRepository(deps.pool), new NoOpOrderEventPublisher());
app.post('/orders', async (request, reply) => {
const user = await deps.authenticate(request);
const input = parseJson(createOrderSchema, request.body);
try {
const order = await service.create({
userId: user.id,
idempotencyKey: input.idempotencyKey ?? null,
items: input.items.map((item) => ({ ...item, ean: item.ean ?? null })),
totals: input.totals,
});
return reply.code(201).send(serializeOrder(order));
} catch (error) {
throw mapOrderError(error);
}
});
app.post('/orders/:id/transitions', async (request, reply) => {
const user = await deps.authenticate(request);
const { id } = parseJson(orderIdParamSchema, request.params);
const { state } = parseJson(transitionSchema, request.body);
try {
const order = await service.transition(id, state as OrderState, user.id);
return reply.send(serializeOrder(order));
} catch (error) {
throw mapOrderError(error);
}
});
app.get('/orders/:id', async (request, reply) => {
const user = await deps.authenticate(request);
const { id } = parseJson(orderIdParamSchema, request.params);
const order = await service.getOrder(id, user.id);
if (!order) throw new AppError(404, 'ORDER_NOT_FOUND', 'Order not found');
return reply.send(serializeOrder(order));
});
// Admin-only routes
app.get('/orders', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const orders = await service.listOrders();
return reply.send(orders.map(serializeOrder));
});
app.get('/orders/:id/admin', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(orderIdParamSchema, request.params);
const order = await service.getOrderAdmin(id);
if (!order) throw new AppError(404, 'ORDER_NOT_FOUND', 'Order not found');
return reply.send(serializeOrder(order));
});
app.post('/orders/:id/transitions/admin', async (request, reply) => {
const user = await deps.authenticate(request);
requireRole(user, 'admin');
const { id } = parseJson(orderIdParamSchema, request.params);
const { state } = parseJson(transitionSchema, request.body);
try {
const order = await service.transitionAdmin(id, state as OrderState);
return reply.send(serializeOrder(order));
} catch (error) {
throw mapOrderError(error);
}
});
}
function mapOrderError(error: unknown): Error {
if (error instanceof OrderStateTransitionError)
return new AppError(409, 'ORDER_STATE_TRANSITION_INVALID', error.message);
if (error instanceof OrderNotFoundError)
return new AppError(404, 'ORDER_NOT_FOUND', error.message);
return error instanceof Error ? error : new Error('Unknown order error');
}
function serializeOrder(order: {
id: string;
state: string;
currency: string;
subtotalCents: number;
discountCents: number;
taxCents: number;
totalCents: number;
createdAt: Date;
updatedAt: Date;
idempotencyKey: string | null;
userId?: string;
items: Array<{
id: string;
productId: string;
variantId: string;
sku: string;
ean: string | null;
name: string;
unitPriceCents: number;
discountCents: number;
taxCents: number;
quantity: number;
createdAt: Date;
}>;
}) {
return {
id: order.id,
userId: order.userId,
state: order.state,
currency: order.currency,
subtotalCents: order.subtotalCents,
discountCents: order.discountCents,
taxCents: order.taxCents,
totalCents: order.totalCents,
idempotencyKey: order.idempotencyKey,
items: order.items.map((item) => ({
id: item.id,
productId: item.productId,
variantId: item.variantId,
sku: item.sku,
ean: item.ean,
name: item.name,
unitPriceCents: item.unitPriceCents,
discountCents: item.discountCents,
taxCents: item.taxCents,
quantity: item.quantity,
createdAt: item.createdAt.toISOString(),
})),
createdAt: order.createdAt.toISOString(),
updatedAt: order.updatedAt.toISOString(),
};
}

View File

@@ -0,0 +1,62 @@
import { OrderNotFoundError, OrderStateTransitionError } from '../domain/errors.js';
import type {
OrderEventPublisher,
OrderRepository,
OrderServicePort,
CreateOrderCommand,
} from '../domain/ports.js';
import { isTransitionAllowed, type OrderState, type OrderView } from '../domain/order.js';
export class OrderService implements OrderServicePort {
constructor(
private readonly repo: OrderRepository,
private readonly events: OrderEventPublisher,
) {}
async create(input: CreateOrderCommand): Promise<OrderView> {
const order = await this.repo.create({
...input,
idempotencyKey: input.idempotencyKey ?? null,
});
await this.events.emit({ type: 'OrderCreated', orderId: order.id, userId: order.userId });
return order;
}
async listOrders(): Promise<OrderView[]> {
return this.repo.findAll();
}
async transition(id: string, next: OrderState, userId: string): Promise<OrderView> {
const existing = await this.repo.findByIdAndUserId(id, userId);
if (!existing) throw new OrderNotFoundError();
if (!isTransitionAllowed(existing.state, next))
throw new OrderStateTransitionError(existing.state, next);
const updated = await this.repo.updateState(id, next);
if (!updated) throw new OrderNotFoundError();
if (next === 'PAID') await this.events.emit({ type: 'OrderPaid', orderId: id, userId });
if (next === 'CANCELLED')
await this.events.emit({ type: 'OrderCancelled', orderId: id, userId });
return updated;
}
async getOrder(id: string, userId: string): Promise<OrderView | undefined> {
return this.repo.findByIdAndUserId(id, userId);
}
async getOrderAdmin(id: string): Promise<OrderView | undefined> {
return this.repo.findById(id);
}
async transitionAdmin(id: string, next: OrderState): Promise<OrderView> {
const existing = await this.repo.findById(id);
if (!existing) throw new OrderNotFoundError();
if (!isTransitionAllowed(existing.state, next))
throw new OrderStateTransitionError(existing.state, next);
const updated = await this.repo.updateState(id, next);
if (!updated) throw new OrderNotFoundError();
if (next === 'PAID') await this.events.emit({ type: 'OrderPaid', orderId: id, userId: existing.userId });
if (next === 'CANCELLED')
await this.events.emit({ type: 'OrderCancelled', orderId: id, userId: existing.userId });
return updated;
}
}

View File

@@ -0,0 +1,16 @@
export class OrderStateTransitionError extends Error {
constructor(
public readonly from: string,
public readonly to: string,
) {
super(`Order state transition from ${from} to ${to} is not allowed`);
this.name = 'OrderStateTransitionError';
}
}
export class OrderNotFoundError extends Error {
constructor() {
super('Order not found');
this.name = 'OrderNotFoundError';
}
}

View File

@@ -0,0 +1,62 @@
export type OrderState =
| 'PENDING'
| 'AWAITING_PAYMENT'
| 'PAID'
| 'PROCESSING'
| 'SHIPPED'
| 'DELIVERED'
| 'CANCELLED'
| 'REFUNDED'
| 'PARTIALLY_REFUNDED';
export interface OrderItemInput {
productId: string;
variantId: string;
sku: string;
ean: string | null;
name: string;
unitPriceCents: number;
discountCents: number;
taxCents: number;
quantity: number;
}
export interface OrderItem extends OrderItemInput {
id: string;
orderId: string;
createdAt: Date;
}
export interface Order {
id: string;
userId: string;
idempotencyKey: string | null;
state: OrderState;
currency: 'EUR';
subtotalCents: number;
discountCents: number;
taxCents: number;
totalCents: number;
createdAt: Date;
updatedAt: Date;
}
export interface OrderView extends Order {
items: OrderItem[];
}
export const ALLOWED_TRANSITIONS: Readonly<Record<OrderState, ReadonlyArray<OrderState>>> = {
PENDING: ['AWAITING_PAYMENT', 'CANCELLED'],
AWAITING_PAYMENT: ['PAID', 'CANCELLED'],
PAID: ['PROCESSING', 'SHIPPED', 'CANCELLED', 'REFUNDED'],
PROCESSING: ['SHIPPED', 'CANCELLED', 'REFUNDED'],
SHIPPED: ['DELIVERED', 'PARTIALLY_REFUNDED'],
DELIVERED: ['PARTIALLY_REFUNDED'],
CANCELLED: [],
REFUNDED: [],
PARTIALLY_REFUNDED: [],
};
export function isTransitionAllowed(from: OrderState, to: OrderState): boolean {
return ALLOWED_TRANSITIONS[from].includes(to);
}

View File

@@ -0,0 +1,41 @@
import type { Order, OrderItem, OrderItemInput, OrderState, OrderView } from './order.js';
export interface OrderRepository {
create(input: {
userId: string;
idempotencyKey: string | null;
items: OrderItemInput[];
totals: { subtotalCents: number; discountCents: number; taxCents: number; totalCents: number };
}): Promise<OrderView>;
findAll(): Promise<OrderView[]>;
findById(id: string): Promise<OrderView | undefined>;
findByIdAndUserId(id: string, userId: string): Promise<OrderView | undefined>;
updateState(id: string, state: OrderState): Promise<OrderView | undefined>;
}
export interface OrderEventPublisher {
emit(event: OrderEvent): Promise<void>;
}
export type OrderEvent =
| { type: 'OrderCreated'; orderId: string; userId: string }
| { type: 'OrderPaid'; orderId: string; userId: string }
| { type: 'OrderCancelled'; orderId: string; userId: string };
export interface OrderServicePort {
create(input: CreateOrderCommand): Promise<OrderView>;
listOrders(): Promise<OrderView[]>;
transition(id: string, next: OrderState, userId: string): Promise<OrderView>;
transitionAdmin(id: string, next: OrderState): Promise<OrderView>;
getOrder(id: string, userId: string): Promise<OrderView | undefined>;
getOrderAdmin(id: string): Promise<OrderView | undefined>;
}
export interface CreateOrderCommand {
userId: string;
idempotencyKey?: string | null;
items: OrderItemInput[];
totals: { subtotalCents: number; discountCents: number; taxCents: number; totalCents: number };
}
export type { Order, OrderItem, OrderItemInput, OrderState, OrderView };

Some files were not shown because too many files have changed in this diff Show More