feat(F-187): completed feature
This commit is contained in:
237
project/src/app/tests/pos-cashier-lifecycle.itest.ts
Normal file
237
project/src/app/tests/pos-cashier-lifecycle.itest.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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' } } },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user