feat(F-187): completed feature
This commit is contained in:
@@ -2190,8 +2190,17 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
||||
const user = await authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const result = await pool.query(
|
||||
`SELECT id, email, role FROM backoffice_users
|
||||
WHERE role IN ('pos_manager','pos_cashier') ORDER BY email`,
|
||||
`SELECT id, email, role, active,
|
||||
deactivated_at AS "deactivatedAt", deleted_at AS "deletedAt",
|
||||
created_at AS "createdAt",
|
||||
CASE
|
||||
WHEN deleted_at IS NOT NULL THEN 'deleted'
|
||||
WHEN active THEN 'active'
|
||||
ELSE 'inactive'
|
||||
END AS status
|
||||
FROM backoffice_users
|
||||
WHERE role IN ('pos_manager','pos_cashier')
|
||||
ORDER BY deleted_at NULLS FIRST, active DESC, email`,
|
||||
);
|
||||
return reply.send({ items: result.rows });
|
||||
},
|
||||
@@ -2246,7 +2255,212 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
|
||||
);
|
||||
const created = newUser.rows[0];
|
||||
if (!created) throw new AppError(500, 'USER_CREATE_FAILED', 'User insert returned no row');
|
||||
return reply.code(201).send({ id: created.id, email: body.email, role: body.role });
|
||||
return reply.code(201).send({
|
||||
id: created.id,
|
||||
email: body.email,
|
||||
role: body.role,
|
||||
active: true,
|
||||
deactivatedAt: null,
|
||||
deletedAt: null,
|
||||
status: 'active',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
'/pos/users/:id/status',
|
||||
{
|
||||
schema: {
|
||||
tags: ['POS Admin'],
|
||||
summary: 'Activate or deactivate a POS cashier',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['active'],
|
||||
properties: { active: { type: 'boolean' } },
|
||||
},
|
||||
response: {
|
||||
400: errorSchema,
|
||||
401: errorSchema,
|
||||
403: errorSchema,
|
||||
404: errorSchema,
|
||||
409: errorSchema,
|
||||
},
|
||||
} as FastifySchema,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const admin = await authenticate(request);
|
||||
requireRole(admin, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
const { active } = parseJson(z.object({ active: z.boolean() }), request.body ?? {});
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const targetResult = await client.query<{
|
||||
id: string;
|
||||
email: string;
|
||||
active: boolean;
|
||||
deleted_at: Date | null;
|
||||
}>(
|
||||
`SELECT id, email, active, deleted_at FROM backoffice_users
|
||||
WHERE id = $1 AND role = 'pos_cashier' FOR UPDATE`,
|
||||
[id],
|
||||
);
|
||||
const target = targetResult.rows[0];
|
||||
if (!target) {
|
||||
throw new AppError(404, 'POS_CASHIER_NOT_FOUND', 'Cajero no encontrado');
|
||||
}
|
||||
if (target.deleted_at) {
|
||||
throw new AppError(
|
||||
409,
|
||||
'POS_CASHIER_DELETED',
|
||||
'Un cajero eliminado no se puede reactivar',
|
||||
);
|
||||
}
|
||||
if (!active && target.active) {
|
||||
const open = await client.query(
|
||||
`SELECT id FROM pos_cash_sessions
|
||||
WHERE user_id = $1 AND status = 'OPEN' LIMIT 1`,
|
||||
[id],
|
||||
);
|
||||
if (open.rows[0]) {
|
||||
throw new AppError(
|
||||
409,
|
||||
'POS_CASHIER_HAS_OPEN_SESSION',
|
||||
'Cierra la sesión de caja antes de desactivar el cajero',
|
||||
);
|
||||
}
|
||||
}
|
||||
const updated = await client.query<{
|
||||
id: string;
|
||||
email: string;
|
||||
active: boolean;
|
||||
deactivatedAt: Date | null;
|
||||
deletedAt: Date | null;
|
||||
}>(
|
||||
`UPDATE backoffice_users
|
||||
SET active = $2,
|
||||
deactivated_at = CASE WHEN $2 THEN NULL ELSE COALESCE(deactivated_at, now()) END,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, email, active,
|
||||
deactivated_at AS "deactivatedAt", deleted_at AS "deletedAt"`,
|
||||
[id, active],
|
||||
);
|
||||
if (!active) {
|
||||
await client.query(
|
||||
`UPDATE backoffice_sessions SET revoked_at = COALESCE(revoked_at, now())
|
||||
WHERE user_id = $1 AND revoked_at IS NULL`,
|
||||
[id],
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`INSERT INTO security_audit_log (actor_id, action, target, metadata)
|
||||
VALUES ($1, $2, $3, jsonb_build_object('email', $4::text))`,
|
||||
[
|
||||
admin.id,
|
||||
active ? 'pos.cashier.reactivated' : 'pos.cashier.deactivated',
|
||||
id,
|
||||
target.email,
|
||||
],
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
return reply.send({
|
||||
...updated.rows[0],
|
||||
role: 'pos_cashier',
|
||||
status: active ? 'active' : 'inactive',
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
'/pos/users/:id',
|
||||
{
|
||||
schema: {
|
||||
tags: ['POS Admin'],
|
||||
summary: 'Soft-delete a POS cashier while preserving history',
|
||||
params: {
|
||||
type: 'object',
|
||||
required: ['id'],
|
||||
properties: { id: { type: 'string', format: 'uuid' } },
|
||||
},
|
||||
response: {
|
||||
204: { type: 'null' },
|
||||
401: errorSchema,
|
||||
403: errorSchema,
|
||||
404: errorSchema,
|
||||
409: errorSchema,
|
||||
},
|
||||
} as FastifySchema,
|
||||
},
|
||||
async (request, reply) => {
|
||||
const admin = await authenticate(request);
|
||||
requireRole(admin, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const targetResult = await client.query<{ email: string; deleted_at: Date | null }>(
|
||||
`SELECT email, deleted_at FROM backoffice_users
|
||||
WHERE id = $1 AND role = 'pos_cashier' FOR UPDATE`,
|
||||
[id],
|
||||
);
|
||||
const target = targetResult.rows[0];
|
||||
if (!target) {
|
||||
throw new AppError(404, 'POS_CASHIER_NOT_FOUND', 'Cajero no encontrado');
|
||||
}
|
||||
if (target.deleted_at) {
|
||||
throw new AppError(409, 'POS_CASHIER_ALREADY_DELETED', 'El cajero ya está eliminado');
|
||||
}
|
||||
const open = await client.query(
|
||||
`SELECT id FROM pos_cash_sessions
|
||||
WHERE user_id = $1 AND status = 'OPEN' LIMIT 1`,
|
||||
[id],
|
||||
);
|
||||
if (open.rows[0]) {
|
||||
throw new AppError(
|
||||
409,
|
||||
'POS_CASHIER_HAS_OPEN_SESSION',
|
||||
'Cierra la sesión de caja antes de eliminar el cajero',
|
||||
);
|
||||
}
|
||||
await client.query(
|
||||
`UPDATE backoffice_users
|
||||
SET active = false,
|
||||
deactivated_at = COALESCE(deactivated_at, now()),
|
||||
deleted_at = now(),
|
||||
updated_at = now()
|
||||
WHERE id = $1`,
|
||||
[id],
|
||||
);
|
||||
await client.query(
|
||||
`UPDATE backoffice_sessions SET revoked_at = COALESCE(revoked_at, now())
|
||||
WHERE user_id = $1 AND revoked_at IS NULL`,
|
||||
[id],
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO security_audit_log (actor_id, action, target, metadata)
|
||||
VALUES ($1, 'pos.cashier.deleted', $2, jsonb_build_object('email', $3::text))`,
|
||||
[admin.id, id, target.email],
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
return reply.code(204).send();
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user