feat(F-048): completed feature
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
* calls use cases, maps domain errors to the shared error envelope.
|
||||
*/
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import type { FastifySchema } from 'fastify';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
import { z } from 'zod';
|
||||
import type { Authenticate } from '../../../shared/auth.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
@@ -66,7 +68,98 @@ export async function registerIdentityRoutes(
|
||||
});
|
||||
const logout = new Logout(sessions, hashSessionToken);
|
||||
|
||||
app.post('/auth/register', async (request, reply) => {
|
||||
const registerSchema: FastifySchema = {
|
||||
tags: ['Auth'],
|
||||
summary: 'Register new user',
|
||||
description: 'Crea una cuenta de usuario. Por defecto el rol es `customer`.',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email', 'password'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email', maxLength: 255, description: 'Email único' },
|
||||
password: {
|
||||
type: 'string',
|
||||
minLength: 8,
|
||||
maxLength: 128,
|
||||
description: 'Contraseña (mín. 8 caracteres)',
|
||||
},
|
||||
},
|
||||
},
|
||||
response: {
|
||||
201: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
email: { type: 'string', format: 'email' },
|
||||
role: { type: 'string', enum: ['customer', 'editor', 'admin'] },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
409: errorSchema,
|
||||
},
|
||||
};
|
||||
|
||||
const loginSchema: FastifySchema = {
|
||||
tags: ['Auth'],
|
||||
summary: 'Login',
|
||||
description: 'Inicia sesión y devuelve una cookie de sesión `mdv_session` (válida 7 días).',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['email', 'password'],
|
||||
properties: {
|
||||
email: { type: 'string', format: 'email', description: 'Email' },
|
||||
password: { type: 'string', description: 'Contraseña' },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
email: { type: 'string', format: 'email' },
|
||||
role: { type: 'string', enum: ['customer', 'editor', 'admin'] },
|
||||
},
|
||||
},
|
||||
401: errorSchema,
|
||||
429: errorSchema,
|
||||
},
|
||||
};
|
||||
|
||||
const logoutSchema: FastifySchema = {
|
||||
tags: ['Auth'],
|
||||
summary: 'Logout',
|
||||
description: 'Invalida la sesión actual y elimina la cookie.',
|
||||
response: { 204: { type: 'null' } },
|
||||
};
|
||||
|
||||
const meSchema: FastifySchema = {
|
||||
tags: ['Auth'],
|
||||
summary: 'Current user',
|
||||
description:
|
||||
'Devuelve el usuario autenticado (cookie de sesión) o `{user: null}` si no hay sesión.',
|
||||
response: {
|
||||
200: {
|
||||
oneOf: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
email: { type: 'string', format: 'email' },
|
||||
role: { type: 'string', enum: ['customer', 'editor', 'admin'] },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
user: { type: 'null' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
app.post('/auth/register', { schema: registerSchema }, async (request, reply) => {
|
||||
const input = parseJson(credentialsSchema, request.body);
|
||||
try {
|
||||
const user = await registerUser.execute(input);
|
||||
@@ -81,7 +174,7 @@ export async function registerIdentityRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/auth/login', async (request, reply) => {
|
||||
app.post('/auth/login', { schema: loginSchema }, async (request, reply) => {
|
||||
const input = parseJson(credentialsSchema, request.body);
|
||||
try {
|
||||
const result = await login.execute(input);
|
||||
@@ -101,14 +194,14 @@ export async function registerIdentityRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/auth/logout', async (request, reply) => {
|
||||
app.post('/auth/logout', { schema: logoutSchema }, async (request, reply) => {
|
||||
const token = request.cookies[SESSION_COOKIE_NAME];
|
||||
await logout.execute(token);
|
||||
clearSessionCookie(reply, cookieSecure);
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
app.get('/auth/me', async (request, reply) => {
|
||||
app.get('/auth/me', { schema: meSchema }, async (request, reply) => {
|
||||
try {
|
||||
const user = await deps.authenticate!(request);
|
||||
return reply.send({ id: user.id, email: user.email, role: user.role });
|
||||
|
||||
@@ -13,7 +13,12 @@ export interface UserRepository {
|
||||
create(user: NewUser): Promise<User>;
|
||||
findByEmail(email: string): Promise<(User & { passwordHash: string }) | undefined>;
|
||||
findById(id: string): Promise<User | undefined>;
|
||||
listUsers(params?: { limit?: number; offset?: number; role?: string; q?: string }): Promise<{ items: User[]; total: number }>;
|
||||
listUsers(params?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
role?: string;
|
||||
q?: string;
|
||||
}): Promise<{ items: User[]; total: number }>;
|
||||
updateUser(id: string, patch: { role?: string; passwordHash?: string }): Promise<User>;
|
||||
deleteUser(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -73,14 +73,26 @@ export class PgUserRepository implements UserRepository {
|
||||
return { id: row.id, email: row.email, role: row.role, createdAt: row.created_at };
|
||||
}
|
||||
|
||||
async listUsers(params?: { limit?: number; offset?: number; role?: string; q?: string }): Promise<{ items: User[]; total: number }> {
|
||||
async listUsers(params?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
role?: string;
|
||||
q?: string;
|
||||
}): Promise<{ items: User[]; total: number }> {
|
||||
const limit = params?.limit ?? 20;
|
||||
const offset = params?.offset ?? 0;
|
||||
const conditions: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let i = 1;
|
||||
if (params?.role) { conditions.push(`role = $${i++}`); values.push(params.role); }
|
||||
if (params?.q) { conditions.push(`(email ILIKE $${i++} OR role ILIKE $${i++})`); values.push(`%${params.q}%`); values.push(`%${params.q}%`); }
|
||||
if (params?.role) {
|
||||
conditions.push(`role = $${i++}`);
|
||||
values.push(params.role);
|
||||
}
|
||||
if (params?.q) {
|
||||
conditions.push(`(email ILIKE $${i++} OR role ILIKE $${i++})`);
|
||||
values.push(`%${params.q}%`);
|
||||
values.push(`%${params.q}%`);
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const countResult = await this.pool.query<{ count: string }>(
|
||||
`SELECT COUNT(*) FROM identity_users ${where}`,
|
||||
@@ -92,7 +104,12 @@ export class PgUserRepository implements UserRepository {
|
||||
[...values, limit, offset],
|
||||
);
|
||||
return {
|
||||
items: rows.rows.map((r) => ({ id: r.id, email: r.email, role: r.role, createdAt: r.created_at })),
|
||||
items: rows.rows.map((r) => ({
|
||||
id: r.id,
|
||||
email: r.email,
|
||||
role: r.role,
|
||||
createdAt: r.created_at,
|
||||
})),
|
||||
total,
|
||||
};
|
||||
}
|
||||
@@ -101,8 +118,14 @@ export class PgUserRepository implements UserRepository {
|
||||
const sets: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let i = 1;
|
||||
if (patch.role) { sets.push(`role = $${i++}`); values.push(patch.role); }
|
||||
if (patch.passwordHash) { sets.push(`password_hash = $${i++}`); values.push(patch.passwordHash); }
|
||||
if (patch.role) {
|
||||
sets.push(`role = $${i++}`);
|
||||
values.push(patch.role);
|
||||
}
|
||||
if (patch.passwordHash) {
|
||||
sets.push(`password_hash = $${i++}`);
|
||||
values.push(patch.passwordHash);
|
||||
}
|
||||
if (!sets.length) {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) throw new Error('User not found');
|
||||
|
||||
Reference in New Issue
Block a user