feat(POS-004): completed feature
This commit is contained in:
328
project/src/modules/pos/api/pos.routes.ts
Normal file
328
project/src/modules/pos/api/pos.routes.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
import type { FastifyInstance, FastifySchema } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import type { CurrentUser, Role } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { errorSchema } from '../../../shared/swagger.js';
|
||||
import { requireRole, requireAnyRole } from '../../../shared/auth.js';
|
||||
import { z } from 'zod';
|
||||
import { ListStoresUseCase } from '../application/list-stores.js';
|
||||
import { ListTerminalsUseCase } from '../application/list-terminals.js';
|
||||
import { GetPosConfigUseCase } from '../application/get-pos-config.js';
|
||||
import { OpenCashSessionUseCase } from '../application/open-cash-session.js';
|
||||
import { CloseCashSessionUseCase } from '../application/close-cash-session.js';
|
||||
import { PgStoreRepository } from '../infrastructure/pg-store-repository.js';
|
||||
import { PgTerminalRepository } from '../infrastructure/pg-terminal-repository.js';
|
||||
import { PgPaymentMethodRepository } from '../infrastructure/pg-payment-method-repository.js';
|
||||
import { PgCashSessionRepository } from '../infrastructure/pg-cash-session-repository.js';
|
||||
|
||||
export interface PosRouteDeps {
|
||||
pool: pg.Pool;
|
||||
authenticate: (request: import('fastify').FastifyRequest) => Promise<CurrentUser>;
|
||||
}
|
||||
|
||||
const idParamSchema = z.object({ id: z.string().uuid() });
|
||||
|
||||
export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps) {
|
||||
const { pool, authenticate } = deps;
|
||||
|
||||
const storeRepo = new PgStoreRepository(pool);
|
||||
const terminalRepo = new PgTerminalRepository(pool);
|
||||
const paymentMethodRepo = new PgPaymentMethodRepository(pool);
|
||||
const sessionRepo = new PgCashSessionRepository(pool);
|
||||
|
||||
const listStores = new ListStoresUseCase(storeRepo);
|
||||
const listTerminals = new ListTerminalsUseCase(terminalRepo);
|
||||
const getConfig = new GetPosConfigUseCase(storeRepo, terminalRepo, paymentMethodRepo, sessionRepo);
|
||||
const openSession = new OpenCashSessionUseCase(sessionRepo, terminalRepo);
|
||||
const closeSession = new CloseCashSessionUseCase(sessionRepo);
|
||||
|
||||
// ── Admin: stores ─────────────────────────────────────────────────────────
|
||||
|
||||
app.get('/pos/admin/stores', {
|
||||
schema: {
|
||||
tags: ['POS Admin'],
|
||||
summary: 'List POS stores',
|
||||
querystring: { type: 'object', properties: { active: { type: 'boolean' } } },
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { active } = request.query as { active?: boolean };
|
||||
const result = await listStores.execute({ active });
|
||||
return reply.send(result);
|
||||
});
|
||||
|
||||
app.post('/pos/admin/stores', {
|
||||
schema: {
|
||||
tags: ['POS Admin'],
|
||||
summary: 'Create POS store',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['name', 'slug'],
|
||||
properties: {
|
||||
name: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
slug: { type: 'string', pattern: '^[a-z0-9]+(?:-[a-z0-9]+)*$' },
|
||||
address: { type: 'string' },
|
||||
taxId: { type: 'string' },
|
||||
contactEmail: { type: 'string' },
|
||||
contactPhone: { type: 'string' },
|
||||
receiptHeader: { type: 'string' },
|
||||
receiptFooter: { type: 'string' },
|
||||
},
|
||||
},
|
||||
response: { 400: errorSchema, 401: errorSchema, 403: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const body = parseJson(
|
||||
z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
||||
address: z.string().optional(),
|
||||
taxId: z.string().optional(),
|
||||
contactEmail: z.string().optional(),
|
||||
contactPhone: z.string().optional(),
|
||||
receiptHeader: z.string().optional(),
|
||||
receiptFooter: z.string().optional(),
|
||||
}),
|
||||
request.body ?? {},
|
||||
);
|
||||
const result = await pool.query<{ id: string; name: string; slug: string; active: boolean }>(
|
||||
`INSERT INTO pos_stores (name, slug, address, tax_id, contact_email, contact_phone, receipt_header, receipt_footer)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, name, slug, active`,
|
||||
[body.name, body.slug, body.address, body.taxId, body.contactEmail, body.contactPhone, body.receiptHeader, body.receiptFooter],
|
||||
);
|
||||
return reply.code(201).send(result.rows[0]);
|
||||
});
|
||||
|
||||
// ── Admin: terminals ─────────────────────────────────────────────────────
|
||||
|
||||
app.get('/pos/admin/terminals', {
|
||||
schema: {
|
||||
tags: ['POS Admin'],
|
||||
summary: 'List POS terminals',
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
storeId: { type: 'string', format: 'uuid' },
|
||||
status: { type: 'string', enum: ['active', 'disabled', 'decommissioned'] },
|
||||
},
|
||||
},
|
||||
response: { 401: errorSchema, 403: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { storeId, status } = request.query as { storeId?: string; status?: string };
|
||||
const result = await listTerminals.execute({ storeId, status: status as 'active' | 'disabled' | 'decommissioned' | undefined });
|
||||
return reply.send(result);
|
||||
});
|
||||
|
||||
app.post('/pos/admin/terminals', {
|
||||
schema: {
|
||||
tags: ['POS Admin'],
|
||||
summary: 'Create POS terminal',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['storeId', 'name'],
|
||||
properties: {
|
||||
storeId: { type: 'string', format: 'uuid' },
|
||||
name: { type: 'string', minLength: 1, maxLength: 100 },
|
||||
},
|
||||
},
|
||||
response: { 400: errorSchema, 401: errorSchema, 403: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const body = parseJson(
|
||||
z.object({ storeId: z.string().uuid(), name: z.string().min(1).max(100) }),
|
||||
request.body ?? {},
|
||||
);
|
||||
// Generate a short binding code (8 hex chars)
|
||||
const bindingCode = Math.random().toString(16).slice(2, 10).toUpperCase();
|
||||
const result = await pool.query<{ id: string; name: string; bindingCode: string; storeId: string }>(
|
||||
`INSERT INTO pos_terminals (store_id, name, binding_code)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, name, binding_code as "bindingCode", store_id as "storeId"`,
|
||||
[body.storeId, body.name, bindingCode],
|
||||
);
|
||||
return reply.code(201).send(result.rows[0]);
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/pos/admin/terminals/:id', {
|
||||
schema: {
|
||||
tags: ['POS Admin'],
|
||||
summary: 'Get terminal',
|
||||
params: idParamSchema,
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
const terminal = await terminalRepo.findById(id);
|
||||
if (!terminal) throw new AppError(404, 'TERMINAL_NOT_FOUND', 'Terminal not found');
|
||||
return reply.send(terminal);
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/pos/admin/terminals/:id', {
|
||||
schema: {
|
||||
tags: ['POS Admin'],
|
||||
summary: 'Decommission terminal',
|
||||
params: idParamSchema,
|
||||
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
await pool.query(`UPDATE pos_terminals SET status = 'decommissioned' WHERE id = $1`, [id]);
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
|
||||
// ── Terminal: me + bind + config ───────────────────────────────────────
|
||||
|
||||
app.get('/pos/terminals/me', {
|
||||
schema: {
|
||||
tags: ['POS Terminal'],
|
||||
summary: 'Get current terminal info',
|
||||
headers: { type: 'object', properties: { 'x-terminal-id': { type: 'string', format: 'uuid' } } },
|
||||
response: { 401: errorSchema, 404: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
||||
const terminalId = request.headers['x-terminal-id'] as string | undefined;
|
||||
if (!terminalId) throw new AppError(400, 'MISSING_TERMINAL_ID', 'x-terminal-id header required');
|
||||
const terminal = await terminalRepo.findById(terminalId);
|
||||
if (!terminal) throw new AppError(404, 'TERMINAL_NOT_FOUND', 'Terminal not found');
|
||||
return reply.send(terminal);
|
||||
});
|
||||
|
||||
app.post('/pos/terminals/bind', {
|
||||
schema: {
|
||||
tags: ['POS Terminal'],
|
||||
summary: 'Bind terminal with code',
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['bindingCode'],
|
||||
properties: { bindingCode: { type: 'string', minLength: 8, maxLength: 8 } },
|
||||
},
|
||||
response: { 400: errorSchema, 401: errorSchema, 404: errorSchema, 409: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
||||
const body = parseJson(z.object({ bindingCode: z.string().length(8) }), request.body ?? {});
|
||||
const terminal = await terminalRepo.findByBindingCode(body.bindingCode.toUpperCase());
|
||||
if (!terminal) throw new AppError(404, 'TERMINAL_NOT_FOUND', 'Terminal not found');
|
||||
if (terminal.status !== 'active') throw new AppError(409, 'TERMINAL_NOT_ACTIVE', 'Terminal is not active');
|
||||
const bound = await terminalRepo.bind(terminal.id, body.bindingCode.toUpperCase());
|
||||
return reply.send({ terminalId: bound.id, storeId: bound.storeId });
|
||||
});
|
||||
|
||||
app.get('/pos/config', {
|
||||
schema: {
|
||||
tags: ['POS Terminal'],
|
||||
summary: 'Get POS terminal config',
|
||||
headers: { type: 'object', properties: { 'x-terminal-id': { type: 'string', format: 'uuid' } } },
|
||||
response: { 401: errorSchema, 404: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
||||
const terminalId = request.headers['x-terminal-id'] as string | undefined;
|
||||
if (!terminalId) throw new AppError(400, 'MISSING_TERMINAL_ID', 'x-terminal-id header required');
|
||||
const config = await getConfig.execute(terminalId);
|
||||
return reply.send(config);
|
||||
});
|
||||
|
||||
// ── Cash sessions ───────────────────────────────────────────────────────
|
||||
|
||||
app.get('/pos/sessions/me', {
|
||||
schema: {
|
||||
tags: ['POS Terminal'],
|
||||
summary: 'Get current open session',
|
||||
headers: { type: 'object', properties: { 'x-terminal-id': { type: 'string', format: 'uuid' } } },
|
||||
response: { 401: errorSchema, 404: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
||||
const terminalId = request.headers['x-terminal-id'] as string | undefined;
|
||||
if (!terminalId) throw new AppError(400, 'MISSING_TERMINAL_ID', 'x-terminal-id header required');
|
||||
const session = await sessionRepo.findOpenByTerminal(terminalId);
|
||||
if (!session) throw new AppError(404, 'SESSION_NOT_FOUND', 'No open session');
|
||||
return reply.send(session);
|
||||
});
|
||||
|
||||
app.post('/pos/sessions', {
|
||||
schema: {
|
||||
tags: ['POS Terminal'],
|
||||
summary: 'Open cash session',
|
||||
headers: { type: 'object', properties: { 'x-terminal-id': { type: 'string', format: 'uuid' } } },
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['openingCashCents'],
|
||||
properties: { openingCashCents: { type: 'integer', minimum: 0 } },
|
||||
},
|
||||
response: { 400: errorSchema, 401: errorSchema, 404: errorSchema, 409: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
||||
const terminalId = request.headers['x-terminal-id'] as string | undefined;
|
||||
if (!terminalId) throw new AppError(400, 'MISSING_TERMINAL_ID', 'x-terminal-id header required');
|
||||
const body = parseJson(z.object({ openingCashCents: z.number().int().min(0) }), request.body ?? {});
|
||||
try {
|
||||
const session = await openSession.execute({ terminalId, userId: user.id, openingCashCents: body.openingCashCents });
|
||||
return reply.code(201).send(session);
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) throw err;
|
||||
throw new AppError(409, 'SESSION_ERROR', String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string } }>('/pos/sessions/:id/close', {
|
||||
schema: {
|
||||
tags: ['POS Terminal'],
|
||||
summary: 'Close cash session',
|
||||
params: idParamSchema,
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['closingCashCents', 'actualCashCents'],
|
||||
properties: {
|
||||
closingCashCents: { type: 'integer', minimum: 0 },
|
||||
actualCashCents: { type: 'integer', minimum: 0 },
|
||||
notes: { type: 'string' },
|
||||
},
|
||||
},
|
||||
response: { 400: errorSchema, 401: errorSchema, 404: errorSchema, 409: errorSchema },
|
||||
} as FastifySchema,
|
||||
}, async (request, reply) => {
|
||||
const user = await authenticate(request);
|
||||
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
|
||||
const { id } = parseJson(idParamSchema, request.params);
|
||||
const body = parseJson(
|
||||
z.object({
|
||||
closingCashCents: z.number().int().min(0),
|
||||
actualCashCents: z.number().int().min(0),
|
||||
notes: z.string().optional(),
|
||||
}),
|
||||
request.body ?? {},
|
||||
);
|
||||
try {
|
||||
const session = await closeSession.execute({ sessionId: id, ...body });
|
||||
return reply.send(session);
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) throw err;
|
||||
throw new AppError(409, 'CLOSE_ERROR', String(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user