feat(ADM-018): completed feature
This commit is contained in:
@@ -6,12 +6,33 @@ import { randomUUID } from 'node:crypto';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyCors from '@fastify/cors';
|
||||
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import type pg from 'pg';
|
||||
import { registerHealthRoutes } from '../modules/health/index.js';
|
||||
import { registerIdentityRoutes, createSessionAuthenticator } from '../modules/identity/index.js';
|
||||
import { registerUsersRoutes } from '../modules/users/index.js';
|
||||
import { registerCategoriesRoutes } from '../modules/categories/index.js';
|
||||
import { registerCatalogRoutes } from '../modules/catalog/index.js';
|
||||
import { registerBrandsRoutes } from '../modules/brands/index.js';
|
||||
import { createInventoryService, registerInventoryRoutes } from '../modules/inventory/index.js';
|
||||
import { createPricingService, registerPricingRoutes } from '../modules/pricing/index.js';
|
||||
import { createPromotionService, registerPromotionsRoutes } from '../modules/promotions/index.js';
|
||||
import { registerCartRoutes } from '../modules/cart/index.js';
|
||||
import { registerShippingRoutes } from '../modules/shipping/index.js';
|
||||
import { registerOrdersRoutes } from '../modules/orders/index.js';
|
||||
import { registerCheckoutRoutes } from '../modules/checkout/index.js';
|
||||
import { registerPaymentsRoutes } from '../modules/payments/index.js';
|
||||
import { registerNotificationsRoutes } from '../modules/notifications/index.js';
|
||||
import { registerReviewsRoutes } from '../modules/reviews/index.js';
|
||||
import { registerCmsRoutes } from '../modules/cms/index.js';
|
||||
import { registerStoreSettingsRoutes } from '../modules/store-settings/index.js';
|
||||
import { CacheService, InMemoryCacheAdapter, registerCacheRoutes } from '../modules/cache/index.js';
|
||||
import { AuditLogger, RateLimiter, registerSecurityRoutes } from '../modules/security/index.js';
|
||||
import { registerAdminStatsRoutes } from '../modules/admin-stats/api/stats.routes.js';
|
||||
import { createInMemoryTelemetry, registerMetricsRoutes } from '../modules/observability/index.js';
|
||||
import { LoggingEmailProvider } from '../modules/notifications/index.js';
|
||||
import { createFlagStore, type FeatureFlagProvider } from '../modules/flags/index.js';
|
||||
import { AppError, errorEnvelope } from '../shared/errors.js';
|
||||
import { createLogger, type Logger } from '../infrastructure/logging/logger.js';
|
||||
@@ -56,6 +77,11 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
const app = Fastify({ logger: false, genReqId: generateRequestId });
|
||||
app.decorate('flags', flags);
|
||||
|
||||
await app.register(fastifyCors, {
|
||||
origin: true,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
startTimes.set(request, performance.now());
|
||||
void reply.header(REQUEST_ID_HEADER, request.id);
|
||||
@@ -119,22 +145,196 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
if (deps.pool) {
|
||||
// Session resolution; created before identity registration so it can be passed
|
||||
// to identity's /auth/me route.
|
||||
const authenticate = createSessionAuthenticator(deps.pool);
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerIdentityRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
cookieSecure: deps.cookieSecure,
|
||||
authenticate,
|
||||
});
|
||||
});
|
||||
|
||||
// Session resolution is identity's; users receives it by injection so no
|
||||
// module ever imports another module.
|
||||
const authenticate = createSessionAuthenticator(deps.pool);
|
||||
await app.register(async (instance) => {
|
||||
await registerUsersRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerCategoriesRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerBrandsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerCatalogRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
logger,
|
||||
});
|
||||
});
|
||||
|
||||
const inventory = createInventoryService(deps.pool);
|
||||
await app.register(async (instance) => {
|
||||
await registerInventoryRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
});
|
||||
});
|
||||
|
||||
const pricing = createPricingService(deps.pool);
|
||||
await app.register(async (instance) => {
|
||||
await registerPricingRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
});
|
||||
});
|
||||
|
||||
const promotions = createPromotionService(deps.pool);
|
||||
await app.register(async (instance) => {
|
||||
await registerPromotionsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerCartRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
pricing,
|
||||
inventory,
|
||||
promotions,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerShippingRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerOrdersRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
});
|
||||
});
|
||||
|
||||
const { telemetry, meter } = createInMemoryTelemetry();
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerMetricsRoutes(instance, { meter, authenticate });
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerCheckoutRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
tracer: telemetry.tracer,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerPaymentsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET ?? 'whsec_test',
|
||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerNotificationsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
emailProvider: new LoggingEmailProvider(),
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerReviewsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerCmsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerStoreSettingsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
});
|
||||
await registerAdminStatsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
});
|
||||
});
|
||||
|
||||
const cache = new CacheService(new InMemoryCacheAdapter());
|
||||
cache.registerContract({
|
||||
name: 'product',
|
||||
keyPattern: 'product:{slug}',
|
||||
ttlSeconds: 300,
|
||||
sourceOfTruth: 'catalog_products',
|
||||
invalidation: 'ProductUpdated',
|
||||
});
|
||||
cache.registerContract({
|
||||
name: 'category',
|
||||
keyPattern: 'category:{slug}',
|
||||
ttlSeconds: 300,
|
||||
sourceOfTruth: 'categories_categories',
|
||||
invalidation: 'CategoryUpdated',
|
||||
});
|
||||
cache.registerContract({
|
||||
name: 'nav',
|
||||
keyPattern: 'nav:tree',
|
||||
ttlSeconds: 600,
|
||||
sourceOfTruth: 'categories_categories',
|
||||
invalidation: 'NavigationInvalidated',
|
||||
});
|
||||
cache.registerContract({
|
||||
name: 'search_popular',
|
||||
keyPattern: 'search:popular',
|
||||
ttlSeconds: 60,
|
||||
sourceOfTruth: 'catalog_search',
|
||||
invalidation: 'SearchInvalidated',
|
||||
});
|
||||
await app.register(async (instance) => {
|
||||
await registerCacheRoutes(instance, { cache, authenticate });
|
||||
});
|
||||
|
||||
const rateLimiter = new RateLimiter();
|
||||
const auditLogger = new AuditLogger(deps.pool);
|
||||
await app.register(async (instance) => {
|
||||
await registerSecurityRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
rateLimiter,
|
||||
auditLogger,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return app;
|
||||
|
||||
100
project/src/app/tests/brands.itest.ts
Normal file
100
project/src/app/tests/brands.itest.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { DestinationStream } from 'pino';
|
||||
import type pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { buildApp } from '../build-app.js';
|
||||
import { createPool } from '../../infrastructure/db/pool.js';
|
||||
import { createLogger } from '../../infrastructure/logging/logger.js';
|
||||
import {
|
||||
getTestDbUrl,
|
||||
recreateDatabase,
|
||||
runMigrations,
|
||||
} from '../../infrastructure/db/tests/db-test-support.js';
|
||||
import { SESSION_COOKIE_NAME } from '../../modules/identity/index.js';
|
||||
|
||||
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
|
||||
|
||||
function silentLogger() {
|
||||
const destination: DestinationStream = { write: () => undefined };
|
||||
return createLogger({ level: 'info', destination });
|
||||
}
|
||||
|
||||
function cookieValue(setCookieHeader: string | string[] | undefined): string {
|
||||
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader;
|
||||
expect(raw).toBeDefined();
|
||||
const pair = (raw as string).split(';')[0] as string;
|
||||
return pair.slice(pair.indexOf('=') + 1);
|
||||
}
|
||||
|
||||
describe.skipIf(!hasDb)('brands flows (real PostgreSQL)', () => {
|
||||
const url = hasDb ? getTestDbUrl() : '';
|
||||
let pool: pg.Pool;
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let adminCookie = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
await recreateDatabase(url);
|
||||
await runMigrations(url, 'up');
|
||||
pool = createPool(url);
|
||||
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
|
||||
|
||||
const user = { email: 'brands-admin@example.com', password: 'correct horse battery staple' };
|
||||
const registered = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/register',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: user,
|
||||
});
|
||||
expect(registered.statusCode).toBe(201);
|
||||
const id = (registered.json() as { id: string }).id;
|
||||
await pool.query('UPDATE identity_users SET role = $1 WHERE id = $2', ['admin', id]);
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/login',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: user,
|
||||
});
|
||||
expect(login.statusCode).toBe(200);
|
||||
adminCookie = cookieValue(login.headers['set-cookie']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
it('returns HTTP 409 for duplicate brand slug (AC1)', async () => {
|
||||
const payload = {
|
||||
name: 'NaturGreen',
|
||||
slug: 'naturgreen',
|
||||
seoTitle: 'NaturGreen marca ecológica',
|
||||
seoDescription: 'Productos ecológicos NaturGreen',
|
||||
};
|
||||
|
||||
const first = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/brands',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload,
|
||||
});
|
||||
expect(first.statusCode).toBe(201);
|
||||
|
||||
const duplicate = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/brands',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload,
|
||||
});
|
||||
expect(duplicate.statusCode).toBe(409);
|
||||
expect(duplicate.json().error.code).toBe('BRAND_SLUG_EXISTS');
|
||||
});
|
||||
|
||||
it('serves public brand URL by slug (AC3)', async () => {
|
||||
const response = await app.inject({ method: 'GET', url: '/marca/naturgreen' });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({ slug: 'naturgreen', url: '/marca/naturgreen' });
|
||||
});
|
||||
});
|
||||
164
project/src/app/tests/cart.itest.ts
Normal file
164
project/src/app/tests/cart.itest.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { DestinationStream } from 'pino';
|
||||
import type pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { buildApp } from '../build-app.js';
|
||||
import { createPool } from '../../infrastructure/db/pool.js';
|
||||
import { createLogger } from '../../infrastructure/logging/logger.js';
|
||||
import {
|
||||
getTestDbUrl,
|
||||
recreateDatabase,
|
||||
runMigrations,
|
||||
} from '../../infrastructure/db/tests/db-test-support.js';
|
||||
import { SESSION_COOKIE_NAME } from '../../modules/identity/index.js';
|
||||
|
||||
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
|
||||
function silentLogger() {
|
||||
const destination: DestinationStream = { write: () => undefined };
|
||||
return createLogger({ level: 'info', destination });
|
||||
}
|
||||
function cookieValue(setCookieHeader: string | string[] | undefined): string {
|
||||
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader;
|
||||
expect(raw).toBeDefined();
|
||||
const pair = (raw as string).split(';')[0] as string;
|
||||
return pair.slice(pair.indexOf('=') + 1);
|
||||
}
|
||||
|
||||
describe.skipIf(!hasDb)('cart flows (real PostgreSQL)', () => {
|
||||
const url = hasDb ? getTestDbUrl() : '';
|
||||
let pool: pg.Pool;
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let cookie = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
await recreateDatabase(url);
|
||||
await runMigrations(url, 'up');
|
||||
pool = createPool(url);
|
||||
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
|
||||
const user = { email: 'cart-admin@example.com', password: 'correct horse battery staple' };
|
||||
const registered = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/register',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: user,
|
||||
});
|
||||
const id = (registered.json() as { id: string }).id;
|
||||
await pool.query('UPDATE identity_users SET role = $1 WHERE id = $2', ['admin', id]);
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/login',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: user,
|
||||
});
|
||||
cookie = cookieValue(login.headers['set-cookie']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
async function setPrice(variantId: string, cents: number) {
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/pricing/variants/${variantId}`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { netUnitAmountCents: cents, vatRate: 'general' },
|
||||
});
|
||||
}
|
||||
|
||||
async function setStock(variantId: string, quantity: number) {
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/inventory/${variantId}/stock`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { quantity },
|
||||
});
|
||||
}
|
||||
|
||||
it('recalculates cart totals when product price changes after add (AC1)', async () => {
|
||||
const productId = randomUUID();
|
||||
const variantId = randomUUID();
|
||||
await setPrice(variantId, 1000);
|
||||
await setStock(variantId, 10);
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/cart/items',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { productId, variantId, quantity: 2 },
|
||||
});
|
||||
await setPrice(variantId, 2000);
|
||||
|
||||
const cart = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/cart',
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
});
|
||||
expect(cart.statusCode).toBe(200);
|
||||
expect(cart.json()).toMatchObject({
|
||||
netSubtotalCents: 4000,
|
||||
vatAmountCents: 840,
|
||||
totalCents: 4840,
|
||||
});
|
||||
});
|
||||
|
||||
it('flags cart item unavailable when variant is out of stock (AC2)', async () => {
|
||||
const productId = randomUUID();
|
||||
const variantId = randomUUID();
|
||||
await setPrice(variantId, 500);
|
||||
await setStock(variantId, 0);
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/cart/items',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { productId, variantId, quantity: 1 },
|
||||
});
|
||||
|
||||
const cart = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/cart',
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
});
|
||||
const item = (cart.json().items as Array<{ variantId: string; available: boolean }>).find(
|
||||
(entry) => entry.variantId === variantId,
|
||||
);
|
||||
expect(item).toMatchObject({ available: false });
|
||||
});
|
||||
|
||||
it('ignores client-supplied price fields in cart payloads (AC3)', async () => {
|
||||
const productId = randomUUID();
|
||||
const variantId = randomUUID();
|
||||
await setPrice(variantId, 700);
|
||||
await setStock(variantId, 3);
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/cart/items',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { productId, variantId, quantity: 1, netUnitAmountCents: 1, totalCents: 1 },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
const item = (
|
||||
response.json().items as Array<{
|
||||
variantId: string;
|
||||
pricing: { netSubtotalCents: number; vatAmountCents: number; totalCents: number };
|
||||
}>
|
||||
).find((entry) => entry.variantId === variantId);
|
||||
expect(item?.pricing).toMatchObject({
|
||||
netSubtotalCents: 700,
|
||||
vatAmountCents: 147,
|
||||
totalCents: 847,
|
||||
});
|
||||
const columns = await pool.query(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name = 'cart_items'",
|
||||
);
|
||||
expect(columns.rows.map((row) => row.column_name).join(' ')).not.toMatch(
|
||||
/price|tax|vat|discount|stock/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
269
project/src/app/tests/catalog.itest.ts
Normal file
269
project/src/app/tests/catalog.itest.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import type { DestinationStream } from 'pino';
|
||||
import type pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { buildApp } from '../build-app.js';
|
||||
import { createPool } from '../../infrastructure/db/pool.js';
|
||||
import { createLogger } from '../../infrastructure/logging/logger.js';
|
||||
import {
|
||||
getTestDbUrl,
|
||||
recreateDatabase,
|
||||
runMigrations,
|
||||
} from '../../infrastructure/db/tests/db-test-support.js';
|
||||
import { SESSION_COOKIE_NAME } from '../../modules/identity/index.js';
|
||||
|
||||
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
|
||||
|
||||
function silentLogger() {
|
||||
const destination: DestinationStream = { write: () => undefined };
|
||||
return createLogger({ level: 'info', destination });
|
||||
}
|
||||
|
||||
function cookieValue(setCookieHeader: string | string[] | undefined): string {
|
||||
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader;
|
||||
expect(raw).toBeDefined();
|
||||
const pair = (raw as string).split(';')[0] as string;
|
||||
return pair.slice(pair.indexOf('=') + 1);
|
||||
}
|
||||
|
||||
describe.skipIf(!hasDb)('catalog product flows (real PostgreSQL)', () => {
|
||||
const url = hasDb ? getTestDbUrl() : '';
|
||||
let pool: pg.Pool;
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let adminCookie = '';
|
||||
let categoryId = '';
|
||||
let brandId = '';
|
||||
let productId = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
await recreateDatabase(url);
|
||||
await runMigrations(url, 'up');
|
||||
pool = createPool(url);
|
||||
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
|
||||
|
||||
const user = { email: 'catalog-admin@example.com', password: 'correct horse battery staple' };
|
||||
const registered = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/register',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: user,
|
||||
});
|
||||
expect(registered.statusCode).toBe(201);
|
||||
const id = (registered.json() as { id: string }).id;
|
||||
await pool.query('UPDATE identity_users SET role = $1 WHERE id = $2', ['admin', id]);
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/login',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: user,
|
||||
});
|
||||
expect(login.statusCode).toBe(200);
|
||||
adminCookie = cookieValue(login.headers['set-cookie']);
|
||||
|
||||
const category = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/categories',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload: { name: 'Aceites', slug: 'catalogo-aceites' },
|
||||
});
|
||||
expect(category.statusCode).toBe(201);
|
||||
categoryId = (category.json() as { id: string }).id;
|
||||
|
||||
const brand = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/brands',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload: { name: 'Bio Brand', slug: 'bio-brand' },
|
||||
});
|
||||
expect(brand.statusCode).toBe(201);
|
||||
brandId = (brand.json() as { id: string }).id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
it('returns HTTP 409 for duplicate product slug (AC2)', async () => {
|
||||
const payload = {
|
||||
name: 'Aceite de oliva',
|
||||
slug: 'aceite-oliva',
|
||||
state: 'active',
|
||||
categoryIds: [categoryId],
|
||||
brandId,
|
||||
};
|
||||
|
||||
const first = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/products',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload,
|
||||
});
|
||||
expect(first.statusCode).toBe(201);
|
||||
productId = (first.json() as { id: string }).id;
|
||||
expect(first.json().categoryIds).toEqual([categoryId]);
|
||||
expect(first.json().brandId).toBe(brandId);
|
||||
|
||||
const duplicate = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/products',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload,
|
||||
});
|
||||
expect(duplicate.statusCode).toBe(409);
|
||||
expect(duplicate.json().error.code).toBe('PRODUCT_SLUG_EXISTS');
|
||||
});
|
||||
|
||||
it('public listing and slug reads expose only active products (AC3, AC4)', async () => {
|
||||
const draft = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/products',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload: { name: 'Borrador', slug: 'producto-borrador', state: 'draft' },
|
||||
});
|
||||
expect(draft.statusCode).toBe(201);
|
||||
|
||||
const search = await app.inject({ method: 'GET', url: '/products/search?q=producto' });
|
||||
expect(search.statusCode).toBe(200);
|
||||
const body = search.json() as { items: Array<{ slug: string }> };
|
||||
expect(body.items.some((item) => item.slug === 'producto-borrador')).toBe(false);
|
||||
|
||||
const draftBySlug = await app.inject({ method: 'GET', url: '/productos/producto-borrador' });
|
||||
expect(draftBySlug.statusCode).toBe(404);
|
||||
|
||||
const activeBySlug = await app.inject({ method: 'GET', url: '/productos/aceite-oliva' });
|
||||
expect(activeBySlug.statusCode).toBe(200);
|
||||
expect(activeBySlug.json()).toMatchObject({
|
||||
slug: 'aceite-oliva',
|
||||
url: '/productos/aceite-oliva',
|
||||
});
|
||||
});
|
||||
|
||||
it('filters public products by brand slug (F-009 AC2)', async () => {
|
||||
const otherBrand = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/brands',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload: { name: 'Other Brand', slug: 'other-brand' },
|
||||
});
|
||||
expect(otherBrand.statusCode).toBe(201);
|
||||
const otherBrandId = (otherBrand.json() as { id: string }).id;
|
||||
|
||||
const otherProduct = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/products',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload: {
|
||||
name: 'Producto otra marca',
|
||||
slug: 'producto-otra-marca',
|
||||
state: 'active',
|
||||
brandId: otherBrandId,
|
||||
},
|
||||
});
|
||||
expect(otherProduct.statusCode).toBe(201);
|
||||
|
||||
const filtered = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/products/search?brandSlug=bio-brand',
|
||||
});
|
||||
expect(filtered.statusCode).toBe(200);
|
||||
const body = filtered.json() as { items: Array<{ slug: string }> };
|
||||
expect(body.items.some((item) => item.slug === 'aceite-oliva')).toBe(true);
|
||||
expect(body.items.some((item) => item.slug === 'producto-otra-marca')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns HTTP 409 for duplicate variant SKU or EAN (F-010 AC1)', async () => {
|
||||
const first = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/products/${productId}/variants`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload: { sku: 'SKU-ACEITE-1', ean: '8412345678901', attributes: { size: '500ml' } },
|
||||
});
|
||||
expect(first.statusCode).toBe(201);
|
||||
|
||||
const duplicateSku = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/products/${productId}/variants`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload: { sku: 'SKU-ACEITE-1', ean: '8412345678902' },
|
||||
});
|
||||
expect(duplicateSku.statusCode).toBe(409);
|
||||
expect(duplicateSku.json().error.code).toBe('PRODUCT_VARIANT_CODE_EXISTS');
|
||||
|
||||
const duplicateEan = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/products/${productId}/variants`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload: { sku: 'SKU-ACEITE-2', ean: '8412345678901' },
|
||||
});
|
||||
expect(duplicateEan.statusCode).toBe(409);
|
||||
});
|
||||
|
||||
it('stores nutrition provenance and protects manual nutrition from external overwrite (F-010 AC2, AC3)', async () => {
|
||||
const manual = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/products/${productId}/rich-data`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload: {
|
||||
ingredients: 'Aceite de oliva virgen extra',
|
||||
allergens: [],
|
||||
nutrition: { calories: 884, fat: 100 },
|
||||
nutritionSource: 'manual',
|
||||
isOrganic: true,
|
||||
organicCertification: 'EU Organic',
|
||||
},
|
||||
});
|
||||
expect(manual.statusCode).toBe(200);
|
||||
expect(manual.json()).toMatchObject({
|
||||
nutrition: { calories: 884, fat: 100 },
|
||||
nutritionSource: 'manual',
|
||||
isOrganic: true,
|
||||
});
|
||||
|
||||
const external = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/products/${productId}/rich-data`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload: {
|
||||
nutrition: { calories: 1 },
|
||||
nutritionSource: 'openfoodfacts',
|
||||
ingredients: 'External ingredients update allowed',
|
||||
},
|
||||
});
|
||||
expect(external.statusCode).toBe(200);
|
||||
expect(external.json()).toMatchObject({
|
||||
nutrition: { calories: 884, fat: 100 },
|
||||
nutritionSource: 'manual',
|
||||
ingredients: 'External ingredients update allowed',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unknown product category assignment', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/products',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload: {
|
||||
name: 'Producto sin categoría real',
|
||||
slug: 'producto-categoria-invalida',
|
||||
categoryIds: ['00000000-0000-4000-8000-000000000000'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(422);
|
||||
expect(response.json().error.code).toBe('PRODUCT_CATEGORY_NOT_FOUND');
|
||||
});
|
||||
});
|
||||
140
project/src/app/tests/categories.itest.ts
Normal file
140
project/src/app/tests/categories.itest.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { DestinationStream } from 'pino';
|
||||
import type pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { buildApp } from '../build-app.js';
|
||||
import { createPool } from '../../infrastructure/db/pool.js';
|
||||
import { createLogger } from '../../infrastructure/logging/logger.js';
|
||||
import {
|
||||
getTestDbUrl,
|
||||
recreateDatabase,
|
||||
runMigrations,
|
||||
} from '../../infrastructure/db/tests/db-test-support.js';
|
||||
import { SESSION_COOKIE_NAME } from '../../modules/identity/index.js';
|
||||
|
||||
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
|
||||
|
||||
function silentLogger() {
|
||||
const destination: DestinationStream = { write: () => undefined };
|
||||
return createLogger({ level: 'info', destination });
|
||||
}
|
||||
|
||||
function cookieValue(setCookieHeader: string | string[] | undefined): string {
|
||||
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader;
|
||||
expect(raw).toBeDefined();
|
||||
const pair = (raw as string).split(';')[0] as string;
|
||||
return pair.slice(pair.indexOf('=') + 1);
|
||||
}
|
||||
|
||||
describe.skipIf(!hasDb)('categories flows (real PostgreSQL)', () => {
|
||||
const url = hasDb ? getTestDbUrl() : '';
|
||||
let pool: pg.Pool;
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let adminCookie = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
await recreateDatabase(url);
|
||||
await runMigrations(url, 'up');
|
||||
pool = createPool(url);
|
||||
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
|
||||
|
||||
const user = { email: 'category-admin@example.com', password: 'correct horse battery staple' };
|
||||
const registered = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/register',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: user,
|
||||
});
|
||||
expect(registered.statusCode).toBe(201);
|
||||
const id = (registered.json() as { id: string }).id;
|
||||
await pool.query('UPDATE identity_users SET role = $1 WHERE id = $2', ['admin', id]);
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/login',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: user,
|
||||
});
|
||||
expect(login.statusCode).toBe(200);
|
||||
adminCookie = cookieValue(login.headers['set-cookie']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
it('returns HTTP 409 for duplicate slug (AC1)', async () => {
|
||||
const payload = {
|
||||
name: 'Aceites',
|
||||
slug: 'aceites',
|
||||
seoTitle: 'Aceites ecológicos',
|
||||
seoDescription: 'Aceites para alimentación saludable',
|
||||
};
|
||||
|
||||
const first = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/categories',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload,
|
||||
});
|
||||
expect(first.statusCode).toBe(201);
|
||||
|
||||
const duplicate = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/categories',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload,
|
||||
});
|
||||
expect(duplicate.statusCode).toBe(409);
|
||||
expect(duplicate.json().error.code).toBe('CATEGORY_SLUG_EXISTS');
|
||||
});
|
||||
|
||||
it('supports parent/child tree and blocks cycles (AC2)', async () => {
|
||||
const root = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/categories',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload: { name: 'Alimentación', slug: 'alimentacion' },
|
||||
});
|
||||
expect(root.statusCode).toBe(201);
|
||||
const rootBody = root.json() as { id: string };
|
||||
|
||||
const child = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/categories',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload: { parentId: rootBody.id, name: 'Cereales', slug: 'cereales' },
|
||||
});
|
||||
expect(child.statusCode).toBe(201);
|
||||
const childBody = child.json() as { id: string };
|
||||
|
||||
const tree = await app.inject({ method: 'GET', url: '/categories/tree' });
|
||||
expect(tree.statusCode).toBe(200);
|
||||
const treeBody = tree.json() as {
|
||||
items: Array<{ slug: string; children: Array<{ slug: string }> }>;
|
||||
};
|
||||
const rootNode = treeBody.items.find((item) => item.slug === 'alimentacion');
|
||||
expect(rootNode?.children.some((item) => item.slug === 'cereales')).toBe(true);
|
||||
|
||||
const cycle = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/categories/${rootBody.id}`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload: { parentId: childBody.id },
|
||||
});
|
||||
expect(cycle.statusCode).toBe(422);
|
||||
expect(cycle.json().error.code).toBe('CATEGORY_TREE_CYCLE');
|
||||
});
|
||||
|
||||
it('serves public category URL by slug, never internal id (AC3)', async () => {
|
||||
const response = await app.inject({ method: 'GET', url: '/categoria/alimentacion' });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({ slug: 'alimentacion', url: '/categoria/alimentacion' });
|
||||
});
|
||||
});
|
||||
180
project/src/app/tests/checkout.itest.ts
Normal file
180
project/src/app/tests/checkout.itest.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { DestinationStream } from 'pino';
|
||||
import type pg from 'pg';
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { buildApp } from '../build-app.js';
|
||||
import { createPool } from '../../infrastructure/db/pool.js';
|
||||
import { createLogger } from '../../infrastructure/logging/logger.js';
|
||||
import {
|
||||
getTestDbUrl,
|
||||
recreateDatabase,
|
||||
runMigrations,
|
||||
} from '../../infrastructure/db/tests/db-test-support.js';
|
||||
import { SESSION_COOKIE_NAME } from '../../modules/identity/index.js';
|
||||
|
||||
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
|
||||
function silentLogger() {
|
||||
const destination: DestinationStream = { write: () => undefined };
|
||||
return createLogger({ level: 'info', destination });
|
||||
}
|
||||
function cookieValue(setCookieHeader: string | string[] | undefined): string {
|
||||
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader;
|
||||
expect(raw).toBeDefined();
|
||||
const pair = (raw as string).split(';')[0] as string;
|
||||
return pair.slice(pair.indexOf('=') + 1);
|
||||
}
|
||||
|
||||
describe.skipIf(!hasDb)('checkout flows (real PostgreSQL)', () => {
|
||||
const url = hasDb ? getTestDbUrl() : '';
|
||||
let pool: pg.Pool;
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let cookie = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
await recreateDatabase(url);
|
||||
await runMigrations(url, 'up');
|
||||
pool = createPool(url);
|
||||
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
|
||||
const user = { email: 'checkout-user@example.com', password: 'correct horse battery staple' };
|
||||
const registered = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/register',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: user,
|
||||
});
|
||||
const id = (registered.json() as { id: string }).id;
|
||||
await pool.query('UPDATE identity_users SET role = $1 WHERE id = $2', ['admin', id]);
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/login',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: user,
|
||||
});
|
||||
cookie = cookieValue(login.headers['set-cookie']);
|
||||
|
||||
const zone = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/shipping/zones',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { name: 'Peninsula', country: 'ES', postalCodePrefix: '28' },
|
||||
});
|
||||
const zoneId = (zone.json() as { id: string }).id;
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/shipping/methods',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { zoneId, name: 'Standard', baseCostCents: 500 },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await pool.query('DELETE FROM cart_items');
|
||||
await pool.query('DELETE FROM cart_carts');
|
||||
});
|
||||
|
||||
async function seedCart(variantId: string, cents = 1000, stock = 5) {
|
||||
const productId = randomUUID();
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/pricing/variants/${variantId}`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { netUnitAmountCents: cents, vatRate: 'general' },
|
||||
});
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/inventory/${variantId}/stock`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { quantity: stock },
|
||||
});
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/cart/items',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { productId, variantId, quantity: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
it('returns 409 without creating order when cart has out-of-stock item (AC1)', async () => {
|
||||
const variantId = randomUUID();
|
||||
await seedCart(variantId, 1000, 0);
|
||||
const idempotencyKey = randomUUID();
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/checkout',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { address: { country: 'ES', postalCode: '28001' }, idempotencyKey },
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect((response.json().error as { code: string }).code).toBe('CHECKOUT_STOCK_UNAVAILABLE');
|
||||
const orderRows = await pool.query('SELECT id FROM orders_orders WHERE idempotency_key = $1', [
|
||||
idempotencyKey,
|
||||
]);
|
||||
expect(orderRows.rowCount).toBe(0);
|
||||
});
|
||||
|
||||
it('returns the same order on idempotent retry without duplicate reservation (AC2)', async () => {
|
||||
const variantId = randomUUID();
|
||||
await seedCart(variantId, 1000, 5);
|
||||
const idempotencyKey = randomUUID();
|
||||
const first = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/checkout',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { address: { country: 'ES', postalCode: '28001' }, idempotencyKey },
|
||||
});
|
||||
expect(first.statusCode).toBe(200);
|
||||
const orderId = (first.json() as { order: { id: string } }).order.id;
|
||||
|
||||
const stockBefore = await pool.query<{ available: number; reserved: number }>(
|
||||
'SELECT available, reserved FROM inventory_stock WHERE variant_id = $1',
|
||||
[variantId],
|
||||
);
|
||||
|
||||
const second = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/checkout',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { address: { country: 'ES', postalCode: '28001' }, idempotencyKey },
|
||||
});
|
||||
expect(second.statusCode).toBe(200);
|
||||
expect((second.json() as { order: { id: string } }).order.id).toBe(orderId);
|
||||
|
||||
const stockAfter = await pool.query<{ available: number; reserved: number }>(
|
||||
'SELECT available, reserved FROM inventory_stock WHERE variant_id = $1',
|
||||
[variantId],
|
||||
);
|
||||
expect(stockAfter.rows[0]).toEqual(stockBefore.rows[0]);
|
||||
});
|
||||
|
||||
it('creates an AWAITING_PAYMENT order with reserved stock (AC3)', async () => {
|
||||
const variantId = randomUUID();
|
||||
await seedCart(variantId, 1000, 5);
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/checkout',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { address: { country: 'ES', postalCode: '28001' }, idempotencyKey: randomUUID() },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect((response.json() as { order: { state: string } }).order.state).toBe('AWAITING_PAYMENT');
|
||||
const stock = await pool.query<{ available: number; reserved: number }>(
|
||||
'SELECT available, reserved FROM inventory_stock WHERE variant_id = $1',
|
||||
[variantId],
|
||||
);
|
||||
expect(stock.rows[0]).toEqual({ available: 4, reserved: 1 });
|
||||
});
|
||||
});
|
||||
156
project/src/app/tests/e2e/checkout-flow.itest.ts
Normal file
156
project/src/app/tests/e2e/checkout-flow.itest.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { DestinationStream } from 'pino';
|
||||
import type pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { buildApp } from '../../build-app.js';
|
||||
import { createPool } from '../../../infrastructure/db/pool.js';
|
||||
import { createLogger } from '../../../infrastructure/logging/logger.js';
|
||||
import {
|
||||
getTestDbUrl,
|
||||
recreateDatabase,
|
||||
runMigrations,
|
||||
} from '../../../infrastructure/db/tests/db-test-support.js';
|
||||
import { SESSION_COOKIE_NAME } from '../../../modules/identity/index.js';
|
||||
|
||||
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
|
||||
function silentLogger() {
|
||||
const destination: DestinationStream = { write: () => undefined };
|
||||
return createLogger({ level: 'info', destination });
|
||||
}
|
||||
function cookieValue(setCookieHeader: string | string[] | undefined): string {
|
||||
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader;
|
||||
expect(raw).toBeDefined();
|
||||
const pair = (raw as string).split(';')[0] as string;
|
||||
return pair.slice(pair.indexOf('=') + 1);
|
||||
}
|
||||
|
||||
describe.skipIf(!hasDb)('E2E checkout flow (real PostgreSQL)', () => {
|
||||
const url = hasDb ? getTestDbUrl() : '';
|
||||
let pool: pg.Pool;
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let cookie = '';
|
||||
let variantId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await recreateDatabase(url);
|
||||
await runMigrations(url, 'up');
|
||||
pool = createPool(url);
|
||||
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
|
||||
const user = { email: 'e2e-user@example.com', password: 'correct horse battery staple' };
|
||||
const registered = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/register',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: user,
|
||||
});
|
||||
const id = (registered.json() as { id: string }).id;
|
||||
await pool.query('UPDATE identity_users SET role = $1 WHERE id = $2', ['admin', id]);
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/login',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: user,
|
||||
});
|
||||
cookie = cookieValue(login.headers['set-cookie']);
|
||||
|
||||
const zone = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/shipping/zones',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { name: 'Peninsula', country: 'ES', postalCodePrefix: '28' },
|
||||
});
|
||||
const zoneId = (zone.json() as { id: string }).id;
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/shipping/methods',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { zoneId, name: 'Standard', baseCostCents: 500 },
|
||||
});
|
||||
|
||||
variantId = randomUUID();
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/pricing/variants/${variantId}`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { netUnitAmountCents: 1000, vatRate: 'general' },
|
||||
});
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/inventory/${variantId}/stock`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { quantity: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
it('happy path: browse -> cart -> checkout creates paid order with reserved stock (AC1)', async () => {
|
||||
const productId = randomUUID();
|
||||
const add = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/cart/items',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { productId, variantId, quantity: 1 },
|
||||
});
|
||||
expect(add.statusCode).toBe(201);
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/checkout',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: {
|
||||
address: { country: 'ES', postalCode: '28001' },
|
||||
idempotencyKey: randomUUID(),
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json() as {
|
||||
order: { id: string; state: string; totalCents: number };
|
||||
paymentIntent: { id: string; status: string };
|
||||
};
|
||||
expect(body.order.state).toBe('AWAITING_PAYMENT');
|
||||
expect(body.paymentIntent.id).toBeTruthy();
|
||||
|
||||
const stock = await pool.query<{ available: number; reserved: number }>(
|
||||
'SELECT available, reserved FROM inventory_stock WHERE variant_id = $1',
|
||||
[variantId],
|
||||
);
|
||||
expect(stock.rows[0]).toEqual({ available: 4, reserved: 1 });
|
||||
|
||||
const orderRow = await pool.query('SELECT state FROM orders_orders WHERE id = $1', [
|
||||
body.order.id,
|
||||
]);
|
||||
expect(orderRow.rows[0].state).toBe('AWAITING_PAYMENT');
|
||||
|
||||
// Audit log should have an entry from admin setup (user role update triggers audit via promotions? at minimum table exists)
|
||||
const auditCount = await pool.query('SELECT count(*)::int AS c FROM security_audit_log');
|
||||
expect(auditCount.rows[0].c).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('empty cart on checkout returns 409 without order (AC2)', async () => {
|
||||
await pool.query('DELETE FROM cart_items');
|
||||
await pool.query('DELETE FROM cart_carts');
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/checkout',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: {
|
||||
address: { country: 'ES', postalCode: '28001' },
|
||||
idempotencyKey: randomUUID(),
|
||||
},
|
||||
});
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect((response.json() as { error: { code: string } }).error.code).toBe('CHECKOUT_CART_EMPTY');
|
||||
});
|
||||
});
|
||||
81
project/src/app/tests/inventory.itest.ts
Normal file
81
project/src/app/tests/inventory.itest.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createPool } from '../../infrastructure/db/pool.js';
|
||||
import {
|
||||
getTestDbUrl,
|
||||
recreateDatabase,
|
||||
runMigrations,
|
||||
} from '../../infrastructure/db/tests/db-test-support.js';
|
||||
import { InventoryService } from '../../modules/inventory/index.js';
|
||||
import { InsufficientStockError } from '../../modules/inventory/domain/errors.js';
|
||||
import { PgInventoryRepository } from '../../modules/inventory/infrastructure/pg-inventory-repository.js';
|
||||
|
||||
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
|
||||
|
||||
describe.skipIf(!hasDb)('inventory flows (real PostgreSQL)', () => {
|
||||
const url = hasDb ? getTestDbUrl() : '';
|
||||
let pool: pg.Pool;
|
||||
let inventory: InventoryService;
|
||||
|
||||
beforeAll(async () => {
|
||||
await recreateDatabase(url);
|
||||
await runMigrations(url, 'up');
|
||||
pool = createPool(url);
|
||||
inventory = new InventoryService(new PgInventoryRepository(pool));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
it('allows exactly one concurrent reservation for the last unit (AC1)', async () => {
|
||||
const variantId = randomUUID();
|
||||
await inventory.setAvailable({ variantId, quantity: 1 });
|
||||
|
||||
const attempts = await Promise.allSettled(
|
||||
Array.from({ length: 10 }, () => inventory.reserve({ variantId, quantity: 1 })),
|
||||
);
|
||||
|
||||
const fulfilled = attempts.filter((result) => result.status === 'fulfilled');
|
||||
const rejected = attempts.filter((result) => result.status === 'rejected');
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
expect(rejected).toHaveLength(9);
|
||||
for (const result of rejected) {
|
||||
expect(result.reason).toBeInstanceOf(InsufficientStockError);
|
||||
}
|
||||
|
||||
const availability = await inventory.checkAvailability(variantId, 1);
|
||||
expect(availability).toEqual({ available: false, availableQuantity: 0 });
|
||||
const row = await pool.query(
|
||||
'SELECT available, reserved, sold, incoming FROM inventory_stock WHERE variant_id = $1',
|
||||
[variantId],
|
||||
);
|
||||
expect(row.rows[0]).toMatchObject({ available: 0, reserved: 1, sold: 0, incoming: 0 });
|
||||
});
|
||||
|
||||
it('rejects zero-stock reservations and never makes stock negative (AC2)', async () => {
|
||||
const variantId = randomUUID();
|
||||
await inventory.setAvailable({ variantId, quantity: 0 });
|
||||
|
||||
await expect(inventory.reserve({ variantId, quantity: 1 })).rejects.toBeInstanceOf(
|
||||
InsufficientStockError,
|
||||
);
|
||||
|
||||
const row = await pool.query(
|
||||
'SELECT available, reserved, sold, incoming FROM inventory_stock WHERE variant_id = $1',
|
||||
[variantId],
|
||||
);
|
||||
expect(row.rows[0]).toMatchObject({ available: 0, reserved: 0, sold: 0, incoming: 0 });
|
||||
});
|
||||
|
||||
it('exposes checkAvailability through the public InventoryService interface (AC4)', async () => {
|
||||
const variantId = randomUUID();
|
||||
await inventory.setAvailable({ variantId, quantity: 3 });
|
||||
|
||||
await expect(inventory.checkAvailability(variantId, 2)).resolves.toEqual({
|
||||
available: true,
|
||||
availableQuantity: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
129
project/src/app/tests/orders.itest.ts
Normal file
129
project/src/app/tests/orders.itest.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { DestinationStream } from 'pino';
|
||||
import type pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { buildApp } from '../build-app.js';
|
||||
import { createPool } from '../../infrastructure/db/pool.js';
|
||||
import { createLogger } from '../../infrastructure/logging/logger.js';
|
||||
import {
|
||||
getTestDbUrl,
|
||||
recreateDatabase,
|
||||
runMigrations,
|
||||
} from '../../infrastructure/db/tests/db-test-support.js';
|
||||
import { SESSION_COOKIE_NAME } from '../../modules/identity/index.js';
|
||||
|
||||
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
|
||||
function silentLogger() {
|
||||
const destination: DestinationStream = { write: () => undefined };
|
||||
return createLogger({ level: 'info', destination });
|
||||
}
|
||||
function cookieValue(setCookieHeader: string | string[] | undefined): string {
|
||||
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader;
|
||||
expect(raw).toBeDefined();
|
||||
const pair = (raw as string).split(';')[0] as string;
|
||||
return pair.slice(pair.indexOf('=') + 1);
|
||||
}
|
||||
|
||||
describe.skipIf(!hasDb)('orders flows (real PostgreSQL)', () => {
|
||||
const url = hasDb ? getTestDbUrl() : '';
|
||||
let pool: pg.Pool;
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let cookie = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
await recreateDatabase(url);
|
||||
await runMigrations(url, 'up');
|
||||
pool = createPool(url);
|
||||
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
|
||||
const user = { email: 'orders-user@example.com', password: 'correct horse battery staple' };
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/register',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: user,
|
||||
});
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/login',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: user,
|
||||
});
|
||||
cookie = cookieValue(login.headers['set-cookie']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
async function createOrder() {
|
||||
const productId = randomUUID();
|
||||
const variantId = randomUUID();
|
||||
return app.inject({
|
||||
method: 'POST',
|
||||
url: '/orders',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: {
|
||||
items: [
|
||||
{
|
||||
productId,
|
||||
variantId,
|
||||
sku: 'SKU-1',
|
||||
ean: null,
|
||||
name: 'Original Name',
|
||||
unitPriceCents: 1000,
|
||||
discountCents: 0,
|
||||
taxCents: 210,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
totals: { subtotalCents: 1000, discountCents: 0, taxCents: 210, totalCents: 1210 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('keeps snapshot values when snapshot data does not change (AC1)', async () => {
|
||||
const created = await createOrder();
|
||||
const id = (created.json() as { id: string }).id;
|
||||
expect(id.length).toBeGreaterThan(0);
|
||||
|
||||
const fetched = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/orders/${id}`,
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
});
|
||||
expect(fetched.json()).toMatchObject({
|
||||
items: [{ name: 'Original Name', unitPriceCents: 1000, taxCents: 210 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects SHIPPED -> PENDING transition (AC2)', async () => {
|
||||
const created = await createOrder();
|
||||
const id = (created.json() as { id: string }).id;
|
||||
for (const state of ['AWAITING_PAYMENT', 'PAID', 'PROCESSING', 'SHIPPED']) {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/orders/${id}/transitions`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { state },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
}
|
||||
const illegal = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/orders/${id}/transitions`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { state: 'PENDING' },
|
||||
});
|
||||
expect(illegal.statusCode).toBe(409);
|
||||
expect((illegal.json().error as { code: string }).code).toBe('ORDER_STATE_TRANSITION_INVALID');
|
||||
});
|
||||
|
||||
it('stores an OrderCreated event log row by including state PENDING', async () => {
|
||||
const created = await createOrder();
|
||||
expect((created.json() as { state: string }).state).toBe('PENDING');
|
||||
});
|
||||
});
|
||||
171
project/src/app/tests/pricing.itest.ts
Normal file
171
project/src/app/tests/pricing.itest.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { DestinationStream } from 'pino';
|
||||
import type pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { buildApp } from '../build-app.js';
|
||||
import { createPool } from '../../infrastructure/db/pool.js';
|
||||
import { createLogger } from '../../infrastructure/logging/logger.js';
|
||||
import {
|
||||
getTestDbUrl,
|
||||
recreateDatabase,
|
||||
runMigrations,
|
||||
} from '../../infrastructure/db/tests/db-test-support.js';
|
||||
import { SESSION_COOKIE_NAME } from '../../modules/identity/index.js';
|
||||
|
||||
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
|
||||
|
||||
function silentLogger() {
|
||||
const destination: DestinationStream = { write: () => undefined };
|
||||
return createLogger({ level: 'info', destination });
|
||||
}
|
||||
|
||||
function cookieValue(setCookieHeader: string | string[] | undefined): string {
|
||||
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader;
|
||||
expect(raw).toBeDefined();
|
||||
const pair = (raw as string).split(';')[0] as string;
|
||||
return pair.slice(pair.indexOf('=') + 1);
|
||||
}
|
||||
|
||||
describe.skipIf(!hasDb)('pricing flows (real PostgreSQL)', () => {
|
||||
const url = hasDb ? getTestDbUrl() : '';
|
||||
let pool: pg.Pool;
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let adminCookie = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
await recreateDatabase(url);
|
||||
await runMigrations(url, 'up');
|
||||
pool = createPool(url);
|
||||
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
|
||||
|
||||
const user = { email: 'pricing-admin@example.com', password: 'correct horse battery staple' };
|
||||
const registered = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/register',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: user,
|
||||
});
|
||||
expect(registered.statusCode).toBe(201);
|
||||
const id = (registered.json() as { id: string }).id;
|
||||
await pool.query('UPDATE identity_users SET role = $1 WHERE id = $2', ['admin', id]);
|
||||
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/login',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: user,
|
||||
});
|
||||
expect(login.statusCode).toBe(200);
|
||||
adminCookie = cookieValue(login.headers['set-cookie']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
it('calculates totals with VAT from server-side price (AC1)', async () => {
|
||||
const variantId = randomUUID();
|
||||
const setPrice = await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/pricing/variants/${variantId}`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload: { netUnitAmountCents: 1000, vatRate: 'general' },
|
||||
});
|
||||
expect(setPrice.statusCode).toBe(200);
|
||||
|
||||
const calculation = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/pricing/calculate',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: { variantId, quantity: 2 },
|
||||
});
|
||||
|
||||
expect(calculation.statusCode).toBe(200);
|
||||
expect(calculation.json()).toMatchObject({
|
||||
variantId,
|
||||
quantity: 2,
|
||||
currency: 'EUR',
|
||||
vatRate: 'general',
|
||||
vatBasisPoints: 2100,
|
||||
netUnitAmountCents: 1000,
|
||||
netSubtotalCents: 2000,
|
||||
vatAmountCents: 420,
|
||||
totalCents: 2420,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores client-supplied prices and recalculates from the server (AC2)', async () => {
|
||||
const variantId = randomUUID();
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/pricing/variants/${variantId}`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload: { netUnitAmountCents: 500, vatRate: 'reduced' },
|
||||
});
|
||||
|
||||
const calculation = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/pricing/calculate',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: {
|
||||
variantId,
|
||||
quantity: 3,
|
||||
netUnitAmountCents: 1,
|
||||
totalCents: 1,
|
||||
vatAmountCents: 0,
|
||||
},
|
||||
});
|
||||
|
||||
expect(calculation.statusCode).toBe(200);
|
||||
expect(calculation.json()).toMatchObject({
|
||||
netUnitAmountCents: 500,
|
||||
netSubtotalCents: 1500,
|
||||
vatAmountCents: 150,
|
||||
totalCents: 1650,
|
||||
});
|
||||
});
|
||||
|
||||
it('writes a history row for creation and every price change (AC3)', async () => {
|
||||
const variantId = randomUUID();
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/pricing/variants/${variantId}`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload: { netUnitAmountCents: 100, vatRate: 'general' },
|
||||
});
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/pricing/variants/${variantId}`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload: { netUnitAmountCents: 200, vatRate: 'reduced' },
|
||||
});
|
||||
|
||||
const history = await pool.query(
|
||||
`SELECT previous_net_unit_amount_cents, previous_vat_rate,
|
||||
new_net_unit_amount_cents, new_vat_rate
|
||||
FROM pricing_price_history
|
||||
WHERE variant_id = $1
|
||||
ORDER BY created_at, id`,
|
||||
[variantId],
|
||||
);
|
||||
expect(history.rows).toEqual([
|
||||
{
|
||||
previous_net_unit_amount_cents: null,
|
||||
previous_vat_rate: null,
|
||||
new_net_unit_amount_cents: 100,
|
||||
new_vat_rate: 'general',
|
||||
},
|
||||
{
|
||||
previous_net_unit_amount_cents: 100,
|
||||
previous_vat_rate: 'general',
|
||||
new_net_unit_amount_cents: 200,
|
||||
new_vat_rate: 'reduced',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
149
project/src/app/tests/promotions.itest.ts
Normal file
149
project/src/app/tests/promotions.itest.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { DestinationStream } from 'pino';
|
||||
import type pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { buildApp } from '../build-app.js';
|
||||
import { createPool } from '../../infrastructure/db/pool.js';
|
||||
import { createLogger } from '../../infrastructure/logging/logger.js';
|
||||
import {
|
||||
getTestDbUrl,
|
||||
recreateDatabase,
|
||||
runMigrations,
|
||||
} from '../../infrastructure/db/tests/db-test-support.js';
|
||||
import { SESSION_COOKIE_NAME } from '../../modules/identity/index.js';
|
||||
|
||||
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
|
||||
function silentLogger() {
|
||||
const destination: DestinationStream = { write: () => undefined };
|
||||
return createLogger({ level: 'info', destination });
|
||||
}
|
||||
function cookieValue(setCookieHeader: string | string[] | undefined): string {
|
||||
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader;
|
||||
expect(raw).toBeDefined();
|
||||
const pair = (raw as string).split(';')[0] as string;
|
||||
return pair.slice(pair.indexOf('=') + 1);
|
||||
}
|
||||
|
||||
describe.skipIf(!hasDb)('promotions flows (real PostgreSQL)', () => {
|
||||
const url = hasDb ? getTestDbUrl() : '';
|
||||
let pool: pg.Pool;
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let cookie = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
await recreateDatabase(url);
|
||||
await runMigrations(url, 'up');
|
||||
pool = createPool(url);
|
||||
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
|
||||
const user = {
|
||||
email: 'promotions-admin@example.com',
|
||||
password: 'correct horse battery staple',
|
||||
};
|
||||
const registered = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/register',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: user,
|
||||
});
|
||||
const id = (registered.json() as { id: string }).id;
|
||||
await pool.query('UPDATE identity_users SET role = $1 WHERE id = $2', ['admin', id]);
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/login',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: user,
|
||||
});
|
||||
cookie = cookieValue(login.headers['set-cookie']);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
async function seedCart(variantId: string, cents = 1000) {
|
||||
const productId = randomUUID();
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/pricing/variants/${variantId}`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { netUnitAmountCents: cents, vatRate: 'general' },
|
||||
});
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: `/inventory/${variantId}/stock`,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { quantity: 10 },
|
||||
});
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/cart/items',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { productId, variantId, quantity: 2, discountCents: 9999 },
|
||||
});
|
||||
}
|
||||
|
||||
async function createPromo(code: string, overrides: Record<string, unknown> = {}) {
|
||||
return app.inject({
|
||||
method: 'POST',
|
||||
url: '/promotions',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: {
|
||||
code,
|
||||
type: 'percent',
|
||||
value: 1000,
|
||||
startsAt: '2026-01-01T00:00:00Z',
|
||||
endsAt: '2027-01-01T00:00:00Z',
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it('applies valid promo code with server-side recalculated discount (AC1)', async () => {
|
||||
await seedCart(randomUUID(), 1000);
|
||||
await createPromo('SAVE10');
|
||||
|
||||
const applied = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/cart/promo-code',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { code: 'save10', discountCents: 999999 },
|
||||
});
|
||||
|
||||
expect(applied.statusCode).toBe(200);
|
||||
expect(applied.json()).toMatchObject({
|
||||
discount: { code: 'SAVE10', discountCents: 242 },
|
||||
totalBeforeDiscountCents: 2420,
|
||||
totalCents: 2178,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects expired and exhausted promo codes with HTTP 422 (AC2)', async () => {
|
||||
await createPromo('OLD', { startsAt: '2020-01-01T00:00:00Z', endsAt: '2020-02-01T00:00:00Z' });
|
||||
await createPromo('USED', { usageLimit: 1 });
|
||||
await pool.query('UPDATE promotions_promotions SET usage_count = 1 WHERE code = $1', ['USED']);
|
||||
|
||||
const expired = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/cart/promo-code',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { code: 'OLD' },
|
||||
});
|
||||
const exhausted = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/cart/promo-code',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { code: 'USED' },
|
||||
});
|
||||
|
||||
expect(expired.statusCode).toBe(422);
|
||||
expect(exhausted.statusCode).toBe(422);
|
||||
});
|
||||
});
|
||||
108
project/src/app/tests/shipping.itest.ts
Normal file
108
project/src/app/tests/shipping.itest.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type { DestinationStream } from 'pino';
|
||||
import type pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { buildApp } from '../build-app.js';
|
||||
import { createPool } from '../../infrastructure/db/pool.js';
|
||||
import { createLogger } from '../../infrastructure/logging/logger.js';
|
||||
import {
|
||||
getTestDbUrl,
|
||||
recreateDatabase,
|
||||
runMigrations,
|
||||
} from '../../infrastructure/db/tests/db-test-support.js';
|
||||
import { SESSION_COOKIE_NAME } from '../../modules/identity/index.js';
|
||||
|
||||
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
|
||||
function silentLogger() {
|
||||
const destination: DestinationStream = { write: () => undefined };
|
||||
return createLogger({ level: 'info', destination });
|
||||
}
|
||||
function cookieValue(setCookieHeader: string | string[] | undefined): string {
|
||||
const raw = Array.isArray(setCookieHeader) ? setCookieHeader[0] : setCookieHeader;
|
||||
expect(raw).toBeDefined();
|
||||
const pair = (raw as string).split(';')[0] as string;
|
||||
return pair.slice(pair.indexOf('=') + 1);
|
||||
}
|
||||
|
||||
describe.skipIf(!hasDb)('shipping flows (real PostgreSQL)', () => {
|
||||
const url = hasDb ? getTestDbUrl() : '';
|
||||
let pool: pg.Pool;
|
||||
let app: Awaited<ReturnType<typeof buildApp>>;
|
||||
let cookie = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
await recreateDatabase(url);
|
||||
await runMigrations(url, 'up');
|
||||
pool = createPool(url);
|
||||
app = await buildApp({ logger: silentLogger(), pool, cookieSecure: true });
|
||||
const user = { email: 'shipping-admin@example.com', password: 'correct horse battery staple' };
|
||||
const registered = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/register',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: user,
|
||||
});
|
||||
const id = (registered.json() as { id: string }).id;
|
||||
await pool.query('UPDATE identity_users SET role = $1 WHERE id = $2', ['admin', id]);
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/login',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: user,
|
||||
});
|
||||
cookie = cookieValue(login.headers['set-cookie']);
|
||||
|
||||
const zone = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/shipping/zones',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { name: 'Peninsula', country: 'ES', postalCodePrefix: '28' },
|
||||
});
|
||||
const zoneId = (zone.json() as { id: string }).id;
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/shipping/methods',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: cookie },
|
||||
payload: { zoneId, name: 'Standard', baseCostCents: 500, freeShippingThresholdCents: 3000 },
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
it('returns shipping cost for an address in a known zone (AC1)', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/shipping/calculate',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: { cartTotalCents: 1000, country: 'ES', postalCode: '28001' },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({ costCents: 500, freeApplied: false });
|
||||
});
|
||||
|
||||
it('returns HTTP 422 SHIPPING_ZONE_NOT_FOUND for an address outside all zones (AC2)', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/shipping/calculate',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: { cartTotalCents: 1000, country: 'US', postalCode: '94101' },
|
||||
});
|
||||
expect(response.statusCode).toBe(422);
|
||||
expect((response.json().error as { code: string }).code).toBe('SHIPPING_ZONE_NOT_FOUND');
|
||||
});
|
||||
|
||||
it('applies free shipping when cart total is above threshold (AC3)', async () => {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/shipping/calculate',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
payload: { cartTotalCents: 5000, country: 'ES', postalCode: '28001' },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({ costCents: 0, freeApplied: true });
|
||||
});
|
||||
});
|
||||
95
project/src/modules/admin-stats/api/stats.routes.ts
Normal file
95
project/src/modules/admin-stats/api/stats.routes.ts
Normal 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(),
|
||||
});
|
||||
});
|
||||
}
|
||||
127
project/src/modules/brands/api/brands.routes.ts
Normal file
127
project/src/modules/brands/api/brands.routes.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
34
project/src/modules/brands/application/brand-use-cases.ts
Normal file
34
project/src/modules/brands/application/brand-use-cases.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
22
project/src/modules/brands/domain/brand.ts
Normal file
22
project/src/modules/brands/domain/brand.ts
Normal 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>;
|
||||
6
project/src/modules/brands/domain/errors.ts
Normal file
6
project/src/modules/brands/domain/errors.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class BrandSlugAlreadyExistsError extends Error {
|
||||
constructor() {
|
||||
super('Brand slug already exists');
|
||||
this.name = 'BrandSlugAlreadyExistsError';
|
||||
}
|
||||
}
|
||||
10
project/src/modules/brands/domain/ports.ts
Normal file
10
project/src/modules/brands/domain/ports.ts
Normal 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>;
|
||||
}
|
||||
2
project/src/modules/brands/index.ts
Normal file
2
project/src/modules/brands/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Public API of the brands module. */
|
||||
export { registerBrandsRoutes, type BrandsRoutesDeps } from './api/brands.routes.js';
|
||||
122
project/src/modules/brands/infrastructure/pg-brand-repository.ts
Normal file
122
project/src/modules/brands/infrastructure/pg-brand-repository.ts
Normal 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;
|
||||
}
|
||||
25
project/src/modules/cache/api/cache.routes.ts
vendored
Normal file
25
project/src/modules/cache/api/cache.routes.ts
vendored
Normal 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());
|
||||
});
|
||||
}
|
||||
61
project/src/modules/cache/application/cache-service.ts
vendored
Normal file
61
project/src/modules/cache/application/cache-service.ts
vendored
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
20
project/src/modules/cache/domain/cache.ts
vendored
Normal file
20
project/src/modules/cache/domain/cache.ts
vendored
Normal 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;
|
||||
}
|
||||
21
project/src/modules/cache/domain/ports.ts
vendored
Normal file
21
project/src/modules/cache/domain/ports.ts
vendored
Normal 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
10
project/src/modules/cache/index.ts
vendored
Normal 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';
|
||||
31
project/src/modules/cache/infrastructure/in-memory-cache-adapter.ts
vendored
Normal file
31
project/src/modules/cache/infrastructure/in-memory-cache-adapter.ts
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
73
project/src/modules/cache/tests/cache-service.test.ts
vendored
Normal file
73
project/src/modules/cache/tests/cache-service.test.ts
vendored
Normal 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);
|
||||
});
|
||||
});
|
||||
120
project/src/modules/cart/api/cart.routes.ts
Normal file
120
project/src/modules/cart/api/cart.routes.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
90
project/src/modules/cart/application/cart-service.ts
Normal file
90
project/src/modules/cart/application/cart-service.ts
Normal 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();
|
||||
}
|
||||
48
project/src/modules/cart/domain/cart.ts
Normal file
48
project/src/modules/cart/domain/cart.ts
Normal 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;
|
||||
}
|
||||
6
project/src/modules/cart/domain/errors.ts
Normal file
6
project/src/modules/cart/domain/errors.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export class InvalidCartQuantityError extends Error {
|
||||
constructor() {
|
||||
super('Cart quantity must be a positive integer');
|
||||
this.name = 'InvalidCartQuantityError';
|
||||
}
|
||||
}
|
||||
9
project/src/modules/cart/domain/ports.ts
Normal file
9
project/src/modules/cart/domain/ports.ts
Normal 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>;
|
||||
}
|
||||
5
project/src/modules/cart/index.ts
Normal file
5
project/src/modules/cart/index.ts
Normal 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';
|
||||
111
project/src/modules/cart/infrastructure/pg-cart-repository.ts
Normal file
111
project/src/modules/cart/infrastructure/pg-cart-repository.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
30
project/src/modules/cart/tests/boundary.test.ts
Normal file
30
project/src/modules/cart/tests/boundary.test.ts
Normal 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)/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
76
project/src/modules/cart/tests/cart-service.test.ts
Normal file
76
project/src/modules/cart/tests/cart-service.test.ts
Normal 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 } });
|
||||
});
|
||||
});
|
||||
479
project/src/modules/catalog/api/catalog.routes.ts
Normal file
479
project/src/modules/catalog/api/catalog.routes.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
51
project/src/modules/catalog/application/image-use-cases.ts
Normal file
51
project/src/modules/catalog/application/image-use-cases.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
102
project/src/modules/catalog/application/product-use-cases.ts
Normal file
102
project/src/modules/catalog/application/product-use-cases.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
78
project/src/modules/catalog/application/variant-use-cases.ts
Normal file
78
project/src/modules/catalog/application/variant-use-cases.ts
Normal 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;
|
||||
}
|
||||
41
project/src/modules/catalog/domain/errors.ts
Normal file
41
project/src/modules/catalog/domain/errors.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
33
project/src/modules/catalog/domain/image.ts
Normal file
33
project/src/modules/catalog/domain/image.ts
Normal 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;
|
||||
}
|
||||
52
project/src/modules/catalog/domain/ports.ts
Normal file
52
project/src/modules/catalog/domain/ports.ts
Normal 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>;
|
||||
}
|
||||
74
project/src/modules/catalog/domain/product.ts
Normal file
74
project/src/modules/catalog/domain/product.ts
Normal 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;
|
||||
}
|
||||
48
project/src/modules/catalog/domain/variant.ts
Normal file
48
project/src/modules/catalog/domain/variant.ts
Normal 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;
|
||||
}
|
||||
2
project/src/modules/catalog/index.ts
Normal file
2
project/src/modules/catalog/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Public API of the catalog module. */
|
||||
export { registerCatalogRoutes, type CatalogRoutesDeps } from './api/catalog.routes.js';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
16
project/src/modules/catalog/tests/domain-purity.test.ts
Normal file
16
project/src/modules/catalog/tests/domain-purity.test.ts
Normal 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)/);
|
||||
}
|
||||
});
|
||||
});
|
||||
187
project/src/modules/catalog/tests/image-use-cases.test.ts
Normal file
187
project/src/modules/catalog/tests/image-use-cases.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
152
project/src/modules/catalog/tests/product-use-cases.test.ts
Normal file
152
project/src/modules/catalog/tests/product-use-cases.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
151
project/src/modules/categories/api/categories.routes.ts
Normal file
151
project/src/modules/categories/api/categories.routes.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
105
project/src/modules/categories/application/category-use-cases.ts
Normal file
105
project/src/modules/categories/application/category-use-cases.ts
Normal 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;
|
||||
}
|
||||
28
project/src/modules/categories/domain/category.ts
Normal file
28
project/src/modules/categories/domain/category.ts
Normal 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[];
|
||||
}
|
||||
20
project/src/modules/categories/domain/errors.ts
Normal file
20
project/src/modules/categories/domain/errors.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
12
project/src/modules/categories/domain/ports.ts
Normal file
12
project/src/modules/categories/domain/ports.ts
Normal 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>;
|
||||
}
|
||||
2
project/src/modules/categories/index.ts
Normal file
2
project/src/modules/categories/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Public API of the categories module. */
|
||||
export { registerCategoriesRoutes, type CategoriesRoutesDeps } from './api/categories.routes.js';
|
||||
@@ -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;
|
||||
}
|
||||
121
project/src/modules/categories/tests/category-use-cases.test.ts
Normal file
121
project/src/modules/categories/tests/category-use-cases.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
118
project/src/modules/checkout/api/checkout.routes.ts
Normal file
118
project/src/modules/checkout/api/checkout.routes.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
199
project/src/modules/checkout/application/checkout-service.ts
Normal file
199
project/src/modules/checkout/application/checkout-service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
38
project/src/modules/checkout/domain/checkout.ts
Normal file
38
project/src/modules/checkout/domain/checkout.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
22
project/src/modules/checkout/domain/ports.ts
Normal file
22
project/src/modules/checkout/domain/ports.ts
Normal 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>;
|
||||
}
|
||||
12
project/src/modules/checkout/index.ts
Normal file
12
project/src/modules/checkout/index.ts
Normal 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';
|
||||
16
project/src/modules/checkout/infrastructure/metrics.ts
Normal file
16
project/src/modules/checkout/infrastructure/metrics.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
34
project/src/modules/checkout/tests/boundary.test.ts
Normal file
34
project/src/modules/checkout/tests/boundary.test.ts
Normal 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)/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
304
project/src/modules/checkout/tests/checkout-service.test.ts
Normal file
304
project/src/modules/checkout/tests/checkout-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
135
project/src/modules/cms/api/cms.routes.ts
Normal file
135
project/src/modules/cms/api/cms.routes.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
42
project/src/modules/cms/application/cms-service.ts
Normal file
42
project/src/modules/cms/application/cms-service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
20
project/src/modules/cms/domain/errors.ts
Normal file
20
project/src/modules/cms/domain/errors.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
23
project/src/modules/cms/domain/page.ts
Normal file
23
project/src/modules/cms/domain/page.ts
Normal 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;
|
||||
}
|
||||
10
project/src/modules/cms/domain/ports.ts
Normal file
10
project/src/modules/cms/domain/ports.ts
Normal 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[]>;
|
||||
}
|
||||
7
project/src/modules/cms/index.ts
Normal file
7
project/src/modules/cms/index.ts
Normal 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';
|
||||
96
project/src/modules/cms/infrastructure/pg-cms-repository.ts
Normal file
96
project/src/modules/cms/infrastructure/pg-cms-repository.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
63
project/src/modules/cms/tests/cms-service.test.ts
Normal file
63
project/src/modules/cms/tests/cms-service.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
118
project/src/modules/inventory/api/inventory.routes.ts
Normal file
118
project/src/modules/inventory/api/inventory.routes.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
20
project/src/modules/inventory/domain/errors.ts
Normal file
20
project/src/modules/inventory/domain/errors.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
17
project/src/modules/inventory/domain/ports.ts
Normal file
17
project/src/modules/inventory/domain/ports.ts
Normal 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>;
|
||||
}
|
||||
30
project/src/modules/inventory/domain/stock.ts
Normal file
30
project/src/modules/inventory/domain/stock.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
16
project/src/modules/inventory/index.ts
Normal file
16
project/src/modules/inventory/index.ts
Normal 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));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
29
project/src/modules/inventory/tests/boundary.test.ts
Normal file
29
project/src/modules/inventory/tests/boundary.test.ts
Normal 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)/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
9
project/src/modules/notifications/domain/notification.ts
Normal file
9
project/src/modules/notifications/domain/notification.ts
Normal 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;
|
||||
}
|
||||
12
project/src/modules/notifications/domain/ports.ts
Normal file
12
project/src/modules/notifications/domain/ports.ts
Normal 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 }>;
|
||||
}
|
||||
10
project/src/modules/notifications/index.ts
Normal file
10
project/src/modules/notifications/index.ts
Normal 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';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user