feat(F-187): completed feature

This commit is contained in:
chattie
2026-08-22 22:23:44 +02:00
parent a3f6edd325
commit ca12f46bff
22 changed files with 1053 additions and 59 deletions

View File

@@ -35,6 +35,16 @@ interface PaymentMethod {
active: boolean;
sortOrder: number;
}
interface PosCashier {
id: string;
email: string;
role: 'pos_cashier';
active: boolean;
deactivatedAt: string | null;
deletedAt: string | null;
createdAt: string;
status: 'active' | 'inactive' | 'deleted';
}
interface ReceiptSettings {
storeId: string;
name: string;
@@ -85,6 +95,10 @@ export default function PosAdminPage() {
const [savingTouch, setSavingTouch] = useState(false);
const [touchMessage, setTouchMessage] = useState('');
const [paymentMethods, setPaymentMethods] = useState<PaymentMethod[]>([]);
const [cashiers, setCashiers] = useState<PosCashier[]>([]);
const [newCashier, setNewCashier] = useState({ email: '', password: '' });
const [savingCashier, setSavingCashier] = useState(false);
const [cashierMessage, setCashierMessage] = useState('');
const [newMethod, setNewMethod] = useState({
code: '',
label: '',
@@ -118,14 +132,16 @@ export default function PosAdminPage() {
setLoading(true);
setError('');
try {
const [storeData, terminalData, catalogData] = await Promise.all([
const [storeData, terminalData, catalogData, userData] = await Promise.all([
api.get<{ stores: PosStore[] }>('/api/pos/admin/stores'),
api.get<{ terminals: PosTerminal[] }>('/api/pos/admin/terminals'),
api.get<{ items: CatalogOption[] }>('/api/pos/admin/catalog-products'),
api.get<{ items: Array<PosCashier | { role: string }> }>('/api/pos/users'),
]);
setStores(storeData.stores);
setTerminals(terminalData.terminals);
setCatalogOptions(catalogData.items);
setCashiers(userData.items.filter((item): item is PosCashier => item.role === 'pos_cashier'));
const selected = storeId || storeData.stores.find((store) => store.active)?.id || '';
setStoreId(selected);
await loadStoreConfiguration(selected);
@@ -229,6 +245,57 @@ export default function PosAdminPage() {
}
};
const createCashier = async (event: React.FormEvent) => {
event.preventDefault();
setSavingCashier(true);
setCashierMessage('');
try {
await api.post('/api/pos/users', {
email: newCashier.email.trim().toLowerCase(),
password: newCashier.password,
role: 'pos_cashier',
});
setNewCashier({ email: '', password: '' });
setCashierMessage('Cajero creado');
await load();
} catch (err) {
setCashierMessage(err instanceof Error ? err.message : 'No se pudo crear el cajero');
} finally {
setSavingCashier(false);
}
};
const toggleCashier = async (cashier: PosCashier) => {
const nextActive = !cashier.active;
const action = nextActive ? 'reactivar' : 'desactivar';
if (!window.confirm(`¿Quieres ${action} a ${cashier.email}?`)) return;
setCashierMessage('');
try {
await api.patch(`/api/pos/users/${cashier.id}/status`, { active: nextActive });
setCashierMessage(nextActive ? 'Cajero reactivado' : 'Cajero desactivado');
await load();
} catch (err) {
setCashierMessage(err instanceof Error ? err.message : `No se pudo ${action} el cajero`);
}
};
const deleteCashier = async (cashier: PosCashier) => {
if (
!window.confirm(
`¿Eliminar definitivamente a ${cashier.email}? No podrá reactivarse. Su historial de ventas y caja se conservará.`,
)
)
return;
setCashierMessage('');
try {
await api.delete(`/api/pos/users/${cashier.id}`);
setCashierMessage('Cajero eliminado; su histórico se conserva');
await load();
} catch (err) {
setCashierMessage(err instanceof Error ? err.message : 'No se pudo eliminar el cajero');
}
};
const saveReceipt = async (event: React.FormEvent) => {
event.preventDefault();
setSavingReceipt(true);
@@ -362,6 +429,116 @@ export default function PosAdminPage() {
)}
</section>
<section className="rounded-xl border border-gray-200 bg-white p-6">
<div>
<h2 className="font-bold">Cajeros</h2>
<p className="mt-1 text-sm text-gray-500">
Desactivar corta el acceso y permite reactivarlo. Eliminar es irreversible, pero
conserva la atribución histórica de ventas y sesiones de caja.
</p>
</div>
<form
onSubmit={createCashier}
className="mt-5 grid gap-3 rounded-xl bg-gray-50 p-4 md:grid-cols-[1fr_1fr_auto] md:items-end"
>
<label className="text-sm font-medium">
Email
<input
type="email"
value={newCashier.email}
onChange={(event) => setNewCashier({ ...newCashier, email: event.target.value })}
required
className="mt-1 w-full rounded-xl border px-3 py-2.5"
/>
</label>
<label className="text-sm font-medium">
Contraseña inicial
<input
type="password"
value={newCashier.password}
onChange={(event) => setNewCashier({ ...newCashier, password: event.target.value })}
required
minLength={8}
maxLength={200}
className="mt-1 w-full rounded-xl border px-3 py-2.5"
/>
</label>
<button
disabled={savingCashier}
className="rounded-xl bg-[#2D6A4F] px-5 py-2.5 text-sm font-semibold text-white disabled:opacity-50"
>
{savingCashier ? 'Creando…' : 'Crear cajero'}
</button>
</form>
<div className="mt-5 overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-xs uppercase text-gray-500">
<tr>
<th className="px-4 py-3">Email</th>
<th className="px-4 py-3">Estado</th>
<th className="px-4 py-3">Creado</th>
<th className="px-4 py-3 text-right">Acciones</th>
</tr>
</thead>
<tbody className="divide-y">
{cashiers.map((cashier) => (
<tr key={cashier.id} className={cashier.status === 'deleted' ? 'opacity-60' : ''}>
<td className="px-4 py-3 font-medium">{cashier.email}</td>
<td className="px-4 py-3">
<span
className={`rounded-full px-2.5 py-1 text-xs font-semibold ${
cashier.status === 'active'
? 'bg-green-100 text-green-800'
: cashier.status === 'inactive'
? 'bg-amber-100 text-amber-800'
: 'bg-gray-200 text-gray-700'
}`}
>
{cashier.status === 'active'
? 'Activo'
: cashier.status === 'inactive'
? 'Inactivo'
: 'Eliminado'}
</span>
</td>
<td className="px-4 py-3 text-gray-500">
{new Date(cashier.createdAt).toLocaleDateString('es-ES')}
</td>
<td className="px-4 py-3">
{cashier.status !== 'deleted' && (
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => void toggleCashier(cashier)}
className="rounded-lg border px-3 py-2 text-xs font-semibold"
>
{cashier.active ? 'Desactivar' : 'Reactivar'}
</button>
<button
type="button"
onClick={() => void deleteCashier(cashier)}
className="rounded-lg border border-red-200 px-3 py-2 text-xs font-semibold text-red-700"
>
Eliminar
</button>
</div>
)}
</td>
</tr>
))}
{!loading && cashiers.length === 0 && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-gray-500">
No hay cajeros configurados.
</td>
</tr>
)}
</tbody>
</table>
</div>
{cashierMessage && <p className="mt-3 text-sm">{cashierMessage}</p>}
</section>
{configuring && (
<section className="rounded-xl border border-gray-200 bg-white p-6">
<div className="flex justify-between">

View File

@@ -0,0 +1,41 @@
/*
* F-187 — Safe POS cashier lifecycle.
*
* POS cashiers are referenced by cash sessions and receipt/reporting history.
* Their rows therefore use deactivation/deletion tombstones instead of physical
* deletion. Existing backoffice accounts remain active.
*
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
export const up = (pgm) => {
pgm.addColumns('backoffice_users', {
active: { type: 'boolean', notNull: true, default: true },
deactivated_at: { type: 'timestamptz' },
deleted_at: { type: 'timestamptz' },
});
pgm.addConstraint('backoffice_users', 'backoffice_users_lifecycle_check', {
check:
'(active = true AND deactivated_at IS NULL AND deleted_at IS NULL) OR ' +
'(active = false AND deactivated_at IS NOT NULL)',
});
pgm.createIndex('backoffice_users', ['role', 'active', 'deleted_at'], {
name: 'backoffice_users_pos_cashier_lifecycle_idx',
where: "role = 'pos_cashier'",
});
};
export const down = (pgm) => {
pgm.dropIndex('backoffice_users', ['role', 'active', 'deleted_at'], {
name: 'backoffice_users_pos_cashier_lifecycle_idx',
ifExists: true,
});
pgm.dropConstraint('backoffice_users', 'backoffice_users_lifecycle_check', {
ifExists: true,
});
pgm.dropColumns('backoffice_users', ['active', 'deactivated_at', 'deleted_at'], {
ifExists: true,
});
};

View File

@@ -0,0 +1,237 @@
import { createHash } from 'node:crypto';
import argon2 from 'argon2';
import type pg from 'pg';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildApp } from '../build-app.js';
import { createPool } from '../../infrastructure/db/pool.js';
import {
getTestDbUrl,
recreateDatabase,
runMigrations,
} from '../../infrastructure/db/tests/db-test-support.js';
const hasDb = Boolean(process.env.TEST_DATABASE_URL);
const ADMIN_ID = '10000000-0000-4000-8000-000000000187';
const CASHIER_ID = '20000000-0000-4000-8000-000000000187';
const MANAGER_ID = '30000000-0000-4000-8000-000000000187';
const TERMINAL_ID = '40000000-0000-4000-8000-000000000187';
const STORE_ID = '00000000-0000-0000-0000-000000000001';
const CASH_SESSION_ID = '50000000-0000-4000-8000-000000000187';
const ADMIN_TOKEN = 'f187-admin-session-token';
const CASHIER_TOKEN = 'f187-cashier-session-token';
const PASSWORD = 'cashier-password-187';
function tokenHash(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function cookie(token: string): string {
return `backoffice_session=${token}`;
}
describe.skipIf(!hasDb)('F-187 POS cashier lifecycle (real PostgreSQL)', () => {
const url = hasDb ? getTestDbUrl() : '';
let pool: pg.Pool;
let app: Awaited<ReturnType<typeof buildApp>>;
beforeAll(async () => {
await recreateDatabase(url);
await runMigrations(url, 'up');
pool = createPool(url);
const passwordHash = await argon2.hash(PASSWORD);
await pool.query(
`INSERT INTO backoffice_users (id, email, password_hash, role)
VALUES
($1, 'admin-f187@example.test', $4, 'admin'),
($2, 'cashier-f187@example.test', $4, 'pos_cashier'),
($3, 'manager-f187@example.test', $4, 'pos_manager')`,
[ADMIN_ID, CASHIER_ID, MANAGER_ID, passwordHash],
);
await pool.query(
`INSERT INTO backoffice_sessions (user_id, token_hash, expires_at)
VALUES ($1, $3, now() + interval '1 hour'),
($2, $4, now() + interval '1 hour')`,
[ADMIN_ID, CASHIER_ID, tokenHash(ADMIN_TOKEN), tokenHash(CASHIER_TOKEN)],
);
await pool.query(
`INSERT INTO pos_terminals (id, store_id, name, binding_code, bound_at)
VALUES ($1, $2, 'Caja F-187', 'F187CODE', now())`,
[TERMINAL_ID, STORE_ID],
);
app = await buildApp({ pool, cookieSecure: false });
});
afterAll(async () => {
await app.close();
await pool.end();
});
it('lists lifecycle status for admins and rejects non-admin listing', async () => {
const listed = await app.inject({
method: 'GET',
url: '/pos/users',
headers: { cookie: cookie(ADMIN_TOKEN) },
});
expect(listed.statusCode).toBe(200);
expect(listed.json().items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: CASHIER_ID,
role: 'pos_cashier',
active: true,
status: 'active',
deletedAt: null,
}),
]),
);
const forbidden = await app.inject({
method: 'GET',
url: '/pos/users',
headers: { cookie: cookie(CASHIER_TOKEN) },
});
expect(forbidden.statusCode).toBe(403);
});
it('deactivates, revokes sessions, blocks auth and can reactivate without restoring sessions', async () => {
const deactivated = await app.inject({
method: 'PATCH',
url: `/pos/users/${CASHIER_ID}/status`,
headers: { cookie: cookie(ADMIN_TOKEN) },
payload: { active: false },
});
expect(deactivated.statusCode).toBe(200);
expect(deactivated.json()).toMatchObject({ id: CASHIER_ID, active: false, status: 'inactive' });
const session = await pool.query<{ revoked_at: Date | null }>(
'SELECT revoked_at FROM backoffice_sessions WHERE user_id = $1',
[CASHIER_ID],
);
expect(session.rows[0]?.revoked_at).toBeInstanceOf(Date);
const oldSession = await app.inject({
method: 'GET',
url: '/pos/config',
headers: { cookie: cookie(CASHIER_TOKEN), 'x-terminal-id': TERMINAL_ID },
});
expect(oldSession.statusCode).toBe(401);
const blockedLogin = await app.inject({
method: 'POST',
url: '/backoffice/auth/login',
payload: { email: 'cashier-f187@example.test', password: PASSWORD },
});
expect(blockedLogin.statusCode).toBe(401);
expect(blockedLogin.json().error.code).toBe('INVALID_CREDENTIALS');
const reactivated = await app.inject({
method: 'PATCH',
url: `/pos/users/${CASHIER_ID}/status`,
headers: { cookie: cookie(ADMIN_TOKEN) },
payload: { active: true },
});
expect(reactivated.statusCode).toBe(200);
expect(reactivated.json()).toMatchObject({ active: true, status: 'active' });
const revokedStill = await pool.query<{ revoked_at: Date | null }>(
'SELECT revoked_at FROM backoffice_sessions WHERE user_id = $1',
[CASHIER_ID],
);
expect(revokedStill.rows[0]?.revoked_at).toBeInstanceOf(Date);
const login = await app.inject({
method: 'POST',
url: '/backoffice/auth/login',
payload: { email: 'cashier-f187@example.test', password: PASSWORD },
});
expect(login.statusCode).toBe(200);
});
it('blocks lifecycle mutations during an open cash session', async () => {
await pool.query(
`INSERT INTO pos_cash_sessions
(id, terminal_id, store_id, user_id, opening_cash_cents)
VALUES ($1, $2, $3, $4, 1000)`,
[CASH_SESSION_ID, TERMINAL_ID, STORE_ID, CASHIER_ID],
);
for (const request of [
{ method: 'PATCH' as const, payload: { active: false } },
{ method: 'DELETE' as const, payload: undefined },
]) {
const response = await app.inject({
method: request.method,
url:
request.method === 'PATCH'
? `/pos/users/${CASHIER_ID}/status`
: `/pos/users/${CASHIER_ID}`,
headers: { cookie: cookie(ADMIN_TOKEN) },
...(request.payload ? { payload: request.payload } : {}),
});
expect(response.statusCode).toBe(409);
expect(response.json().error.code).toBe('POS_CASHIER_HAS_OPEN_SESSION');
}
});
it('soft-deletes after close, preserves attribution and rejects reactivation', async () => {
await pool.query(
`UPDATE pos_cash_sessions SET status = 'CLOSED', closed_at = now(), updated_at = now()
WHERE id = $1`,
[CASH_SESSION_ID],
);
const deleted = await app.inject({
method: 'DELETE',
url: `/pos/users/${CASHIER_ID}`,
headers: { cookie: cookie(ADMIN_TOKEN) },
});
expect(deleted.statusCode).toBe(204);
const preserved = await pool.query<{
id: string;
active: boolean;
deleted_at: Date | null;
historical_user_id: string;
}>(
`SELECT u.id, u.active, u.deleted_at, cs.user_id AS historical_user_id
FROM backoffice_users u
JOIN pos_cash_sessions cs ON cs.user_id = u.id
WHERE u.id = $1 AND cs.id = $2`,
[CASHIER_ID, CASH_SESSION_ID],
);
expect(preserved.rows[0]).toMatchObject({
id: CASHIER_ID,
active: false,
historical_user_id: CASHIER_ID,
});
expect(preserved.rows[0]?.deleted_at).toBeInstanceOf(Date);
const reactivate = await app.inject({
method: 'PATCH',
url: `/pos/users/${CASHIER_ID}/status`,
headers: { cookie: cookie(ADMIN_TOKEN) },
payload: { active: true },
});
expect(reactivate.statusCode).toBe(409);
expect(reactivate.json().error.code).toBe('POS_CASHIER_DELETED');
const managerTarget = await app.inject({
method: 'PATCH',
url: `/pos/users/${MANAGER_ID}/status`,
headers: { cookie: cookie(ADMIN_TOKEN) },
payload: { active: false },
});
expect(managerTarget.statusCode).toBe(404);
expect(managerTarget.json().error.code).toBe('POS_CASHIER_NOT_FOUND');
const audit = await pool.query<{ action: string }>(
`SELECT action FROM security_audit_log WHERE target = $1 ORDER BY created_at`,
[CASHIER_ID],
);
expect(audit.rows.map((row) => row.action)).toEqual([
'pos.cashier.deactivated',
'pos.cashier.reactivated',
'pos.cashier.deleted',
]);
});
});

View File

@@ -71,7 +71,7 @@ export async function registerBackofficeRoutes(
properties: {
id: { type: 'string', format: 'uuid' },
email: { type: 'string', format: 'email' },
role: { type: 'string', enum: ['admin', 'editor'] },
role: { type: 'string', enum: ['admin', 'editor', 'pos_manager', 'pos_cashier'] },
},
},
401: errorSchema,
@@ -97,7 +97,7 @@ export async function registerBackofficeRoutes(
properties: {
id: { type: 'string', format: 'uuid' },
email: { type: 'string', format: 'email' },
role: { type: 'string', enum: ['admin', 'editor'] },
role: { type: 'string', enum: ['admin', 'editor', 'pos_manager', 'pos_cashier'] },
},
},
{ type: 'object', properties: { user: { type: 'null' } } },

View File

@@ -3,13 +3,16 @@
* Backoffice users (admin/editor) are physically separated from storefront
* customers (identity_users) and authenticate through a separate mechanism.
*/
export type BackofficeRole = 'admin' | 'editor';
export type BackofficeRole = 'admin' | 'editor' | 'pos_cashier' | 'pos_manager';
export interface BackofficeUser {
id: string;
email: string;
role: BackofficeRole;
mfaEnrolled: boolean;
active: boolean;
deactivatedAt: Date | null;
deletedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}

View File

@@ -4,7 +4,7 @@
*/
import type { FastifyRequest } from 'fastify';
import type pg from 'pg';
import type { Authenticate } from '../../../shared/auth.js';
import type { Authenticate, Role } 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';
@@ -22,6 +22,8 @@ const RESOLVE_SQL = `
WHERE s.token_hash = $1
AND s.revoked_at IS NULL
AND s.expires_at > now()
AND u.active = true
AND u.deleted_at IS NULL
`;
export function createBackofficeSessionAuthenticator(pool: pg.Pool): Authenticate {
@@ -31,7 +33,7 @@ export function createBackofficeSessionAuthenticator(pool: pg.Pool): Authenticat
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' };
return { id: row.id, email: row.email, role: row.role as Role };
};
}

View File

@@ -14,6 +14,9 @@ interface UserRow {
password_hash: string;
role: string;
mfa_enrolled: boolean;
active: boolean;
deactivated_at: Date | null;
deleted_at: Date | null;
created_at: Date;
updated_at: Date;
}
@@ -47,7 +50,8 @@ export class PgBackofficeUserRepository implements BackofficeUserRepository {
async findByEmail(email: string): Promise<BackofficeUserWithHash | undefined> {
const result = await this.pool.query<UserRow>(
'SELECT * FROM backoffice_users WHERE email = $1',
`SELECT * FROM backoffice_users
WHERE email = $1 AND active = true AND deleted_at IS NULL`,
[email.toLowerCase()],
);
const row = result.rows[0];
@@ -76,6 +80,9 @@ function toUser(row: UserRow): BackofficeUser {
email: row.email,
role: row.role as BackofficeRole,
mfaEnrolled: row.mfa_enrolled,
active: row.active,
deactivatedAt: row.deactivated_at,
deletedAt: row.deleted_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
};

View File

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

View File

@@ -38,6 +38,12 @@ export class CashSessionNotOpenError extends PosError {
}
}
export class CashierUnavailableError extends PosError {
constructor(userId: string) {
super('POS_CASHIER_UNAVAILABLE', `POS user ${userId} is inactive or deleted`);
}
}
export class TerminalNotBoundError extends PosError {
constructor(terminalId: string) {
super('TERMINAL_NOT_BOUND', `Terminal ${terminalId} is not bound`);

View File

@@ -1,6 +1,11 @@
import type pg from 'pg';
import type { PosCashSessionRepository } from '../domain/ports.js';
import type { PosCashSession, OpenCashSessionInput, CloseCashSessionInput } from '../domain/cash-session.js';
import type {
PosCashSession,
OpenCashSessionInput,
CloseCashSessionInput,
} from '../domain/cash-session.js';
import { CashierUnavailableError } from '../domain/errors.js';
interface SessionRow {
id: string;
@@ -60,21 +65,43 @@ export class PgCashSessionRepository implements PosCashSessionRepository {
}
async open(input: OpenCashSessionInput): Promise<PosCashSession> {
// Get store_id from terminal
const terminal = await this.pool.query<{ store_id: string }>(
'SELECT store_id FROM pos_terminals WHERE id = $1',
[input.terminalId],
);
if (!terminal.rows[0]) throw new Error(`Terminal ${input.terminalId} not found`);
const storeId = terminal.rows[0].store_id;
const client = await this.pool.connect();
try {
await client.query('BEGIN');
// This row lock serializes opening against cashier deactivation/deletion.
const cashier = await client.query<{ active: boolean; deleted_at: Date | null }>(
`SELECT active, deleted_at FROM backoffice_users
WHERE id = $1 AND role IN ('admin', 'pos_manager', 'pos_cashier')
FOR UPDATE`,
[input.userId],
);
const cashierRow = cashier.rows[0];
if (!cashierRow?.active || cashierRow.deleted_at) {
throw new CashierUnavailableError(input.userId);
}
const result = await this.pool.query<SessionRow>(
`INSERT INTO pos_cash_sessions (terminal_id, store_id, user_id, opening_cash_cents)
VALUES ($1, $2, $3, $4) RETURNING *`,
[input.terminalId, storeId, input.userId, input.openingCashCents],
);
if (!result.rows[0]) throw new Error('Failed to create session');
return toSession(result.rows[0]);
const terminal = await client.query<{ store_id: string }>(
'SELECT store_id FROM pos_terminals WHERE id = $1',
[input.terminalId],
);
if (!terminal.rows[0]) throw new Error(`Terminal ${input.terminalId} not found`);
const storeId = terminal.rows[0].store_id;
const result = await client.query<SessionRow>(
`INSERT INTO pos_cash_sessions (terminal_id, store_id, user_id, opening_cash_cents)
VALUES ($1, $2, $3, $4) RETURNING *`,
[input.terminalId, storeId, input.userId, input.openingCashCents],
);
const row = result.rows[0];
if (!row) throw new Error('Failed to create session');
await client.query('COMMIT');
return toSession(row);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async close(input: CloseCashSessionInput): Promise<PosCashSession> {
@@ -85,7 +112,13 @@ export class PgCashSessionRepository implements PosCashSessionRepository {
closing_cash_cents = $2, actual_cash_cents = $3,
difference_cents = $4, notes = $5, updated_at = now()
WHERE id = $1 RETURNING *`,
[input.sessionId, input.closingCashCents, input.actualCashCents, difference, input.notes ?? null],
[
input.sessionId,
input.closingCashCents,
input.actualCashCents,
difference,
input.notes ?? null,
],
);
if (!result.rows[0]) throw new Error(`Session ${input.sessionId} not found`);
return toSession(result.rows[0]);