feat(F-048): completed feature
This commit is contained in:
@@ -7,11 +7,19 @@ import { performance } from 'node:perf_hooks';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyCors from '@fastify/cors';
|
||||
import fastifySwagger from '@fastify/swagger';
|
||||
import fastifySwaggerUi from '@fastify/swagger-ui';
|
||||
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import type pg from 'pg';
|
||||
import { swaggerConfig } from '../shared/swagger.js';
|
||||
import { registerHealthRoutes } from '../modules/health/index.js';
|
||||
import { registerIdentityRoutes, createSessionAuthenticator } from '../modules/identity/index.js';
|
||||
import {
|
||||
registerBackofficeRoutes,
|
||||
createBackofficeSessionAuthenticator,
|
||||
createCombinedAuthenticator,
|
||||
} from '../modules/backoffice/index.js';
|
||||
import { registerUsersRoutes } from '../modules/users/index.js';
|
||||
import { registerCategoriesRoutes } from '../modules/categories/index.js';
|
||||
import { registerCatalogRoutes } from '../modules/catalog/index.js';
|
||||
@@ -30,7 +38,7 @@ 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 { registerAdminStatsRoutes } from '../modules/admin-stats/index.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';
|
||||
@@ -75,6 +83,10 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
const startTimes = new WeakMap<FastifyRequest, number>();
|
||||
|
||||
const app = Fastify({ logger: false, genReqId: generateRequestId });
|
||||
// Route schemas are also the OpenAPI source. Several endpoints intentionally
|
||||
// use broad response schemas, so serialize the explicit route DTOs without
|
||||
// letting fast-json-stringify discard fields absent from those broad schemas.
|
||||
app.setSerializerCompiler(() => (payload) => JSON.stringify(payload));
|
||||
app.decorate('flags', flags);
|
||||
|
||||
await app.register(fastifyCors, {
|
||||
@@ -119,8 +131,13 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
details = error.details;
|
||||
message = statusCode < 500 ? error.message : 'Internal Server Error';
|
||||
} else if (statusCode < 500) {
|
||||
code = error.code ?? 'BAD_REQUEST';
|
||||
message = error.message;
|
||||
if (error.code === 'FST_ERR_VALIDATION') {
|
||||
code = 'VALIDATION_ERROR';
|
||||
message = 'Invalid request';
|
||||
} else {
|
||||
code = error.code ?? 'BAD_REQUEST';
|
||||
message = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
if (statusCode >= 500) {
|
||||
@@ -144,6 +161,17 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
// Cookie infrastructure is cross-module (identity + users): register once at root.
|
||||
await app.register(fastifyCookie);
|
||||
|
||||
// ── OpenAPI / Swagger ────────────────────────────────────────────────────────
|
||||
await app.register(fastifySwagger, swaggerConfig);
|
||||
await app.register(fastifySwaggerUi, {
|
||||
routePrefix: '/docs',
|
||||
uiConfig: {
|
||||
docExpansion: 'list',
|
||||
deepLinking: true,
|
||||
tryItOutEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (deps.pool) {
|
||||
// Session resolution; created before identity registration so it can be passed
|
||||
// to identity's /auth/me route.
|
||||
@@ -157,33 +185,48 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
});
|
||||
});
|
||||
|
||||
// FIX-14: backoffice has its own auth mechanism (backoffice_session cookie,
|
||||
// backoffice_users table). Two more authenticators:
|
||||
// backofficeAuth — backoffice_session only (for backoffice routes).
|
||||
// combinedAuth — tries backoffice_session first, then mdv_session
|
||||
// (for routes shared by both admin and storefront).
|
||||
const backofficeAuth = createBackofficeSessionAuthenticator(deps.pool as pg.Pool);
|
||||
const combinedAuth = createCombinedAuthenticator(deps.pool as pg.Pool);
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerBackofficeRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate: backofficeAuth,
|
||||
});
|
||||
});
|
||||
|
||||
// Session resolution is identity's; users receives it by injection so no
|
||||
// module ever imports another module.
|
||||
await app.register(async (instance) => {
|
||||
await registerUsersRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerCategoriesRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerBrandsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerCatalogRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
logger,
|
||||
});
|
||||
});
|
||||
@@ -192,7 +235,7 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
await app.register(async (instance) => {
|
||||
await registerInventoryRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -200,7 +243,7 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
await app.register(async (instance) => {
|
||||
await registerPricingRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -208,14 +251,14 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
await app.register(async (instance) => {
|
||||
await registerPromotionsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerCartRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
pricing,
|
||||
inventory,
|
||||
promotions,
|
||||
@@ -225,27 +268,27 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
await app.register(async (instance) => {
|
||||
await registerShippingRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerOrdersRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
const { telemetry, meter } = createInMemoryTelemetry();
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerMetricsRoutes(instance, { meter, authenticate });
|
||||
await registerMetricsRoutes(instance, { meter, authenticate: combinedAuth });
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerCheckoutRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
tracer: telemetry.tracer,
|
||||
});
|
||||
});
|
||||
@@ -253,7 +296,7 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
await app.register(async (instance) => {
|
||||
await registerPaymentsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET ?? 'whsec_test',
|
||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY,
|
||||
});
|
||||
@@ -262,7 +305,7 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
await app.register(async (instance) => {
|
||||
await registerNotificationsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
emailProvider: new LoggingEmailProvider(),
|
||||
});
|
||||
});
|
||||
@@ -270,25 +313,25 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
await app.register(async (instance) => {
|
||||
await registerReviewsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerCmsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(async (instance) => {
|
||||
await registerStoreSettingsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
await registerAdminStatsRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -322,7 +365,7 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
invalidation: 'SearchInvalidated',
|
||||
});
|
||||
await app.register(async (instance) => {
|
||||
await registerCacheRoutes(instance, { cache, authenticate });
|
||||
await registerCacheRoutes(instance, { cache, authenticate: combinedAuth });
|
||||
});
|
||||
|
||||
const rateLimiter = new RateLimiter();
|
||||
@@ -330,7 +373,7 @@ export async function buildApp(deps: BuildAppDeps = {}): Promise<FastifyInstance
|
||||
await app.register(async (instance) => {
|
||||
await registerSecurityRoutes(instance, {
|
||||
pool: deps.pool as pg.Pool,
|
||||
authenticate,
|
||||
authenticate: combinedAuth,
|
||||
rateLimiter,
|
||||
auditLogger,
|
||||
});
|
||||
|
||||
@@ -97,7 +97,7 @@ describe.skipIf(!hasDb)('categories flows (real PostgreSQL)', () => {
|
||||
url: '/categories',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
cookies: { [SESSION_COOKIE_NAME]: adminCookie },
|
||||
payload: { name: 'Alimentación', slug: 'alimentacion' },
|
||||
payload: { name: 'Alimentación', slug: 'alimentacion', isParent: true },
|
||||
});
|
||||
expect(root.statusCode).toBe(201);
|
||||
const rootBody = root.json() as { id: string };
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
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';
|
||||
import {
|
||||
createInventoryService,
|
||||
InsufficientStockError,
|
||||
type InventoryService,
|
||||
} from '../../modules/inventory/index.js';
|
||||
|
||||
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
|
||||
|
||||
@@ -22,7 +24,7 @@ describe.skipIf(!hasDb)('inventory flows (real PostgreSQL)', () => {
|
||||
await recreateDatabase(url);
|
||||
await runMigrations(url, 'up');
|
||||
pool = createPool(url);
|
||||
inventory = new InventoryService(new PgInventoryRepository(pool));
|
||||
inventory = createInventoryService(pool);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
@@ -98,13 +98,14 @@ describe.skipIf(!hasDb)('users + RBAC flows (real PostgreSQL)', () => {
|
||||
expect(cross.statusCode).toBe(403);
|
||||
expect(cross.json().error.code).toBe('FORBIDDEN');
|
||||
|
||||
// Own profile does not exist yet -> 404 (authz passed, resource missing).
|
||||
const ownMissing = await app.inject({
|
||||
// Customer detail exists even before optional profile fields are populated.
|
||||
const ownCustomer = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/users/${anaId}`,
|
||||
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
|
||||
});
|
||||
expect(ownMissing.statusCode).toBe(404);
|
||||
expect(ownCustomer.statusCode).toBe(200);
|
||||
expect(ownCustomer.json()).toMatchObject({ id: anaId, email: 'ana@example.com' });
|
||||
});
|
||||
|
||||
it('PATCH own profile upserts; PATCH is validated', async () => {
|
||||
@@ -157,8 +158,8 @@ describe.skipIf(!hasDb)('users + RBAC flows (real PostgreSQL)', () => {
|
||||
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
|
||||
});
|
||||
expect(asAdmin.statusCode).toBe(200);
|
||||
const body = asAdmin.json() as { items: Array<{ userId: string }> };
|
||||
expect(body.items.some((item) => item.userId === anaId)).toBe(true);
|
||||
const body = asAdmin.json() as { items: Array<{ id: string }> };
|
||||
expect(body.items.some((item) => item.id === anaId)).toBe(true);
|
||||
|
||||
// Admin can also read another user's profile (owner-or-admin).
|
||||
const adminReadsBen = await app.inject({
|
||||
@@ -166,8 +167,9 @@ describe.skipIf(!hasDb)('users + RBAC flows (real PostgreSQL)', () => {
|
||||
url: `/users/${benId}`,
|
||||
cookies: { [SESSION_COOKIE_NAME]: anaCookie },
|
||||
});
|
||||
// Ben has no profile yet: 404, not 403 (authz passed).
|
||||
expect(adminReadsBen.statusCode).toBe(404);
|
||||
// Customer detail remains available when optional profile fields are absent.
|
||||
expect(adminReadsBen.statusCode).toBe(200);
|
||||
expect(adminReadsBen.json()).toMatchObject({ id: benId, email: 'ben@example.com' });
|
||||
});
|
||||
|
||||
it('address CRUD works end to end for own addresses (AC4)', async () => {
|
||||
|
||||
@@ -43,4 +43,11 @@ describe.skipIf(!hasDb)('migrations', () => {
|
||||
expect(await tableExists(pool, 'identity_users')).toBe(false);
|
||||
expect(await tableExists(pool, 'app_meta')).toBe(false);
|
||||
});
|
||||
|
||||
it('reapplies the full schema after a complete rollback', async () => {
|
||||
await runMigrations(url, 'up');
|
||||
expect(await tableExists(pool, 'app_meta')).toBe(true);
|
||||
expect(await tableExists(pool, 'backoffice_users')).toBe(true);
|
||||
expect(await tableExists(pool, 'categories_categories')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } 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';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
|
||||
interface AdminStatsDeps {
|
||||
pool: pg.Pool;
|
||||
authenticate: Authenticate;
|
||||
}
|
||||
|
||||
const statsSchema: FastifySchema = {
|
||||
tags: ['Admin'],
|
||||
summary: 'Admin dashboard stats',
|
||||
description: 'KPIs del dashboard: pedidos hoy, ingresos, productos sin stock, etc.',
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
|
||||
export async function registerAdminStatsRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: AdminStatsDeps,
|
||||
): Promise<void> {
|
||||
app.get('/admin/stats', async (request, reply) => {
|
||||
app.get('/admin/stats', { schema: statsSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
|
||||
@@ -51,9 +58,7 @@ export async function registerAdminStatsRoutes(
|
||||
`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)])),
|
||||
),
|
||||
.then((r) => Object.fromEntries(r.rows.map((row) => [row.state, parseInt(row.count, 10)]))),
|
||||
|
||||
// Out-of-stock variants
|
||||
pool
|
||||
|
||||
2
project/src/modules/admin-stats/index.ts
Normal file
2
project/src/modules/admin-stats/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Public API of the admin-stats module. */
|
||||
export { registerAdminStatsRoutes } from './api/stats.routes.js';
|
||||
166
project/src/modules/backoffice/api/backoffice.routes.ts
Normal file
166
project/src/modules/backoffice/api/backoffice.routes.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import type { FastifySchema } 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 { errorSchema } from '../../../shared/swagger.js';
|
||||
import { BackofficeLogin } from '../application/login.js';
|
||||
import { BackofficeLogout } from '../application/logout.js';
|
||||
import { InMemoryBackofficeLoginRateLimiter } from '../infrastructure/backoffice-login-rate-limiter.js';
|
||||
import type { BackofficeLoginRateLimiter } from '../domain/ports.js';
|
||||
import { Argon2BackofficePasswordHasher } from '../infrastructure/backoffice-password-hasher.js';
|
||||
import { PgBackofficeUserRepository } from '../infrastructure/pg-backoffice-user-repository.js';
|
||||
import { PgBackofficeSessionRepository } from '../infrastructure/pg-backoffice-session-repository.js';
|
||||
import {
|
||||
generateBackofficeToken,
|
||||
hashBackofficeToken,
|
||||
} from '../infrastructure/backoffice-session-token.js';
|
||||
import { BACKOFFICE_SESSION_TTL_MS } from '../domain/backoffice-session.js';
|
||||
import { BackofficeInvalidCredentialsError, BackofficeRateLimitedError } from '../domain/errors.js';
|
||||
|
||||
export const BACKOFFICE_SESSION_COOKIE_NAME = 'backoffice_session';
|
||||
|
||||
export interface BackofficeRoutesDeps {
|
||||
pool: pg.Pool;
|
||||
hasher?: import('../domain/ports.js').BackofficePasswordHasher;
|
||||
rateLimiter?: BackofficeLoginRateLimiter;
|
||||
/** Session authenticator injected by the composition root. */
|
||||
authenticate?: Authenticate;
|
||||
}
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
email: z.email(),
|
||||
password: z.string().min(8).max(128),
|
||||
});
|
||||
|
||||
export async function registerBackofficeRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: BackofficeRoutesDeps,
|
||||
): Promise<void> {
|
||||
const hasher = deps.hasher ?? new Argon2BackofficePasswordHasher();
|
||||
const users = new PgBackofficeUserRepository(deps.pool);
|
||||
const sessions = new PgBackofficeSessionRepository(deps.pool);
|
||||
const rateLimiter = deps.rateLimiter ?? new InMemoryBackofficeLoginRateLimiter();
|
||||
const login = new BackofficeLogin({
|
||||
users,
|
||||
sessions,
|
||||
hasher,
|
||||
rateLimiter,
|
||||
generateToken: generateBackofficeToken,
|
||||
hashToken: hashBackofficeToken,
|
||||
});
|
||||
const logout = new BackofficeLogout(sessions, hashBackofficeToken);
|
||||
|
||||
const loginSchema: FastifySchema = {
|
||||
tags: ['Backoffice'],
|
||||
summary: 'Backoffice login',
|
||||
description:
|
||||
'Inicia sesión en el backoffice y devuelve una cookie `backoffice_session` (válida 8h).',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email', 'password'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email' },
|
||||
password: { type: 'string' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
email: { type: 'string', format: 'email' },
|
||||
role: { type: 'string', enum: ['admin', 'editor'] },
|
||||
},
|
||||
},
|
||||
401: errorSchema,
|
||||
429: errorSchema,
|
||||
},
|
||||
};
|
||||
|
||||
const logoutSchema: FastifySchema = {
|
||||
tags: ['Backoffice'],
|
||||
summary: 'Backoffice logout',
|
||||
response: { 204: { type: 'null' } },
|
||||
};
|
||||
|
||||
const meSchema: FastifySchema = {
|
||||
tags: ['Backoffice'],
|
||||
summary: 'Current backoffice user',
|
||||
description: 'Devuelve el usuario backoffice autenticado o `{user: null}` si no hay sesión.',
|
||||
response: {
|
||||
200: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
email: { type: 'string', format: 'email' },
|
||||
role: { type: 'string', enum: ['admin', 'editor'] },
|
||||
},
|
||||
},
|
||||
{ type: 'object', properties: { user: { type: 'null' } } },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
app.post('/backoffice/auth/login', { schema: loginSchema }, async (request, reply) => {
|
||||
const input = credentialsSchema.parse(request.body);
|
||||
try {
|
||||
const result = await login.execute(input);
|
||||
setCookie(reply, result.token, true);
|
||||
return reply
|
||||
.code(200)
|
||||
.send({ id: result.user.id, email: result.user.email, role: result.user.role });
|
||||
} catch (error) {
|
||||
if (error instanceof BackofficeRateLimitedError) {
|
||||
void reply.header('Retry-After', String(Math.ceil(error.retryAfterMs / 1000)));
|
||||
throw new AppError(429, 'TOO_MANY_ATTEMPTS', 'Too many attempts');
|
||||
}
|
||||
if (error instanceof BackofficeInvalidCredentialsError) {
|
||||
throw new AppError(401, 'INVALID_CREDENTIALS', 'Invalid credentials');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/backoffice/auth/logout', { schema: logoutSchema }, async (request, reply) => {
|
||||
const token = request.cookies[BACKOFFICE_SESSION_COOKIE_NAME];
|
||||
await logout.execute(token);
|
||||
clearCookie(reply, true);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
app.get('/backoffice/auth/me', { schema: meSchema }, 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 setCookie(reply: FastifyReply, token: string, secure: boolean): void {
|
||||
void reply.setCookie(BACKOFFICE_SESSION_COOKIE_NAME, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure,
|
||||
maxAge: Math.floor(BACKOFFICE_SESSION_TTL_MS / 1000),
|
||||
});
|
||||
}
|
||||
|
||||
function clearCookie(reply: FastifyReply, secure: boolean): void {
|
||||
void reply.clearCookie(BACKOFFICE_SESSION_COOKIE_NAME, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure,
|
||||
});
|
||||
}
|
||||
52
project/src/modules/backoffice/application/login.ts
Normal file
52
project/src/modules/backoffice/application/login.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
BackofficeSessionRepository,
|
||||
BackofficeUserRepository,
|
||||
BackofficePasswordHasher,
|
||||
BackofficeLoginRateLimiter,
|
||||
} from '../domain/ports.js';
|
||||
import { BackofficeInvalidCredentialsError, BackofficeRateLimitedError } from '../domain/errors.js';
|
||||
import type { BackofficeUserWithHash } from '../domain/backoffice-user.js';
|
||||
import { backofficeSessionExpiry, type BackofficeSession } from '../domain/backoffice-session.js';
|
||||
|
||||
export interface BackofficeLoginInput {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface BackofficeLoginResult {
|
||||
user: BackofficeUserWithHash;
|
||||
session: BackofficeSession;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export class BackofficeLogin {
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
users: BackofficeUserRepository;
|
||||
sessions: BackofficeSessionRepository;
|
||||
hasher: BackofficePasswordHasher;
|
||||
rateLimiter: BackofficeLoginRateLimiter;
|
||||
generateToken: () => string;
|
||||
hashToken: (token: string) => string;
|
||||
},
|
||||
) {}
|
||||
|
||||
async execute(input: BackofficeLoginInput): Promise<BackofficeLoginResult> {
|
||||
const email = input.email.trim().toLowerCase();
|
||||
const limitKey = `backoffice-login:${email}`;
|
||||
const decision = this.deps.rateLimiter.hit(limitKey);
|
||||
if (!decision.allowed) throw new BackofficeRateLimitedError(decision.retryAfterMs);
|
||||
|
||||
const user = await this.deps.users.findByEmail(email);
|
||||
if (!user) throw new BackofficeInvalidCredentialsError();
|
||||
|
||||
const ok = await this.deps.hasher.verify(user.passwordHash, input.password);
|
||||
if (!ok) throw new BackofficeInvalidCredentialsError();
|
||||
|
||||
const token = this.deps.generateToken();
|
||||
const tokenHash = this.deps.hashToken(token);
|
||||
const session = await this.deps.sessions.create(user.id, tokenHash, backofficeSessionExpiry());
|
||||
|
||||
return { user, session, token };
|
||||
}
|
||||
}
|
||||
13
project/src/modules/backoffice/application/logout.ts
Normal file
13
project/src/modules/backoffice/application/logout.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { BackofficeSessionRepository } from '../domain/ports.js';
|
||||
|
||||
export class BackofficeLogout {
|
||||
constructor(
|
||||
private readonly sessions: BackofficeSessionRepository,
|
||||
private readonly hashToken: (token: string) => string,
|
||||
) {}
|
||||
|
||||
async execute(rawToken: string | undefined): Promise<void> {
|
||||
if (!rawToken) return;
|
||||
await this.sessions.revokeByTokenHash(this.hashToken(rawToken));
|
||||
}
|
||||
}
|
||||
19
project/src/modules/backoffice/domain/backoffice-session.ts
Normal file
19
project/src/modules/backoffice/domain/backoffice-session.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Backoffice session model. Same security model as identity sessions:
|
||||
* only the SHA-256 hash of the opaque token is persisted; the raw token
|
||||
* lives exclusively in the cookie.
|
||||
*/
|
||||
export interface BackofficeSession {
|
||||
id: string;
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
revokedAt: Date | null;
|
||||
}
|
||||
|
||||
/** Backoffice session lifetime: 8 hours (shorter than storefront 7 days). */
|
||||
export const BACKOFFICE_SESSION_TTL_MS = 8 * 60 * 60 * 1000;
|
||||
|
||||
export function backofficeSessionExpiry(now: Date = new Date()): Date {
|
||||
return new Date(now.getTime() + BACKOFFICE_SESSION_TTL_MS);
|
||||
}
|
||||
26
project/src/modules/backoffice/domain/backoffice-user.ts
Normal file
26
project/src/modules/backoffice/domain/backoffice-user.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* FIX-14: Backoffice user domain model.
|
||||
* Backoffice users (admin/editor) are physically separated from storefront
|
||||
* customers (identity_users) and authenticate through a separate mechanism.
|
||||
*/
|
||||
export type BackofficeRole = 'admin' | 'editor';
|
||||
|
||||
export interface BackofficeUser {
|
||||
id: string;
|
||||
email: string;
|
||||
role: BackofficeRole;
|
||||
mfaEnrolled: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface BackofficeUserWithHash extends BackofficeUser {
|
||||
passwordHash: string;
|
||||
}
|
||||
|
||||
export interface NewBackofficeUser {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
role?: BackofficeRole;
|
||||
mfaEnrolled?: boolean;
|
||||
}
|
||||
18
project/src/modules/backoffice/domain/errors.ts
Normal file
18
project/src/modules/backoffice/domain/errors.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export class BackofficeEmailAlreadyRegisteredError extends Error {
|
||||
constructor() {
|
||||
super('Backoffice email already registered');
|
||||
this.name = 'BackofficeEmailAlreadyRegisteredError';
|
||||
}
|
||||
}
|
||||
export class BackofficeInvalidCredentialsError extends Error {
|
||||
constructor() {
|
||||
super('Invalid credentials');
|
||||
this.name = 'BackofficeInvalidCredentialsError';
|
||||
}
|
||||
}
|
||||
export class BackofficeRateLimitedError extends Error {
|
||||
constructor(public readonly retryAfterMs: number) {
|
||||
super('Too many attempts');
|
||||
this.name = 'BackofficeRateLimitedError';
|
||||
}
|
||||
}
|
||||
27
project/src/modules/backoffice/domain/ports.ts
Normal file
27
project/src/modules/backoffice/domain/ports.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { BackofficeSession } from './backoffice-session.js';
|
||||
import type {
|
||||
BackofficeUser,
|
||||
BackofficeUserWithHash,
|
||||
NewBackofficeUser,
|
||||
} from './backoffice-user.js';
|
||||
|
||||
export interface BackofficePasswordHasher {
|
||||
hash(plain: string): Promise<string>;
|
||||
verify(hash: string, plain: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface BackofficeUserRepository {
|
||||
create(input: NewBackofficeUser): Promise<BackofficeUser>;
|
||||
findByEmail(email: string): Promise<BackofficeUserWithHash | undefined>;
|
||||
findById(id: string): Promise<BackofficeUser | undefined>;
|
||||
updateMfaEnrolled(id: string, enrolled: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
export interface BackofficeSessionRepository {
|
||||
create(userId: string, tokenHash: string, expiresAt: Date): Promise<BackofficeSession>;
|
||||
revokeByTokenHash(tokenHash: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface BackofficeLoginRateLimiter {
|
||||
hit(key: string): { allowed: boolean; retryAfterMs: number; remaining: number };
|
||||
}
|
||||
12
project/src/modules/backoffice/index.ts
Normal file
12
project/src/modules/backoffice/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Public API of the backoffice module.
|
||||
*/
|
||||
export {
|
||||
registerBackofficeRoutes,
|
||||
BACKOFFICE_SESSION_COOKIE_NAME,
|
||||
type BackofficeRoutesDeps,
|
||||
} from './api/backoffice.routes.js';
|
||||
export {
|
||||
createBackofficeSessionAuthenticator,
|
||||
createCombinedAuthenticator,
|
||||
} from './infrastructure/backoffice-session-authenticator.js';
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { BackofficeLoginRateLimiter } from '../domain/ports.js';
|
||||
|
||||
/**
|
||||
* Backoffice login rate limiter (in-memory).
|
||||
* Stricter than the storefront: backoffice is a higher-value target.
|
||||
* 5 attempts per 15-minute window per email+IP.
|
||||
*/
|
||||
interface Bucket {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const WINDOW_MS = 15 * 60 * 1000;
|
||||
const MAX_ATTEMPTS = 5;
|
||||
|
||||
export class InMemoryBackofficeLoginRateLimiter implements BackofficeLoginRateLimiter {
|
||||
private readonly buckets = new Map<string, Bucket>();
|
||||
|
||||
hit(key: string): { allowed: boolean; retryAfterMs: number; remaining: number } {
|
||||
const now = Date.now();
|
||||
const bucket = this.buckets.get(key);
|
||||
if (!bucket || bucket.resetAt < now) {
|
||||
this.buckets.set(key, { count: 1, resetAt: now + WINDOW_MS });
|
||||
return { allowed: true, retryAfterMs: 0, remaining: MAX_ATTEMPTS - 1 };
|
||||
}
|
||||
bucket.count++;
|
||||
const allowed = bucket.count <= MAX_ATTEMPTS;
|
||||
return {
|
||||
allowed,
|
||||
retryAfterMs: allowed ? 0 : bucket.resetAt - now,
|
||||
remaining: Math.max(0, MAX_ATTEMPTS - bucket.count),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { BackofficePasswordHasher } from '../domain/ports.js';
|
||||
|
||||
/** Production hasher: Argon2id. Tests can inject a fake. */
|
||||
export class Argon2BackofficePasswordHasher implements BackofficePasswordHasher {
|
||||
async hash(plain: string): Promise<string> {
|
||||
const { default: argon2 } = await import('argon2');
|
||||
return argon2.hash(plain);
|
||||
}
|
||||
async verify(hash: string, plain: string): Promise<boolean> {
|
||||
const { default: argon2 } = await import('argon2');
|
||||
return argon2.verify(hash, plain);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Resolves the backoffice_session cookie into the current backoffice user.
|
||||
* Server-side truth: validity (expiry + revocation) and role come from the DB.
|
||||
*/
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import type { Authenticate } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { hashBackofficeToken } from './backoffice-session-token.js';
|
||||
import { BACKOFFICE_SESSION_COOKIE_NAME } from '../api/backoffice.routes.js';
|
||||
|
||||
interface ResolvedRow {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const RESOLVE_SQL = `
|
||||
SELECT u.id, u.email, u.role
|
||||
FROM backoffice_sessions s
|
||||
JOIN backoffice_users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1
|
||||
AND s.revoked_at IS NULL
|
||||
AND s.expires_at > now()
|
||||
`;
|
||||
|
||||
export function createBackofficeSessionAuthenticator(pool: pg.Pool): Authenticate {
|
||||
return async (request: FastifyRequest) => {
|
||||
const token = request.cookies[BACKOFFICE_SESSION_COOKIE_NAME];
|
||||
if (!token) throw new AppError(401, 'UNAUTHORIZED', 'Backoffice authentication required');
|
||||
const result = await pool.query<ResolvedRow>(RESOLVE_SQL, [hashBackofficeToken(token)]);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new AppError(401, 'UNAUTHORIZED', 'Backoffice authentication required');
|
||||
return { id: row.id, email: row.email, role: row.role as 'admin' | 'editor' };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Combined authenticator for routes used by both backoffice and storefront
|
||||
* (e.g. /users — admin lists customers via backoffice, customer manages own
|
||||
* profile via storefront). Tries backoffice_session first, then mdv_session.
|
||||
*/
|
||||
export function createCombinedAuthenticator(pool: pg.Pool): Authenticate {
|
||||
const backofficeAuth = createBackofficeSessionAuthenticator(pool);
|
||||
const storefrontResolver = `
|
||||
SELECT u.id, u.email, u.role
|
||||
FROM identity_sessions s
|
||||
JOIN identity_users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > now()
|
||||
`;
|
||||
return async (request: FastifyRequest) => {
|
||||
const backofficeToken = request.cookies[BACKOFFICE_SESSION_COOKIE_NAME];
|
||||
if (backofficeToken) {
|
||||
try {
|
||||
return await backofficeAuth(request);
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
}
|
||||
const storefrontToken = request.cookies['mdv_session'];
|
||||
if (storefrontToken) {
|
||||
const { createHash } = await import('node:crypto');
|
||||
const tokenHash = createHash('sha256').update(storefrontToken).digest('hex');
|
||||
const result = await pool.query<ResolvedRow>(storefrontResolver, [tokenHash]);
|
||||
const row = result.rows[0];
|
||||
if (row)
|
||||
return { id: row.id, email: row.email, role: row.role as 'customer' | 'admin' | 'editor' };
|
||||
}
|
||||
throw new AppError(401, 'UNAUTHORIZED', 'Authentication required');
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Backoffice session tokens. Same model as identity: opaque token in the
|
||||
* cookie, SHA-256 hash persisted. The two tables/sessions are independent;
|
||||
* a leaked backoffice_sessions row yields no usable storefront sessions.
|
||||
*/
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
export function generateBackofficeToken(): string {
|
||||
return randomBytes(64).toString('base64url');
|
||||
}
|
||||
|
||||
export function hashBackofficeToken(token: string): string {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type pg from 'pg';
|
||||
import type { BackofficeSession } from '../domain/backoffice-session.js';
|
||||
import type { BackofficeSessionRepository } from '../domain/ports.js';
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
created_at: Date;
|
||||
expires_at: Date;
|
||||
revoked_at: Date | null;
|
||||
}
|
||||
|
||||
export class PgBackofficeSessionRepository implements BackofficeSessionRepository {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async create(userId: string, tokenHash: string, expiresAt: Date): Promise<BackofficeSession> {
|
||||
const result = await this.pool.query<SessionRow>(
|
||||
`INSERT INTO backoffice_sessions (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3) RETURNING *`,
|
||||
[userId, tokenHash, expiresAt],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new Error('backoffice_sessions INSERT returned no row');
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
createdAt: row.created_at,
|
||||
expiresAt: row.expires_at,
|
||||
revokedAt: row.revoked_at,
|
||||
};
|
||||
}
|
||||
|
||||
async revokeByTokenHash(tokenHash: string): Promise<boolean> {
|
||||
const result = await this.pool.query(
|
||||
`UPDATE backoffice_sessions SET revoked_at = now()
|
||||
WHERE token_hash = $1 AND revoked_at IS NULL`,
|
||||
[tokenHash],
|
||||
);
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type pg from 'pg';
|
||||
import type {
|
||||
BackofficeRole,
|
||||
BackofficeUser,
|
||||
BackofficeUserWithHash,
|
||||
NewBackofficeUser,
|
||||
} from '../domain/backoffice-user.js';
|
||||
import type { BackofficeUserRepository } from '../domain/ports.js';
|
||||
import { BackofficeEmailAlreadyRegisteredError } from '../domain/errors.js';
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
email: string;
|
||||
password_hash: string;
|
||||
role: string;
|
||||
mfa_enrolled: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
const UNIQUE_VIOLATION = '23505';
|
||||
|
||||
export class PgBackofficeUserRepository implements BackofficeUserRepository {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async create(input: NewBackofficeUser): Promise<BackofficeUser> {
|
||||
try {
|
||||
const result = await this.pool.query<UserRow>(
|
||||
`INSERT INTO backoffice_users (email, password_hash, role, mfa_enrolled)
|
||||
VALUES ($1, $2, COALESCE($3, 'admin'), COALESCE($4, false))
|
||||
RETURNING *`,
|
||||
[
|
||||
input.email.toLowerCase(),
|
||||
input.passwordHash,
|
||||
input.role ?? null,
|
||||
input.mfaEnrolled ?? null,
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new Error('backoffice_users INSERT returned no row');
|
||||
return toUser(row);
|
||||
} catch (error) {
|
||||
if (isUniqueViolation(error)) throw new BackofficeEmailAlreadyRegisteredError();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<BackofficeUserWithHash | undefined> {
|
||||
const result = await this.pool.query<UserRow>(
|
||||
'SELECT * FROM backoffice_users WHERE email = $1',
|
||||
[email.toLowerCase()],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? { ...toUser(row), passwordHash: row.password_hash } : undefined;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<BackofficeUser | undefined> {
|
||||
const result = await this.pool.query<UserRow>('SELECT * FROM backoffice_users WHERE id = $1', [
|
||||
id,
|
||||
]);
|
||||
const row = result.rows[0];
|
||||
return row ? toUser(row) : undefined;
|
||||
}
|
||||
|
||||
async updateMfaEnrolled(id: string, enrolled: boolean): Promise<void> {
|
||||
await this.pool.query(
|
||||
'UPDATE backoffice_users SET mfa_enrolled = $2, updated_at = now() WHERE id = $1',
|
||||
[id, enrolled],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function toUser(row: UserRow): BackofficeUser {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
role: row.role as BackofficeRole,
|
||||
mfaEnrolled: row.mfa_enrolled,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function isUniqueViolation(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code: string }).code === UNIQUE_VIOLATION
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } 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 { errorSchema } from '../../../shared/swagger.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import {
|
||||
CreateBrand,
|
||||
@@ -50,12 +52,22 @@ export async function registerBrandsRoutes(
|
||||
const createBrand = new CreateBrand(repository);
|
||||
const updateBrand = new UpdateBrand(repository);
|
||||
|
||||
app.get('/brands', async (_request, reply) => {
|
||||
const listBrandsSchema: FastifySchema = {
|
||||
tags: ['Brands'],
|
||||
summary: 'List brands (público)',
|
||||
};
|
||||
app.get('/brands', { schema: listBrandsSchema }, async (_request, reply) => {
|
||||
const items = await listBrands.execute();
|
||||
return reply.send({ items: items.map(serializeBrand) });
|
||||
});
|
||||
|
||||
app.get('/marca/:slug', async (request, reply) => {
|
||||
const publicBrandSchema: FastifySchema = {
|
||||
tags: ['Brands'],
|
||||
summary: 'Get brand by slug (público)',
|
||||
params: { type: 'object', required: ['slug'], properties: { slug: { type: 'string' } } },
|
||||
response: { 404: errorSchema },
|
||||
};
|
||||
app.get('/marca/:slug', { schema: publicBrandSchema }, async (request, reply) => {
|
||||
const { slug } = parseJson(slugParamSchema, request.params);
|
||||
const brand = await getBySlug.execute(slug);
|
||||
if (!brand) {
|
||||
@@ -64,7 +76,13 @@ export async function registerBrandsRoutes(
|
||||
return reply.send(serializeBrand(brand));
|
||||
});
|
||||
|
||||
app.post('/brands', async (request, reply) => {
|
||||
const createBrandSchema: FastifySchema = {
|
||||
tags: ['Brands'],
|
||||
summary: 'Create brand (admin)',
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/brands', { schema: createBrandSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(newBrandSchema, request.body);
|
||||
@@ -76,7 +94,18 @@ export async function registerBrandsRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/brands/:id', async (request, reply) => {
|
||||
const updateBrandSchema: FastifySchema = {
|
||||
tags: ['Brands'],
|
||||
summary: 'Update brand (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.patch('/brands/:id', { schema: updateBrandSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
@@ -93,7 +122,17 @@ export async function registerBrandsRoutes(
|
||||
});
|
||||
|
||||
// DELETE /brands/:id
|
||||
app.delete('/brands/:id', async (request, reply) => {
|
||||
const deleteBrandSchema: FastifySchema = {
|
||||
tags: ['Brands'],
|
||||
summary: 'Delete brand (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.delete('/brands/:id', { schema: deleteBrandSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
|
||||
16
project/src/modules/cache/api/cache.routes.ts
vendored
16
project/src/modules/cache/api/cache.routes.ts
vendored
@@ -1,6 +1,8 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } from 'fastify';
|
||||
import type { Authenticate } from '../../../shared/auth.js';
|
||||
import { requireRole } from '../../../shared/auth.js';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
import { CacheService } from '../application/cache-service.js';
|
||||
|
||||
export interface CacheRoutesDeps {
|
||||
@@ -12,13 +14,23 @@ export async function registerCacheRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: CacheRoutesDeps,
|
||||
): Promise<void> {
|
||||
app.get('/cache/contracts', async (request, reply) => {
|
||||
const contractsSchema: FastifySchema = {
|
||||
tags: ['Admin'],
|
||||
summary: 'List cache contracts (admin)',
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/cache/contracts', { schema: contractsSchema }, 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) => {
|
||||
const cacheMetricsSchema: FastifySchema = {
|
||||
tags: ['Admin'],
|
||||
summary: 'Cache metrics',
|
||||
response: { 401: errorSchema },
|
||||
};
|
||||
app.get('/cache/metrics', { schema: cacheMetricsSchema }, async (request, reply) => {
|
||||
await deps.authenticate(request);
|
||||
return reply.send(deps.cache.metrics());
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } 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 { errorSchema } from '../../../shared/swagger.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import type { InventoryServicePort } from '../../inventory/index.js';
|
||||
import type { PricingServicePort } from '../../pricing/index.js';
|
||||
@@ -38,12 +40,24 @@ export async function registerCartRoutes(
|
||||
deps.promotions,
|
||||
);
|
||||
|
||||
app.get('/cart', async (request, reply) => {
|
||||
const cartSchema: FastifySchema = {
|
||||
tags: ['Cart'],
|
||||
summary: 'Get cart',
|
||||
description: 'Devuelve el carrito del usuario autenticado.',
|
||||
response: { 401: errorSchema },
|
||||
};
|
||||
app.get('/cart', { schema: cartSchema }, 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 addItemSchema: FastifySchema = {
|
||||
tags: ['Cart'],
|
||||
summary: 'Add item to cart',
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema },
|
||||
};
|
||||
app.post('/cart/items', { schema: addItemSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const input = parseJson(itemBodySchema, request.body);
|
||||
try {
|
||||
@@ -53,7 +67,22 @@ export async function registerCartRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/cart/items/:variantId', async (request, reply) => {
|
||||
const updateItemSchema: FastifySchema = {
|
||||
tags: ['Cart'],
|
||||
summary: 'Change item quantity',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['variantId'],
|
||||
properties: { variantId: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['quantity'],
|
||||
properties: { quantity: { type: 'integer', minimum: 1 } },
|
||||
},
|
||||
response: { 401: errorSchema },
|
||||
};
|
||||
app.patch('/cart/items/:variantId', { schema: updateItemSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { variantId } = parseJson(variantParamSchema, request.params);
|
||||
const { quantity } = parseJson(quantityBodySchema, request.body);
|
||||
@@ -64,13 +93,33 @@ export async function registerCartRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/cart/items/:variantId', async (request, reply) => {
|
||||
const removeItemSchema: FastifySchema = {
|
||||
tags: ['Cart'],
|
||||
summary: 'Remove item from cart',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['variantId'],
|
||||
properties: { variantId: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 401: errorSchema },
|
||||
};
|
||||
app.delete('/cart/items/:variantId', { schema: removeItemSchema }, 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 promoSchema: FastifySchema = {
|
||||
tags: ['Cart'],
|
||||
summary: 'Apply promo code',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['code'],
|
||||
properties: { code: { type: 'string', maxLength: 64 } },
|
||||
},
|
||||
response: { 401: errorSchema },
|
||||
};
|
||||
app.post('/cart/promo-code', { schema: promoSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { code } = parseJson(promoCodeBodySchema, request.body);
|
||||
try {
|
||||
|
||||
@@ -32,9 +32,10 @@ export class CartService {
|
||||
}
|
||||
|
||||
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()));
|
||||
const normalizedCode = code.trim().toUpperCase();
|
||||
if (!this.promotions) return this.toView(await this.carts.setPromoCode(userId, normalizedCode));
|
||||
await this.promotions.validateCode(normalizedCode);
|
||||
return this.toView(await this.carts.setPromoCode(userId, normalizedCode));
|
||||
}
|
||||
|
||||
private async toView(
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
/** Public API of the cart module. */
|
||||
import type pg from 'pg';
|
||||
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 { PgCartRepository } from './infrastructure/pg-cart-repository.js';
|
||||
|
||||
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';
|
||||
|
||||
export function createCartService(
|
||||
pool: pg.Pool,
|
||||
pricing: PricingServicePort,
|
||||
inventory: InventoryServicePort,
|
||||
promotions?: PromotionServicePort,
|
||||
): CartService {
|
||||
return new CartService(new PgCartRepository(pool), pricing, inventory, promotions);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } 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 { errorSchema } from '../../../shared/swagger.js';
|
||||
import {
|
||||
AttachProductImage,
|
||||
DetachProductImage,
|
||||
@@ -121,7 +123,7 @@ const imageUrlSchema = z
|
||||
|
||||
const newImageSchema = z.object({
|
||||
url: imageUrlSchema,
|
||||
altText: z.string().trim().min(1).max(300),
|
||||
altText: z.string().trim().max(300).optional().default(''),
|
||||
role: z.enum(PRODUCT_IMAGE_ROLES),
|
||||
variantId: z.uuid().optional().nullable(),
|
||||
position: z.number().int().min(0).optional(),
|
||||
@@ -180,7 +182,21 @@ export async function registerCatalogRoutes(
|
||||
const reorderImages = new ReorderProductImages(images);
|
||||
|
||||
// Admin: list all products (any state)
|
||||
app.get('/catalog/products', async (request, reply) => {
|
||||
const catalogListSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'List all products (admin)',
|
||||
description: 'Lista todos los productos incluyendo los no activos. Requiere rol admin.',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: { type: 'string', description: 'Búsqueda por nombre' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
||||
offset: { type: 'integer', minimum: 0, default: 0 },
|
||||
},
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/catalog/products', { schema: catalogListSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const q = (request.query as { q?: string }).q;
|
||||
@@ -190,7 +206,18 @@ export async function registerCatalogRoutes(
|
||||
return reply.send({ items: result.items.map((p) => serializeProduct(p)), total: result.total });
|
||||
});
|
||||
|
||||
app.get('/productos/:slug', async (request, reply) => {
|
||||
const publicProductSchema: FastifySchema = {
|
||||
tags: ['Catalog'],
|
||||
summary: 'Get product by slug (público)',
|
||||
description: 'Devuelve un producto activo con todas sus imágenes.',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['slug'],
|
||||
properties: { slug: { type: 'string', description: 'URL slug del producto' } },
|
||||
},
|
||||
response: { 404: errorSchema },
|
||||
};
|
||||
app.get('/productos/:slug', { schema: publicProductSchema }, async (request, reply) => {
|
||||
const { slug } = parseJson(slugParamSchema, request.params);
|
||||
const product = await getBySlug.execute(slug);
|
||||
if (!product) {
|
||||
@@ -200,8 +227,18 @@ export async function registerCatalogRoutes(
|
||||
return reply.send(serializeProduct(product, productImages));
|
||||
});
|
||||
|
||||
// Admin: get product by ID (for editor)
|
||||
app.get('/products/:id', async (request, reply) => {
|
||||
const adminGetProductSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Get product by ID (admin)',
|
||||
description: 'Obtiene un producto por su UUID (incluye no activos). Requiere admin.',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.get('/products/:id', { schema: adminGetProductSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
@@ -213,7 +250,23 @@ export async function registerCatalogRoutes(
|
||||
return reply.send(serializeProduct(product, productImages));
|
||||
});
|
||||
|
||||
app.get('/products/search', async (request, reply) => {
|
||||
const searchSchema: FastifySchema = {
|
||||
tags: ['Catalog'],
|
||||
summary: 'Search products (público)',
|
||||
description: 'Búsqueda full-text con filtros opcionales por marca y categoría.',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: { type: 'string', maxLength: 200, description: 'Consulta de búsqueda' },
|
||||
brandSlug: { type: 'string', description: 'Slug de marca' },
|
||||
categorySlug: { type: 'string', description: 'Slug de categoría' },
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 },
|
||||
offset: { type: 'integer', minimum: 0, default: 0 },
|
||||
},
|
||||
},
|
||||
response: { 200: { type: 'object', properties: { items: { type: 'array' } } } },
|
||||
};
|
||||
app.get('/products/search', { schema: searchSchema }, async (request, reply) => {
|
||||
const input = parseJson(searchQuerySchema, request.query);
|
||||
const startedAt = performance.now();
|
||||
const items = await searchProducts.execute(input);
|
||||
@@ -234,16 +287,36 @@ export async function registerCatalogRoutes(
|
||||
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 suggestSchema: FastifySchema = {
|
||||
tags: ['Catalog'],
|
||||
summary: 'Search suggestions',
|
||||
description: 'Devuelve correcciones sugeridas para la consulta de búsqueda.',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
required: ['q'],
|
||||
properties: { q: { type: 'string', minLength: 2, maxLength: 200 } },
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: { suggestions: { type: 'array', items: { type: 'string' } } },
|
||||
},
|
||||
},
|
||||
};
|
||||
app.get('/products/suggest', { schema: suggestSchema }, 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 createProductSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Create product',
|
||||
description: 'Crea un nuevo producto. Requiere rol admin.',
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/products', { schema: createProductSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(newProductSchema, request.body);
|
||||
@@ -255,19 +328,49 @@ export async function registerCatalogRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/products/:id/variants', async (request, reply) => {
|
||||
const variantsSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'List product variants',
|
||||
description: 'Lista las variantes de un producto.',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
};
|
||||
app.get('/products/:id/variants', { schema: variantsSchema }, 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 imagesSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'List product images',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
};
|
||||
app.get('/products/:id/images', { schema: imagesSchema }, 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 attachImageSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Attach image to product',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.post('/products/:id/images', { schema: attachImageSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
@@ -283,27 +386,70 @@ export async function registerCatalogRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
const deleteImageSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Delete product image',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id', 'imageId'],
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
imageId: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
},
|
||||
response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.delete(
|
||||
'/products/:id/images/:imageId',
|
||||
{ schema: deleteImageSchema },
|
||||
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) });
|
||||
});
|
||||
const reorderImagesSwaggerSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Reorder product images',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.patch(
|
||||
'/products/:id/images/reorder',
|
||||
{ schema: reorderImagesSwaggerSchema },
|
||||
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 createVariantSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Create product variant',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/products/:id/variants', { schema: createVariantSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
@@ -319,23 +465,53 @@ export async function registerCatalogRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
const updateVariantSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Update product variant',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id', 'variantId'],
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
variantId: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.patch(
|
||||
'/products/:id/variants/:variantId',
|
||||
{ schema: updateVariantSchema },
|
||||
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);
|
||||
}
|
||||
return reply.send(serializeVariant(variant));
|
||||
} catch (error) {
|
||||
throw mapProductError(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.patch('/products/:id/rich-data', async (request, reply) => {
|
||||
const richDataSchema2: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Set product rich data',
|
||||
description: 'Establece datos ricos: ingredientes, alérgenos, nutrición, orgánico.',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.patch('/products/:id/rich-data', { schema: richDataSchema2 }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
@@ -347,7 +523,18 @@ export async function registerCatalogRoutes(
|
||||
return reply.send(serializeRichData(data));
|
||||
});
|
||||
|
||||
app.patch('/products/:id', async (request, reply) => {
|
||||
const updateProductSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Update product',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.patch('/products/:id', { schema: updateProductSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
@@ -363,8 +550,25 @@ export async function registerCatalogRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
// PATCH /products/:id/state — change product state
|
||||
app.patch('/products/:id/state', async (request, reply) => {
|
||||
const setStateSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Change product state',
|
||||
description: 'Cambia el estado del producto (draft, active, archived).',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['state'],
|
||||
properties: {
|
||||
state: { type: 'string', enum: ['draft', 'active', 'archived', 'pending_review'] },
|
||||
},
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.patch('/products/:id/state', { schema: setStateSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
@@ -376,8 +580,17 @@ export async function registerCatalogRoutes(
|
||||
return reply.send(serializeProduct(product));
|
||||
});
|
||||
|
||||
// DELETE /products/:id
|
||||
app.delete('/products/:id', async (request, reply) => {
|
||||
const deleteProductSchema: FastifySchema = {
|
||||
tags: ['Admin Products'],
|
||||
summary: 'Delete product',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.delete('/products/:id', { schema: deleteProductSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
|
||||
@@ -14,7 +14,11 @@ export interface ProductRepository {
|
||||
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 }>;
|
||||
listAll(options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
q?: string;
|
||||
}): Promise<{ items: Product[]; total: number }>;
|
||||
categoriesExist(categoryIds: readonly string[]): Promise<boolean>;
|
||||
brandExists(brandId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export const PRODUCT_ATTRIBUTES = [
|
||||
'zero-waste',
|
||||
] as const;
|
||||
|
||||
export type ProductAttribute = typeof PRODUCT_ATTRIBUTES[number];
|
||||
export type ProductAttribute = (typeof PRODUCT_ATTRIBUTES)[number];
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
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 {
|
||||
NewProduct,
|
||||
Product,
|
||||
ProductAttribute,
|
||||
ProductPatch,
|
||||
ProductState,
|
||||
} from '../domain/product.js';
|
||||
import type { ProductRepository } from '../domain/ports.js';
|
||||
|
||||
export interface ProductRow {
|
||||
@@ -239,12 +245,27 @@ export function toProduct(row: ProductRow): Product {
|
||||
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),
|
||||
? (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,
|
||||
|
||||
@@ -46,7 +46,7 @@ export class PgProductSearchRepository implements ProductSearchRepository {
|
||||
// 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})`)
|
||||
.map(() => `(name ILIKE $${ilikeParam} OR description ILIKE $${ilikeParam})`)
|
||||
.join(' AND ');
|
||||
const wordFallback = words.length > 0 ? `(${wordConditions})` : 'TRUE';
|
||||
searchWhere.push(
|
||||
|
||||
@@ -76,11 +76,16 @@ class FakeProductRepository implements ProductRepository {
|
||||
// noop for tests
|
||||
}
|
||||
|
||||
async listAll(options?: { limit?: number; offset?: number; q?: string }): Promise<{ items: Product[]; total: number }> {
|
||||
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()));
|
||||
if (options?.q)
|
||||
items = items.filter((p) => p.name.toLowerCase().includes(options.q!.toLowerCase()));
|
||||
return { items: items.slice(offset, offset + limit), total: items.length };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,11 +77,16 @@ class FakeProductRepository implements ProductRepository {
|
||||
// noop for tests
|
||||
}
|
||||
|
||||
async listAll(options?: { limit?: number; offset?: number; q?: string }): Promise<{ items: Product[]; total: number }> {
|
||||
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()));
|
||||
if (options?.q)
|
||||
items = items.filter((p) => p.name.toLowerCase().includes(options.q!.toLowerCase()));
|
||||
return { items: items.slice(offset, offset + limit), total: items.length };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } 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 { errorSchema } from '../../../shared/swagger.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import {
|
||||
CreateCategory,
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
} from '../application/category-use-cases.js';
|
||||
import type { Category, CategoryTreeNode } from '../domain/category.js';
|
||||
import {
|
||||
CategoryParentNotContainerError,
|
||||
CategoryParentNotFoundError,
|
||||
CategorySlugAlreadyExistsError,
|
||||
CategoryTreeCycleError,
|
||||
@@ -38,6 +41,7 @@ const newCategorySchema = z.object({
|
||||
slug: slugSchema,
|
||||
seoTitle: z.string().min(1).max(200).optional().nullable(),
|
||||
seoDescription: z.string().min(1).max(500).optional().nullable(),
|
||||
isParent: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const categoryPatchSchema = newCategorySchema
|
||||
@@ -57,12 +61,23 @@ export async function registerCategoriesRoutes(
|
||||
const updateCategory = new UpdateCategory(repository);
|
||||
const deleteCategory = new DeleteCategory(repository);
|
||||
|
||||
app.get('/categories/tree', async (_request, reply) => {
|
||||
const treeSchema: FastifySchema = {
|
||||
tags: ['Categories'],
|
||||
summary: 'Category tree (público)',
|
||||
description: 'Devuelve el árbol completo de categorías.',
|
||||
};
|
||||
app.get('/categories/tree', { schema: treeSchema }, async (_request, reply) => {
|
||||
const items = await listTree.execute();
|
||||
return reply.send({ items: items.map(serializeTreeNode) });
|
||||
});
|
||||
|
||||
app.get('/categoria/:slug', async (request, reply) => {
|
||||
const publicCatSchema: FastifySchema = {
|
||||
tags: ['Categories'],
|
||||
summary: 'Get category by slug (público)',
|
||||
params: { type: 'object', required: ['slug'], properties: { slug: { type: 'string' } } },
|
||||
response: { 404: errorSchema },
|
||||
};
|
||||
app.get('/categoria/:slug', { schema: publicCatSchema }, async (request, reply) => {
|
||||
const { slug } = parseJson(slugParamSchema, request.params);
|
||||
const category = await getBySlug.execute(slug);
|
||||
if (!category) {
|
||||
@@ -71,7 +86,13 @@ export async function registerCategoriesRoutes(
|
||||
return reply.send(serializeCategory(category));
|
||||
});
|
||||
|
||||
app.post('/categories', async (request, reply) => {
|
||||
const createCatSchema: FastifySchema = {
|
||||
tags: ['Categories'],
|
||||
summary: 'Create category (admin)',
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/categories', { schema: createCatSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(newCategorySchema, request.body);
|
||||
@@ -83,7 +104,18 @@ export async function registerCategoriesRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/categories/:id', async (request, reply) => {
|
||||
const updateCatSchema: FastifySchema = {
|
||||
tags: ['Categories'],
|
||||
summary: 'Update category (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.patch('/categories/:id', { schema: updateCatSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
@@ -99,7 +131,17 @@ export async function registerCategoriesRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/categories/:id', async (request, reply) => {
|
||||
const deleteCatSchema: FastifySchema = {
|
||||
tags: ['Categories'],
|
||||
summary: 'Delete category (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.delete('/categories/:id', { schema: deleteCatSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
@@ -124,6 +166,9 @@ function mapCategoryError(error: unknown): Error {
|
||||
if (error instanceof CategoryTreeCycleError) {
|
||||
return new AppError(422, 'CATEGORY_TREE_CYCLE', error.message);
|
||||
}
|
||||
if (error instanceof CategoryParentNotContainerError) {
|
||||
return new AppError(422, 'CATEGORY_PARENT_NOT_CONTAINER', error.message);
|
||||
}
|
||||
return error instanceof Error ? error : new Error('Unknown category error');
|
||||
}
|
||||
|
||||
@@ -136,6 +181,7 @@ function serializeCategory(category: Category) {
|
||||
url: `/categoria/${category.slug}`,
|
||||
seoTitle: category.seoTitle,
|
||||
seoDescription: category.seoDescription,
|
||||
isParent: category.isParent,
|
||||
createdAt: category.createdAt.toISOString(),
|
||||
updatedAt: category.updatedAt.toISOString(),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Category, CategoryPatch, CategoryTreeNode, NewCategory } from '../domain/category.js';
|
||||
import { CategoryParentNotFoundError, CategoryTreeCycleError } from '../domain/errors.js';
|
||||
import {
|
||||
CategoryParentNotContainerError,
|
||||
CategoryParentNotFoundError,
|
||||
CategoryTreeCycleError,
|
||||
} from '../domain/errors.js';
|
||||
import type { CategoryRepository } from '../domain/ports.js';
|
||||
|
||||
export class GetCategoryBySlug {
|
||||
@@ -22,11 +26,11 @@ export class CreateCategory {
|
||||
constructor(private readonly categories: CategoryRepository) {}
|
||||
|
||||
async execute(input: NewCategory): Promise<Category> {
|
||||
await this.assertParentExists(input.parentId);
|
||||
await this.assertValidParent(input.parentId);
|
||||
return this.categories.create(input);
|
||||
}
|
||||
|
||||
private async assertParentExists(parentId: string | null | undefined): Promise<void> {
|
||||
private async assertValidParent(parentId: string | null | undefined): Promise<void> {
|
||||
if (parentId === undefined || parentId === null) {
|
||||
return;
|
||||
}
|
||||
@@ -34,6 +38,9 @@ export class CreateCategory {
|
||||
if (!parent) {
|
||||
throw new CategoryParentNotFoundError();
|
||||
}
|
||||
if (!parent.isParent) {
|
||||
throw new CategoryParentNotContainerError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +69,9 @@ export class UpdateCategory {
|
||||
if (parentIsDescendant) {
|
||||
throw new CategoryTreeCycleError();
|
||||
}
|
||||
if (!parent.isParent) {
|
||||
throw new CategoryParentNotContainerError();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface Category {
|
||||
slug: string;
|
||||
seoTitle: string | null;
|
||||
seoDescription: string | null;
|
||||
/** FIX-19: true = contenedor (puede tener hijos); false = hoja (child). */
|
||||
isParent: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -18,6 +20,7 @@ export interface NewCategory {
|
||||
slug: string;
|
||||
seoTitle?: string | null;
|
||||
seoDescription?: string | null;
|
||||
isParent?: boolean;
|
||||
}
|
||||
|
||||
/** Fields a category update may set. Undefined = leave unchanged; parentId null = move to root. */
|
||||
|
||||
@@ -18,3 +18,12 @@ export class CategoryTreeCycleError extends Error {
|
||||
this.name = 'CategoryTreeCycleError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CategoryParentNotContainerError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
'Category parent must be a parent (container) category; a child cannot contain categories',
|
||||
);
|
||||
this.name = 'CategoryParentNotContainerError';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface CategoryRow {
|
||||
slug: string;
|
||||
seo_title: string | null;
|
||||
seo_description: string | null;
|
||||
is_parent: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -22,6 +23,7 @@ const UPDATABLE: ReadonlyArray<[keyof CategoryPatch, string]> = [
|
||||
['slug', 'slug'],
|
||||
['seoTitle', 'seo_title'],
|
||||
['seoDescription', 'seo_description'],
|
||||
['isParent', 'is_parent'],
|
||||
];
|
||||
|
||||
export class PgCategoryRepository implements CategoryRepository {
|
||||
@@ -55,8 +57,8 @@ export class PgCategoryRepository implements CategoryRepository {
|
||||
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)
|
||||
`INSERT INTO categories_categories (parent_id, name, slug, seo_title, seo_description, is_parent)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.parentId ?? null,
|
||||
@@ -64,6 +66,7 @@ export class PgCategoryRepository implements CategoryRepository {
|
||||
input.slug,
|
||||
input.seoTitle ?? null,
|
||||
input.seoDescription ?? null,
|
||||
input.isParent ?? false,
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
@@ -146,6 +149,7 @@ function toCategory(row: CategoryRow): Category {
|
||||
slug: row.slug,
|
||||
seoTitle: row.seo_title,
|
||||
seoDescription: row.seo_description,
|
||||
isParent: row.is_parent,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
UpdateCategory,
|
||||
} from '../application/category-use-cases.js';
|
||||
import type { Category, CategoryPatch, NewCategory } from '../domain/category.js';
|
||||
import { CategoryParentNotFoundError, CategoryTreeCycleError } from '../domain/errors.js';
|
||||
import {
|
||||
CategoryParentNotContainerError,
|
||||
CategoryParentNotFoundError,
|
||||
CategoryTreeCycleError,
|
||||
} from '../domain/errors.js';
|
||||
import type { CategoryRepository } from '../domain/ports.js';
|
||||
|
||||
function category(input: Partial<Category> & Pick<Category, 'id' | 'name' | 'slug'>): Category {
|
||||
@@ -13,6 +17,7 @@ function category(input: Partial<Category> & Pick<Category, 'id' | 'name' | 'slu
|
||||
parentId: null,
|
||||
seoTitle: null,
|
||||
seoDescription: null,
|
||||
isParent: false,
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
||||
...input,
|
||||
@@ -42,6 +47,7 @@ class FakeCategoryRepository implements CategoryRepository {
|
||||
slug: input.slug,
|
||||
seoTitle: input.seoTitle ?? null,
|
||||
seoDescription: input.seoDescription ?? null,
|
||||
isParent: input.isParent ?? false,
|
||||
});
|
||||
this.categories.push(created);
|
||||
return created;
|
||||
@@ -103,6 +109,16 @@ describe('category use cases', () => {
|
||||
).rejects.toBeInstanceOf(CategoryParentNotFoundError);
|
||||
});
|
||||
|
||||
it('rejects a leaf category as parent', async () => {
|
||||
const repo = new FakeCategoryRepository([
|
||||
category({ id: 'leaf', name: 'Aceites', slug: 'aceites', isParent: false }),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
new CreateCategory(repo).execute({ parentId: 'leaf', name: 'Oliva', slug: 'oliva' }),
|
||||
).rejects.toBeInstanceOf(CategoryParentNotContainerError);
|
||||
});
|
||||
|
||||
it('rejects self-parent and descendant-as-parent updates', async () => {
|
||||
const repo = new FakeCategoryRepository([
|
||||
category({ id: 'root', name: 'Alimentación', slug: 'alimentacion' }),
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } 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 { errorSchema } from '../../../shared/swagger.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { CartService } from '../../cart/index.js';
|
||||
import { PgCartRepository } from '../../cart/infrastructure/pg-cart-repository.js';
|
||||
import { createCartService } from '../../cart/index.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 { createOrderService } from '../../orders/index.js';
|
||||
import { createPricingService } from '../../pricing/index.js';
|
||||
import { createShippingService } from '../../shipping/index.js';
|
||||
import { CheckoutService } from '../application/checkout-service.js';
|
||||
@@ -42,8 +41,8 @@ export async function registerCheckoutRoutes(
|
||||
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 orders = createOrderService(deps.pool);
|
||||
const cart = createCartService(deps.pool, pricing, inventory);
|
||||
const metrics = new InMemoryCheckoutMetrics();
|
||||
const tracer = deps.tracer ?? createNoOpTelemetry().tracer;
|
||||
const orderLookup = new PgIdempotencyLookup(deps.pool);
|
||||
@@ -60,7 +59,33 @@ export async function registerCheckoutRoutes(
|
||||
tracer,
|
||||
});
|
||||
|
||||
app.post('/checkout', async (request, reply) => {
|
||||
const checkoutSchema: FastifySchema = {
|
||||
tags: ['Checkout'],
|
||||
summary: 'Checkout',
|
||||
description: 'Procesa el carrito del usuario y crea el pedido.',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['address', 'idempotencyKey'],
|
||||
properties: {
|
||||
address: {
|
||||
type: 'object',
|
||||
required: ['country', 'postalCode'],
|
||||
properties: {
|
||||
country: { type: 'string' },
|
||||
postalCode: { type: 'string' },
|
||||
},
|
||||
},
|
||||
promoCode: { type: 'string', maxLength: 64, nullable: true },
|
||||
idempotencyKey: {
|
||||
type: 'string',
|
||||
maxLength: 120,
|
||||
description: 'Idempotency key para evitar duplicados',
|
||||
},
|
||||
},
|
||||
},
|
||||
response: { 401: errorSchema },
|
||||
};
|
||||
app.post('/checkout', { schema: checkoutSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const input = parseJson(checkoutBodySchema, request.body);
|
||||
try {
|
||||
|
||||
@@ -257,8 +257,12 @@ describe('CheckoutService', () => {
|
||||
throw new Error('should not transition on retry');
|
||||
},
|
||||
listOrders: async () => [],
|
||||
getOrderAdmin: async () => { throw new Error('not used'); },
|
||||
transitionAdmin: async () => { throw new Error('not used'); },
|
||||
getOrderAdmin: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
transitionAdmin: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
getOrder: async (id) => ({
|
||||
id,
|
||||
userId: 'u-1',
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } 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 { errorSchema } from '../../../shared/swagger.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { CmsService } from '../application/cms-service.js';
|
||||
import { DuplicateSlugError, PageNotFoundError, PageNotPublishedError } from '../domain/errors.js';
|
||||
@@ -46,14 +48,25 @@ 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 listPagesSchema: FastifySchema = {
|
||||
tags: ['CMS'],
|
||||
summary: 'List CMS pages (admin)',
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/cms/pages', { schema: listPagesSchema }, 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 createPageSchema: FastifySchema = {
|
||||
tags: ['CMS'],
|
||||
summary: 'Create CMS page (admin)',
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/cms/pages', { schema: createPageSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(createSchema, request.body);
|
||||
@@ -65,7 +78,18 @@ export async function registerCmsRoutes(app: FastifyInstance, deps: CmsRoutesDep
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/cms/pages/:id', async (request, reply) => {
|
||||
const updatePageSchema: FastifySchema = {
|
||||
tags: ['CMS'],
|
||||
summary: 'Update CMS page (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.patch('/cms/pages/:id', { schema: updatePageSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = idParamSchema.parse(request.params);
|
||||
@@ -78,7 +102,17 @@ export async function registerCmsRoutes(app: FastifyInstance, deps: CmsRoutesDep
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/cms/pages/:id/publish', async (request, reply) => {
|
||||
const publishSchema: FastifySchema = {
|
||||
tags: ['CMS'],
|
||||
summary: 'Publish page (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/cms/pages/:id/publish', { schema: publishSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = idParamSchema.parse(request.params);
|
||||
@@ -90,7 +124,17 @@ export async function registerCmsRoutes(app: FastifyInstance, deps: CmsRoutesDep
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/cms/pages/:id/unpublish', async (request, reply) => {
|
||||
const unpublishSchema: FastifySchema = {
|
||||
tags: ['CMS'],
|
||||
summary: 'Unpublish page (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/cms/pages/:id/unpublish', { schema: unpublishSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = idParamSchema.parse(request.params);
|
||||
@@ -102,7 +146,13 @@ export async function registerCmsRoutes(app: FastifyInstance, deps: CmsRoutesDep
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/cms/pages/:slug', async (request, reply) => {
|
||||
const getPageSchema: FastifySchema = {
|
||||
tags: ['CMS'],
|
||||
summary: 'Get published page (público)',
|
||||
params: { type: 'object', required: ['slug'], properties: { slug: { type: 'string' } } },
|
||||
response: { 404: errorSchema },
|
||||
};
|
||||
app.get('/cms/pages/:slug', { schema: getPageSchema }, async (request, reply) => {
|
||||
const { slug } = slugParamSchema.parse(request.params);
|
||||
try {
|
||||
const page = await service.getPublicPageBySlug(slug);
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } from 'fastify';
|
||||
|
||||
interface HealthResponse {
|
||||
status: 'ok';
|
||||
}
|
||||
const healthSchema: FastifySchema = {
|
||||
tags: ['Health'],
|
||||
summary: 'Health check',
|
||||
description: 'Verifica que el servicio está operativo.',
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', const: 'ok' },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export async function registerHealthRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get('/health', async (): Promise<HealthResponse> => {
|
||||
app.get('/health', { schema: healthSchema }, async (): Promise<{ status: 'ok' }> => {
|
||||
return { status: 'ok' };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* calls use cases, maps domain errors to the shared error envelope.
|
||||
*/
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import type { FastifySchema } from 'fastify';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
import { z } from 'zod';
|
||||
import type { Authenticate } from '../../../shared/auth.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
@@ -66,7 +68,98 @@ export async function registerIdentityRoutes(
|
||||
});
|
||||
const logout = new Logout(sessions, hashSessionToken);
|
||||
|
||||
app.post('/auth/register', async (request, reply) => {
|
||||
const registerSchema: FastifySchema = {
|
||||
tags: ['Auth'],
|
||||
summary: 'Register new user',
|
||||
description: 'Crea una cuenta de usuario. Por defecto el rol es `customer`.',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email', 'password'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email', maxLength: 255, description: 'Email único' },
|
||||
password: {
|
||||
type: 'string',
|
||||
minLength: 8,
|
||||
maxLength: 128,
|
||||
description: 'Contraseña (mín. 8 caracteres)',
|
||||
},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
201: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
email: { type: 'string', format: 'email' },
|
||||
role: { type: 'string', enum: ['customer', 'editor', 'admin'] },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
409: errorSchema,
|
||||
},
|
||||
};
|
||||
|
||||
const loginSchema: FastifySchema = {
|
||||
tags: ['Auth'],
|
||||
summary: 'Login',
|
||||
description: 'Inicia sesión y devuelve una cookie de sesión `mdv_session` (válida 7 días).',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email', 'password'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email', description: 'Email' },
|
||||
password: { type: 'string', description: 'Contraseña' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
email: { type: 'string', format: 'email' },
|
||||
role: { type: 'string', enum: ['customer', 'editor', 'admin'] },
|
||||
},
|
||||
},
|
||||
401: errorSchema,
|
||||
429: errorSchema,
|
||||
},
|
||||
};
|
||||
|
||||
const logoutSchema: FastifySchema = {
|
||||
tags: ['Auth'],
|
||||
summary: 'Logout',
|
||||
description: 'Invalida la sesión actual y elimina la cookie.',
|
||||
response: { 204: { type: 'null' } },
|
||||
};
|
||||
|
||||
const meSchema: FastifySchema = {
|
||||
tags: ['Auth'],
|
||||
summary: 'Current user',
|
||||
description:
|
||||
'Devuelve el usuario autenticado (cookie de sesión) o `{user: null}` si no hay sesión.',
|
||||
response: {
|
||||
200: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
email: { type: 'string', format: 'email' },
|
||||
role: { type: 'string', enum: ['customer', 'editor', 'admin'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
user: { type: 'null' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
app.post('/auth/register', { schema: registerSchema }, async (request, reply) => {
|
||||
const input = parseJson(credentialsSchema, request.body);
|
||||
try {
|
||||
const user = await registerUser.execute(input);
|
||||
@@ -81,7 +174,7 @@ export async function registerIdentityRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/auth/login', async (request, reply) => {
|
||||
app.post('/auth/login', { schema: loginSchema }, async (request, reply) => {
|
||||
const input = parseJson(credentialsSchema, request.body);
|
||||
try {
|
||||
const result = await login.execute(input);
|
||||
@@ -101,14 +194,14 @@ export async function registerIdentityRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/auth/logout', async (request, reply) => {
|
||||
app.post('/auth/logout', { schema: logoutSchema }, async (request, reply) => {
|
||||
const token = request.cookies[SESSION_COOKIE_NAME];
|
||||
await logout.execute(token);
|
||||
clearSessionCookie(reply, cookieSecure);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
app.get('/auth/me', async (request, reply) => {
|
||||
app.get('/auth/me', { schema: meSchema }, async (request, reply) => {
|
||||
try {
|
||||
const user = await deps.authenticate!(request);
|
||||
return reply.send({ id: user.id, email: user.email, role: user.role });
|
||||
|
||||
@@ -13,7 +13,12 @@ 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 }>;
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -73,14 +73,26 @@ export class PgUserRepository implements UserRepository {
|
||||
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 }> {
|
||||
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}%`); }
|
||||
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}`,
|
||||
@@ -92,7 +104,12 @@ export class PgUserRepository implements UserRepository {
|
||||
[...values, limit, offset],
|
||||
);
|
||||
return {
|
||||
items: rows.rows.map((r) => ({ id: r.id, email: r.email, role: r.role, createdAt: r.created_at })),
|
||||
items: rows.rows.map((r) => ({
|
||||
id: r.id,
|
||||
email: r.email,
|
||||
role: r.role,
|
||||
createdAt: r.created_at,
|
||||
})),
|
||||
total,
|
||||
};
|
||||
}
|
||||
@@ -101,8 +118,14 @@ export class PgUserRepository implements UserRepository {
|
||||
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 (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');
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } 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 { errorSchema } from '../../../shared/swagger.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { InventoryService } from '../application/inventory-service.js';
|
||||
import {
|
||||
@@ -39,14 +41,43 @@ export async function registerInventoryRoutes(
|
||||
): 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);
|
||||
});
|
||||
const availabilitySchema: FastifySchema = {
|
||||
tags: ['Inventory'],
|
||||
summary: 'Check availability (público)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['variantId'],
|
||||
properties: { variantId: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
querystring: { type: 'object', properties: { quantity: { type: 'integer', default: 1 } } },
|
||||
};
|
||||
app.get(
|
||||
'/inventory/:variantId/availability',
|
||||
{ schema: availabilitySchema },
|
||||
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 setStockSchema: FastifySchema = {
|
||||
tags: ['Inventory'],
|
||||
summary: 'Set available stock (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['variantId'],
|
||||
properties: { variantId: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['quantity'],
|
||||
properties: { quantity: { type: 'integer', minimum: 0 } },
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.put('/inventory/:variantId/stock', { schema: setStockSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { variantId } = parseJson(variantParamSchema, request.params);
|
||||
@@ -59,47 +90,127 @@ export async function registerInventoryRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
const reserveSchema: FastifySchema = {
|
||||
tags: ['Inventory'],
|
||||
summary: 'Reserve stock (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['variantId'],
|
||||
properties: { variantId: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['quantity'],
|
||||
properties: { quantity: { type: 'integer', minimum: 1 } },
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post(
|
||||
'/inventory/:variantId/reservations',
|
||||
{ schema: reserveSchema },
|
||||
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);
|
||||
}
|
||||
});
|
||||
const releaseSchema: FastifySchema = {
|
||||
tags: ['Inventory'],
|
||||
summary: 'Release reservation (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['variantId'],
|
||||
properties: { variantId: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['quantity'],
|
||||
properties: { quantity: { type: 'integer', minimum: 1 } },
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post(
|
||||
'/inventory/:variantId/reservations/release',
|
||||
{ schema: releaseSchema },
|
||||
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);
|
||||
}
|
||||
});
|
||||
const confirmSchema: FastifySchema = {
|
||||
tags: ['Inventory'],
|
||||
summary: 'Confirm reservation (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['variantId'],
|
||||
properties: { variantId: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['quantity'],
|
||||
properties: { quantity: { type: 'integer', minimum: 1 } },
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post(
|
||||
'/inventory/:variantId/reservations/confirm',
|
||||
{ schema: confirmSchema },
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// POST /inventory/bulk-adjust — atomic bulk stock adjustment
|
||||
app.post('/inventory/bulk-adjust', async (request, reply) => {
|
||||
const bulkSchema: FastifySchema = {
|
||||
tags: ['Inventory'],
|
||||
summary: 'Bulk stock adjustment (admin)',
|
||||
description: 'Ajuste atómico de stock para múltiples variantes en una transacción.',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['items'],
|
||||
properties: {
|
||||
items: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['variantId', 'quantity'],
|
||||
properties: {
|
||||
variantId: { type: 'string', format: 'uuid' },
|
||||
quantity: { type: 'integer', minimum: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/inventory/bulk-adjust', { schema: bulkSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { items } = parseJson(bulkAdjustBodySchema, request.body);
|
||||
|
||||
@@ -5,6 +5,11 @@ import { PgInventoryRepository } from './infrastructure/pg-inventory-repository.
|
||||
|
||||
export { registerInventoryRoutes, type InventoryRoutesDeps } from './api/inventory.routes.js';
|
||||
export { InventoryService } from './application/inventory-service.js';
|
||||
export {
|
||||
InsufficientReservedStockError,
|
||||
InsufficientStockError,
|
||||
InvalidStockQuantityError,
|
||||
} from './domain/errors.js';
|
||||
export type {
|
||||
InventoryRepository,
|
||||
InventoryService as InventoryServicePort,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } 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 { errorSchema } from '../../../shared/swagger.js';
|
||||
import { NotificationsService } from '../application/notifications-service.js';
|
||||
import { PgNotificationsRepository } from '../infrastructure/pg-notifications-repository.js';
|
||||
|
||||
@@ -32,7 +34,13 @@ export async function registerNotificationsRoutes(
|
||||
deps.emailProvider,
|
||||
);
|
||||
|
||||
app.post('/notifications/dispatch', async (request, reply) => {
|
||||
const dispatchSwaggerSchema: FastifySchema = {
|
||||
tags: ['Admin'],
|
||||
summary: 'Dispatch notification (admin)',
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/notifications/dispatch', { schema: dispatchSwaggerSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(dispatchSchema, request.body);
|
||||
@@ -40,18 +48,28 @@ export async function registerNotificationsRoutes(
|
||||
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);
|
||||
});
|
||||
const getMessageSchema: FastifySchema = {
|
||||
tags: ['Admin'],
|
||||
summary: 'Get notification message (admin)',
|
||||
params: { type: 'object', required: ['eventId'], properties: { eventId: { type: 'string' } } },
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.get(
|
||||
'/notifications/messages/:eventId',
|
||||
{ schema: getMessageSchema },
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import { z } from 'zod';
|
||||
import type { Authenticate, CurrentUser } from '../../../shared/auth.js';
|
||||
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 { errorSchema } from '../../../shared/swagger.js';
|
||||
import { OrderService } from '../application/order-service.js';
|
||||
import { OrderNotFoundError, OrderStateTransitionError } from '../domain/errors.js';
|
||||
import type { OrderState } from '../domain/order.js';
|
||||
@@ -63,7 +65,14 @@ export async function registerOrdersRoutes(
|
||||
): Promise<void> {
|
||||
const service = new OrderService(new PgOrderRepository(deps.pool), new NoOpOrderEventPublisher());
|
||||
|
||||
app.post('/orders', async (request, reply) => {
|
||||
const createOrderSwaggerSchema: FastifySchema = {
|
||||
tags: ['Orders'],
|
||||
summary: 'Create order',
|
||||
description: 'Crea un pedido. Requiere autenticación.',
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema },
|
||||
};
|
||||
app.post('/orders', { schema: createOrderSwaggerSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const input = parseJson(createOrderSchema, request.body);
|
||||
try {
|
||||
@@ -79,7 +88,37 @@ export async function registerOrdersRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/orders/:id/transitions', async (request, reply) => {
|
||||
const transitionOrderSchema: FastifySchema = {
|
||||
tags: ['Orders'],
|
||||
summary: 'Transition order state (customer)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['state'],
|
||||
properties: {
|
||||
state: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'PENDING',
|
||||
'AWAITING_PAYMENT',
|
||||
'PAID',
|
||||
'PROCESSING',
|
||||
'SHIPPED',
|
||||
'DELIVERED',
|
||||
'CANCELLED',
|
||||
'REFUNDED',
|
||||
'PARTIALLY_REFUNDED',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
response: { 401: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.post('/orders/:id/transitions', { schema: transitionOrderSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id } = parseJson(orderIdParamSchema, request.params);
|
||||
const { state } = parseJson(transitionSchema, request.body);
|
||||
@@ -91,7 +130,18 @@ export async function registerOrdersRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/orders/:id', async (request, reply) => {
|
||||
const getOrderSchema: FastifySchema = {
|
||||
tags: ['Orders'],
|
||||
summary: 'Get order',
|
||||
description: 'Obtiene un pedido. El cliente solo ve los suyos; admin ve cualquiera.',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 401: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.get('/orders/:id', { schema: getOrderSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id } = parseJson(orderIdParamSchema, request.params);
|
||||
const order = await service.getOrder(id, user.id);
|
||||
@@ -100,14 +150,29 @@ export async function registerOrdersRoutes(
|
||||
});
|
||||
|
||||
// Admin-only routes
|
||||
app.get('/orders', async (request, reply) => {
|
||||
const listOrdersSchema: FastifySchema = {
|
||||
tags: ['Orders'],
|
||||
summary: 'List orders (admin)',
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/orders', { schema: listOrdersSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const orders = await service.listOrders();
|
||||
return reply.send(orders.map(serializeOrder));
|
||||
});
|
||||
|
||||
app.get('/orders/:id/admin', async (request, reply) => {
|
||||
const getOrderAdminSchema: FastifySchema = {
|
||||
tags: ['Orders'],
|
||||
summary: 'Get order (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.get('/orders/:id/admin', { schema: getOrderAdminSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(orderIdParamSchema, request.params);
|
||||
@@ -116,18 +181,53 @@ export async function registerOrdersRoutes(
|
||||
return reply.send(serializeOrder(order));
|
||||
});
|
||||
|
||||
app.post('/orders/:id/transitions/admin', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(orderIdParamSchema, request.params);
|
||||
const { state } = parseJson(transitionSchema, request.body);
|
||||
try {
|
||||
const order = await service.transitionAdmin(id, state as OrderState);
|
||||
return reply.send(serializeOrder(order));
|
||||
} catch (error) {
|
||||
throw mapOrderError(error);
|
||||
}
|
||||
});
|
||||
const adminTransitionSchema: FastifySchema = {
|
||||
tags: ['Orders'],
|
||||
summary: 'Force order transition (admin)',
|
||||
description: 'Fuerza una transición de estado de pedido.',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['state'],
|
||||
properties: {
|
||||
state: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'PENDING',
|
||||
'AWAITING_PAYMENT',
|
||||
'PAID',
|
||||
'PROCESSING',
|
||||
'SHIPPED',
|
||||
'DELIVERED',
|
||||
'CANCELLED',
|
||||
'REFUNDED',
|
||||
'PARTIALLY_REFUNDED',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.post(
|
||||
'/orders/:id/transitions/admin',
|
||||
{ schema: adminTransitionSchema },
|
||||
async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(orderIdParamSchema, request.params);
|
||||
const { state } = parseJson(transitionSchema, request.body);
|
||||
try {
|
||||
const order = await service.transitionAdmin(id, state as OrderState);
|
||||
return reply.send(serializeOrder(order));
|
||||
} catch (error) {
|
||||
throw mapOrderError(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function mapOrderError(error: unknown): Error {
|
||||
|
||||
@@ -54,7 +54,8 @@ export class OrderService implements OrderServicePort {
|
||||
throw new OrderStateTransitionError(existing.state, next);
|
||||
const updated = await this.repo.updateState(id, next);
|
||||
if (!updated) throw new OrderNotFoundError();
|
||||
if (next === 'PAID') await this.events.emit({ type: 'OrderPaid', orderId: id, userId: existing.userId });
|
||||
if (next === 'PAID')
|
||||
await this.events.emit({ type: 'OrderPaid', orderId: id, userId: existing.userId });
|
||||
if (next === 'CANCELLED')
|
||||
await this.events.emit({ type: 'OrderCancelled', orderId: id, userId: existing.userId });
|
||||
return updated;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } 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 { errorSchema } from '../../../shared/swagger.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { PaymentsService } from '../application/payments-service.js';
|
||||
@@ -84,7 +86,9 @@ export async function registerPaymentsRoutes(
|
||||
getOrder: async () => undefined,
|
||||
listOrders: async () => [],
|
||||
getOrderAdmin: async () => undefined,
|
||||
transitionAdmin: async () => { throw new Error('Payments should not call transitionAdmin'); },
|
||||
transitionAdmin: async () => {
|
||||
throw new Error('Payments should not call transitionAdmin');
|
||||
},
|
||||
};
|
||||
const events = { emit: async () => undefined };
|
||||
const service = new PaymentsService({
|
||||
@@ -94,7 +98,13 @@ export async function registerPaymentsRoutes(
|
||||
provider: 'stripe',
|
||||
});
|
||||
|
||||
app.post('/payments/webhook', async (request, reply) => {
|
||||
const webhookSchema: FastifySchema = {
|
||||
tags: ['Payments'],
|
||||
summary: 'Stripe webhook',
|
||||
description: 'Endpoint接收 Stripe webhook回调。无需认证。',
|
||||
response: { 400: errorSchema },
|
||||
};
|
||||
app.post('/payments/webhook', { schema: webhookSchema }, async (request, reply) => {
|
||||
const signature = request.headers['stripe-signature'];
|
||||
const signatureHeader = Array.isArray(signature) ? signature[0] : signature;
|
||||
const rawBody = (request.body as Buffer | string) ?? '';
|
||||
@@ -115,56 +125,120 @@ export async function registerPaymentsRoutes(
|
||||
});
|
||||
|
||||
// ── Admin management ───────────────────────────────────────────────────────
|
||||
app.get('/admin/payments', async (request, reply) => {
|
||||
const listPaymentsSchema: FastifySchema = {
|
||||
tags: ['Payments'],
|
||||
summary: 'List transactions (admin)',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', default: 50 },
|
||||
offset: { type: 'integer', default: 0 },
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['requires_payment', 'succeeded', 'failed', 'refunded', 'chargeback'],
|
||||
},
|
||||
q: { type: 'string' },
|
||||
},
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/admin/payments', { schema: listPaymentsSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const parsed = parseJson(z.object({
|
||||
limit: z.coerce.number().int().positive().max(100).optional().default(50),
|
||||
offset: z.coerce.number().int().min(0).optional().default(0),
|
||||
status: z.enum(['requires_payment','succeeded','failed','refunded','chargeback']).optional(),
|
||||
q: z.string().optional(),
|
||||
}), request.query ?? {});
|
||||
const parsed = parseJson(
|
||||
z.object({
|
||||
limit: z.coerce.number().int().positive().max(100).optional().default(50),
|
||||
offset: z.coerce.number().int().min(0).optional().default(0),
|
||||
status: z
|
||||
.enum(['requires_payment', 'succeeded', 'failed', 'refunded', 'chargeback'])
|
||||
.optional(),
|
||||
q: z.string().optional(),
|
||||
}),
|
||||
request.query ?? {},
|
||||
);
|
||||
const conditions: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let i = 1;
|
||||
if (parsed.status) { conditions.push(`status = $${i++}`); values.push(parsed.status); }
|
||||
if (parsed.q) { conditions.push(`(provider_payment_id ILIKE $${i++})`); values.push(`%${parsed.q}%`); }
|
||||
if (parsed.status) {
|
||||
conditions.push(`status = $${i++}`);
|
||||
values.push(parsed.status);
|
||||
}
|
||||
if (parsed.q) {
|
||||
conditions.push(`(provider_payment_id ILIKE $${i++})`);
|
||||
values.push(`%${parsed.q}%`);
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const countResult = await deps.pool.query<{ count: string }>(
|
||||
`SELECT COUNT(*) FROM payments_transactions ${where}`, values,
|
||||
`SELECT COUNT(*) FROM payments_transactions ${where}`,
|
||||
values,
|
||||
);
|
||||
const total = parseInt(countResult.rows[0]?.count ?? '0', 10);
|
||||
const rows = await deps.pool.query(
|
||||
`SELECT * FROM payments_transactions ${where} ORDER BY created_at DESC LIMIT $${i++} OFFSET $${i}`,
|
||||
[...values, parsed.limit, parsed.offset],
|
||||
);
|
||||
return reply.send({ items: rows.rows.map(r => ({
|
||||
id: r.id, provider: r.provider, providerPaymentId: r.provider_payment_id,
|
||||
orderId: r.order_id, amountCents: r.amount_cents, currency: r.currency,
|
||||
status: r.status, raw: r.raw, createdAt: r.created_at,
|
||||
})), total });
|
||||
return reply.send({
|
||||
items: rows.rows.map((r) => ({
|
||||
id: r.id,
|
||||
provider: r.provider,
|
||||
providerPaymentId: r.provider_payment_id,
|
||||
orderId: r.order_id,
|
||||
amountCents: r.amount_cents,
|
||||
currency: r.currency,
|
||||
status: r.status,
|
||||
raw: r.raw,
|
||||
createdAt: r.created_at,
|
||||
})),
|
||||
total,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/admin/payments/:id/refund', async (request, reply) => {
|
||||
const refundSchema: FastifySchema = {
|
||||
tags: ['Payments'],
|
||||
summary: 'Refund transaction (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/admin/payments/:id/refund', { schema: refundSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(z.object({ id: z.string().uuid() }), request.params);
|
||||
const tx = await deps.pool.query<{ status: string; provider_payment_id: string }>(
|
||||
`SELECT status, provider_payment_id FROM payments_transactions WHERE id = $1`, [id],
|
||||
`SELECT status, provider_payment_id FROM payments_transactions WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
if (!tx.rows[0]) throw new AppError(404, 'NOT_FOUND', 'Transaction not found');
|
||||
if (tx.rows[0].status !== 'succeeded') throw new AppError(400, 'BAD_STATE', 'Can only refund succeeded payments');
|
||||
if (tx.rows[0].status !== 'succeeded')
|
||||
throw new AppError(400, 'BAD_STATE', 'Can only refund succeeded payments');
|
||||
await provider.refund(tx.rows[0].provider_payment_id);
|
||||
await deps.pool.query(
|
||||
`UPDATE payments_transactions SET status = 'refunded' WHERE id = $1`, [id],
|
||||
);
|
||||
await deps.pool.query(`UPDATE payments_transactions SET status = 'refunded' WHERE id = $1`, [
|
||||
id,
|
||||
]);
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/payments/orders/:orderId/transactions', async (request, reply) => {
|
||||
await deps.authenticate(request);
|
||||
const { orderId } = orderIdParamSchema.parse(request.params);
|
||||
const rows = await repository.listByOrderId(orderId);
|
||||
return reply.send({ items: rows });
|
||||
});
|
||||
const orderTxSchema: FastifySchema = {
|
||||
tags: ['Payments'],
|
||||
summary: 'Get order transactions',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['orderId'],
|
||||
properties: { orderId: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 401: errorSchema },
|
||||
};
|
||||
app.get(
|
||||
'/payments/orders/:orderId/transactions',
|
||||
{ schema: orderTxSchema },
|
||||
async (request, reply) => {
|
||||
await deps.authenticate(request);
|
||||
const { orderId } = orderIdParamSchema.parse(request.params);
|
||||
const rows = await repository.listByOrderId(orderId);
|
||||
return reply.send({ items: rows });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { OrderEventPublisher, OrderServicePort, OrderState } from '../../orders/index.js';
|
||||
import { OrderNotFoundError } from '../../orders/domain/errors.js';
|
||||
import {
|
||||
OrderNotFoundError,
|
||||
type OrderEventPublisher,
|
||||
type OrderServicePort,
|
||||
type OrderState,
|
||||
} from '../../orders/index.js';
|
||||
import type { PaymentRepository } from '../domain/ports.js';
|
||||
import type { PaymentEvent, PaymentTransaction } from '../domain/payment.js';
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ export class StripePaymentProvider implements PaymentProvider {
|
||||
}
|
||||
|
||||
async refund(paymentIntentId: string): Promise<void> {
|
||||
if (!this.stripe) throw new AppError(500, 'STRIPE_NOT_CONFIGURED', 'Stripe is not configured for refunds');
|
||||
if (!this.stripe)
|
||||
throw new AppError(500, 'STRIPE_NOT_CONFIGURED', 'Stripe is not configured for refunds');
|
||||
await this.stripe.refunds.create({ payment_intent: paymentIntentId });
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,9 @@ describe('PaymentsService', () => {
|
||||
}) satisfies OrderView,
|
||||
getOrder: async () => undefined,
|
||||
getOrderAdmin: async () => undefined,
|
||||
transitionAdmin: async () => { throw new Error('not used'); },
|
||||
transitionAdmin: async () => {
|
||||
throw new Error('not used');
|
||||
},
|
||||
};
|
||||
let recorded = 0;
|
||||
let seen = 0;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } 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 { errorSchema } from '../../../shared/swagger.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { PricingService } from '../application/pricing-service.js';
|
||||
import { InvalidPriceError, PriceNotFoundError } from '../domain/errors.js';
|
||||
@@ -35,7 +37,17 @@ export async function registerPricingRoutes(
|
||||
): Promise<void> {
|
||||
const pricing = new PricingService(new PgPricingRepository(deps.pool));
|
||||
|
||||
app.get('/pricing/variants/:variantId', async (request, reply) => {
|
||||
const getPriceSchema: FastifySchema = {
|
||||
tags: ['Pricing'],
|
||||
summary: 'Get variant price (público)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['variantId'],
|
||||
properties: { variantId: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 404: errorSchema },
|
||||
};
|
||||
app.get('/pricing/variants/:variantId', { schema: getPriceSchema }, async (request, reply) => {
|
||||
const { variantId } = parseJson(variantParamSchema, request.params);
|
||||
const price = await pricing.getVariantPrice(variantId);
|
||||
if (!price) {
|
||||
@@ -44,7 +56,18 @@ export async function registerPricingRoutes(
|
||||
return reply.send(serializeVariantPrice(price));
|
||||
});
|
||||
|
||||
app.put('/pricing/variants/:variantId', async (request, reply) => {
|
||||
const setPriceSchema: FastifySchema = {
|
||||
tags: ['Pricing'],
|
||||
summary: 'Set variant price (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['variantId'],
|
||||
properties: { variantId: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.put('/pricing/variants/:variantId', { schema: setPriceSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { variantId } = parseJson(variantParamSchema, request.params);
|
||||
@@ -57,7 +80,13 @@ export async function registerPricingRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/pricing/calculate', async (request, reply) => {
|
||||
const calcSchema: FastifySchema = {
|
||||
tags: ['Pricing'],
|
||||
summary: 'Calculate price (público)',
|
||||
description: 'Calcula el precio con IVA para una variante y cantidad.',
|
||||
body: { type: 'object' },
|
||||
};
|
||||
app.post('/pricing/calculate', { schema: calcSchema }, async (request, reply) => {
|
||||
const input = parseJson(calculateBodySchema, request.body);
|
||||
try {
|
||||
const calculation = await pricing.calculate(input);
|
||||
@@ -68,31 +97,72 @@ export async function registerPricingRoutes(
|
||||
});
|
||||
|
||||
// ── Admin tax rates ─────────────────────────────────────────────────────────
|
||||
app.get('/admin/tax-rates', async (request, reply) => {
|
||||
const listTaxSchema: FastifySchema = {
|
||||
tags: ['Pricing'],
|
||||
summary: 'List tax rates (admin)',
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/admin/tax-rates', { schema: listTaxSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const result = await deps.pool.query<{
|
||||
id: string; name: string; rate_percent: number; country: string; applies_to: string; active: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
rate_percent: number;
|
||||
country: string;
|
||||
applies_to: string;
|
||||
active: boolean;
|
||||
}>('SELECT * FROM tax_rates ORDER BY rate_percent ASC');
|
||||
return reply.send({ items: result.rows.map(r => ({
|
||||
id: r.id, name: r.name, ratePercent: Number(r.rate_percent),
|
||||
country: r.country, appliesTo: r.applies_to, active: r.active,
|
||||
})) });
|
||||
return reply.send({
|
||||
items: result.rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
ratePercent: Number(r.rate_percent),
|
||||
country: r.country,
|
||||
appliesTo: r.applies_to,
|
||||
active: r.active,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
app.patch('/admin/tax-rates/:id', async (request, reply) => {
|
||||
const patchTaxSchema: FastifySchema = {
|
||||
tags: ['Pricing'],
|
||||
summary: 'Update tax rate (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.patch('/admin/tax-rates/:id', { schema: patchTaxSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const id = (request.params as { id: string }).id;
|
||||
const patch = parseJson(z.object({
|
||||
name: z.string().min(1).max(40).optional(),
|
||||
ratePercent: z.number().min(0).max(100).optional(),
|
||||
active: z.boolean().optional(),
|
||||
}), request.body);
|
||||
const sets: string[] = []; const values: unknown[] = []; let i = 1;
|
||||
if (patch.name !== undefined) { sets.push(`name = $${i++}`); values.push(patch.name); }
|
||||
if (patch.ratePercent !== undefined) { sets.push(`rate_percent = $${i++}`); values.push(patch.ratePercent); }
|
||||
if (patch.active !== undefined) { sets.push(`active = $${i++}`); values.push(patch.active); }
|
||||
const patch = parseJson(
|
||||
z.object({
|
||||
name: z.string().min(1).max(40).optional(),
|
||||
ratePercent: z.number().min(0).max(100).optional(),
|
||||
active: z.boolean().optional(),
|
||||
}),
|
||||
request.body,
|
||||
);
|
||||
const sets: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let i = 1;
|
||||
if (patch.name !== undefined) {
|
||||
sets.push(`name = $${i++}`);
|
||||
values.push(patch.name);
|
||||
}
|
||||
if (patch.ratePercent !== undefined) {
|
||||
sets.push(`rate_percent = $${i++}`);
|
||||
values.push(patch.ratePercent);
|
||||
}
|
||||
if (patch.active !== undefined) {
|
||||
sets.push(`active = $${i++}`);
|
||||
values.push(patch.active);
|
||||
}
|
||||
if (!sets.length) return reply.send({ ok: true });
|
||||
values.push(id);
|
||||
await deps.pool.query(`UPDATE tax_rates SET ${sets.join(', ')} WHERE id = $${i}`, values);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } 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';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
import { PromotionServiceImpl } from '../application/promotion-service.js';
|
||||
import type { Promotion } from '../domain/promotion.js';
|
||||
import { PgPromotionRepository } from '../infrastructure/pg-promotion-repository.js';
|
||||
@@ -32,7 +34,12 @@ export async function registerPromotionsRoutes(
|
||||
const service = new PromotionServiceImpl(new PgPromotionRepository(deps.pool));
|
||||
|
||||
// List all promotions (admin)
|
||||
app.get('/promotions', async (request, reply) => {
|
||||
const listPromoSchema: FastifySchema = {
|
||||
tags: ['Promotions'],
|
||||
summary: 'List promotions (admin)',
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/promotions', { schema: listPromoSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const items = await service.list();
|
||||
@@ -40,7 +47,13 @@ export async function registerPromotionsRoutes(
|
||||
});
|
||||
|
||||
// Create promotion (admin)
|
||||
app.post('/promotions', async (request, reply) => {
|
||||
const createPromoSchema: FastifySchema = {
|
||||
tags: ['Promotions'],
|
||||
summary: 'Create promotion (admin)',
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/promotions', { schema: createPromoSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(promotionBodySchema, request.body);
|
||||
@@ -49,7 +62,14 @@ export async function registerPromotionsRoutes(
|
||||
});
|
||||
|
||||
// Update promotion (admin)
|
||||
app.patch('/promotions/:code', async (request, reply) => {
|
||||
const patchPromoSchema: FastifySchema = {
|
||||
tags: ['Promotions'],
|
||||
summary: 'Update promotion (admin)',
|
||||
params: { type: 'object', required: ['code'], properties: { code: { type: 'string' } } },
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.patch('/promotions/:code', { schema: patchPromoSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { code } = parseJson(z.object({ code: z.string() }), request.params);
|
||||
@@ -60,12 +80,20 @@ export async function registerPromotionsRoutes(
|
||||
} catch (error) {
|
||||
throw error instanceof Error && error.message === 'Promotion not found'
|
||||
? new AppError(404, 'NOT_FOUND', 'Promotion not found')
|
||||
: (error instanceof Error ? error : new Error('Unknown error'));
|
||||
: error instanceof Error
|
||||
? error
|
||||
: new Error('Unknown error');
|
||||
}
|
||||
});
|
||||
|
||||
// Delete promotion (admin)
|
||||
app.delete('/promotions/:code', async (request, reply) => {
|
||||
const deletePromoSchema: FastifySchema = {
|
||||
tags: ['Promotions'],
|
||||
summary: 'Delete promotion (admin)',
|
||||
params: { type: 'object', required: ['code'], properties: { code: { type: 'string' } } },
|
||||
response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.delete('/promotions/:code', { schema: deletePromoSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { code } = parseJson(z.object({ code: z.string() }), request.params);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { NewPromotion, Promotion, PromotionDiscount, PromotionType } from '../domain/promotion.js';
|
||||
import type { NewPromotion, Promotion, PromotionDiscount } from '../domain/promotion.js';
|
||||
import { PromotionInvalidError } from '../domain/errors.js';
|
||||
import type { PromotionRepository, PromotionService } from '../domain/ports.js';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Public API of the promotions module. */
|
||||
import type pg from 'pg';
|
||||
import { PromotionServiceImpl } from './application/promotion-service.js';
|
||||
import type { PromotionService, PromotionRepository } from './domain/ports.js';
|
||||
import type { PromotionService } from './domain/ports.js';
|
||||
import { PgPromotionRepository } from './infrastructure/pg-promotion-repository.js';
|
||||
|
||||
export { registerPromotionsRoutes, type PromotionsRoutesDeps } from './api/promotions.routes.js';
|
||||
|
||||
@@ -43,11 +43,9 @@ describe('PromotionService', () => {
|
||||
),
|
||||
).resolves.toMatchObject({ code: 'SAVE10', discountCents: 200 });
|
||||
await expect(
|
||||
new PromotionServiceImpl(repo({ ...PROMO, type: 'fixed_amount', value: 5000 })).calculateDiscount(
|
||||
'x',
|
||||
1200,
|
||||
new Date('2026-06-01T00:00:00Z'),
|
||||
),
|
||||
new PromotionServiceImpl(
|
||||
repo({ ...PROMO, type: 'fixed_amount', value: 5000 }),
|
||||
).calculateDiscount('x', 1200, new Date('2026-06-01T00:00:00Z')),
|
||||
).resolves.toMatchObject({ discountCents: 1200 });
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } 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 { errorSchema } from '../../../shared/swagger.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { ReviewsService } from '../application/reviews-service.js';
|
||||
import {
|
||||
@@ -47,7 +49,14 @@ export async function registerReviewsRoutes(
|
||||
const verifier = new PgOrderItemVerifier(deps.pool);
|
||||
const service = new ReviewsService(repository, verifier);
|
||||
|
||||
app.post('/reviews', async (request, reply) => {
|
||||
const submitReviewSchema: FastifySchema = {
|
||||
tags: ['Reviews'],
|
||||
summary: 'Submit review',
|
||||
description: 'Crea una reseña. Requiere compra verificada.',
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema },
|
||||
};
|
||||
app.post('/reviews', { schema: submitReviewSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const input = parseJson(submitSchema, request.body);
|
||||
try {
|
||||
@@ -58,7 +67,18 @@ export async function registerReviewsRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/reviews/:id/moderate', async (request, reply) => {
|
||||
const moderateSwaggerSchema: FastifySchema = {
|
||||
tags: ['Reviews'],
|
||||
summary: 'Moderate review (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.patch('/reviews/:id/moderate', { schema: moderateSwaggerSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = moderateParamSchema.parse(request.params);
|
||||
@@ -71,13 +91,35 @@ export async function registerReviewsRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/reviews', async (request, reply) => {
|
||||
const listReviewsSchema: FastifySchema = {
|
||||
tags: ['Reviews'],
|
||||
summary: 'List reviews (público)',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
required: ['productId'],
|
||||
properties: { productId: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
};
|
||||
app.get('/reviews', { schema: listReviewsSchema }, async (request, reply) => {
|
||||
const { productId } = productIdQuerySchema.parse(request.query);
|
||||
const result = await service.listPublishedByProduct(productId);
|
||||
return reply.send(result);
|
||||
});
|
||||
|
||||
app.get('/reviews/admin', async (request, reply) => {
|
||||
const adminReviewsSchema: FastifySchema = {
|
||||
tags: ['Reviews'],
|
||||
summary: 'List reviews (admin)',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', enum: ['pending', 'published', 'rejected'] },
|
||||
limit: { type: 'integer', default: 20 },
|
||||
offset: { type: 'integer', default: 0 },
|
||||
},
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/reviews/admin', { schema: adminReviewsSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { status, limit, offset } = adminListSchema.parse(request.query ?? {});
|
||||
|
||||
@@ -9,7 +9,11 @@ export interface ReviewsRepository {
|
||||
insert(review: SubmitReviewCommand): Promise<Review | undefined>;
|
||||
setStatus(id: string, status: 'published' | 'rejected'): Promise<Review | undefined>;
|
||||
listPublishedByProduct(productId: string): Promise<PublishedReview[]>;
|
||||
listAll(params?: { status?: string; limit?: number; offset?: number }): Promise<{ items: Review[]; total: number }>;
|
||||
listAll(params?: {
|
||||
status?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ items: Review[]; total: number }>;
|
||||
aggregate(productId: string): Promise<ProductReviewAggregate>;
|
||||
findByOrderItemId(orderItemId: string): Promise<Review | undefined>;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } 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 { errorSchema } from '../../../shared/swagger.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { AuditLogger } from '../application/audit-logger.js';
|
||||
import { RateLimiter } from '../application/rate-limiter.js';
|
||||
@@ -31,7 +33,6 @@ const updateUserBodySchema = z.object({
|
||||
role: z.enum(['customer', 'editor', 'admin']).optional(),
|
||||
password: z.string().min(8).optional(),
|
||||
});
|
||||
const uuidParamSchema = z.object({ id: z.string().uuid() });
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
function isPgUniqueViolation(err: unknown): boolean {
|
||||
@@ -42,7 +43,12 @@ export async function registerSecurityRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: SecurityRoutesDeps,
|
||||
): Promise<void> {
|
||||
app.get('/admin/mfa/status', async (request, reply) => {
|
||||
const mfaStatusSchema: FastifySchema = {
|
||||
tags: ['Admin'],
|
||||
summary: 'MFA status',
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/admin/mfa/status', { schema: mfaStatusSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const result = await deps.pool.query<{ mfa_enrolled: boolean }>(
|
||||
@@ -53,7 +59,12 @@ export async function registerSecurityRoutes(
|
||||
return reply.send({ mfaEnrolled: row?.mfa_enrolled ?? false });
|
||||
});
|
||||
|
||||
app.post('/admin/mfa/enroll', async (request, reply) => {
|
||||
const enrollMfaSchema: FastifySchema = {
|
||||
tags: ['Admin'],
|
||||
summary: 'Enroll MFA',
|
||||
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/admin/mfa/enroll', { schema: enrollMfaSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
await deps.pool.query('UPDATE identity_users SET mfa_enrolled = true WHERE id = $1', [user.id]);
|
||||
@@ -66,7 +77,13 @@ export async function registerSecurityRoutes(
|
||||
return reply.send({ mfaEnrolled: true });
|
||||
});
|
||||
|
||||
app.post('/admin/audit/log', async (request, reply) => {
|
||||
const auditLogSchema: FastifySchema = {
|
||||
tags: ['Admin'],
|
||||
summary: 'Write audit log',
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/admin/audit/log', { schema: auditLogSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(
|
||||
@@ -86,7 +103,21 @@ export async function registerSecurityRoutes(
|
||||
return reply.code(201).send({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/admin/audit', async (request, reply) => {
|
||||
const getAuditSchema: FastifySchema = {
|
||||
tags: ['Admin'],
|
||||
summary: 'Get audit log',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
actorId: { type: 'string' },
|
||||
action: { type: 'string' },
|
||||
limit: { type: 'integer', default: 50 },
|
||||
offset: { type: 'integer', default: 0 },
|
||||
},
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/admin/audit', { schema: getAuditSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { actorId, action, limit, offset } = parseJson(
|
||||
@@ -102,7 +133,13 @@ export async function registerSecurityRoutes(
|
||||
return reply.send(result);
|
||||
});
|
||||
|
||||
app.get('/admin/rate-limit/check', async (request, reply) => {
|
||||
const rateLimitCheckSchema: FastifySchema = {
|
||||
tags: ['Admin'],
|
||||
summary: 'Check rate limit',
|
||||
querystring: { type: 'object', required: ['key'], properties: { key: { type: 'string' } } },
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/admin/rate-limit/check', { schema: rateLimitCheckSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const parsed = parseJson(z.object({ key: z.string().min(1).max(120) }), request.query ?? {});
|
||||
@@ -114,15 +151,35 @@ export async function registerSecurityRoutes(
|
||||
});
|
||||
|
||||
// ── Admin user management ──────────────────────────────────────────────────
|
||||
app.get('/admin/users', async (request, reply) => {
|
||||
const listAdminUsersSchema: FastifySchema = {
|
||||
tags: ['Admin'],
|
||||
summary: 'List users (admin)',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', default: 20 },
|
||||
offset: { type: 'integer', default: 0 },
|
||||
role: { type: 'string', enum: ['customer', 'editor', 'admin'] },
|
||||
q: { type: 'string' },
|
||||
},
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/admin/users', { schema: listAdminUsersSchema }, async (request, reply) => {
|
||||
const admin = await deps.authenticate(request);
|
||||
requireRole(admin, 'admin');
|
||||
const { limit, offset, role, q } = parseJson(userQuerySchema, request.query ?? {});
|
||||
const conditions: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let i = 1;
|
||||
if (role) { conditions.push(`role = $${i++}`); values.push(role); }
|
||||
if (q) { conditions.push(`(email ILIKE $${i++})`); values.push(`%${q}%`); }
|
||||
if (role) {
|
||||
conditions.push(`role = $${i++}`);
|
||||
values.push(role);
|
||||
}
|
||||
if (q) {
|
||||
conditions.push(`(email ILIKE $${i++})`);
|
||||
values.push(`%${q}%`);
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const countResult = await deps.pool.query<{ count: string }>(
|
||||
`SELECT COUNT(*) FROM identity_users ${where}`,
|
||||
@@ -134,12 +191,23 @@ export async function registerSecurityRoutes(
|
||||
[...values, limit, offset],
|
||||
);
|
||||
return reply.send({
|
||||
items: rows.rows.map((r) => ({ id: r.id, email: r.email, role: r.role, createdAt: r.created_at })),
|
||||
items: rows.rows.map((r) => ({
|
||||
id: r.id,
|
||||
email: r.email,
|
||||
role: r.role,
|
||||
createdAt: r.created_at,
|
||||
})),
|
||||
total,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/admin/users', async (request, reply) => {
|
||||
const createAdminUserSchema: FastifySchema = {
|
||||
tags: ['Admin'],
|
||||
summary: 'Create user (admin)',
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/admin/users', { schema: createAdminUserSchema }, async (request, reply) => {
|
||||
const admin = await deps.authenticate(request);
|
||||
requireRole(admin, 'admin');
|
||||
const input = parseJson(createUserBodySchema, request.body);
|
||||
@@ -151,7 +219,9 @@ export async function registerSecurityRoutes(
|
||||
[input.email.toLowerCase(), passwordHash, input.role],
|
||||
);
|
||||
const r = result.rows[0];
|
||||
return reply.code(201).send({ id: r.id, email: r.email, role: r.role, createdAt: r.created_at });
|
||||
return reply
|
||||
.code(201)
|
||||
.send({ id: r.id, email: r.email, role: r.role, createdAt: r.created_at });
|
||||
} catch (err) {
|
||||
if (isPgUniqueViolation(err)) {
|
||||
throw new AppError(409, 'EMAIL_EXISTS', 'Email already registered');
|
||||
@@ -160,7 +230,18 @@ export async function registerSecurityRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/admin/users/:id', async (request, reply) => {
|
||||
const patchAdminUserSchema: FastifySchema = {
|
||||
tags: ['Admin'],
|
||||
summary: 'Update user (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.patch('/admin/users/:id', { schema: patchAdminUserSchema }, async (request, reply) => {
|
||||
const admin = await deps.authenticate(request);
|
||||
requireRole(admin, 'admin');
|
||||
const id = (request.params as { id: string }).id;
|
||||
@@ -168,7 +249,10 @@ export async function registerSecurityRoutes(
|
||||
const sets: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let idx = 1;
|
||||
if (patch.role) { sets.push(`role = $${idx++}`); values.push(patch.role); }
|
||||
if (patch.role) {
|
||||
sets.push(`role = $${idx++}`);
|
||||
values.push(patch.role);
|
||||
}
|
||||
if (patch.password) {
|
||||
const { default: argon2 } = await import('argon2');
|
||||
sets.push(`password_hash = $${idx++}`);
|
||||
@@ -191,11 +275,22 @@ export async function registerSecurityRoutes(
|
||||
return reply.send(result.rows[0]);
|
||||
});
|
||||
|
||||
app.delete('/admin/users/:id', async (request, reply) => {
|
||||
const deleteAdminUserSchema: FastifySchema = {
|
||||
tags: ['Admin'],
|
||||
summary: 'Delete user (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.delete('/admin/users/:id', { schema: deleteAdminUserSchema }, async (request, reply) => {
|
||||
const admin = await deps.authenticate(request);
|
||||
requireRole(admin, 'admin');
|
||||
const id = (request.params as { id: string }).id;
|
||||
if (admin.id === id) throw new AppError(400, 'CANNOT_DELETE_SELF', 'Cannot delete your own account');
|
||||
if (admin.id === id)
|
||||
throw new AppError(400, 'CANNOT_DELETE_SELF', 'Cannot delete your own account');
|
||||
await deps.pool.query(`DELETE FROM identity_users WHERE id = $1`, [id]);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } 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 { errorSchema } from '../../../shared/swagger.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { ShippingService } from '../application/shipping-service.js';
|
||||
import { InvalidShippingAddressError, ShippingZoneNotFoundError } from '../domain/errors.js';
|
||||
@@ -43,7 +45,13 @@ export async function registerShippingRoutes(
|
||||
): Promise<void> {
|
||||
const service = new ShippingService(new PgShippingRepository(deps.pool));
|
||||
|
||||
app.post('/shipping/zones', async (request, reply) => {
|
||||
const createZoneSchema: FastifySchema = {
|
||||
tags: ['Shipping'],
|
||||
summary: 'Create shipping zone (admin)',
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/shipping/zones', { schema: createZoneSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(zoneBodySchema, request.body);
|
||||
@@ -55,7 +63,13 @@ export async function registerShippingRoutes(
|
||||
return reply.code(201).send({ id: result.rows[0]?.id });
|
||||
});
|
||||
|
||||
app.post('/shipping/methods', async (request, reply) => {
|
||||
const createMethodSchema: FastifySchema = {
|
||||
tags: ['Shipping'],
|
||||
summary: 'Create shipping method (admin)',
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/shipping/methods', { schema: createMethodSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(methodBodySchema, request.body);
|
||||
@@ -74,40 +88,94 @@ export async function registerShippingRoutes(
|
||||
});
|
||||
|
||||
// ── Admin management ───────────────────────────────────────────────────────
|
||||
app.get('/admin/shipping/zones', async (request, reply) => {
|
||||
const listZonesSchema: FastifySchema = {
|
||||
tags: ['Shipping'],
|
||||
summary: 'List zones (admin)',
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/admin/shipping/zones', { schema: listZonesSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const result = await deps.pool.query<{
|
||||
id: string; name: string; country: string; postal_code_prefix: string | null; active: boolean;
|
||||
id: string;
|
||||
name: string;
|
||||
country: string;
|
||||
postal_code_prefix: string | null;
|
||||
active: boolean;
|
||||
}>('SELECT * FROM shipping_zones ORDER BY created_at DESC');
|
||||
return reply.send({ items: result.rows.map(r => ({
|
||||
id: r.id, name: r.name, country: r.country,
|
||||
postalCodePrefix: r.postal_code_prefix, active: r.active,
|
||||
})) });
|
||||
return reply.send({
|
||||
items: result.rows.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
country: r.country,
|
||||
postalCodePrefix: r.postal_code_prefix,
|
||||
active: r.active,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
app.patch('/admin/shipping/zones/:id', async (request, reply) => {
|
||||
const patchZoneSchema: FastifySchema = {
|
||||
tags: ['Shipping'],
|
||||
summary: 'Update zone (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.patch('/admin/shipping/zones/:id', { schema: patchZoneSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const id = (request.params as { id: string }).id;
|
||||
const patch = parseJson(z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
country: z.string().min(2).max(80).optional(),
|
||||
postalCodePrefix: z.string().max(20).optional().nullable(),
|
||||
active: z.boolean().optional(),
|
||||
}), request.body);
|
||||
const sets: string[] = []; const values: unknown[] = []; let i = 1;
|
||||
if (patch.name !== undefined) { sets.push(`name = $${i++}`); values.push(patch.name); }
|
||||
if (patch.country !== undefined) { sets.push(`country = $${i++}`); values.push(patch.country); }
|
||||
if (patch.postalCodePrefix !== undefined) { sets.push(`postal_code_prefix = $${i++}`); values.push(patch.postalCodePrefix); }
|
||||
if (patch.active !== undefined) { sets.push(`active = $${i++}`); values.push(patch.active); }
|
||||
if (!sets.length) { return reply.send({ ok: true }); }
|
||||
const patch = parseJson(
|
||||
z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
country: z.string().min(2).max(80).optional(),
|
||||
postalCodePrefix: z.string().max(20).optional().nullable(),
|
||||
active: z.boolean().optional(),
|
||||
}),
|
||||
request.body,
|
||||
);
|
||||
const sets: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let i = 1;
|
||||
if (patch.name !== undefined) {
|
||||
sets.push(`name = $${i++}`);
|
||||
values.push(patch.name);
|
||||
}
|
||||
if (patch.country !== undefined) {
|
||||
sets.push(`country = $${i++}`);
|
||||
values.push(patch.country);
|
||||
}
|
||||
if (patch.postalCodePrefix !== undefined) {
|
||||
sets.push(`postal_code_prefix = $${i++}`);
|
||||
values.push(patch.postalCodePrefix);
|
||||
}
|
||||
if (patch.active !== undefined) {
|
||||
sets.push(`active = $${i++}`);
|
||||
values.push(patch.active);
|
||||
}
|
||||
if (!sets.length) {
|
||||
return reply.send({ ok: true });
|
||||
}
|
||||
values.push(id);
|
||||
await deps.pool.query(`UPDATE shipping_zones SET ${sets.join(', ')} WHERE id = $${i}`, values);
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/admin/shipping/zones/:id', async (request, reply) => {
|
||||
const deleteZoneSchema: FastifySchema = {
|
||||
tags: ['Shipping'],
|
||||
summary: 'Delete zone (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.delete('/admin/shipping/zones/:id', { schema: deleteZoneSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const id = (request.params as { id: string }).id;
|
||||
@@ -116,21 +184,38 @@ export async function registerShippingRoutes(
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
app.get('/admin/shipping/methods', async (request, reply) => {
|
||||
const listMethodsSchema: FastifySchema = {
|
||||
tags: ['Shipping'],
|
||||
summary: 'List methods (admin)',
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/admin/shipping/methods', { schema: listMethodsSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const result = await deps.pool.query<{
|
||||
id: string; zone_id: string; zone_name: string; name: string;
|
||||
base_cost_cents: number; free_shipping_threshold_cents: number | null; active: boolean;
|
||||
id: string;
|
||||
zone_id: string;
|
||||
zone_name: string;
|
||||
name: string;
|
||||
base_cost_cents: number;
|
||||
free_shipping_threshold_cents: number | null;
|
||||
active: boolean;
|
||||
}>(
|
||||
`SELECT sm.*, sz.name as zone_name FROM shipping_methods sm
|
||||
JOIN shipping_zones sz ON sz.id = sm.zone_id
|
||||
ORDER BY sm.created_at DESC`
|
||||
ORDER BY sm.created_at DESC`,
|
||||
);
|
||||
return reply.send({ items: result.rows.map(r => ({
|
||||
id: r.id, zoneId: r.zone_id, zoneName: r.zone_name, name: r.name,
|
||||
baseCostCents: r.base_cost_cents, freeShippingThresholdCents: r.free_shipping_threshold_cents, active: r.active,
|
||||
})) });
|
||||
return reply.send({
|
||||
items: result.rows.map((r) => ({
|
||||
id: r.id,
|
||||
zoneId: r.zone_id,
|
||||
zoneName: r.zone_name,
|
||||
name: r.name,
|
||||
baseCostCents: r.base_cost_cents,
|
||||
freeShippingThresholdCents: r.free_shipping_threshold_cents,
|
||||
active: r.active,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/admin/shipping/methods', async (request, reply) => {
|
||||
@@ -140,42 +225,112 @@ export async function registerShippingRoutes(
|
||||
const result = await deps.pool.query<{ id: string }>(
|
||||
`INSERT INTO shipping_methods (zone_id, name, base_cost_cents, free_shipping_threshold_cents, active)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||
[input.zoneId, input.name, input.baseCostCents, input.freeShippingThresholdCents ?? null, input.active ?? true],
|
||||
[
|
||||
input.zoneId,
|
||||
input.name,
|
||||
input.baseCostCents,
|
||||
input.freeShippingThresholdCents ?? null,
|
||||
input.active ?? true,
|
||||
],
|
||||
);
|
||||
return reply.code(201).send({ id: result.rows[0]?.id });
|
||||
});
|
||||
|
||||
app.patch('/admin/shipping/methods/:id', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const id = (request.params as { id: string }).id;
|
||||
const patch = parseJson(z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
baseCostCents: z.number().int().min(0).optional(),
|
||||
freeShippingThresholdCents: z.number().int().min(0).optional().nullable(),
|
||||
active: z.boolean().optional(),
|
||||
}), request.body);
|
||||
const sets: string[] = []; const values: unknown[] = []; let i = 1;
|
||||
if (patch.name !== undefined) { sets.push(`name = $${i++}`); values.push(patch.name); }
|
||||
if (patch.baseCostCents !== undefined) { sets.push(`base_cost_cents = $${i++}`); values.push(patch.baseCostCents); }
|
||||
if (patch.freeShippingThresholdCents !== undefined) { sets.push(`free_shipping_threshold_cents = $${i++}`); values.push(patch.freeShippingThresholdCents); }
|
||||
if (patch.active !== undefined) { sets.push(`active = $${i++}`); values.push(patch.active); }
|
||||
if (!sets.length) { return reply.send({ ok: true }); }
|
||||
values.push(id);
|
||||
await deps.pool.query(`UPDATE shipping_methods SET ${sets.join(', ')} WHERE id = $${i}`, values);
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
const patchMethodSchema: FastifySchema = {
|
||||
tags: ['Shipping'],
|
||||
summary: 'Update method (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.patch(
|
||||
'/admin/shipping/methods/:id',
|
||||
{ schema: patchMethodSchema },
|
||||
async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const id = (request.params as { id: string }).id;
|
||||
const patch = parseJson(
|
||||
z.object({
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
baseCostCents: z.number().int().min(0).optional(),
|
||||
freeShippingThresholdCents: z.number().int().min(0).optional().nullable(),
|
||||
active: z.boolean().optional(),
|
||||
}),
|
||||
request.body,
|
||||
);
|
||||
const sets: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let i = 1;
|
||||
if (patch.name !== undefined) {
|
||||
sets.push(`name = $${i++}`);
|
||||
values.push(patch.name);
|
||||
}
|
||||
if (patch.baseCostCents !== undefined) {
|
||||
sets.push(`base_cost_cents = $${i++}`);
|
||||
values.push(patch.baseCostCents);
|
||||
}
|
||||
if (patch.freeShippingThresholdCents !== undefined) {
|
||||
sets.push(`free_shipping_threshold_cents = $${i++}`);
|
||||
values.push(patch.freeShippingThresholdCents);
|
||||
}
|
||||
if (patch.active !== undefined) {
|
||||
sets.push(`active = $${i++}`);
|
||||
values.push(patch.active);
|
||||
}
|
||||
if (!sets.length) {
|
||||
return reply.send({ ok: true });
|
||||
}
|
||||
values.push(id);
|
||||
await deps.pool.query(
|
||||
`UPDATE shipping_methods SET ${sets.join(', ')} WHERE id = $${i}`,
|
||||
values,
|
||||
);
|
||||
return reply.send({ ok: true });
|
||||
},
|
||||
);
|
||||
|
||||
app.delete('/admin/shipping/methods/:id', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const id = (request.params as { id: string }).id;
|
||||
await deps.pool.query('DELETE FROM shipping_methods WHERE id = $1', [id]);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
const deleteMethodSchema: FastifySchema = {
|
||||
tags: ['Shipping'],
|
||||
summary: 'Delete method (admin)',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.delete(
|
||||
'/admin/shipping/methods/:id',
|
||||
{ schema: deleteMethodSchema },
|
||||
async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const id = (request.params as { id: string }).id;
|
||||
await deps.pool.query('DELETE FROM shipping_methods WHERE id = $1', [id]);
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
|
||||
// ── Customer-facing ───────────────────────────────────────────────────────
|
||||
app.post('/shipping/calculate', async (request, reply) => {
|
||||
const calcShippingSchema: FastifySchema = {
|
||||
tags: ['Shipping'],
|
||||
summary: 'Calculate shipping (público)',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['cartTotalCents', 'country', 'postalCode'],
|
||||
properties: {
|
||||
cartTotalCents: { type: 'integer', minimum: 0 },
|
||||
country: { type: 'string' },
|
||||
postalCode: { type: 'string' },
|
||||
},
|
||||
},
|
||||
};
|
||||
app.post('/shipping/calculate', { schema: calcShippingSchema }, async (request, reply) => {
|
||||
const input = parseJson(calculateBodySchema, request.body);
|
||||
try {
|
||||
const quote = await service.calculate(input.cartTotalCents, {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } 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 { AppError } from '../../../shared/errors.js';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
|
||||
export interface StoreSettingsRoutesDeps {
|
||||
pool: pg.Pool;
|
||||
@@ -38,7 +39,12 @@ export async function registerStoreSettingsRoutes(
|
||||
deps: StoreSettingsRoutesDeps,
|
||||
): Promise<void> {
|
||||
// GET /admin/settings — fetch all settings
|
||||
app.get('/admin/settings', async (request, reply) => {
|
||||
const getSettingsSchema: FastifySchema = {
|
||||
tags: ['Admin'],
|
||||
summary: 'Get store settings (admin)',
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/admin/settings', { schema: getSettingsSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const result = await deps.pool.query<{ key: string; value: string }>(
|
||||
@@ -61,7 +67,13 @@ export async function registerStoreSettingsRoutes(
|
||||
});
|
||||
|
||||
// PATCH /admin/settings — update one or more settings
|
||||
app.patch('/admin/settings', async (request, reply) => {
|
||||
const patchSettingsSchema: FastifySchema = {
|
||||
tags: ['Admin'],
|
||||
summary: 'Update store settings (admin)',
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.patch('/admin/settings', { schema: patchSettingsSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const input = parseJson(updateSettingsSchema, request.body);
|
||||
@@ -94,7 +106,9 @@ export async function registerStoreSettingsRoutes(
|
||||
`SELECT key, value FROM store_settings`,
|
||||
);
|
||||
const map: Record<string, string> = {};
|
||||
for (const row of result.rows) { map[row.key] = row.value; }
|
||||
for (const row of result.rows) {
|
||||
map[row.key] = row.value;
|
||||
}
|
||||
return reply.send({
|
||||
storeName: map['store_name'] ?? '',
|
||||
storeTagline: map['store_tagline'] ?? '',
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
* checks, so a non-owner always gets 403 regardless of resource existence.
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { FastifySchema } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type pg from 'pg';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
import { requireOwnerOrAdmin, requireRole, type Authenticate } from '../../../shared/auth.js';
|
||||
import { GetCustomer, GetProfile, ListCustomers, ListProfiles, UpdateProfile } from '../application/profile-use-cases.js';
|
||||
import { GetCustomer, ListCustomers, UpdateProfile } from '../application/profile-use-cases.js';
|
||||
import {
|
||||
CreateAddress,
|
||||
DeleteAddress,
|
||||
@@ -60,17 +62,28 @@ export async function registerUsersRoutes(
|
||||
): Promise<void> {
|
||||
const profiles = new PgProfileRepository(deps.pool);
|
||||
const addresses = new PgAddressRepository(deps.pool);
|
||||
const getProfile = new GetProfile(profiles);
|
||||
const getCustomer = new GetCustomer(profiles);
|
||||
const updateProfile = new UpdateProfile(profiles);
|
||||
const listProfiles = new ListProfiles(profiles);
|
||||
const listCustomers = new ListCustomers(profiles);
|
||||
const listAddresses = new ListAddresses(addresses);
|
||||
const createAddress = new CreateAddress(addresses);
|
||||
const updateAddress = new UpdateAddress(addresses);
|
||||
const deleteAddress = new DeleteAddress(addresses);
|
||||
|
||||
app.get('/users', async (request, reply) => {
|
||||
const listUsersSchema: FastifySchema = {
|
||||
tags: ['Users'],
|
||||
summary: 'List users (admin)',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
q: { type: 'string' },
|
||||
limit: { type: 'integer', default: 20 },
|
||||
offset: { type: 'integer', default: 0 },
|
||||
},
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/users', { schema: listUsersSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const query = request.query as Record<string, string | undefined>;
|
||||
@@ -84,7 +97,17 @@ export async function registerUsersRoutes(
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/users/:id', async (request, reply) => {
|
||||
const getUserSchema: FastifySchema = {
|
||||
tags: ['Users'],
|
||||
summary: 'Get user',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.get('/users/:id', { schema: getUserSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id } = parseJson(uuidParamSchema, request.params);
|
||||
requireOwnerOrAdmin(user, id);
|
||||
@@ -95,7 +118,18 @@ export async function registerUsersRoutes(
|
||||
return reply.send(serializeCustomer(customer));
|
||||
});
|
||||
|
||||
app.patch('/users/:id', async (request, reply) => {
|
||||
const patchUserSchema: FastifySchema = {
|
||||
tags: ['Users'],
|
||||
summary: 'Update user profile',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.patch('/users/:id', { schema: patchUserSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id } = parseJson(uuidParamSchema, request.params);
|
||||
requireOwnerOrAdmin(user, id);
|
||||
@@ -104,7 +138,17 @@ export async function registerUsersRoutes(
|
||||
return reply.send(serializeProfile(profile));
|
||||
});
|
||||
|
||||
app.get('/users/:id/addresses', async (request, reply) => {
|
||||
const addressesSchema: FastifySchema = {
|
||||
tags: ['Users'],
|
||||
summary: 'List user addresses',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.get('/users/:id/addresses', { schema: addressesSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id } = parseJson(uuidParamSchema, request.params);
|
||||
requireOwnerOrAdmin(user, id);
|
||||
@@ -112,7 +156,18 @@ export async function registerUsersRoutes(
|
||||
return reply.send({ items: items.map(serializeAddress) });
|
||||
});
|
||||
|
||||
app.post('/users/:id/addresses', async (request, reply) => {
|
||||
const createAddressSchema: FastifySchema = {
|
||||
tags: ['Users'],
|
||||
summary: 'Create address',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 201: { type: 'object' }, 401: errorSchema, 403: errorSchema },
|
||||
};
|
||||
app.post('/users/:id/addresses', { schema: createAddressSchema }, async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id } = parseJson(uuidParamSchema, request.params);
|
||||
requireOwnerOrAdmin(user, id);
|
||||
@@ -121,28 +176,63 @@ export async function registerUsersRoutes(
|
||||
return reply.code(201).send(serializeAddress(address));
|
||||
});
|
||||
|
||||
app.patch('/users/:id/addresses/:addressId', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id, addressId } = parseJson(addressIdParamSchema, request.params);
|
||||
requireOwnerOrAdmin(user, id);
|
||||
const patch = parseJson(addressPatchSchema, request.body);
|
||||
const address = await updateAddress.execute(id, addressId, patch);
|
||||
if (!address) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Address not found');
|
||||
}
|
||||
return reply.send(serializeAddress(address));
|
||||
});
|
||||
const updateAddressSchema: FastifySchema = {
|
||||
tags: ['Users'],
|
||||
summary: 'Update address',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id', 'addressId'],
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
addressId: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
},
|
||||
body: { type: 'object' },
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.patch(
|
||||
'/users/:id/addresses/:addressId',
|
||||
{ schema: updateAddressSchema },
|
||||
async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id, addressId } = parseJson(addressIdParamSchema, request.params);
|
||||
requireOwnerOrAdmin(user, id);
|
||||
const patch = parseJson(addressPatchSchema, request.body);
|
||||
const address = await updateAddress.execute(id, addressId, patch);
|
||||
if (!address) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Address not found');
|
||||
}
|
||||
return reply.send(serializeAddress(address));
|
||||
},
|
||||
);
|
||||
|
||||
app.delete('/users/:id/addresses/:addressId', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id, addressId } = parseJson(addressIdParamSchema, request.params);
|
||||
requireOwnerOrAdmin(user, id);
|
||||
const deleted = await deleteAddress.execute(id, addressId);
|
||||
if (!deleted) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Address not found');
|
||||
}
|
||||
return reply.code(204).send();
|
||||
});
|
||||
const deleteAddressSchema: FastifySchema = {
|
||||
tags: ['Users'],
|
||||
summary: 'Delete address',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id', 'addressId'],
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
addressId: { type: 'string', format: 'uuid' },
|
||||
},
|
||||
},
|
||||
response: { 204: { type: 'null' }, 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
};
|
||||
app.delete(
|
||||
'/users/:id/addresses/:addressId',
|
||||
{ schema: deleteAddressSchema },
|
||||
async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const { id, addressId } = parseJson(addressIdParamSchema, request.params);
|
||||
requireOwnerOrAdmin(user, id);
|
||||
const deleted = await deleteAddress.execute(id, addressId);
|
||||
if (!deleted) {
|
||||
throw new AppError(404, 'NOT_FOUND', 'Address not found');
|
||||
}
|
||||
return reply.code(204).send();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function serializeProfile(profile: Profile) {
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
* happen in the API layer before these run.
|
||||
*/
|
||||
import type { ProfileRepository } from '../domain/ports.js';
|
||||
import type { CustomerListOptions, CustomerListResult, CustomerSummary, Profile, ProfilePatch } from '../domain/profile.js';
|
||||
import type {
|
||||
CustomerListOptions,
|
||||
CustomerListResult,
|
||||
CustomerSummary,
|
||||
Profile,
|
||||
ProfilePatch,
|
||||
} from '../domain/profile.js';
|
||||
|
||||
export class GetProfile {
|
||||
constructor(private readonly profiles: ProfileRepository) {}
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
* Ports (driven interfaces). Domain owns them; infrastructure implements them.
|
||||
* All operations are scoped by userId so ownership is enforced in the query.
|
||||
*/
|
||||
import type { CustomerListOptions, CustomerListResult, CustomerSummary, Profile, ProfilePatch } from './profile.js';
|
||||
import type {
|
||||
CustomerListOptions,
|
||||
CustomerListResult,
|
||||
CustomerSummary,
|
||||
Profile,
|
||||
ProfilePatch,
|
||||
} from './profile.js';
|
||||
import type { Address, AddressPatch, NewAddress } from './address.js';
|
||||
|
||||
export interface ProfileRepository {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { AppError } from './errors.js';
|
||||
|
||||
export type Role = 'customer' | 'admin';
|
||||
export type Role = 'customer' | 'admin' | 'editor';
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
|
||||
127
project/src/shared/swagger.ts
Normal file
127
project/src/shared/swagger.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* OpenAPI 3.0 configuration and shared schemas for the API.
|
||||
* Route-level schemas are defined in each module's routes file.
|
||||
*/
|
||||
import type { FastifySchema } from 'fastify';
|
||||
|
||||
export const swaggerConfig = {
|
||||
openapi: {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
title: 'mercadodevida API',
|
||||
description:
|
||||
'API REST completa del e-commerce mercadodevida. ' +
|
||||
'Incluye gestión de productos, catálogo, carrito, pedidos, pagos, ' +
|
||||
'CMS, reseñas, promociones, inventario, precios y configuración de tienda.',
|
||||
version: '1.0.0',
|
||||
contact: {
|
||||
name: 'Soporte mercadodevida',
|
||||
email: 'soporte@mercadodevida.com',
|
||||
},
|
||||
license: {
|
||||
name: 'MIT',
|
||||
url: 'https://opensource.org/licenses/MIT',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: 'http://localhost:3000',
|
||||
description: 'Desarrollo local',
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{ name: 'Health', description: 'Health checks' },
|
||||
{ name: 'Auth', description: 'Autenticación y sesiones' },
|
||||
{ name: 'Catalog', description: 'Catálogo de productos (público)' },
|
||||
{ name: 'Admin Products', description: 'Gestión de productos (admin)' },
|
||||
{ name: 'Cart', description: 'Carrito de compras' },
|
||||
{ name: 'Checkout', description: 'Proceso de compra' },
|
||||
{ name: 'Orders', description: 'Pedidos' },
|
||||
{ name: 'Payments', description: 'Pagos y transacciones' },
|
||||
{ name: 'Users', description: 'Gestión de usuarios y perfiles' },
|
||||
{ name: 'Categories', description: 'Categorías' },
|
||||
{ name: 'Brands', description: 'Marcas' },
|
||||
{ name: 'Inventory', description: 'Inventario y stock' },
|
||||
{ name: 'Pricing', description: 'Precios y IVA' },
|
||||
{ name: 'Shipping', description: 'Envíos y zonas' },
|
||||
{ name: 'Promotions', description: 'Promociones y cupones' },
|
||||
{ name: 'Reviews', description: 'Reseñas de productos' },
|
||||
{ name: 'CMS', description: 'Páginas de contenido' },
|
||||
{ name: 'Admin', description: 'Panel de administración' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ── Shared schemas ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const errorSchema = {
|
||||
type: 'object',
|
||||
required: ['error', 'requestId'],
|
||||
properties: {
|
||||
error: {
|
||||
type: 'object',
|
||||
required: ['statusCode', 'code', 'message'],
|
||||
properties: {
|
||||
statusCode: { type: 'integer' },
|
||||
code: { type: 'string' },
|
||||
message: { type: 'string' },
|
||||
details: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
||||
},
|
||||
},
|
||||
requestId: { type: 'string' },
|
||||
},
|
||||
};
|
||||
|
||||
export const paginationQuerySchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
maximum: 100,
|
||||
default: 20,
|
||||
description: 'Resultados por página',
|
||||
},
|
||||
offset: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
default: 0,
|
||||
description: 'Desplazamiento desde el inicio',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const paginationResponseSchema = (itemSchema: Record<string, unknown>) => ({
|
||||
type: 'object',
|
||||
properties: {
|
||||
items: { type: 'array', items: itemSchema },
|
||||
total: { type: 'integer', description: 'Total de registros' },
|
||||
},
|
||||
});
|
||||
|
||||
// ── Common route config helpers ───────────────────────────────────────────────
|
||||
|
||||
/** Mark a route as requiring authentication. */
|
||||
export function withAuth(schema: FastifySchema): FastifySchema {
|
||||
return {
|
||||
...schema,
|
||||
headers: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
cookie: { type: 'string', description: 'mdv_session cookie de sesión' },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Mark a route as admin-only. */
|
||||
export function withAdmin(schema: FastifySchema): FastifySchema {
|
||||
return withAuth({
|
||||
...schema,
|
||||
response: {
|
||||
...(schema.response ?? {}),
|
||||
401: errorSchema,
|
||||
403: errorSchema,
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user