feat(F-048): completed feature

This commit is contained in:
chattie
2026-08-19 07:17:14 +02:00
parent 8ee1938af9
commit 835ab66eda
187 changed files with 12361 additions and 1065 deletions

View File

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

View File

@@ -0,0 +1,2 @@
/** Public API of the admin-stats module. */
export { registerAdminStatsRoutes } from './api/stats.routes.js';

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

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

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

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

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

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

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

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

View File

@@ -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),
};
}
}

View File

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

View File

@@ -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');
};
}

View File

@@ -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');
}

View File

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

View File

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

View File

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

View File

@@ -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());
});

View File

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

View File

@@ -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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(

View File

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

View File

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

View File

@@ -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(),
};

View File

@@ -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();
}
}
}

View File

@@ -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. */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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' };
});
}

View File

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

View File

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

View File

@@ -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');

View File

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

View File

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

View File

@@ -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);
},
);
}

View File

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

View File

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

View File

@@ -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 });
},
);
}

View File

@@ -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';

View File

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

View File

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

View File

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

View File

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

View File

@@ -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';

View File

@@ -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';

View File

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

View File

@@ -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 ?? {});

View File

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

View File

@@ -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();
});

View File

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

View File

@@ -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'] ?? '',

View File

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

View File

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

View File

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