From 0a5ea36c259c0ac1204a52b4752272d4143c5532 Mon Sep 17 00:00:00 2001 From: pfonseca-dev Date: Mon, 19 Oct 2026 09:56:57 -0300 Subject: [PATCH 1/8] =?UTF-8?q?refactor(tables):=20Padronizar=20DTOs=20de?= =?UTF-8?q?=20valida=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/tables/dto/create-table.dto.ts | 11 + .../src/modules/tables/dto/table-id.dto.ts | 12 + .../tables/dto/table-status-change.dto.ts | 12 + .../modules/tables/dto/table-status.dto.ts | 10 + .../src/modules/tables/dto/table-token.dto.ts | 9 + .../modules/tables/dto/update-table.dto.ts | 6 + .../src/modules/tables/enity/table.entity.ts | 21 ++ .../repositories/prisma-table.repository.ts | 295 ++++++++++++++++++ .../table-with-attendance.repository.ts | 12 + .../tables/repositories/table.repository.ts | 44 +++ .../repository/prima-table.repository.ts | 15 - .../tables/repository/table.repository.ts | 3 - .../src/modules/tables/table.controller.ts | 37 +-- backend/src/modules/tables/table.factory.ts | 14 + backend/src/modules/tables/table.routers.ts | 33 +- backend/src/modules/tables/table.schema.ts | 23 -- backend/src/modules/tables/table.service.ts | 206 ++++++------ .../tables/utils/resolve-table-status.ts | 27 ++ 18 files changed, 618 insertions(+), 172 deletions(-) create mode 100644 backend/src/modules/tables/dto/create-table.dto.ts create mode 100644 backend/src/modules/tables/dto/table-id.dto.ts create mode 100644 backend/src/modules/tables/dto/table-status-change.dto.ts create mode 100644 backend/src/modules/tables/dto/table-status.dto.ts create mode 100644 backend/src/modules/tables/dto/table-token.dto.ts create mode 100644 backend/src/modules/tables/dto/update-table.dto.ts create mode 100644 backend/src/modules/tables/enity/table.entity.ts create mode 100644 backend/src/modules/tables/repositories/prisma-table.repository.ts create mode 100644 backend/src/modules/tables/repositories/table-with-attendance.repository.ts create mode 100644 backend/src/modules/tables/repositories/table.repository.ts delete mode 100644 backend/src/modules/tables/repository/prima-table.repository.ts delete mode 100644 backend/src/modules/tables/repository/table.repository.ts create mode 100644 backend/src/modules/tables/table.factory.ts delete mode 100644 backend/src/modules/tables/table.schema.ts create mode 100644 backend/src/modules/tables/utils/resolve-table-status.ts diff --git a/backend/src/modules/tables/dto/create-table.dto.ts b/backend/src/modules/tables/dto/create-table.dto.ts new file mode 100644 index 0000000..8de13f2 --- /dev/null +++ b/backend/src/modules/tables/dto/create-table.dto.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const createTableSchema = z.object({ + number: z.number().int().positive().max(4294967295), + + name: z.string().trim().min(1).max(100), + + active: z.boolean().default(true), +}); + +export type CreateTableDTO = z.infer; \ No newline at end of file diff --git a/backend/src/modules/tables/dto/table-id.dto.ts b/backend/src/modules/tables/dto/table-id.dto.ts new file mode 100644 index 0000000..b7e713e --- /dev/null +++ b/backend/src/modules/tables/dto/table-id.dto.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const tableIdSchema = z.object({ + id: z.string() + .regex(/^[1-9]\d*$/) + .transform((value) => BigInt(value)) + .refine( + (value) => value <= 18446744073709551615n, + "Invalid table id"), +}); + +export type TableIdDTO = z.infer; \ No newline at end of file diff --git a/backend/src/modules/tables/dto/table-status-change.dto.ts b/backend/src/modules/tables/dto/table-status-change.dto.ts new file mode 100644 index 0000000..be73f4a --- /dev/null +++ b/backend/src/modules/tables/dto/table-status-change.dto.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +import { tableStatusSchema } from "./table-status.dto.js" +import { tableIdSchema } from "./table-id.dto.js"; + + +export const tableStatusChangeSchema = z.object({ + status: tableStatusSchema, + attendanceId: tableIdSchema.shape.id.nullable(), +}); + +export type TableStatusChangeDTO = z.infer; \ No newline at end of file diff --git a/backend/src/modules/tables/dto/table-status.dto.ts b/backend/src/modules/tables/dto/table-status.dto.ts new file mode 100644 index 0000000..f8525f6 --- /dev/null +++ b/backend/src/modules/tables/dto/table-status.dto.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const tableStatusSchema = z.enum([ + "available", + "occupied", + "closing_requested", + "awaiting_payment" +]); + +export type TableStatusDTO = z.infer; \ No newline at end of file diff --git a/backend/src/modules/tables/dto/table-token.dto.ts b/backend/src/modules/tables/dto/table-token.dto.ts new file mode 100644 index 0000000..f39bd14 --- /dev/null +++ b/backend/src/modules/tables/dto/table-token.dto.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const tableTokenSchema = z.object({ + token: z.string() + .min(1) + .max(120) +}); + +export type TableTokenDTO = z.infer; \ No newline at end of file diff --git a/backend/src/modules/tables/dto/update-table.dto.ts b/backend/src/modules/tables/dto/update-table.dto.ts new file mode 100644 index 0000000..d46919a --- /dev/null +++ b/backend/src/modules/tables/dto/update-table.dto.ts @@ -0,0 +1,6 @@ +import { z } from "zod"; +import { createTableSchema } from "./create-table.dto.js"; + +export const updateTableSchema = createTableSchema; + +export type UpdateTableDTO = z.infer; \ No newline at end of file diff --git a/backend/src/modules/tables/enity/table.entity.ts b/backend/src/modules/tables/enity/table.entity.ts new file mode 100644 index 0000000..d4010cb --- /dev/null +++ b/backend/src/modules/tables/enity/table.entity.ts @@ -0,0 +1,21 @@ + + +export class TableEntity { + constructor( + public readonly id: bigint, + public number: number, + public name: string | null, + public active: boolean, + public readonly qrToken: string, + public readonly createdAt: Date, + public updatedAt: Date, + ){} + + isActive(): boolean { + return this.active; + } + + canBeUsed(): boolean { + return this.isActive(); + } +} \ No newline at end of file diff --git a/backend/src/modules/tables/repositories/prisma-table.repository.ts b/backend/src/modules/tables/repositories/prisma-table.repository.ts new file mode 100644 index 0000000..153e68f --- /dev/null +++ b/backend/src/modules/tables/repositories/prisma-table.repository.ts @@ -0,0 +1,295 @@ +import { prisma } from "../../../database/prisma.js"; +import { AppError } from "../../../shared/errors/app-errors.js"; + +import type { TableRepository, CreateTableData, UpdateTableData } from "./table.repository.js"; +import type { RestaurantTable } from "../../../generated/prisma/client.js"; +import type { TableWithAttendance } from "./table-with-attendance.repository.js"; +import type { TableStatusChangeDTO } from "../dto/table-status-change.dto.js"; +import type { TableStatusDTO } from "../dto/table-status.dto.js"; + +import type { Prisma } from "../../../generated/prisma/client.js"; + +import { TableEntity } from "../enity/table.entity.js"; +import { resolveTableStatus } from "../utils/resolve-table-status.js"; + +const attendanceInclude = { + attendances: { + where: { + status: { + in: [ + "OPEN", + "CLOSING_REQUESTED", + "AWAITING_PAYMENT" + ] + } + }, + select: { + id: true, + status: true + }, + orderBy: [ + { createdAt: "desc" }, + { id: "desc" } + ], + take: 1, + }, +} satisfies Prisma.RestaurantTableInclude; + +const nextStatus: Record = { + available: "occupied", + occupied: "closing_requested", + closing_requested: "awaiting_payment", + awaiting_payment: "available" +}; + +export class PrismaTableRepository implements TableRepository { + + async existsById(id: bigint): Promise { + const table = await prisma.restaurantTable.findUnique({ + where: { id }, + select: { id: true } + }); + + return table !== null; + } + + async create(data: CreateTableData): Promise { + const table= await prisma.restaurantTable.create({ + data: { + number: data.number, + name: data.name, + active: data.active, + qrToken: data.qrToken, + } + }); + + return this.toEntity(table); + } + + async findById(id: bigint): Promise { + const table = await prisma.restaurantTable.findUnique({ + where: { id } + }); + + if (!table) { + return null; + } + + return this.toEntity(table) + } + + async findAll(): Promise { + const tables = await prisma.restaurantTable.findMany({ + orderBy: { + number: "asc" + } + }); + + return tables.map(table => this.toEntity(table)) + } + + async delete(id: bigint): Promise { + const table = await prisma.restaurantTable.delete({ + where: { id } + }); + + return this.toEntity(table); + } + + async findByToken(token: string): Promise { + const table = await prisma.restaurantTable.findUnique({ + where: { qrToken: token } + }); + + if (!table) { + return null; + } + + return this.toEntity(table); + } + + async findByIdWithAttendance(id: bigint): Promise { + const table = await prisma.restaurantTable.findUnique({ + where: { id }, + include: attendanceInclude + }); + + if (!table) { + return null; + } + + return { + table: this.toEntity(table), + attendance: table.attendances[0] ?? null + } + } + + async findAllWithAttendance(): Promise { + const tables = await prisma.restaurantTable.findMany({ + include: attendanceInclude, + orderBy: { + number: "asc" + } + }); + + return tables.map(table => ({ + table: this.toEntity(table), + attendance: table.attendances[0] ?? null + })); + } + + async updateWithAttendanceCheck(id: bigint, data: UpdateTableData): Promise { + return prisma.$transaction(async (tx) => { + //bloquea a mesa durante uma transação + await tx.$queryRaw` + SELECT id + FROM restaurant_tables + WHERE id = ${id} + FOR UPDATE + `; + + //busca a mesa e seu atendimento atual + const current = await tx.restaurantTable.findUnique({ + where: { id }, + include: attendanceInclude + }) + + //verifica se a mesa existe + if (!current){ + throw new AppError("Mesa não encontrada", 404); + } + + //Impedir a desativação com atendimento ativo + if (!data.active && current.attendances.length > 0){ + throw new AppError("Feche o atendimento antes de desativar a mesa", 409); + } + + const updated = await tx.restaurantTable.update({ + where: { id }, + data: { + number: data.number, + name: data.name, + active: data.active, + }, + include: attendanceInclude, + }); + + return { + table: this.toEntity(updated), + attendance: updated.attendances[0] ?? null, + } + }) + } + + async changeStatusTransaction(id: bigint, change: TableStatusChangeDTO): Promise { + return prisma.$transaction(async (tx) => { + //bloquear a mesa + await tx.$queryRaw` + SELECT id + FROM restaurant_tables + WHERE id = ${id} + FOR UPDATE + `; + + //buscar a mesa e o atendimento atual + const table = await tx.restaurantTable.findUnique({ + where: { id }, + include: attendanceInclude + }) + + if (!table){ + throw new AppError("Mesa nao encontrada", 404); + } + + const attendance = table.attendances[0] ?? null; + + //descobrir o status atual + const currentStatus = resolveTableStatus(attendance) + + //verifcar se o atendimento mudou + if ((attendance?.id ?? null) !== change.attendanceId){ + throw new AppError("O atendimento da mesa mudou. Atualize a mesa e tente novamente", 409); + } + + //validar a transicao + if (nextStatus[currentStatus] !== change.status) { + throw new AppError("Transição de estado inválida para esta mesa", 409); + } + + //abre um atendimento + if (!attendance) { + if (!table.active) { + throw new AppError("Mesa inativa nao pode iniciar atendimento", 409); + } + + await tx.attendance.create({ + data: { + tableId: id, + status: "OPEN" + } + }); + } else { + //determinar o proximo status do atendimento + const status = change.status === "closing_requested" + ? "CLOSING_REQUESTED" + : change.status === "awaiting_payment" + ? "AWAITING_PAYMENT" + : "CLOSED"; + + //atualizar o atendimento + const updated = await tx.attendance.updateMany({ + where: { + id: attendance.id, + status: attendance.status + }, + data: { + status, + ...(status === "CLOSING_REQUESTED" + ? { closingRequestedAt: new Date()} + : {}), + ...(status === "CLOSED" + ? { closedAt: new Date()} + : {}) + } + }); + + if (updated.count !== 1) { + throw new AppError("O estado da mesa mudou. Atualize a mesa e tente novamente", 409); + } + + //cria o trabalho de impressao da conta + if (status == "CLOSING_REQUESTED") { + await tx.printJob.create({ + data: { + attendanceId: attendance.id, + type: "BILL" + } + }); + } + } + + //recupera o estado atualizado + const result = await tx.restaurantTable.findUniqueOrThrow({ + where: { id }, + include: attendanceInclude + }); + + return { + table: this.toEntity(result), + attendance: result.attendances[0] ?? null + }; + }); + } + + private toEntity(table: RestaurantTable): TableEntity { + return new TableEntity( + table.id, + table.number, + table.name, + table.active, + table.qrToken, + table.createdAt, + table.updatedAt + ) + } +} \ No newline at end of file diff --git a/backend/src/modules/tables/repositories/table-with-attendance.repository.ts b/backend/src/modules/tables/repositories/table-with-attendance.repository.ts new file mode 100644 index 0000000..24f08a0 --- /dev/null +++ b/backend/src/modules/tables/repositories/table-with-attendance.repository.ts @@ -0,0 +1,12 @@ +import type { AttendanceStatus } from "../../attendance/entity/attendance.entity.js"; +import type { TableEntity } from "../enity/table.entity.js"; + +export interface TableAttendanceData { + id: bigint; + status: AttendanceStatus; +} + +export interface TableWithAttendance { + table: TableEntity; + attendance: TableAttendanceData | null; +} diff --git a/backend/src/modules/tables/repositories/table.repository.ts b/backend/src/modules/tables/repositories/table.repository.ts new file mode 100644 index 0000000..c8c2d76 --- /dev/null +++ b/backend/src/modules/tables/repositories/table.repository.ts @@ -0,0 +1,44 @@ +import { TableEntity } from "../enity/table.entity.js"; +import type { TableWithAttendance } from "./table-with-attendance.repository.js"; +import type { TableStatusChangeDTO } from "../dto/table-status-change.dto.js"; + +export interface TableRepository { + existsById (id: bigint): Promise; + + create(data: CreateTableData): Promise; + + findById(id: bigint): Promise; + + findAll(): Promise; + + delete(id: bigint): Promise; + + findByToken(token: string): Promise; + + findByIdWithAttendance(id: bigint): Promise; + + findAllWithAttendance(): Promise; + + updateWithAttendanceCheck( + id: bigint, + data: UpdateTableData, + ): Promise; + + changeStatusTransaction( + id: bigint, + change: TableStatusChangeDTO + ): Promise; +} + +export interface CreateTableData { + number: number; + name: string; + active: boolean; + qrToken: string; +} + +export interface UpdateTableData { + number: number; + name: string; + active: boolean; +} \ No newline at end of file diff --git a/backend/src/modules/tables/repository/prima-table.repository.ts b/backend/src/modules/tables/repository/prima-table.repository.ts deleted file mode 100644 index c55c9d3..0000000 --- a/backend/src/modules/tables/repository/prima-table.repository.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { prisma } from "../../../database/prisma.js"; - -import type { TableRepository } from "./table.repository.js"; - -export class PrismaTableRepository implements TableRepository { - - async existsById(id: bigint): Promise { - const table = await prisma.restaurantTable.findUnique({ - where: { id }, - select: { id: true } - }); - - return table !== null; - } -} \ No newline at end of file diff --git a/backend/src/modules/tables/repository/table.repository.ts b/backend/src/modules/tables/repository/table.repository.ts deleted file mode 100644 index 61ac068..0000000 --- a/backend/src/modules/tables/repository/table.repository.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface TableRepository { - existsById (id: bigint): Promise; -} \ No newline at end of file diff --git a/backend/src/modules/tables/table.controller.ts b/backend/src/modules/tables/table.controller.ts index 39476ca..a34ca4e 100644 --- a/backend/src/modules/tables/table.controller.ts +++ b/backend/src/modules/tables/table.controller.ts @@ -1,31 +1,36 @@ import { TableService } from "./table.service.js"; -import { TableSchema, TableIdSchema, TableTokenSchema, TableStatusChangeSchema } from "./table.schema.js"; import { FastifyReply, FastifyRequest } from "fastify"; +import { createTableSchema } from "./dto/create-table.dto.js"; +import { tableIdSchema } from "./dto/table-id.dto.js"; +import { tableTokenSchema } from "./dto/table-token.dto.js"; +import { tableStatusChangeSchema } from "./dto/table-status-change.dto.js"; +import { updateTableSchema } from "./dto/update-table.dto.js"; + export class TableController { constructor(private readonly tableService: TableService) {} async changeStatus(request: FastifyRequest, reply: FastifyReply) { - const params = TableIdSchema.parse(request.params); - const change = TableStatusChangeSchema.parse(request.body); + const params = tableIdSchema.parse(request.params); + const change = tableStatusChangeSchema.parse(request.body); reply.header("Cache-Control", "no-store"); return this.tableService.changeStatus(params, change); } async getTableByToken(request: FastifyRequest, reply: FastifyReply) { - const { token } = TableTokenSchema.parse(request.params); + const { token } = tableTokenSchema.parse(request.params); reply.header("Cache-Control", "no-store"); return this.tableService.getTableByToken(token); } async getQrCode(request: FastifyRequest, reply: FastifyReply) { - const params = TableIdSchema.parse(request.params); + const params = tableIdSchema.parse(request.params); const image = await this.tableService.getQrCode(params); return reply.type("image/png").header("Content-Disposition", `inline; filename="mesa-${params.id}.png"`).send(image); } async createTable(request: FastifyRequest, reply: FastifyReply) { - const table = TableSchema.safeParse(request.body); + const table = createTableSchema.safeParse(request.body); if (!table.success) { return reply.status(400).send({ error: "Invalid table data" }); @@ -35,29 +40,21 @@ export class TableController { } async deleteTable(request: FastifyRequest, reply: FastifyReply) { - const tableId = TableIdSchema.safeParse(request.params); + const params = tableIdSchema.parse(request.params); - if (!tableId.success) { - return reply.status(400).send({ error: "Invalid table id" }); - } - - return await this.tableService.deleteTable(tableId.data); + return await this.tableService.deleteTable(params); } async editTable(request: FastifyRequest, reply: FastifyReply) { - const tableId = TableIdSchema.safeParse(request.params); - const table = TableSchema.safeParse(request.body); - - if (!tableId.success || !table.success) { - return reply.status(400).send({ error: "Invalid table data or id" }); - } + const params = tableIdSchema.parse(request.params); + const data = updateTableSchema.parse(request.body); - return await this.tableService.editTable(tableId.data, table.data); + return await this.tableService.editTable(params, data); } async getTableById(request: FastifyRequest, reply: FastifyReply) { reply.header("Cache-Control", "no-store"); - const tableId = TableIdSchema.safeParse(request.params); + const tableId = tableIdSchema.safeParse(request.params); if (!tableId.success) { return reply.status(400).send({ error: "Invalid table id" }); diff --git a/backend/src/modules/tables/table.factory.ts b/backend/src/modules/tables/table.factory.ts new file mode 100644 index 0000000..cd63db4 --- /dev/null +++ b/backend/src/modules/tables/table.factory.ts @@ -0,0 +1,14 @@ +import { env } from "../../config/env.js"; + +import { TableService } from "./table.service.js"; + +import { PrismaTableRepository } from "./repositories/prisma-table.repository.js"; + +export function makeTableService(): TableService { + const tableRepository = new PrismaTableRepository(); + + return new TableService ( + tableRepository, + env.FRONTEND_URL + ) +} \ No newline at end of file diff --git a/backend/src/modules/tables/table.routers.ts b/backend/src/modules/tables/table.routers.ts index d90d8b2..3a0de9e 100644 --- a/backend/src/modules/tables/table.routers.ts +++ b/backend/src/modules/tables/table.routers.ts @@ -1,23 +1,33 @@ import { TableController } from "./table.controller.js"; -import { TableService } from "./table.service.js"; -import { prisma } from "../../database/prisma.js"; import { FastifyInstance } from "fastify"; +import { makeTableService } from "./table.factory.js"; +const tableService = makeTableService(); -import { env } from "../../config/env.js"; - -const tableService = new TableService(prisma, env.FRONTEND_URL); const tableController = new TableController(tableService); export function tableRoutes(app: FastifyInstance) { - app.patch("/tables/:id/status", (request, reply) => tableController.changeStatus(request, reply)); - app.get("/tables/by-token/:token", (request, reply) => tableController.getTableByToken(request, reply)); - app.get("/tables/:id/qrcode", (request, reply) => tableController.getQrCode(request, reply)); + app.get("/tables/by-token/:token", (request, reply) => + tableController.getTableByToken(request, reply) + ); + app.get("/tables/:id/qrcode", (request, reply) => + tableController.getQrCode(request, reply) + ); + app.get("/tables/:id", (request, reply) => + tableController.getTableById(request, reply), + ); + app.get("/tables", (request, reply) => + tableController.getAllTables(request, reply), + ); app.post("/tables", (request, reply) => tableController.createTable(request, reply), ); + app.patch("/tables/:id/status", (request, reply) => + tableController.changeStatus(request, reply) + ) + ; app.delete("/tables/:id", (request, reply) => tableController.deleteTable(request, reply), ); @@ -26,11 +36,4 @@ export function tableRoutes(app: FastifyInstance) { tableController.editTable(request, reply), ); - app.get("/tables/:id", (request, reply) => - tableController.getTableById(request, reply), - ); - - app.get("/tables", (request, reply) => - tableController.getAllTables(request, reply), - ); } diff --git a/backend/src/modules/tables/table.schema.ts b/backend/src/modules/tables/table.schema.ts deleted file mode 100644 index 5b2b27a..0000000 --- a/backend/src/modules/tables/table.schema.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { z } from "zod"; - -export const TableSchema = z.object({ - number: z.number().int().positive().max(4294967295), - name: z.string().trim().min(1).max(100), - active: z.boolean().default(true), -}); - -export const TableIdSchema = z.object({ - id: z.string().regex(/^[1-9]\d*$/).transform((value) => BigInt(value)) - .refine((value) => value <= 18446744073709551615n, "Invalid table id"), -}); -export const TableTokenSchema = z.object({ token: z.string().min(1).max(120) }); -export const TableStatusSchema = z.enum(["available", "occupied", "closing_requested", "awaiting_payment"]); -export const TableStatusChangeSchema = z.object({ - status: TableStatusSchema, - attendanceId: TableIdSchema.shape.id.nullable(), -}); - -export type TableDTO = z.infer; -export type TableIdDTO = z.infer; -export type TableStatus = z.infer; -export type TableStatusChangeDTO = z.infer; diff --git a/backend/src/modules/tables/table.service.ts b/backend/src/modules/tables/table.service.ts index 1aeb768..073757b 100644 --- a/backend/src/modules/tables/table.service.ts +++ b/backend/src/modules/tables/table.service.ts @@ -1,144 +1,158 @@ import { randomUUID } from "node:crypto"; import QRCode from "qrcode"; -import type { prisma } from "../../database/prisma.js"; -import type { TableDTO, TableIdDTO, TableStatus, TableStatusChangeDTO } from "./table.schema.js"; -import type { Prisma } from "../../generated/prisma/client.js"; + import { AppError } from "../../shared/errors/app-errors.js"; -const attendanceInclude = { - attendances: { - where: { status: { in: ["OPEN", "CLOSING_REQUESTED", "AWAITING_PAYMENT"] } }, - select: { id: true, status: true }, - take: 1, - }, -} satisfies Prisma.RestaurantTableInclude; - -const nextStatus: Record = { - available: "occupied", - occupied: "closing_requested", - closing_requested: "awaiting_payment", - awaiting_payment: "available", -}; +import type { CreateTableDTO } from "./dto/create-table.dto.js"; +import type { TableStatusDTO } from "./dto/table-status.dto.js"; +import type { TableStatusChangeDTO } from "./dto/table-status-change.dto.js"; +import type { TableIdDTO } from "./dto/table-id.dto.js"; +import type { UpdateTableDTO } from "./dto/update-table.dto.js"; + +import type { TableRepository } from "./repositories/table.repository.js"; +import type { TableWithAttendance } from "./repositories/table-with-attendance.repository.js"; + +import { resolveTableStatus } from "./utils/resolve-table-status.js"; export class TableService { - constructor(private readonly tableRepository: typeof prisma, private readonly frontendUrl: string) {} + constructor( + private readonly tableRepository: TableRepository, + private readonly frontendUrl: string + ) {} - private present(table: { id: bigint; number: number; name: string | null; active: boolean; qrToken: string; attendances?: { id: bigint; status: string }[] }) { + private present ({table, attendance}: TableWithAttendance) { const url = new URL("/cardapio", this.frontendUrl); + url.searchParams.set("mesa", table.qrToken); - const attendance = table.attendances?.[0]; - const status: TableStatus = !attendance ? "available" - : attendance.status === "AWAITING_PAYMENT" ? "awaiting_payment" - : attendance.status === "CLOSING_REQUESTED" ? "closing_requested" : "occupied"; - return { id: table.id.toString(), number: table.number, name: table.name, active: table.active, menuUrl: url.toString(), status, attendanceId: attendance?.id.toString() ?? null }; - } - async createTable(table: TableDTO) { - try { - const created = await this.tableRepository.restaurantTable.create({ data: { ...table, qrToken: randomUUID() } }); - return this.present(created); - } catch (error) { return this.handleWriteError(error); } - } + const status = resolveTableStatus(attendance); - async deleteTable({ id }: TableIdDTO) { - try { - return this.present(await this.tableRepository.restaurantTable.delete({ where: { id } })); - } catch (error) { return this.handleWriteError(error); } + return { + id: table.id.toString(), + number: table.number, + name: table.name, + active: table.active, + menuUrl: url.toString(), + status, + attendanceId: attendance?.id.toString() ?? null + } } - async editTable({ id }: TableIdDTO, table: TableDTO) { + async createTable(data: CreateTableDTO) { try { - return await this.tableRepository.$transaction(async (tx) => { - const current = await this.lockTable(tx, id); - if (!table.active && current.attendances.length) { - throw new AppError("Feche o atendimento antes de desativar a mesa", 409); - } - return this.present(await tx.restaurantTable.update({ where: { id }, data: table, include: attendanceInclude })); + const table = await this.tableRepository.create({ + number: data.number, + name: data.name, + active: data.active, + qrToken: randomUUID() }); - } catch (error) { return this.handleWriteError(error); } + + return this.present({table, attendance: null}); + + } catch (error) { + return this.handleWriteError(error); + } } async getTableById({ id }: TableIdDTO) { - const table = await this.tableRepository.restaurantTable.findUnique({ where: { id }, include: attendanceInclude }); - if (!table) throw new AppError("Mesa não encontrada", 404); + const table = await this.tableRepository.findByIdWithAttendance(id); + + if (!table) { + throw new AppError("Mesa não encontrada", 404); + }; + return this.present(table); } async getAllTables() { - const tables = await this.tableRepository.restaurantTable.findMany({ - orderBy: { number: "asc" }, - include: attendanceInclude, - }); + const tables = await this.tableRepository.findAllWithAttendance(); + return tables.map((table) => this.present(table)); } - private async lockTable(tx: Prisma.TransactionClient, id: bigint) { - // Serializa as ações de tables sobre a mesma mesa, inclusive abertura/desativação. - await tx.$queryRaw`SELECT id FROM restaurant_tables WHERE id = ${id} FOR UPDATE`; - const table = await tx.restaurantTable.findUnique({ where: { id }, include: attendanceInclude }); - if (!table) throw new AppError("Mesa não encontrada", 404); - return table; + async deleteTable({ id }: TableIdDTO) { + try { + const table = await this.tableRepository.delete(id); + + return this.present({table, attendance: null}); + + } catch (error) { + return this.handleWriteError(error); + } + } + + async editTable({ id }: TableIdDTO, data: UpdateTableDTO) { + try { + const updated = await this.tableRepository.updateWithAttendanceCheck( + id, data + ); + + return this.present(updated); + + } catch (error) { + return this.handleWriteError(error); + } } async changeStatus({ id }: TableIdDTO, change: TableStatusChangeDTO) { try { - return await this.tableRepository.$transaction(async (tx) => { - const table = await this.lockTable(tx, id); - const attendance = table.attendances[0]; - const current = this.present(table); - if ((attendance?.id ?? null) !== change.attendanceId) { - throw new AppError("O atendimento da mesa mudou. Atualize a mesa e tente novamente", 409); - } - if (nextStatus[current.status] !== change.status) { - throw new AppError("Transição de estado inválida para esta mesa", 409); - } - - if (!attendance) { - if (!table.active) throw new AppError("Mesa inativa não pode iniciar atendimento", 409); - await tx.attendance.create({ data: { tableId: id, status: "OPEN" } }); - } else { - const status = change.status === "closing_requested" ? "CLOSING_REQUESTED" - : change.status === "awaiting_payment" ? "AWAITING_PAYMENT" : "CLOSED"; - const updated = await tx.attendance.updateMany({ - where: { id: attendance.id, status: attendance.status }, - data: { - status, - ...(status === "CLOSING_REQUESTED" ? { closingRequestedAt: new Date() } : {}), - ...(status === "CLOSED" ? { closedAt: new Date() } : {}), - }, - }); - if (updated.count !== 1) throw new AppError("O estado da mesa mudou. Atualize a mesa e tente novamente", 409); - if (status === "CLOSING_REQUESTED") { - await tx.printJob.create({ data: { attendanceId: attendance.id, type: "BILL" } }); - } - } - const updated = await tx.restaurantTable.findUniqueOrThrow({ where: { id }, include: attendanceInclude }); - return this.present(updated); - }); + const result = await this.tableRepository.changeStatusTransaction(id, change); + + return this.present(result); + } catch (error) { - if ((error as { code?: string })?.code === "P2002") { + if ((error as { code?: string })?.code === "P2002"){ throw new AppError("Mesa já possui atendimento em andamento", 409); } + throw error; + } } async getTableByToken(token: string) { - const table = await this.tableRepository.restaurantTable.findUnique({ where: { qrToken: token } }); - if (!table || !table.active) throw new AppError("QR Code inválido ou mesa indisponível", 404); - return { id: table.id.toString(), number: table.number, name: table.name }; + const table = await this.tableRepository.findByToken(token); + + if (!table || !table.active) { + throw new AppError("QR Code inválido ou mesa indisponível", 404); + } + + return { + id: table.id.toString(), + number: table.number, + name: table.name + }; } async getQrCode(id: TableIdDTO) { const table = await this.getTableById(id); - return QRCode.toBuffer(table.menuUrl, { type: "png", width: 512, margin: 4, errorCorrectionLevel: "M" }); + + return QRCode.toBuffer( + table.menuUrl, + { + type: "png", + width: 512, + margin: 4, + errorCorrectionLevel: "M" + } + ); } private handleWriteError(error: unknown): never { const code = (error as { code?: string })?.code; - if (code === "P2002") throw new AppError("Número de mesa já cadastrado", 409); - if (code === "P2025") throw new AppError("Mesa não encontrada", 404); - if (code === "P2003") throw new AppError("Mesa com histórico de atendimento não pode ser excluída; desative-a", 409); + + if (code === "P2002") { + throw new AppError("Número de mesa já cadastrado", 409); + }; + + if (code === "P2025") { + throw new AppError("Mesa não encontrada", 404); + }; + + if (code === "P2003") { + throw new AppError("Mesa com histórico de atendimento não pode ser excluída; desative-a", 409); + }; + throw error; } } diff --git a/backend/src/modules/tables/utils/resolve-table-status.ts b/backend/src/modules/tables/utils/resolve-table-status.ts new file mode 100644 index 0000000..10376e0 --- /dev/null +++ b/backend/src/modules/tables/utils/resolve-table-status.ts @@ -0,0 +1,27 @@ +import type { AttendanceStatus } from "../../attendance/entity/attendance.entity.js"; +import type { TableStatusDTO } from "../dto/table-status.dto.js"; + +export function resolveTableStatus( + attendance: { status: AttendanceStatus } | null +): TableStatusDTO { + + if (!attendance) { + return "available"; + } + + switch (attendance.status) { + case "OPEN": + return "occupied"; + + case "CLOSING_REQUESTED": + return "closing_requested"; + + case "AWAITING_PAYMENT": + return "awaiting_payment"; + + case "CLOSED": + + case "CANCELLED": + return "available"; + } +} \ No newline at end of file From bcde7150f5215d85a93bc23a5a6d47da267377f7 Mon Sep 17 00:00:00 2001 From: pfonseca-dev Date: Mon, 19 Oct 2026 10:01:03 -0300 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20Altera=C3=A7=C3=A3o=20na=20depend?= =?UTF-8?q?=C3=AAncia=20do=20prisma=20nos=20modulos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/attendance/attendance.factory.ts | 2 +- .../modules/attendance/attendance.service.ts | 2 +- .../entity/attendance-total.entity.ts | 8 +++++ .../attendance/entity/attendance.entity.ts | 23 ++++++++++++-- .../repositories/attendance.repository.ts | 7 ++--- .../prisma-attendance.repository.ts | 31 +++++++++---------- .../types/ttendance-list-item.type.ts | 7 +++-- .../repositories/prisma-order.repository.ts | 1 - package-lock.json | 3 ++ package.json | 1 + 10 files changed, 55 insertions(+), 30 deletions(-) create mode 100644 backend/src/modules/attendance/entity/attendance-total.entity.ts diff --git a/backend/src/modules/attendance/attendance.factory.ts b/backend/src/modules/attendance/attendance.factory.ts index b6ecc98..0a58869 100644 --- a/backend/src/modules/attendance/attendance.factory.ts +++ b/backend/src/modules/attendance/attendance.factory.ts @@ -1,6 +1,6 @@ import { AttendanceService } from "./attendance.service.js"; import { PrismaAttendanceRepository } from "./repositories/prisma-attendance.repository.js"; -import { PrismaTableRepository } from "./repositories/prisma-attendance.repository.js"; +import { PrismaTableRepository } from "../tables/repositories/prisma-table.repository.js"; export function makeAttendanceService(): AttendanceService{ const attendanceRepository = new PrismaAttendanceRepository; diff --git a/backend/src/modules/attendance/attendance.service.ts b/backend/src/modules/attendance/attendance.service.ts index 019d0ec..b8f673b 100644 --- a/backend/src/modules/attendance/attendance.service.ts +++ b/backend/src/modules/attendance/attendance.service.ts @@ -1,7 +1,7 @@ import { AppError } from "../../shared/errors/app-errors.js"; import type { AttendanceRepository } from "./repositories/attendance.repository.js"; -import type { TableRepository } from "../tables/repository/table.repository.js"; +import type { TableRepository } from "../tables/repositories/table.repository.js"; import type { CreateAttendanceDTO } from "./dto/create-attendance.dto.js"; diff --git a/backend/src/modules/attendance/entity/attendance-total.entity.ts b/backend/src/modules/attendance/entity/attendance-total.entity.ts new file mode 100644 index 0000000..7aa2ac5 --- /dev/null +++ b/backend/src/modules/attendance/entity/attendance-total.entity.ts @@ -0,0 +1,8 @@ +import type { AttendanceStatus } from "./attendance.entity.js"; + +export interface AttendanceTotal { + attendanceId: bigint; + tableId: bigint; + status: AttendanceStatus; + total: string; +} diff --git a/backend/src/modules/attendance/entity/attendance.entity.ts b/backend/src/modules/attendance/entity/attendance.entity.ts index 9c02c23..33990df 100644 --- a/backend/src/modules/attendance/entity/attendance.entity.ts +++ b/backend/src/modules/attendance/entity/attendance.entity.ts @@ -1,6 +1,23 @@ -import type { AttendanceStatus } from "../../../generated/prisma/enums.js"; +export type AttendanceStatus = + | "OPEN" + | "CLOSING_REQUESTED" + | "CLOSED" + | "CANCELLED" + | "AWAITING_PAYMENT"; -export class AttendanceEntity{ +export interface AttendanceData { + id: bigint; + tableId: bigint; + status: AttendanceStatus; + openedAt: Date; + closingRequestedAt: Date | null; + closedAt: Date | null; + closedById: bigint | null; + createdAt: Date; + updatedAt: Date; +} + +export class AttendanceEntity implements AttendanceData { constructor( public readonly id: bigint, public readonly tableId: bigint, @@ -28,4 +45,4 @@ export class AttendanceEntity{ canReceiveOrders(): boolean{ return this.isOpen() } -} \ No newline at end of file +} diff --git a/backend/src/modules/attendance/repositories/attendance.repository.ts b/backend/src/modules/attendance/repositories/attendance.repository.ts index 107eaba..67972fa 100644 --- a/backend/src/modules/attendance/repositories/attendance.repository.ts +++ b/backend/src/modules/attendance/repositories/attendance.repository.ts @@ -1,6 +1,5 @@ -import type { AttendanceEntity } from "../entity/attendance.entity.js"; -import type { AttendanceTotal } from "../../../generated/prisma/client.js"; -import type { AttendanceStatus } from "../../../generated/prisma/client.js"; +import type { AttendanceEntity, AttendanceStatus } from "../entity/attendance.entity.js"; +import type { AttendanceTotal } from "../entity/attendance-total.entity.js"; import type { AttendanceListItem } from "../types/ttendance-list-item.type.js"; export interface AttendanceRepository{ @@ -17,4 +16,4 @@ export interface AttendanceRepository{ requestCancelled(attendanceId: bigint): Promise; findAllByStatus(status: AttendanceStatus): Promise; -} \ No newline at end of file +} diff --git a/backend/src/modules/attendance/repositories/prisma-attendance.repository.ts b/backend/src/modules/attendance/repositories/prisma-attendance.repository.ts index 304a5f3..4753b7e 100644 --- a/backend/src/modules/attendance/repositories/prisma-attendance.repository.ts +++ b/backend/src/modules/attendance/repositories/prisma-attendance.repository.ts @@ -1,12 +1,11 @@ import { prisma } from "../../../database/prisma.js"; -import { Attendance, AttendanceTotal } from "../../../generated/prisma/client.js"; +import type { Attendance } from "../../../generated/prisma/client.js"; import { AttendanceEntity } from "../entity/attendance.entity.js"; -import { AttendanceRepository } from "./attendance.repository.js"; +import type { AttendanceRepository } from "./attendance.repository.js"; -import { TableRepository } from "../../tables/repository/table.repository.js"; - -import type { AttendanceStatus } from "../../../generated/prisma/client.js"; +import type { AttendanceStatus } from "../entity/attendance.entity.js"; +import type { AttendanceTotal } from "../entity/attendance-total.entity.js"; import type { AttendanceListItem } from "../types/ttendance-list-item.type.js"; export class PrismaAttendanceRepository implements AttendanceRepository{ @@ -52,7 +51,16 @@ export class PrismaAttendanceRepository implements AttendanceRepository{ where: { attendanceId } }); - return total; + if (!total) { + return null; + } + + return { + attendanceId: total.attendanceId, + tableId: total.tableId, + status: total.status, + total: total.total.toString(), + }; } async requestClosing(attendanceId: bigint, requestedAt: Date): Promise { @@ -131,14 +139,3 @@ export class PrismaAttendanceRepository implements AttendanceRepository{ ); } } - -export class PrismaTableRepository implements TableRepository{ - async existsById(id: bigint): Promise { - const table = await prisma.restaurantTable.findUnique({ - where: { id }, - select: { id: true } - }); - - return table !== null; - } -} \ No newline at end of file diff --git a/backend/src/modules/attendance/types/ttendance-list-item.type.ts b/backend/src/modules/attendance/types/ttendance-list-item.type.ts index b5490e5..e82d88d 100644 --- a/backend/src/modules/attendance/types/ttendance-list-item.type.ts +++ b/backend/src/modules/attendance/types/ttendance-list-item.type.ts @@ -1,9 +1,10 @@ -import type { Attendance } from "../../../generated/prisma/client.js"; +import type { AttendanceData } from "../entity/attendance.entity.js"; -export type AttendanceListItem = Attendance & { +export type AttendanceListItem = AttendanceData & { + activeTableId: bigint | null; table: { id: bigint; number: number; name: string | null; } -} \ No newline at end of file +} diff --git a/backend/src/modules/orders/repositories/prisma-order.repository.ts b/backend/src/modules/orders/repositories/prisma-order.repository.ts index f83c76c..dd9680e 100644 --- a/backend/src/modules/orders/repositories/prisma-order.repository.ts +++ b/backend/src/modules/orders/repositories/prisma-order.repository.ts @@ -1,5 +1,4 @@ import { prisma } from "../../../database/prisma.js"; -import { Prisma } from "../../../generated/prisma/client.js"; import { OrderEntity } from "../entity/orders.entity.js"; diff --git a/package-lock.json b/package-lock.json index e9235cc..c3a5bd8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@prisma/adapter-mariadb": "^7.10.0", "@prisma/client": "^7.10.0", "fastify": "^5.12.3", + "qrcode": "^1.5.4", "zod": "^4.6.5" } }, @@ -2768,6 +2769,7 @@ "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } @@ -5857,6 +5859,7 @@ "version": "1.5.4", "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", diff --git a/package.json b/package.json index fdf969f..6c4391d 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@prisma/adapter-mariadb": "^7.10.0", "@prisma/client": "^7.10.0", "fastify": "^5.12.3", + "qrcode": "^1.5.4", "zod": "^4.6.5" } } From b2f0d63e96904a1d8b86c499db0c2fffb32dfc46 Mon Sep 17 00:00:00 2001 From: pfonseca-dev Date: Mon, 19 Oct 2026 10:54:52 -0300 Subject: [PATCH 3/8] refactore(categories): Padronizar modulo de Categorias --- .../modules/categories/category.controller.ts | 135 ++++++------- .../modules/categories/category.factory.ts | 10 + .../src/modules/categories/category.routes.ts | 57 +++--- .../modules/categories/category.service.ts | 68 +++---- .../modules/categories/dto/category-id.dto.ts | 9 + .../modules/categories/dto/category.dto.ts | 17 -- .../categories/dto/create-category.dto.ts | 11 ++ .../categories/dto/update-category.dto.ts | 11 ++ .../categories/entity/category.entity.ts | 37 ++-- .../repository/category.repository.ts | 34 ++-- .../repository/prisma-category.repository.ts | 182 +++++++++++------- 11 files changed, 313 insertions(+), 258 deletions(-) create mode 100644 backend/src/modules/categories/category.factory.ts create mode 100644 backend/src/modules/categories/dto/category-id.dto.ts delete mode 100644 backend/src/modules/categories/dto/category.dto.ts create mode 100644 backend/src/modules/categories/dto/create-category.dto.ts create mode 100644 backend/src/modules/categories/dto/update-category.dto.ts diff --git a/backend/src/modules/categories/category.controller.ts b/backend/src/modules/categories/category.controller.ts index decc013..944f150 100644 --- a/backend/src/modules/categories/category.controller.ts +++ b/backend/src/modules/categories/category.controller.ts @@ -1,92 +1,81 @@ +import type { FastifyRequest, FastifyReply } from "fastify"; + import CategoryService from "./category.service.js"; -import { CategoryIdSchema, CategorySchema } from "./dto/category.dto.js"; -import { CategoryOutput, CategoryResponse } from "./entity/category.entity.js"; -import { FastifyRequest, FastifyReply } from "fastify"; + +import { createCategorySchema } from "./dto/create-category.dto.js"; +import { updateCategorySchema } from "./dto/update-category.dto.js"; +import { categoryIdSchema } from "./dto/category-id.dto.js"; + +import type { CategoryEntity } from "./entity/category.entity.js"; export default class CategoryController { - constructor(private readonly categoryService: CategoryService) {} - async createCategory(request: FastifyRequest, reply: FastifyReply) { - const parsedBody = CategorySchema.safeParse(request.body); + constructor( + private readonly categoryService: CategoryService + ) {} + + async createCategory(request: FastifyRequest, reply: FastifyReply) { + + const data = createCategorySchema.parse(request.body); - if (!parsedBody.success) { - return this.sendValidationError(reply, parsedBody.error.issues); + const category = await this.categoryService.createCategory(data); + + return reply.status(201).send(this.toResponse(category)); } - const category = await this.categoryService.createCategory(parsedBody.data); - return reply.status(201).send(this.toResponse(category)); - } + async getAllCategories(_request: FastifyRequest, reply: FastifyReply) { + + const categories = await this.categoryService.getAllCategories(); + + return reply.status(200).send( + categories.map((category) => this.toResponse(category)) + ); + } - async deleteCategory(request: FastifyRequest, reply: FastifyReply) { - const id = this.parseId(request, reply); - if (id === null) return; + async getCategoryById(request: FastifyRequest, reply: FastifyReply) { - await this.categoryService.deleteCategory(id); - return reply.status(204).send(); - } + const { id } = categoryIdSchema.parse(request.params); - async getAllCategories(_request: FastifyRequest, reply: FastifyReply) { - const categories = await this.categoryService.getAllCategories(); - return reply - .status(200) - .send(categories.map((category) => this.toResponse(category))); - } + const category = await this.categoryService.getCategoryById(id); - async getCategoryById(request: FastifyRequest, reply: FastifyReply) { - const id = this.parseId(request, reply); - if (id === null) return; + if (!category) { + return reply.status(404).send({ + message: "Category not found" + }); + } - const category = await this.categoryService.getCategoryById(id); - if (!category) { - return reply.status(404).send({ message: "Category not found" }); + return reply.status(200).send(this.toResponse(category)); } - return reply.status(200).send(this.toResponse(category)); - } - async updateCategory(request: FastifyRequest, reply: FastifyReply) { - const id = this.parseId(request, reply); - if (id === null) return; + async updateCategory(request: FastifyRequest, reply: FastifyReply) { - const parsedBody = CategorySchema.safeParse(request.body); - if (!parsedBody.success) { - return this.sendValidationError(reply, parsedBody.error.issues); + const { id } = categoryIdSchema.parse(request.params); + + const data = updateCategorySchema.parse(request.body); + + const category = await this.categoryService.updateCategory(id, data); + + return reply.status(200).send(this.toResponse(category)); } - const category = await this.categoryService.updateCategory( - id, - parsedBody.data, - ); - return reply.status(200).send(this.toResponse(category)); - } - - private parseId(request: FastifyRequest, reply: FastifyReply): bigint | null { - const { id } = request.params as { id: string }; - const parsedId = CategoryIdSchema.safeParse(id); - - if (!parsedId.success) { - this.sendValidationError( - reply, - parsedId.error.issues, - "Invalid category id", - ); - return null; + async deleteCategory(request: FastifyRequest, reply: FastifyReply) { + + const { id } = categoryIdSchema.parse(request.params); + + await this.categoryService.deleteCategory(id); + + return reply.status(204).send(); } - return parsedId.data; - } - - private sendValidationError( - reply: FastifyReply, - issues: unknown, - message = "Invalid request body", - ) { - return reply.status(400).send({ message, issues }); - } - - private toResponse(category: CategoryOutput): CategoryResponse { - return { - ...category, - id: category.id.toString(), - }; - } -} + private toResponse(category: CategoryEntity) { + + return { + id: category.id.toString(), + name: category.name, + description: category.description, + active: category.active, + createdAt: category.createdAt, + updatedAt: category.updatedAt + }; + } +} \ No newline at end of file diff --git a/backend/src/modules/categories/category.factory.ts b/backend/src/modules/categories/category.factory.ts new file mode 100644 index 0000000..3536568 --- /dev/null +++ b/backend/src/modules/categories/category.factory.ts @@ -0,0 +1,10 @@ +import CategoryService from "./category.service.js"; + +import { PrismaCategoryRepository } from "./repository/prisma-category.repository.js"; + +export function makeCategoryService(): CategoryService { + + const categoryRepository = new PrismaCategoryRepository(); + + return new CategoryService(categoryRepository); +} \ No newline at end of file diff --git a/backend/src/modules/categories/category.routes.ts b/backend/src/modules/categories/category.routes.ts index af564b0..7b55245 100644 --- a/backend/src/modules/categories/category.routes.ts +++ b/backend/src/modules/categories/category.routes.ts @@ -1,29 +1,30 @@ -import { FastifyInstance } from "fastify"; +import type { FastifyInstance } from "fastify"; + import CategoryController from "./category.controller.js"; -import CategoryService from "./category.service.js"; -import PrismaCategoryRepository from "./repository/prisma-category.repository.js"; - -const categoryRepository = new PrismaCategoryRepository(); -const categoryService = new CategoryService(categoryRepository); -const categoryController = new CategoryController(categoryService); - -export function registerCategoryRoutes(app: FastifyInstance) { - app.get("/categories", (request, reply) => - categoryController.getAllCategories(request, reply), - ); - app.get("/categories/:id", (request, reply) => - categoryController.getCategoryById(request, reply), - ); - app.post( - "/categories", - (request, reply) => categoryController.createCategory(request, reply), - ); - app.put( - "/categories/:id", - (request, reply) => categoryController.updateCategory(request, reply), - ); - app.delete( - "/categories/:id", - (request, reply) => categoryController.deleteCategory(request, reply), - ); -} +import { makeCategoryService } from "./category.factory.js"; + +export async function registerCategoryRoutes(app: FastifyInstance) { + + const categoryService = makeCategoryService(); + const categoryController = new CategoryController(categoryService); + + app.post("/", (request, reply) => + categoryController.createCategory(request, reply) + ); + + app.get("/", (request, reply) => + categoryController.getAllCategories(request, reply) + ); + + app.get("/:id", (request, reply) => + categoryController.getCategoryById(request, reply) + ); + + app.put("/:id", (request, reply) => + categoryController.updateCategory(request, reply) + ); + + app.delete("/:id", (request, reply) => + categoryController.deleteCategory(request, reply) + ); +} \ No newline at end of file diff --git a/backend/src/modules/categories/category.service.ts b/backend/src/modules/categories/category.service.ts index 0a0c020..c6f3d82 100644 --- a/backend/src/modules/categories/category.service.ts +++ b/backend/src/modules/categories/category.service.ts @@ -1,44 +1,38 @@ -import { CategoryDtoType } from "./dto/category.dto.js"; -export type { CategoryDtoType } from "./dto/category.dto.js"; -import CategoryRepository from "./repository/category.repository.js"; -import { CategoryInput, CategoryOutput } from "./entity/category.entity.js"; +import type { CreateCategoryDTO } from "./dto/create-category.dto.js"; +import type { UpdateCategoryDTO } from "./dto/update-category.dto.js"; + +import type { CategoryEntity } from "./entity/category.entity.js"; +import type { CategoryRepository } from "./repository/category.repository.js"; export default class CategoryService { - constructor(private readonly categoryRepository: CategoryRepository) {} + constructor( + private readonly categoryRepository: CategoryRepository + ) {} - async createCategory(categoryDto: CategoryDtoType): Promise { - const categoryInput: CategoryInput = { - name: categoryDto.name, - description: categoryDto.description, - active: categoryDto.active, - createdAt: new Date(), - updatedAt: new Date(), - }; - return this.categoryRepository.createCategory(categoryInput); - } + async createCategory( + categoryDto: CreateCategoryDTO + ): Promise { + return this.categoryRepository.create(categoryDto); + } - async getAllCategories(): Promise { - return this.categoryRepository.getAllCategories(); - } + async getAllCategories(): Promise { + return this.categoryRepository.findAll(); + } - async getCategoryById(id: bigint): Promise { - return this.categoryRepository.getCategoryById(id); - } + async getCategoryById( + id: bigint + ): Promise { + return this.categoryRepository.findById(id); + } - async updateCategory( - id: bigint, - categoryDto: CategoryDtoType, - ): Promise { - const categoryInput: CategoryInput = { - name: categoryDto.name, - description: categoryDto.description, - active: categoryDto.active, - updatedAt: new Date(), - }; - return this.categoryRepository.updateCategory(id, categoryInput); - } + async updateCategory( + id: bigint, + categoryDto: UpdateCategoryDTO + ): Promise { + return this.categoryRepository.update(id, categoryDto); + } - async deleteCategory(id: bigint): Promise { - return this.categoryRepository.deleteCategory(id); - } -} + async deleteCategory(id: bigint): Promise { + await this.categoryRepository.delete(id); + } +} \ No newline at end of file diff --git a/backend/src/modules/categories/dto/category-id.dto.ts b/backend/src/modules/categories/dto/category-id.dto.ts new file mode 100644 index 0000000..1de5360 --- /dev/null +++ b/backend/src/modules/categories/dto/category-id.dto.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const categoryIdSchema = z.object({ + id: z.string() + .regex(/^\d+$/, "Category id must be a positive integer") + .transform(BigInt) +}); + +export type CategoryIdDTO = z.infer; \ No newline at end of file diff --git a/backend/src/modules/categories/dto/category.dto.ts b/backend/src/modules/categories/dto/category.dto.ts deleted file mode 100644 index 7b91a52..0000000 --- a/backend/src/modules/categories/dto/category.dto.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { z } from "zod"; - -export const CategorySchema = z - .object({ - name: z.string().trim().min(1).max(100), - description: z.string().trim().max(255).nullable().optional(), - active: z.boolean().optional().default(true), - }) - .strict(); - -export const CategoryIdSchema = z - .string() - .regex(/^\d+$/, "Category id must be a positive integer") - .transform(BigInt) - .refine((id) => id > 0n, "Category id must be a positive integer"); - -export type CategoryDtoType = z.infer; diff --git a/backend/src/modules/categories/dto/create-category.dto.ts b/backend/src/modules/categories/dto/create-category.dto.ts new file mode 100644 index 0000000..2cfe9fc --- /dev/null +++ b/backend/src/modules/categories/dto/create-category.dto.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const createCategorySchema = z.object({ + name: z.string().trim().min(1).max(100), + + description: z.string().trim().max(255).nullable().optional(), + + active: z.boolean().default(true), +}).strict(); + +export type CreateCategoryDTO = z.infer ; \ No newline at end of file diff --git a/backend/src/modules/categories/dto/update-category.dto.ts b/backend/src/modules/categories/dto/update-category.dto.ts new file mode 100644 index 0000000..15736a2 --- /dev/null +++ b/backend/src/modules/categories/dto/update-category.dto.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const updateCategorySchema = z.object({ + name: z.string().trim().min(1).max(100), + + description: z.string().trim().max(255).nullable().optional(), + + active: z.boolean(), +}).strict(); + +export type UpdateCategoryDTO = z.infer; \ No newline at end of file diff --git a/backend/src/modules/categories/entity/category.entity.ts b/backend/src/modules/categories/entity/category.entity.ts index f3d09cd..58eafb9 100644 --- a/backend/src/modules/categories/entity/category.entity.ts +++ b/backend/src/modules/categories/entity/category.entity.ts @@ -1,25 +1,14 @@ -export type CategoryInput = { - name: string; - description?: string | null; - active: boolean; - createdAt?: Date; - updatedAt: Date; -}; +export class CategoryEntity { + constructor( + public readonly id: bigint, + public name: string, + public description: string | null, + public active: boolean, + public readonly createdAt: Date, + public updatedAt: Date + ) {} -export type CategoryOutput = { - id: bigint; - name: string; - description?: string | null; - active: boolean; - createdAt: Date; - updatedAt: Date; -}; - -export type CategoryResponse = { - id: string; - name: string; - description?: string | null; - active: boolean; - createdAt: Date; - updatedAt: Date; -}; + isActive(): boolean{ + return this.active + } +} diff --git a/backend/src/modules/categories/repository/category.repository.ts b/backend/src/modules/categories/repository/category.repository.ts index 18c04f0..264dedb 100644 --- a/backend/src/modules/categories/repository/category.repository.ts +++ b/backend/src/modules/categories/repository/category.repository.ts @@ -1,16 +1,28 @@ -import { CategoryInput, CategoryOutput } from "../entity/category.entity.js"; +import type { CategoryEntity } from "../entity/category.entity.js"; -export default abstract class CategoryRepository { - abstract createCategory(category: CategoryInput): Promise; +export type CreateCategoryData = { + name: string; + description?: string | null; + active: boolean; +}; - abstract getAllCategories(): Promise; +export type UpdateCategoryData = { + name: string; + description?: string | null; + active: boolean; +}; - abstract getCategoryById(id: bigint): Promise; +export interface CategoryRepository { + create(data: CreateCategoryData): Promise; - abstract updateCategory( - id: bigint, - category: CategoryInput, - ): Promise; + findAll(): Promise; - abstract deleteCategory(id: bigint): Promise; -} + findById(id: bigint): Promise; + + update( + id: bigint, + data: UpdateCategoryData + ): Promise; + + delete(id: bigint): Promise; +} \ No newline at end of file diff --git a/backend/src/modules/categories/repository/prisma-category.repository.ts b/backend/src/modules/categories/repository/prisma-category.repository.ts index 26bd15e..ad00a40 100644 --- a/backend/src/modules/categories/repository/prisma-category.repository.ts +++ b/backend/src/modules/categories/repository/prisma-category.repository.ts @@ -1,79 +1,125 @@ -import { CategoryInput, CategoryOutput } from "../entity/category.entity.js"; import { prisma } from "../../../database/prisma.js"; import { Prisma } from "../../../generated/prisma/client.js"; import { AppError } from "../../../shared/errors/app-errors.js"; -import CategoryRepository from "./category.repository.js"; - -export default class PrismaCategoryRepository extends CategoryRepository { - constructor(private readonly prismaClient: typeof prisma = prisma) { - super(); - } - - async createCategory(category: CategoryInput): Promise { - try { - return await this.prismaClient.category.create({ - data: { - ...category, - createdAt: category.createdAt ?? undefined, - }, - }); - } catch (error) { - this.handlePrismaError(error); + +import { CategoryEntity } from "../entity/category.entity.js"; + +import type { + CategoryRepository, + CreateCategoryData, + UpdateCategoryData +} from "./category.repository.js"; + +export class PrismaCategoryRepository implements CategoryRepository { + + constructor( + private readonly prismaClient: typeof prisma = prisma + ) {} + + private toEntity(category: { + id: bigint; + name: string; + description: string | null; + active: boolean; + createdAt: Date; + updatedAt: Date; + }): CategoryEntity { + + return new CategoryEntity( + category.id, + category.name, + category.description, + category.active, + category.createdAt, + category.updatedAt + ); } - } - - async deleteCategory(id: bigint): Promise { - try { - await this.prismaClient.category.delete({ - where: { id }, - }); - } catch (error) { - this.handlePrismaError(error); + + async create(data: CreateCategoryData): Promise { + try { + const category = await this.prismaClient.category.create({ + data + }); + + return this.toEntity(category); + + } catch (error) { + this.handlePrismaError(error); + } } - } - - async getAllCategories(): Promise { - return this.prismaClient.category.findMany(); - } - - async getCategoryById(id: bigint): Promise { - return this.prismaClient.category.findUnique({ - where: { id }, - }); - } - - async updateCategory( - id: bigint, - category: CategoryInput, - ): Promise { - try { - return await this.prismaClient.category.update({ - where: { id }, - data: { - ...category, - createdAt: category.createdAt ?? undefined, - }, - }); - } catch (error) { - this.handlePrismaError(error); + + async findAll(): Promise { + + const categories = await this.prismaClient.category.findMany(); + + return categories.map((category) => this.toEntity(category)); } - } - private handlePrismaError(error: unknown): never { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === "P2002") { - throw new AppError("Category name already exists", 409); - } + async findById(id: bigint): Promise { - if (error.code === "P2025") { - throw new AppError("Category not found", 404); - } + const category = await this.prismaClient.category.findUnique({ + where: { id } + }); - if (error.code === "P2003") { - throw new AppError("Category cannot be deleted because it has products", 409); - } + return category ? this.toEntity(category) : null; } - throw error; - } -} + async update( + id: bigint, + data: UpdateCategoryData + ): Promise { + + try { + const category = await this.prismaClient.category.update({ + where: { id }, + data + }); + + return this.toEntity(category); + + } catch (error) { + this.handlePrismaError(error); + } + } + + async delete(id: bigint): Promise { + + try { + await this.prismaClient.category.delete({ + where: { id } + }); + + } catch (error) { + this.handlePrismaError(error); + } + } + + private handlePrismaError(error: unknown): never { + + if (error instanceof Prisma.PrismaClientKnownRequestError) { + + if (error.code === "P2002") { + throw new AppError( + "Já existe uma categoria com esse nome", + 409 + ); + } + + if (error.code === "P2025") { + throw new AppError( + "Categoria não encontrada", + 404 + ); + } + + if (error.code === "P2003") { + throw new AppError( + "Não é possível excluir uma categoria que possui produtos", + 409 + ); + } + } + + throw error; + } +} \ No newline at end of file From 8a22bbb76bdb4bd08400c76fab9dff3feccd2ee6 Mon Sep 17 00:00:00 2001 From: pfonseca-dev Date: Mon, 19 Oct 2026 11:15:07 -0300 Subject: [PATCH 4/8] =?UTF-8?q?refactore(orders):=20desacoplar=20enums=20d?= =?UTF-8?q?e=20dom=C3=ADnio=20do=20Prisma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/modules/orders/entity/orders.entity.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/backend/src/modules/orders/entity/orders.entity.ts b/backend/src/modules/orders/entity/orders.entity.ts index 2917982..636c5b6 100644 --- a/backend/src/modules/orders/entity/orders.entity.ts +++ b/backend/src/modules/orders/entity/orders.entity.ts @@ -1,4 +1,14 @@ -import type { OrderStatus, CancellationSource } from "../../../generated/prisma/enums.js"; +export type OrderStatus = + | "PENDING" + | "PREPARING" + | "READY" + | "DELIVERED" + | "CANCELLED"; + +export type CancellationSource = + | "CUSTOMER" + | "EMPLOYEE" + | "ADMIN"; export class OrderEntity { constructor( @@ -19,4 +29,4 @@ export class OrderEntity { isPending(): boolean { return this.status === "PENDING"; } -} \ No newline at end of file +} From 55677d4d8e12b9d32514890c87f49d52e0e0ee2d Mon Sep 17 00:00:00 2001 From: pfonseca-dev Date: Mon, 19 Oct 2026 11:25:36 -0300 Subject: [PATCH 5/8] =?UTF-8?q?refactore(tables):=20desacoplar=20enums=20d?= =?UTF-8?q?e=20dom=C3=ADnio=20do=20Prisma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modules/tables/dto/table-status.dto.ts | 10 +- .../src/modules/tables/enity/table-status.ts | 13 ++ .../repositories/prisma-table.repository.ts | 150 ++++++++-------- .../tables/repositories/table.repository.ts | 8 +- backend/src/modules/tables/table.service.ts | 80 ++------- .../utils/resolve-attendance-transition.ts | 41 +++++ .../tables/utils/resolve-table-status.ts | 6 +- backend/tests/tables.integration.test.ts | 5 +- backend/tests/tables.test.ts | 162 ++++++++++++++++-- 9 files changed, 308 insertions(+), 167 deletions(-) create mode 100644 backend/src/modules/tables/enity/table-status.ts create mode 100644 backend/src/modules/tables/utils/resolve-attendance-transition.ts diff --git a/backend/src/modules/tables/dto/table-status.dto.ts b/backend/src/modules/tables/dto/table-status.dto.ts index f8525f6..4c86b9d 100644 --- a/backend/src/modules/tables/dto/table-status.dto.ts +++ b/backend/src/modules/tables/dto/table-status.dto.ts @@ -1,10 +1,6 @@ import { z } from "zod"; +import { tableStatuses } from "../enity/table-status.js"; -export const tableStatusSchema = z.enum([ - "available", - "occupied", - "closing_requested", - "awaiting_payment" -]); +export const tableStatusSchema = z.enum(tableStatuses); -export type TableStatusDTO = z.infer; \ No newline at end of file +export type TableStatusDTO = z.infer; diff --git a/backend/src/modules/tables/enity/table-status.ts b/backend/src/modules/tables/enity/table-status.ts new file mode 100644 index 0000000..2f3ad3f --- /dev/null +++ b/backend/src/modules/tables/enity/table-status.ts @@ -0,0 +1,13 @@ +export const tableStatuses = [ + "available", + "occupied", + "closing_requested", + "awaiting_payment", +] as const; + +export type TableStatus = typeof tableStatuses[number]; + +export interface TableStatusChange { + status: TableStatus; + attendanceId: bigint | null; +} diff --git a/backend/src/modules/tables/repositories/prisma-table.repository.ts b/backend/src/modules/tables/repositories/prisma-table.repository.ts index 153e68f..b598cdc 100644 --- a/backend/src/modules/tables/repositories/prisma-table.repository.ts +++ b/backend/src/modules/tables/repositories/prisma-table.repository.ts @@ -4,13 +4,12 @@ import { AppError } from "../../../shared/errors/app-errors.js"; import type { TableRepository, CreateTableData, UpdateTableData } from "./table.repository.js"; import type { RestaurantTable } from "../../../generated/prisma/client.js"; import type { TableWithAttendance } from "./table-with-attendance.repository.js"; -import type { TableStatusChangeDTO } from "../dto/table-status-change.dto.js"; -import type { TableStatusDTO } from "../dto/table-status.dto.js"; +import type { TableStatusChange } from "../enity/table-status.js"; import type { Prisma } from "../../../generated/prisma/client.js"; import { TableEntity } from "../enity/table.entity.js"; -import { resolveTableStatus } from "../utils/resolve-table-status.js"; +import { resolveAttendanceTransition } from "../utils/resolve-attendance-transition.js"; const attendanceInclude = { attendances: { @@ -35,17 +34,15 @@ const attendanceInclude = { }, } satisfies Prisma.RestaurantTableInclude; -const nextStatus: Record = { - available: "occupied", - occupied: "closing_requested", - closing_requested: "awaiting_payment", - awaiting_payment: "available" -}; +type PrismaTableWithAttendance = Prisma.RestaurantTableGetPayload<{ + include: typeof attendanceInclude; +}>; export class PrismaTableRepository implements TableRepository { + constructor(private readonly prismaClient: typeof prisma = prisma) {} async existsById(id: bigint): Promise { - const table = await prisma.restaurantTable.findUnique({ + const table = await this.prismaClient.restaurantTable.findUnique({ where: { id }, select: { id: true } }); @@ -54,20 +51,24 @@ export class PrismaTableRepository implements TableRepository { } async create(data: CreateTableData): Promise { - const table= await prisma.restaurantTable.create({ - data: { - number: data.number, - name: data.name, - active: data.active, - qrToken: data.qrToken, - } - }); + try { + const table= await this.prismaClient.restaurantTable.create({ + data: { + number: data.number, + name: data.name, + active: data.active, + qrToken: data.qrToken, + } + }); - return this.toEntity(table); + return this.toEntity(table); + } catch (error) { + this.handleWriteError(error); + } } async findById(id: bigint): Promise { - const table = await prisma.restaurantTable.findUnique({ + const table = await this.prismaClient.restaurantTable.findUnique({ where: { id } }); @@ -79,7 +80,7 @@ export class PrismaTableRepository implements TableRepository { } async findAll(): Promise { - const tables = await prisma.restaurantTable.findMany({ + const tables = await this.prismaClient.restaurantTable.findMany({ orderBy: { number: "asc" } @@ -89,15 +90,19 @@ export class PrismaTableRepository implements TableRepository { } async delete(id: bigint): Promise { - const table = await prisma.restaurantTable.delete({ - where: { id } - }); + try { + const table = await this.prismaClient.restaurantTable.delete({ + where: { id } + }); - return this.toEntity(table); + return this.toEntity(table); + } catch (error) { + this.handleWriteError(error); + } } async findByToken(token: string): Promise { - const table = await prisma.restaurantTable.findUnique({ + const table = await this.prismaClient.restaurantTable.findUnique({ where: { qrToken: token } }); @@ -109,7 +114,7 @@ export class PrismaTableRepository implements TableRepository { } async findByIdWithAttendance(id: bigint): Promise { - const table = await prisma.restaurantTable.findUnique({ + const table = await this.prismaClient.restaurantTable.findUnique({ where: { id }, include: attendanceInclude }); @@ -118,28 +123,22 @@ export class PrismaTableRepository implements TableRepository { return null; } - return { - table: this.toEntity(table), - attendance: table.attendances[0] ?? null - } + return this.toWithAttendance(table); } async findAllWithAttendance(): Promise { - const tables = await prisma.restaurantTable.findMany({ + const tables = await this.prismaClient.restaurantTable.findMany({ include: attendanceInclude, orderBy: { number: "asc" } }); - return tables.map(table => ({ - table: this.toEntity(table), - attendance: table.attendances[0] ?? null - })); + return tables.map(table => this.toWithAttendance(table)); } async updateWithAttendanceCheck(id: bigint, data: UpdateTableData): Promise { - return prisma.$transaction(async (tx) => { + return this.prismaClient.$transaction(async (tx) => { //bloquea a mesa durante uma transação await tx.$queryRaw` SELECT id @@ -174,15 +173,12 @@ export class PrismaTableRepository implements TableRepository { include: attendanceInclude, }); - return { - table: this.toEntity(updated), - attendance: updated.attendances[0] ?? null, - } - }) + return this.toWithAttendance(updated); + }).catch(error => this.handleWriteError(error)); } - async changeStatusTransaction(id: bigint, change: TableStatusChangeDTO): Promise { - return prisma.$transaction(async (tx) => { + async changeStatusTransaction(id: bigint, change: TableStatusChange): Promise { + return this.prismaClient.$transaction(async (tx) => { //bloquear a mesa await tx.$queryRaw` SELECT id @@ -203,39 +199,17 @@ export class PrismaTableRepository implements TableRepository { const attendance = table.attendances[0] ?? null; - //descobrir o status atual - const currentStatus = resolveTableStatus(attendance) - - //verifcar se o atendimento mudou - if ((attendance?.id ?? null) !== change.attendanceId){ - throw new AppError("O atendimento da mesa mudou. Atualize a mesa e tente novamente", 409); - } - - //validar a transicao - if (nextStatus[currentStatus] !== change.status) { - throw new AppError("Transição de estado inválida para esta mesa", 409); - } + // As regras usam o estado lido sob o bloqueio da transação. + const status = resolveAttendanceTransition(table.active, attendance, change); - //abre um atendimento if (!attendance) { - if (!table.active) { - throw new AppError("Mesa inativa nao pode iniciar atendimento", 409); - } - await tx.attendance.create({ data: { tableId: id, - status: "OPEN" + status, } }); } else { - //determinar o proximo status do atendimento - const status = change.status === "closing_requested" - ? "CLOSING_REQUESTED" - : change.status === "awaiting_payment" - ? "AWAITING_PAYMENT" - : "CLOSED"; - //atualizar o atendimento const updated = await tx.attendance.updateMany({ where: { @@ -274,13 +248,43 @@ export class PrismaTableRepository implements TableRepository { include: attendanceInclude }); - return { - table: this.toEntity(result), - attendance: result.attendances[0] ?? null - }; + return this.toWithAttendance(result); + }).catch((error: unknown) => { + if ((error as { code?: string })?.code === "P2002") { + throw new AppError("Mesa já possui atendimento em andamento", 409); + } + + throw error; }); } + private handleWriteError(error: unknown): never { + const code = (error as { code?: string })?.code; + + if (code === "P2002") { + throw new AppError("Número de mesa já cadastrado", 409); + } + + if (code === "P2025") { + throw new AppError("Mesa não encontrada", 404); + } + + if (code === "P2003") { + throw new AppError("Mesa com histórico de atendimento não pode ser excluída; desative-a", 409); + } + + throw error; + } + + private toWithAttendance(table: PrismaTableWithAttendance): TableWithAttendance { + const attendance = table.attendances[0]; + + return { + table: this.toEntity(table), + attendance: attendance ? { id: attendance.id, status: attendance.status } : null, + }; + } + private toEntity(table: RestaurantTable): TableEntity { return new TableEntity( table.id, diff --git a/backend/src/modules/tables/repositories/table.repository.ts b/backend/src/modules/tables/repositories/table.repository.ts index c8c2d76..9764491 100644 --- a/backend/src/modules/tables/repositories/table.repository.ts +++ b/backend/src/modules/tables/repositories/table.repository.ts @@ -1,6 +1,6 @@ -import { TableEntity } from "../enity/table.entity.js"; +import type { TableEntity } from "../enity/table.entity.js"; import type { TableWithAttendance } from "./table-with-attendance.repository.js"; -import type { TableStatusChangeDTO } from "../dto/table-status-change.dto.js"; +import type { TableStatusChange } from "../enity/table-status.js"; export interface TableRepository { existsById (id: bigint): Promise; @@ -26,7 +26,7 @@ export interface TableRepository { changeStatusTransaction( id: bigint, - change: TableStatusChangeDTO + change: TableStatusChange ): Promise; } @@ -41,4 +41,4 @@ export interface UpdateTableData { number: number; name: string; active: boolean; -} \ No newline at end of file +} diff --git a/backend/src/modules/tables/table.service.ts b/backend/src/modules/tables/table.service.ts index 073757b..f607718 100644 --- a/backend/src/modules/tables/table.service.ts +++ b/backend/src/modules/tables/table.service.ts @@ -4,8 +4,7 @@ import QRCode from "qrcode"; import { AppError } from "../../shared/errors/app-errors.js"; import type { CreateTableDTO } from "./dto/create-table.dto.js"; -import type { TableStatusDTO } from "./dto/table-status.dto.js"; -import type { TableStatusChangeDTO } from "./dto/table-status-change.dto.js"; +import type { TableStatusChange } from "./enity/table-status.js"; import type { TableIdDTO } from "./dto/table-id.dto.js"; import type { UpdateTableDTO } from "./dto/update-table.dto.js"; @@ -39,19 +38,14 @@ export class TableService { } async createTable(data: CreateTableDTO) { - try { - const table = await this.tableRepository.create({ - number: data.number, - name: data.name, - active: data.active, - qrToken: randomUUID() - }); - - return this.present({table, attendance: null}); - - } catch (error) { - return this.handleWriteError(error); - } + const table = await this.tableRepository.create({ + number: data.number, + name: data.name, + active: data.active, + qrToken: randomUUID() + }); + + return this.present({table, attendance: null}); } async getTableById({ id }: TableIdDTO) { @@ -71,43 +65,23 @@ export class TableService { } async deleteTable({ id }: TableIdDTO) { - try { - const table = await this.tableRepository.delete(id); - - return this.present({table, attendance: null}); + const table = await this.tableRepository.delete(id); - } catch (error) { - return this.handleWriteError(error); - } + return this.present({table, attendance: null}); } async editTable({ id }: TableIdDTO, data: UpdateTableDTO) { - try { - const updated = await this.tableRepository.updateWithAttendanceCheck( - id, data - ); - - return this.present(updated); + const updated = await this.tableRepository.updateWithAttendanceCheck( + id, data + ); - } catch (error) { - return this.handleWriteError(error); - } + return this.present(updated); } - async changeStatus({ id }: TableIdDTO, change: TableStatusChangeDTO) { - try { - const result = await this.tableRepository.changeStatusTransaction(id, change); - - return this.present(result); - - } catch (error) { - if ((error as { code?: string })?.code === "P2002"){ - throw new AppError("Mesa já possui atendimento em andamento", 409); - } - - throw error; + async changeStatus({ id }: TableIdDTO, change: TableStatusChange) { + const result = await this.tableRepository.changeStatusTransaction(id, change); - } + return this.present(result); } async getTableByToken(token: string) { @@ -137,22 +111,4 @@ export class TableService { } ); } - - private handleWriteError(error: unknown): never { - const code = (error as { code?: string })?.code; - - if (code === "P2002") { - throw new AppError("Número de mesa já cadastrado", 409); - }; - - if (code === "P2025") { - throw new AppError("Mesa não encontrada", 404); - }; - - if (code === "P2003") { - throw new AppError("Mesa com histórico de atendimento não pode ser excluída; desative-a", 409); - }; - - throw error; - } } diff --git a/backend/src/modules/tables/utils/resolve-attendance-transition.ts b/backend/src/modules/tables/utils/resolve-attendance-transition.ts new file mode 100644 index 0000000..e1e4cf4 --- /dev/null +++ b/backend/src/modules/tables/utils/resolve-attendance-transition.ts @@ -0,0 +1,41 @@ +import { AppError } from "../../../shared/errors/app-errors.js"; +import type { AttendanceStatus } from "../../attendance/entity/attendance.entity.js"; +import type { TableStatus, TableStatusChange } from "../enity/table-status.js"; +import { resolveTableStatus } from "./resolve-table-status.js"; + +const nextStatus: Record = { + available: "occupied", + occupied: "closing_requested", + closing_requested: "awaiting_payment", + awaiting_payment: "available", +}; + +export function resolveAttendanceTransition( + active: boolean, + attendance: { id: bigint; status: AttendanceStatus } | null, + change: TableStatusChange, +): AttendanceStatus { + const currentStatus = resolveTableStatus(attendance); + + if ((attendance?.id ?? null) !== change.attendanceId) { + throw new AppError("O atendimento da mesa mudou. Atualize a mesa e tente novamente", 409); + } + + if (nextStatus[currentStatus] !== change.status) { + throw new AppError("Transição de estado inválida para esta mesa", 409); + } + + if (!attendance) { + if (!active) { + throw new AppError("Mesa inativa nao pode iniciar atendimento", 409); + } + + return "OPEN"; + } + + return change.status === "closing_requested" + ? "CLOSING_REQUESTED" + : change.status === "awaiting_payment" + ? "AWAITING_PAYMENT" + : "CLOSED"; +} diff --git a/backend/src/modules/tables/utils/resolve-table-status.ts b/backend/src/modules/tables/utils/resolve-table-status.ts index 10376e0..eb07292 100644 --- a/backend/src/modules/tables/utils/resolve-table-status.ts +++ b/backend/src/modules/tables/utils/resolve-table-status.ts @@ -1,9 +1,9 @@ import type { AttendanceStatus } from "../../attendance/entity/attendance.entity.js"; -import type { TableStatusDTO } from "../dto/table-status.dto.js"; +import type { TableStatus } from "../enity/table-status.js"; export function resolveTableStatus( attendance: { status: AttendanceStatus } | null -): TableStatusDTO { +): TableStatus { if (!attendance) { return "available"; @@ -24,4 +24,4 @@ export function resolveTableStatus( case "CANCELLED": return "available"; } -} \ No newline at end of file +} diff --git a/backend/tests/tables.integration.test.ts b/backend/tests/tables.integration.test.ts index 7c47124..7d0fdff 100644 --- a/backend/tests/tables.integration.test.ts +++ b/backend/tests/tables.integration.test.ts @@ -8,7 +8,8 @@ test('tables: persisted lifecycle, database uniqueness and concurrent actions', skip: process.env.RUN_TABLES_DB_TESTS !== '1', }, async () => { const { prisma } = await import('../src/database/prisma.js'); - const service = new TableService(prisma, 'http://localhost:5173'); + const { PrismaTableRepository } = await import('../src/modules/tables/repositories/prisma-table.repository.js'); + const service = new TableService(new PrismaTableRepository(prisma), 'http://localhost:5173'); let tableId: bigint | undefined; try { const table = await prisma.restaurantTable.create({ data: { @@ -32,7 +33,7 @@ test('tables: persisted lifecycle, database uniqueness and concurrent actions', if (state !== 'occupied') current = await service.changeStatus(params, { status: state, attendanceId }); assert.equal(current.status, state); // Nova instância lê o estado persistido, inclusive após aguardar pagamento. - const reloaded = await new TableService(prisma, 'http://localhost:5173').getTableById(params); + const reloaded = await new TableService(new PrismaTableRepository(prisma), 'http://localhost:5173').getTableById(params); assert.equal(reloaded.status, state); assert.equal(reloaded.attendanceId, attendanceId.toString()); const stored = await prisma.attendance.findUniqueOrThrow({ where: { id: attendanceId } }); diff --git a/backend/tests/tables.test.ts b/backend/tests/tables.test.ts index a9d6e19..bd38412 100644 --- a/backend/tests/tables.test.ts +++ b/backend/tests/tables.test.ts @@ -4,23 +4,48 @@ import Fastify from 'fastify'; import { TableService } from '../src/modules/tables/table.service.js'; import { TableController } from '../src/modules/tables/table.controller.js'; import { errorHandler } from '../src/shared/errors/error-handler.js'; +import { AppError } from '../src/shared/errors/app-errors.js'; +import { TableEntity } from '../src/modules/tables/enity/table.entity.js'; +import type { TableRepository } from '../src/modules/tables/repositories/table.repository.js'; +import type { PrismaClient } from '../src/generated/prisma/client.js'; + +// O cliente real é apenas construído pelo módulo; todas as operações usam o double abaixo. +process.env.DATABASE_URL ??= 'mysql://test:test@127.0.0.1:1/tables_test'; +process.env.JWT_SECRET ??= 'tables-test-secret'; +process.env.FRONTEND_URL ??= 'https://menu.example'; +const { PrismaTableRepository } = await import('../src/modules/tables/repositories/prisma-table.repository.js'); +const { Prisma } = await import('../src/generated/prisma/client.js'); + +function databaseError(code: string) { + return new Prisma.PrismaClientKnownRequestError('Database error', { code, clientVersion: 'test' }); +} function fixture(initialStatus?: string) { - let row = { id: 9007199254740993n, number: 5, name: 'Varanda', active: true, qrToken: 'existing-token' }; + let row = { id: 9007199254740993n, number: 5, name: 'Varanda', active: true, qrToken: 'existing-token', createdAt: new Date(), updatedAt: new Date() }; let nextId = 1n; let attendances: any[] = initialStatus ? [{ id: nextId++, tableId: row.id, status: initialStatus }] : []; let printJobs: any[] = []; const present = () => ({ ...row, attendances: attendances.filter((item) => ['OPEN', 'CLOSING_REQUESTED', 'AWAITING_PAYMENT'].includes(item.status)) }); - const repository: any = { restaurantTable: { - create: async ({ data }: any) => { row = { ...row, ...data }; return row; }, - update: async ({ data }: any) => { row = { ...row, ...data }; return present(); }, - findUnique: async ({ where }: any) => where.id === row.id || where.qrToken === row.qrToken ? present() : null, + let inTransaction = false; + let locked = false; + const assertLocked = () => { + if (inTransaction) assert.equal(locked, true, 'transaction must lock the table before reading or writing'); + }; + const client: any = { restaurantTable: { + create: async ({ data }: any) => { assertLocked(); row = { ...row, ...data }; return row; }, + update: async ({ data }: any) => { assertLocked(); row = { ...row, ...data }; return present(); }, + delete: async () => row, + findUnique: async ({ where }: any) => { + assertLocked(); + return where.id === row.id || where.qrToken === row.qrToken ? present() : null; + }, findUniqueOrThrow: async () => present(), findMany: async () => [present()], }, attendance: { - create: async ({ data }: any) => { const item = { ...data, id: nextId++ }; attendances.push(item); return item; }, + create: async ({ data }: any) => { assertLocked(); const item = { ...data, id: nextId++ }; attendances.push(item); return item; }, updateMany: async ({ where, data }: any) => { + assertLocked(); const item = attendances.find((item) => item.id === where.id && item.status === where.status); if (!item) return { count: 0 }; Object.assign(item, data); @@ -28,18 +53,30 @@ function fixture(initialStatus?: string) { }, }, printJob: { create: async ({ data }: any) => { printJobs.push(data); return data; } }, - $queryRaw: async () => [{ id: row.id }], + $queryRaw: async (sql: TemplateStringsArray, id: bigint) => { + assert.equal(inTransaction, true); + assert.match(sql.join('?'), /SELECT id\s+FROM restaurant_tables\s+WHERE id = \?\s+FOR UPDATE/); + assert.equal(typeof id, 'bigint'); + locked = true; + return id === row.id ? [{ id: row.id }] : []; + }, $transaction: async (callback: any) => { const snapshot = structuredClone({ row, attendances, printJobs }); - try { return await callback(repository); } + inTransaction = true; + locked = false; + try { return await callback(client); } catch (error) { ({ row, attendances, printJobs } = snapshot); throw error; + } finally { + inTransaction = false; + locked = false; } }, }; - const service = new TableService(repository as any, 'https://menu.example'); - return { service, repository, row: () => row, attendances: () => attendances, printJobs: () => printJobs }; + const repository = new PrismaTableRepository(client as PrismaClient); + const service = new TableService(repository, 'https://menu.example'); + return { service, repository, client, row: () => row, attendances: () => attendances, printJobs: () => printJobs }; } test('creation assigns unique tokens; editing preserves the QR URL', async () => { @@ -116,18 +153,18 @@ test('inactive and missing tables cannot start; occupied tables cannot be deacti }); test('stale attendance, duplicate starts and lost updates return conflicts', async () => { - const { service, row, repository } = fixture('OPEN'); + const { service, row, client } = fixture('OPEN'); await assert.rejects(service.changeStatus({ id: row().id }, { status: 'closing_requested', attendanceId: 99n }), { statusCode: 409 }); - repository.attendance.updateMany = async () => ({ count: 0 }); + client.attendance.updateMany = async () => ({ count: 0 }); await assert.rejects(service.changeStatus({ id: row().id }, { status: 'closing_requested', attendanceId: 1n }), { statusCode: 409 }); const available = fixture(); - available.repository.attendance.create = async () => { throw { code: 'P2002' }; }; + available.client.attendance.create = async () => { throw databaseError('P2002'); }; await assert.rejects(available.service.changeStatus({ id: available.row().id }, { status: 'occupied', attendanceId: null }), { statusCode: 409, message: 'Mesa já possui atendimento em andamento' }); }); test('bill request rolls back if the print job fails', async () => { - const { service, row, repository, printJobs } = fixture('OPEN'); - repository.printJob.create = async () => { throw new Error('Print queue unavailable'); }; + const { service, row, client, printJobs } = fixture('OPEN'); + client.printJob.create = async () => { throw new Error('Print queue unavailable'); }; await assert.rejects(service.changeStatus({ id: row().id }, { status: 'closing_requested', attendanceId: 1n }), /Print queue unavailable/); assert.equal((await service.getTableById({ id: row().id })).status, 'occupied'); assert.equal(printJobs().length, 0); @@ -139,7 +176,7 @@ test('HTTP status endpoint validates IDs and bodies and returns serializable sta const app = Fastify(); app.setErrorHandler(errorHandler); app.patch('/tables/:id/status', (req, reply) => controller.changeStatus(req, reply)); - const patch = (payload: unknown, id = row().id.toString()) => app.inject({ method: 'PATCH', url: `/tables/${id}/status`, payload }); + const patch = (payload: Record, id = row().id.toString()) => app.inject({ method: 'PATCH', url: `/tables/${id}/status`, payload }); try { for (const payload of [{}, { status: 'unknown', attendanceId: null }, { status: 'occupied' }, { status: 'occupied', attendanceId: '0' }]) { assert.equal((await patch(payload)).statusCode, 400); @@ -177,3 +214,96 @@ test('HTTP routes return PNG, identity, 404 and validation errors', async () => assert.equal((await app.inject('/tables/by-token/missing')).statusCode, 404); } finally { await app.close(); } }); + +test('write errors keep their application messages and status codes', async () => { + const scenarios = [ + { operation: 'create', code: 'P2002', statusCode: 409, message: 'Número de mesa já cadastrado' }, + { operation: 'update', code: 'P2002', statusCode: 409, message: 'Número de mesa já cadastrado' }, + { operation: 'update', code: 'P2025', statusCode: 404, message: 'Mesa não encontrada' }, + { operation: 'delete', code: 'P2025', statusCode: 404, message: 'Mesa não encontrada' }, + { operation: 'delete', code: 'P2003', statusCode: 409, message: 'Mesa com histórico de atendimento não pode ser excluída; desative-a' }, + ]; + + for (const { operation, code, statusCode, message } of scenarios) { + const { service, client, row } = fixture(); + client.restaurantTable[operation] = async () => { throw databaseError(code); }; + const data = { number: 5, name: 'Varanda', active: true }; + const result = operation === 'create' + ? service.createTable(data) + : operation === 'update' + ? service.editTable({ id: row().id }, data) + : service.deleteTable({ id: row().id }); + await assert.rejects(result, (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.statusCode, statusCode); + assert.equal(error.message, message); + return true; + }); + } +}); + +test('unexpected failures are preserved and status changes do not reuse CRUD error mappings', async () => { + const { service, client, row } = fixture(); + for (const error of [new Error('Connection lost'), databaseError('P2025'), databaseError('P2003')]) { + client.attendance.create = async () => { throw error; }; + await assert.rejects( + service.changeStatus({ id: row().id }, { status: 'occupied', attendanceId: null }), + (actual: unknown) => actual === error, + ); + } + + const unexpected = new Error('Database unavailable'); + client.restaurantTable.create = async () => { throw unexpected; }; + await assert.rejects( + service.createTable({ number: 5, name: 'Varanda', active: true }), + (actual: unknown) => actual === unexpected, + ); +}); + +test('repository returns domain entities and copies attendance projections', async () => { + const { repository, row, attendances } = fixture('OPEN'); + for (const table of [ + await repository.findById(row().id), + await repository.findByToken(row().qrToken), + ...(await repository.findAll()), + ]) { + assert.ok(table instanceof TableEntity); + assert.equal(table.canBeUsed(), true); + assert.equal(table.createdAt, row().createdAt); + assert.equal('attendances' in table, false); + } + + const result = await repository.findByIdWithAttendance(row().id); + assert.ok(result?.table instanceof TableEntity); + assert.deepEqual(result.attendance, { id: 1n, status: 'OPEN' }); + assert.notEqual(result.attendance, attendances()[0]); + result.attendance!.status = 'CANCELLED'; + assert.equal(attendances()[0].status, 'OPEN'); + assert.deepEqual((await repository.findAllWithAttendance())[0].attendance, { id: 1n, status: 'OPEN' }); + assert.equal(await repository.findById(99n), null); + assert.equal(await repository.findByIdWithAttendance(99n), null); +}); + +test('service works with a repository interface and propagates application errors', async () => { + const table = new TableEntity(1n, 5, 'Varanda', true, 'token', new Date(), new Date()); + const conflict = new AppError('Conflito de domínio', 409); + const unexpected = async (): Promise => { throw new Error('Unexpected repository call'); }; + const repository: TableRepository = { + existsById: unexpected, + create: unexpected, + findById: unexpected, + findAll: unexpected, + delete: unexpected, + findByToken: async () => table, + findByIdWithAttendance: unexpected, + findAllWithAttendance: unexpected, + updateWithAttendanceCheck: unexpected, + changeStatusTransaction: async () => { throw conflict; }, + }; + const service = new TableService(repository, 'https://menu.example'); + assert.deepEqual(await service.getTableByToken('token'), { id: '1', number: 5, name: 'Varanda' }); + await assert.rejects( + service.changeStatus({ id: 1n }, { status: 'occupied', attendanceId: null }), + (error: unknown) => error === conflict, + ); +}); From 160f43733595337958696bb82a9c63218bd94201 Mon Sep 17 00:00:00 2001 From: pfonseca-dev Date: Mon, 19 Oct 2026 11:34:24 -0300 Subject: [PATCH 6/8] =?UTF-8?q?refactore(attendances):=20desacoplar=20enum?= =?UTF-8?q?s=20de=20dom=C3=ADnio=20do=20Prisma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../prisma-attendance.repository.ts | 32 ++- backend/tests/attendance.test.ts | 223 ++++++++++++++++++ 2 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 backend/tests/attendance.test.ts diff --git a/backend/src/modules/attendance/repositories/prisma-attendance.repository.ts b/backend/src/modules/attendance/repositories/prisma-attendance.repository.ts index 4753b7e..46b6121 100644 --- a/backend/src/modules/attendance/repositories/prisma-attendance.repository.ts +++ b/backend/src/modules/attendance/repositories/prisma-attendance.repository.ts @@ -9,8 +9,10 @@ import type { AttendanceTotal } from "../entity/attendance-total.entity.js"; import type { AttendanceListItem } from "../types/ttendance-list-item.type.js"; export class PrismaAttendanceRepository implements AttendanceRepository{ + constructor(private readonly prismaClient: typeof prisma = prisma) {} + async findById(id: bigint): Promise { - const attendance = await prisma.attendance.findUnique({ + const attendance = await this.prismaClient.attendance.findUnique({ where:{id}, }); @@ -22,7 +24,7 @@ export class PrismaAttendanceRepository implements AttendanceRepository{ } async findOpenByTableId(tableId: bigint): Promise { - const attendance = await prisma.attendance.findFirst({ + const attendance = await this.prismaClient.attendance.findFirst({ where: { tableId, status: "OPEN", @@ -37,7 +39,7 @@ export class PrismaAttendanceRepository implements AttendanceRepository{ } async create(tableId: bigint): Promise { - const attendance = await prisma.attendance.create({ + const attendance = await this.prismaClient.attendance.create({ data: { tableId, }, @@ -47,7 +49,7 @@ export class PrismaAttendanceRepository implements AttendanceRepository{ } async findTotalByAttendanceId(attendanceId: bigint): Promise { - const total = await prisma.attendanceTotal.findUnique({ + const total = await this.prismaClient.attendanceTotal.findUnique({ where: { attendanceId } }); @@ -65,7 +67,7 @@ export class PrismaAttendanceRepository implements AttendanceRepository{ async requestClosing(attendanceId: bigint, requestedAt: Date): Promise { - const attendance = await prisma.$transaction(async (tx) => { + const attendance = await this.prismaClient.$transaction(async (tx) => { const updatedAttendance = await tx.attendance.update({ where: { @@ -92,7 +94,7 @@ export class PrismaAttendanceRepository implements AttendanceRepository{ async requestCancelled(attendanceId: bigint): Promise { - const attendance = await prisma.attendance.update({ + const attendance = await this.prismaClient.attendance.update({ where: { id: attendanceId, }, @@ -106,7 +108,7 @@ export class PrismaAttendanceRepository implements AttendanceRepository{ async findAllByStatus(status: AttendanceStatus): Promise { - return prisma.attendance.findMany({ + const attendances = await this.prismaClient.attendance.findMany({ where: { status }, @@ -123,6 +125,22 @@ export class PrismaAttendanceRepository implements AttendanceRepository{ closingRequestedAt: "asc" } }); + + return attendances.map((attendance) => this.toListItem(attendance)); + } + + private toListItem( + attendance: Attendance & { table: AttendanceListItem["table"] } + ): AttendanceListItem { + return { + ...this.toEntity(attendance), + activeTableId: attendance.activeTableId, + table: { + id: attendance.table.id, + number: attendance.table.number, + name: attendance.table.name, + }, + }; } private toEntity(attendance: Attendance): AttendanceEntity { diff --git a/backend/tests/attendance.test.ts b/backend/tests/attendance.test.ts new file mode 100644 index 0000000..c05e12c --- /dev/null +++ b/backend/tests/attendance.test.ts @@ -0,0 +1,223 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import Fastify from 'fastify'; +import { Prisma, type Attendance, type PrismaClient } from '../src/generated/prisma/client.js'; +import { AttendanceEntity, type AttendanceStatus } from '../src/modules/attendance/entity/attendance.entity.js'; +import { AttendanceService } from '../src/modules/attendance/attendance.service.js'; +import type { AttendanceRepository } from '../src/modules/attendance/repositories/attendance.repository.js'; +import type { TableRepository } from '../src/modules/tables/repositories/table.repository.js'; +import { errorHandler } from '../src/shared/errors/error-handler.js'; + +// As operações usam doubles; o cliente padrão não acessa o banco nestes testes. +process.env.DATABASE_URL ??= 'mysql://test:test@127.0.0.1:1/attendance_test'; +process.env.JWT_SECRET ??= 'attendance-test-secret'; +process.env.FRONTEND_URL ??= 'https://menu.example'; +const { PrismaAttendanceRepository } = await import('../src/modules/attendance/repositories/prisma-attendance.repository.js'); + +function record(status: AttendanceStatus = 'OPEN'): Attendance { + const date = new Date('2026-01-01T12:00:00.000Z'); + return { + id: 9007199254740993n, + tableId: 9007199254740994n, + status, + openedAt: date, + closingRequestedAt: status === 'CLOSING_REQUESTED' ? date : null, + closedAt: null, + closedById: null, + createdAt: date, + updatedAt: date, + activeTableId: status === 'CANCELLED' ? null : 9007199254740994n, + }; +} + +function repositoryWith(client: unknown) { + return new PrismaAttendanceRepository(client as PrismaClient); +} + +test('status lists preserve fields and order while copying domain projections', async () => { + for (const status of ['CLOSING_REQUESTED', 'CANCELLED'] as const) { + const expected = [ + { ...record(status), table: { id: 9007199254740994n, number: 5, name: null } }, + { ...record(status), id: 9007199254740995n, closedById: 12n, table: { id: 9007199254740994n, number: 5, name: 'Varanda' } }, + ]; + const rows = expected.map(row => ({ + ...row, + persistenceOnly: 'must not escape', + table: { ...row.table, qrToken: 'must not escape' }, + })); + const repository = repositoryWith({ attendance: { + findMany: async (query: Prisma.AttendanceFindManyArgs) => { + assert.deepEqual(query.where, { status }); + assert.deepEqual(query.orderBy, { closingRequestedAt: 'asc' }); + assert.deepEqual(query.include, { table: { select: { id: true, number: true, name: true } } }); + return rows; + }, + } }); + + const result = await repository.findAllByStatus(status); + assert.deepEqual(result, expected); + assert.notEqual(result[0], rows[0]); + assert.notEqual(result[0].table, rows[0].table); + result[0].table.name = 'Changed'; + result[0].status = 'CLOSED'; + assert.equal(rows[0].table.name, null); + assert.equal(rows[0].status, status); + } +}); + +test('empty lists and unexpected persistence errors keep their behavior', async () => { + const repository = repositoryWith({ attendance: { findMany: async () => [] } }); + assert.deepEqual(await repository.findAllByStatus('CANCELLED'), []); + const failure = new Error('Database unavailable'); + const failing = repositoryWith({ attendance: { findMany: async () => { throw failure; } } }); + await assert.rejects(failing.findAllByStatus('CLOSING_REQUESTED'), error => error === failure); +}); + +test('single reads, creation and cancellation return entities and preserve null results', async () => { + const row = record(); + const repository = repositoryWith({ attendance: { + findUnique: async ({ where }: Prisma.AttendanceFindUniqueArgs) => where.id === row.id ? row : null, + findFirst: async ({ where }: Prisma.AttendanceFindFirstArgs) => { + assert.equal(where?.status, 'OPEN'); + return where.tableId === row.tableId ? row : null; + }, + create: async ({ data }: Prisma.AttendanceCreateArgs) => { + assert.deepEqual(data, { tableId: row.tableId }); + return row; + }, + update: async ({ where, data }: Prisma.AttendanceUpdateArgs) => { + assert.deepEqual(where, { id: row.id }); + assert.deepEqual(data, { status: 'CANCELLED' }); + return record('CANCELLED'); + }, + } }); + + for (const result of [ + await repository.findById(row.id), + await repository.findOpenByTableId(row.tableId), + await repository.create(row.tableId), + ]) { + assert.ok(result instanceof AttendanceEntity); + assert.equal(result.isOpen(), true); + assert.equal(result.canReceiveOrders(), true); + assert.equal(result.id, row.id); + assert.equal('activeTableId' in result, false); + } + const cancelled = await repository.requestCancelled(row.id); + assert.ok(cancelled instanceof AttendanceEntity); + assert.equal(cancelled.status, 'CANCELLED'); + assert.equal(cancelled.canReceiveOrders(), false); + assert.equal(await repository.findById(99n), null); + assert.equal(await repository.findOpenByTableId(99n), null); +}); + +test('attendance totals keep decimal precision and missing totals remain null', async () => { + const row = record(); + const repository = repositoryWith({ attendanceTotal: { + findUnique: async ({ where }: Prisma.AttendanceTotalFindUniqueArgs) => where.attendanceId === row.id + ? { attendanceId: row.id, tableId: row.tableId, status: row.status, total: new Prisma.Decimal('9007199254740993.12') } + : null, + } }); + assert.deepEqual(await repository.findTotalByAttendanceId(row.id), { + attendanceId: row.id, tableId: row.tableId, status: 'OPEN', total: '9007199254740993.12', + }); + assert.equal(await repository.findTotalByAttendanceId(99n), null); +}); + +for (const failPrintJob of [false, true]) { + test(`closing and BILL print job share a transaction (${failPrintJob ? 'rollback' : 'success'})`, async () => { + let row = record(); + const requestedAt = new Date('2026-01-01T13:00:00.000Z'); + const jobs: { attendanceId: bigint; type: string }[] = []; + const failure = new Error('Print queue unavailable'); + const repository = repositoryWith({ + $transaction: async (callback: (tx: unknown) => Promise) => { + const snapshot = { ...row }; + const tx = { + attendance: { update: async ({ where, data }: Prisma.AttendanceUpdateArgs) => { + assert.deepEqual(where, { id: row.id }); + assert.deepEqual(data, { status: 'CLOSING_REQUESTED', closingRequestedAt: requestedAt }); + row = { ...row, status: 'CLOSING_REQUESTED', closingRequestedAt: requestedAt }; + return row; + } }, + printJob: { create: async ({ data }: { data: { attendanceId: bigint; type: string } }) => { + assert.equal(row.status, 'CLOSING_REQUESTED'); + if (failPrintJob) throw failure; + jobs.push(data); + } }, + }; + try { return await callback(tx); } + catch (error) { row = snapshot; jobs.length = 0; throw error; } + }, + }); + + if (failPrintJob) { + await assert.rejects(repository.requestClosing(row.id, requestedAt), error => error === failure); + assert.equal(row.status, 'OPEN'); + assert.equal(row.closingRequestedAt, null); + assert.deepEqual(jobs, []); + } else { + const result = await repository.requestClosing(row.id, requestedAt); + assert.ok(result instanceof AttendanceEntity); + assert.equal(result.status, 'CLOSING_REQUESTED'); + assert.equal(result.closingRequestedAt, requestedAt); + assert.deepEqual(jobs, [{ attendanceId: row.id, type: 'BILL' }]); + } + }); +} + +test('service keeps closing and cancellation guards with repository interfaces', async () => { + const row = record(); + const entity = new AttendanceEntity(row.id, row.tableId, row.status, row.openedAt, null, null, null, row.createdAt, row.updatedAt); + const unexpected = async (): Promise => { throw new Error('Unexpected repository call'); }; + const repository: AttendanceRepository = { + findById: async () => entity, + findOpenByTableId: unexpected, create: unexpected, findTotalByAttendanceId: unexpected, + requestClosing: unexpected, requestCancelled: unexpected, findAllByStatus: unexpected, + }; + const tables: TableRepository = { + existsById: unexpected, create: unexpected, findById: unexpected, findAll: unexpected, + delete: unexpected, findByToken: unexpected, findByIdWithAttendance: unexpected, + findAllWithAttendance: unexpected, updateWithAttendanceCheck: unexpected, changeStatusTransaction: unexpected, + }; + const service = new AttendanceService(repository, tables); + for (const status of ['CLOSING_REQUESTED', 'CLOSED', 'CANCELLED', 'AWAITING_PAYMENT'] as const) { + entity.status = status; + await assert.rejects(service.requestClosing(row.id), { statusCode: 409, message: 'Apenas atendimentos abertos podem solicitar fechamento' }); + await assert.rejects(service.requestCancelled(row.id), { statusCode: 409, message: 'Apenas atendimentos abertos podem solicitar cancelamento' }); + } + repository.findById = async () => null; + await assert.rejects(service.requestClosing(row.id), { statusCode: 404, message: 'Atendimento não encontrado' }); + await assert.rejects(service.requestCancelled(row.id), { statusCode: 404, message: 'Atendimento não encontrado' }); +}); + +test('actual listing routes retain their complete HTTP response contracts', async t => { + const repository = repositoryWith({ attendance: { + findMany: async ({ where }: { where: { status: AttendanceStatus } }) => [{ + ...record(where.status), + table: { id: 9007199254740994n, number: 5, name: null }, + }], + } }); + // Encaminha a instância criada pela Factory ao repository com cliente simulado. + const findAll = repository.findAllByStatus.bind(repository); + t.mock.method(PrismaAttendanceRepository.prototype, 'findAllByStatus', findAll); + const { attendanceRoutes } = await import('../src/modules/attendance/attendance.routes.js'); + const app = Fastify(); + app.setErrorHandler(errorHandler); + await app.register(attendanceRoutes); + try { + for (const [path, status] of [['closing-requested', 'CLOSING_REQUESTED'], ['cancelled', 'CANCELLED']] as const) { + const response = await app.inject(`/attendances/${path}`); + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json(), [{ + id: '9007199254740993', tableId: '9007199254740994', status, + openedAt: '2026-01-01T12:00:00.000Z', + closingRequestedAt: status === 'CLOSING_REQUESTED' ? '2026-01-01T12:00:00.000Z' : null, + closedAt: null, closedById: null, + createdAt: '2026-01-01T12:00:00.000Z', updatedAt: '2026-01-01T12:00:00.000Z', + activeTableId: status === 'CANCELLED' ? null : '9007199254740994', + table: { id: '9007199254740994', number: 5, name: null }, + }]); + } + } finally { await app.close(); } +}); From 6fc8a7e072588eafb35cf3dadd8d258e39ad3903 Mon Sep 17 00:00:00 2001 From: pfonseca-dev Date: Mon, 19 Oct 2026 11:41:46 -0300 Subject: [PATCH 7/8] ci: update GitHub Actions to Node 24 runtime --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6354676..2449d2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,10 @@ jobs: steps: - name: Baixar código - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Configurar Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: 22 cache: npm From c7a9729f83ce6650649f764ddd462a103cd80522 Mon Sep 17 00:00:00 2001 From: pfonseca-dev Date: Mon, 19 Oct 2026 12:01:11 -0300 Subject: [PATCH 8/8] fix: resolve CI warnings and React lint issues --- .github/workflows/ci.yml | 2 +- frontend/app/src/contexts/CartContext.ts | 27 ++++++++++++++ .../{CartContext.tsx => CartProvider.tsx} | 36 ++----------------- frontend/app/src/contexts/useCart.ts | 12 +++++++ frontend/app/src/main.tsx | 2 +- frontend/app/src/pages/client/Cart/index.tsx | 2 +- frontend/app/src/pages/client/Menu/index.tsx | 2 +- .../src/pages/client/ProductDetail/index.tsx | 18 +++++----- 8 files changed, 55 insertions(+), 46 deletions(-) create mode 100644 frontend/app/src/contexts/CartContext.ts rename frontend/app/src/contexts/{CartContext.tsx => CartProvider.tsx} (76%) create mode 100644 frontend/app/src/contexts/useCart.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2449d2c..c1c08a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ permissions: jobs: validate: name: Validar aplicação - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - name: Baixar código diff --git a/frontend/app/src/contexts/CartContext.ts b/frontend/app/src/contexts/CartContext.ts new file mode 100644 index 0000000..93ba542 --- /dev/null +++ b/frontend/app/src/contexts/CartContext.ts @@ -0,0 +1,27 @@ +import { createContext } from "react"; +import type { Product } from "@/services/menu.service"; + +export type CartLine = { + id: string; + product: Product; + quantity: number; + notes: string; +}; + +export type CartContextValue = { + items: CartLine[]; + itemCount: number; + subtotal: number; + serviceFee: number; + total: number; + addItem: (product: Product, quantity: number, notes: string) => void; + removeItem: (lineId: string) => void; + updateQuantity: (lineId: string, quantity: number) => void; + updateNotes: (lineId: string, notes: string) => void; + clearCart: () => void; + splitLine: (lineId: string) => void; +}; + +export const CartContext = createContext( + undefined +); \ No newline at end of file diff --git a/frontend/app/src/contexts/CartContext.tsx b/frontend/app/src/contexts/CartProvider.tsx similarity index 76% rename from frontend/app/src/contexts/CartContext.tsx rename to frontend/app/src/contexts/CartProvider.tsx index 0683c16..59b868e 100644 --- a/frontend/app/src/contexts/CartContext.tsx +++ b/frontend/app/src/contexts/CartProvider.tsx @@ -1,32 +1,10 @@ -import {createContext,useContext,useEffect,useState,type ReactNode,} from "react"; +import { useEffect, useState, type ReactNode } from "react"; import type { Product } from "@/services/menu.service"; - -export type CartLine = { - id: string; - product: Product; - quantity: number; - notes: string; -}; - -type CartContextValue = { - items: CartLine[]; - itemCount: number; - subtotal: number; - serviceFee: number; - total: number; - addItem: (product: Product, quantity: number, notes: string) => void; - removeItem: (lineId: string) => void; - updateQuantity: (lineId: string, quantity: number) => void; - updateNotes: (lineId: string, notes: string) => void; - clearCart: () => void; - splitLine: (lineId: string) => void; -}; +import { CartContext, type CartLine } from "./CartContext"; const CART_STORAGE_KEY = "mesaflow:cart"; const SERVICE_FEE_RATE = 0.1; -const CartContext = createContext(undefined); - function createLineId() { return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; } @@ -131,14 +109,4 @@ export function CartProvider({ children }: { children: ReactNode }) { {children} ); -} - -export function useCart() { - const context = useContext(CartContext); - - if (!context) { - throw new Error("useCart precisa ser usado dentro de um "); - } - - return context; } \ No newline at end of file diff --git a/frontend/app/src/contexts/useCart.ts b/frontend/app/src/contexts/useCart.ts new file mode 100644 index 0000000..5f99911 --- /dev/null +++ b/frontend/app/src/contexts/useCart.ts @@ -0,0 +1,12 @@ +import { useContext } from "react"; +import { CartContext } from "./CartContext"; + +export function useCart() { + const context = useContext(CartContext); + + if (!context) { + throw new Error("useCart precisa ser usado dentro de um "); + } + + return context; +} \ No newline at end of file diff --git a/frontend/app/src/main.tsx b/frontend/app/src/main.tsx index 9f859fa..0c9f991 100644 --- a/frontend/app/src/main.tsx +++ b/frontend/app/src/main.tsx @@ -3,7 +3,7 @@ import { createRoot } from 'react-dom/client' import './index.css' import './styles/components.css' import App from './App.tsx' -import { CartProvider } from './contexts/CartContext' +import { CartProvider } from './contexts/CartProvider.tsx' createRoot(document.getElementById('root')!).render( diff --git a/frontend/app/src/pages/client/Cart/index.tsx b/frontend/app/src/pages/client/Cart/index.tsx index a2dd097..2386ad5 100644 --- a/frontend/app/src/pages/client/Cart/index.tsx +++ b/frontend/app/src/pages/client/Cart/index.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { ChevronLeft, ShoppingCart, Menu as MenuIcon, Clock, ReceiptText } from "lucide-react"; -import { useCart } from "@/contexts/CartContext"; +import { useCart } from "@/contexts/useCart"; import { QuantitySelector, Button } from "@/components"; import { createOrder, CURRENT_ATTENDANCE_ID } from "@/services/orders.service"; import "./styles.css"; diff --git a/frontend/app/src/pages/client/Menu/index.tsx b/frontend/app/src/pages/client/Menu/index.tsx index 975eea0..d9cb9c3 100644 --- a/frontend/app/src/pages/client/Menu/index.tsx +++ b/frontend/app/src/pages/client/Menu/index.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { ShoppingCart } from "lucide-react"; import { fetchCategories, fetchProducts, type Category, type Product } from "@/services/menu.service"; import { ClientHeader, CategoryTabs, ProductCard } from "@/components"; -import { useCart } from "@/contexts/CartContext"; +import { useCart } from "@/contexts/useCart"; import "./styles.css"; function formatPrice(price: string) { diff --git a/frontend/app/src/pages/client/ProductDetail/index.tsx b/frontend/app/src/pages/client/ProductDetail/index.tsx index ef04022..a07e150 100644 --- a/frontend/app/src/pages/client/ProductDetail/index.tsx +++ b/frontend/app/src/pages/client/ProductDetail/index.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { ChevronLeft, ShoppingCart, Menu as MenuIcon, Clock, ReceiptText } from "lucide-react"; -import { useCart } from "@/contexts/CartContext"; +import { useCart } from "@/contexts/useCart"; import { QuantitySelector, TextAreaField, Button } from "@/components"; import type { Product } from "@/services/menu.service"; import "./styles.css"; @@ -19,14 +19,16 @@ function formatPrice(price: string) { export function ProductDetail({ product, categoryName, onBack, onAdded, onGoToCart }: ProductDetailProps) { const { itemCount, items, addItem } = useCart(); - const [quantity, setQuantity] = useState(1); - const [notes, setNotes] = useState(""); - useEffect(() => { + const [quantity, setQuantity] = useState(() => { const existingLine = items.find((line) => line.product.id === product.id); - setQuantity(existingLine?.quantity ?? 1); - setNotes(existingLine?.notes ?? ""); - }, [product.id]); + return existingLine?.quantity ?? 1; + }); + + const [notes, setNotes] = useState(() => { + const existingLine = items.find((line) => line.product.id === product.id); + return existingLine?.notes ?? ""; + }); const quantityReservedElsewhere = items .filter((line) => line.product.id === product.id && line.notes !== notes)