From 48f3af422b6950f7b6e0b1e631dffb0b1a923f9b Mon Sep 17 00:00:00 2001 From: Antonio Bernardino da Silva Date: Wed, 18 Jun 2025 20:08:48 -0300 Subject: [PATCH 1/2] feat: add appointments module --- debug-test.ts | 46 ++ .../migration.sql | 20 + .../migration.sql | 11 + .../migration.sql | 15 + prisma/schema.prisma | 58 +-- src/config/swagger.ts | 28 ++ src/core/routes/index.routes.ts | 4 +- src/modules/appointments/README.md | 65 +++ .../create-appointment.use-case.spec.ts | 461 ++++++++++++++++++ .../create-appointment.use-case.ts | 44 ++ .../delete-appointment.use-case.spec.ts | 72 +++ .../delete-appointment.use-case.ts | 16 + .../find-appointment-by-id.use-case.spec.ts | 88 ++++ .../find-appointment-by-id.use-case.ts | 10 + ...-appointments-by-space-id.use-case.spec.ts | 99 ++++ .../find-appointments-by-space-id.use-case.ts | 10 + ...d-appointments-by-user-id.use-case.spec.ts | 93 ++++ .../find-appointments-by-user-id.use-case.ts | 10 + .../update-appointment.use-case.spec.ts | 166 +++++++ .../update-appointment.use-case.ts | 35 ++ .../domain/entities/appointment.ts | 100 ++++ .../repositories/appointment-repository.ts | 13 + src/modules/appointments/index.ts | 1 + .../prisma-appointment-repository.ts | 221 +++++++++ .../create-appointment.controller.ts | 25 + .../delete-appointment.controller.ts | 14 + .../find-appointment-by-id.controller.ts | 14 + ...ind-appointments-by-space-id.controller.ts | 16 + ...find-appointments-by-user-id.controller.ts | 16 + .../presentation/controllers/index.ts | 6 + .../update-appointment.controller.ts | 22 + .../docs/create-appointment.doc.ts | 77 +++ .../docs/delete-appointment.doc.ts | 21 + .../docs/find-appointment-by-id.doc.ts | 50 ++ .../docs/find-appointments-by-space-id.doc.ts | 85 ++++ .../docs/find-appointments-by-user-id.doc.ts | 85 ++++ .../appointments/presentation/docs/index.ts | 7 + .../docs/update-appointment.doc.ts | 75 +++ .../presentation/routes/appointment-routes.ts | 20 + .../create-appointment-validator.ts | 12 + .../presentation/validators/index.ts | 2 + .../update-appointment-validator.ts | 12 + .../use-cases/create-booking-use-case.ts | 39 -- .../bookings/domain/entities/booking.ts | 17 - .../domain/repositories/booking-repository.ts | 6 - .../presentation/docs/create-rating.doc.ts | 38 +- .../presentation/docs/delete-rating.doc.ts | 23 +- .../docs/find-rating-by-id.doc.ts | 23 +- .../docs/find-ratings-by-space-id.doc.ts | 34 +- .../docs/find-ratings-by-user-id.doc.ts | 34 +- .../presentation/docs/update-rating.doc.ts | 41 +- .../presentation/routes/rating-routes.ts | 12 +- .../create-rating.use-case.spec.ts | 71 --- .../create-rating/create-rating.use-case.ts | 25 - .../delete-rating.use-case.spec.ts | 28 -- .../delete-rating/delete-rating.use-case.ts | 15 - .../find-rating-by-id.use-case.spec.ts | 33 -- .../find-rating-by-id.use-case.ts | 10 - .../find-ratings-by-space-id.use-case.spec.ts | 58 --- .../find-ratings-by-space-id.use-case.ts | 11 - .../find-ratings-by-user-id.use-case.spec.ts | 58 --- .../find-ratings-by-user-id.use-case.ts | 11 - .../get-space-average-rating.use-case.spec.ts | 29 -- .../get-space-average-rating.use-case.ts | 9 - .../update-rating.use-case.spec.ts | 67 --- .../update-rating/update-rating.use-case.ts | 31 -- src/modules/spaces/domain/entities/rating.ts | 75 --- .../domain/repositories/rating-repository.ts | 13 - .../repositories/prisma-rating-repository.ts | 109 ----- .../create-user/create-user.use-case.spec.ts | 125 +++-- .../delete-user/delete-user-use-case.spec.ts | 86 ++-- .../find-user/find-all-users-use-case.spec.ts | 75 ++- .../update-user/update-user-use-case.spec.ts | 2 +- src/modules/users/domain/entities/user.ts | 1 + test/TEST.MD | 105 +++- test/factories/make-appointment.ts | 55 +++ .../in-memory-appointments-repository.ts | 105 ++++ .../in-memory-ratings-repository.ts | 4 +- .../in-memory-users-repository.ts | 2 +- 79 files changed, 2795 insertions(+), 930 deletions(-) create mode 100644 debug-test.ts create mode 100644 prisma/migrations/20250618155007_add_appointments/migration.sql create mode 100644 prisma/migrations/20250618155144_remove_bookings/migration.sql create mode 100644 prisma/migrations/20250618155222_update_booking_status/migration.sql create mode 100644 src/modules/appointments/README.md create mode 100644 src/modules/appointments/application/use-cases/create-appointment/create-appointment.use-case.spec.ts create mode 100644 src/modules/appointments/application/use-cases/create-appointment/create-appointment.use-case.ts create mode 100644 src/modules/appointments/application/use-cases/delete-appointment/delete-appointment.use-case.spec.ts create mode 100644 src/modules/appointments/application/use-cases/delete-appointment/delete-appointment.use-case.ts create mode 100644 src/modules/appointments/application/use-cases/find-appointment/find-appointment-by-id.use-case.spec.ts create mode 100644 src/modules/appointments/application/use-cases/find-appointment/find-appointment-by-id.use-case.ts create mode 100644 src/modules/appointments/application/use-cases/find-appointment/find-appointments-by-space-id.use-case.spec.ts create mode 100644 src/modules/appointments/application/use-cases/find-appointment/find-appointments-by-space-id.use-case.ts create mode 100644 src/modules/appointments/application/use-cases/find-appointment/find-appointments-by-user-id.use-case.spec.ts create mode 100644 src/modules/appointments/application/use-cases/find-appointment/find-appointments-by-user-id.use-case.ts create mode 100644 src/modules/appointments/application/use-cases/update-appointment/update-appointment.use-case.spec.ts create mode 100644 src/modules/appointments/application/use-cases/update-appointment/update-appointment.use-case.ts create mode 100644 src/modules/appointments/domain/entities/appointment.ts create mode 100644 src/modules/appointments/domain/repositories/appointment-repository.ts create mode 100644 src/modules/appointments/index.ts create mode 100644 src/modules/appointments/infra/repositories/prisma-appointment-repository.ts create mode 100644 src/modules/appointments/presentation/controllers/create-appointment.controller.ts create mode 100644 src/modules/appointments/presentation/controllers/delete-appointment.controller.ts create mode 100644 src/modules/appointments/presentation/controllers/find-appointment-by-id.controller.ts create mode 100644 src/modules/appointments/presentation/controllers/find-appointments-by-space-id.controller.ts create mode 100644 src/modules/appointments/presentation/controllers/find-appointments-by-user-id.controller.ts create mode 100644 src/modules/appointments/presentation/controllers/index.ts create mode 100644 src/modules/appointments/presentation/controllers/update-appointment.controller.ts create mode 100644 src/modules/appointments/presentation/docs/create-appointment.doc.ts create mode 100644 src/modules/appointments/presentation/docs/delete-appointment.doc.ts create mode 100644 src/modules/appointments/presentation/docs/find-appointment-by-id.doc.ts create mode 100644 src/modules/appointments/presentation/docs/find-appointments-by-space-id.doc.ts create mode 100644 src/modules/appointments/presentation/docs/find-appointments-by-user-id.doc.ts create mode 100644 src/modules/appointments/presentation/docs/index.ts create mode 100644 src/modules/appointments/presentation/docs/update-appointment.doc.ts create mode 100644 src/modules/appointments/presentation/routes/appointment-routes.ts create mode 100644 src/modules/appointments/presentation/validators/create-appointment-validator.ts create mode 100644 src/modules/appointments/presentation/validators/index.ts create mode 100644 src/modules/appointments/presentation/validators/update-appointment-validator.ts delete mode 100644 src/modules/bookings/applications/use-cases/create-booking-use-case.ts delete mode 100644 src/modules/bookings/domain/entities/booking.ts delete mode 100644 src/modules/bookings/domain/repositories/booking-repository.ts delete mode 100644 src/modules/spaces/application/use-cases/create-rating/create-rating.use-case.spec.ts delete mode 100644 src/modules/spaces/application/use-cases/create-rating/create-rating.use-case.ts delete mode 100644 src/modules/spaces/application/use-cases/delete-rating/delete-rating.use-case.spec.ts delete mode 100644 src/modules/spaces/application/use-cases/delete-rating/delete-rating.use-case.ts delete mode 100644 src/modules/spaces/application/use-cases/find-rating-by-id/find-rating-by-id.use-case.spec.ts delete mode 100644 src/modules/spaces/application/use-cases/find-rating-by-id/find-rating-by-id.use-case.ts delete mode 100644 src/modules/spaces/application/use-cases/find-ratings-by-space-id/find-ratings-by-space-id.use-case.spec.ts delete mode 100644 src/modules/spaces/application/use-cases/find-ratings-by-space-id/find-ratings-by-space-id.use-case.ts delete mode 100644 src/modules/spaces/application/use-cases/find-ratings-by-user-id/find-ratings-by-user-id.use-case.spec.ts delete mode 100644 src/modules/spaces/application/use-cases/find-ratings-by-user-id/find-ratings-by-user-id.use-case.ts delete mode 100644 src/modules/spaces/application/use-cases/get-space-average-rating/get-space-average-rating.use-case.spec.ts delete mode 100644 src/modules/spaces/application/use-cases/get-space-average-rating/get-space-average-rating.use-case.ts delete mode 100644 src/modules/spaces/application/use-cases/update-rating/update-rating.use-case.spec.ts delete mode 100644 src/modules/spaces/application/use-cases/update-rating/update-rating.use-case.ts delete mode 100644 src/modules/spaces/domain/entities/rating.ts delete mode 100644 src/modules/spaces/domain/repositories/rating-repository.ts delete mode 100644 src/modules/spaces/infra/repositories/prisma-rating-repository.ts create mode 100644 test/factories/make-appointment.ts create mode 100644 test/repositories/in-memory-appointments-repository.ts diff --git a/debug-test.ts b/debug-test.ts new file mode 100644 index 0000000..1e03bdd --- /dev/null +++ b/debug-test.ts @@ -0,0 +1,46 @@ +import { makeAppointment } from './test/factories/make-appointment'; + +// Teste para debugar a factory +console.log('Testando factory de agendamentos atualizada...'); + +const appointment1 = makeAppointment({ spaceId: 'space-123' }); +const appointment2 = makeAppointment({ spaceId: 'space-123' }); +const appointment3 = makeAppointment({ spaceId: 'space-123' }); + +console.log('Appointment 1:', { + spaceId: appointment1.spaceId, + startTime: appointment1.startTime.toLocaleTimeString(), + endTime: appointment1.endTime.toLocaleTimeString() +}); + +console.log('Appointment 2:', { + spaceId: appointment2.spaceId, + startTime: appointment2.startTime.toLocaleTimeString(), + endTime: appointment2.endTime.toLocaleTimeString() +}); + +console.log('Appointment 3:', { + spaceId: appointment3.spaceId, + startTime: appointment3.startTime.toLocaleTimeString(), + endTime: appointment3.endTime.toLocaleTimeString() +}); + +// Verificar se há sobreposição +const hasOverlap1_2 = ( + appointment1.startTime < appointment2.endTime && + appointment2.startTime < appointment1.endTime +); + +const hasOverlap2_3 = ( + appointment2.startTime < appointment3.endTime && + appointment3.startTime < appointment2.endTime +); + +const hasOverlap1_3 = ( + appointment1.startTime < appointment3.endTime && + appointment3.startTime < appointment1.endTime +); + +console.log('Há sobreposição entre 1 e 2?', hasOverlap1_2); +console.log('Há sobreposição entre 2 e 3?', hasOverlap2_3); +console.log('Há sobreposição entre 1 e 3?', hasOverlap1_3); \ No newline at end of file diff --git a/prisma/migrations/20250618155007_add_appointments/migration.sql b/prisma/migrations/20250618155007_add_appointments/migration.sql new file mode 100644 index 0000000..ea5019a --- /dev/null +++ b/prisma/migrations/20250618155007_add_appointments/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "appointments" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "space_id" TEXT NOT NULL, + "date" TIMESTAMP(3) NOT NULL, + "start_time" TIMESTAMP(3) NOT NULL, + "end_time" TIMESTAMP(3) NOT NULL, + "status" "BookingStatus" NOT NULL DEFAULT 'PENDING', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "appointments_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "appointments" ADD CONSTRAINT "appointments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "appointments" ADD CONSTRAINT "appointments_space_id_fkey" FOREIGN KEY ("space_id") REFERENCES "spaces"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20250618155144_remove_bookings/migration.sql b/prisma/migrations/20250618155144_remove_bookings/migration.sql new file mode 100644 index 0000000..a7845db --- /dev/null +++ b/prisma/migrations/20250618155144_remove_bookings/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the `bookings` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "bookings" DROP CONSTRAINT "bookings_user_id_fkey"; + +-- DropTable +DROP TABLE "bookings"; diff --git a/prisma/migrations/20250618155222_update_booking_status/migration.sql b/prisma/migrations/20250618155222_update_booking_status/migration.sql new file mode 100644 index 0000000..0e203ae --- /dev/null +++ b/prisma/migrations/20250618155222_update_booking_status/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - The `status` column on the `appointments` table would be dropped and recreated. This will lead to data loss if there is data in the column. + +*/ +-- CreateEnum +CREATE TYPE "AppointmentStatus" AS ENUM ('PENDING', 'CONFIRMED', 'CANCELLED'); + +-- AlterTable +ALTER TABLE "appointments" DROP COLUMN "status", +ADD COLUMN "status" "AppointmentStatus" NOT NULL DEFAULT 'PENDING'; + +-- DropEnum +DROP TYPE "BookingStatus"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c0556b2..17baf6c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -29,29 +29,30 @@ model User { updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") - bookings Booking[] - space Space? - Rating Rating[] + appointments Appointment[] + space Space? + Rating Rating[] @@map("users") } model Space { - id String @id @default(uuid()) @map("id") - title String @map("title") - description String @map("description") - photos String[] @map("photos") - hostId String @unique @map("host_id") - host User @relation(fields: [hostId], references: [id]) - amenities Amenity[] @relation("space_amenities") - rules String @map("rules") + id String @id @default(uuid()) @map("id") + title String @map("title") + description String @map("description") + photos String[] @map("photos") + hostId String @unique @map("host_id") + host User @relation(fields: [hostId], references: [id]) + amenities Amenity[] @relation("space_amenities") + rules String @map("rules") address Address? geoLocation GeoLocation? - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - deletedAt DateTime? @map("deleted_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") Rating Rating[] - averageRating Float @default(0) @map("average_rating") + appointments Appointment[] + averageRating Float @default(0) @map("average_rating") @@map("spaces") } @@ -87,26 +88,27 @@ model Amenity { @@map("amenities") } -enum BookingStatus { +enum AppointmentStatus { PENDING CONFIRMED CANCELLED } -model Booking { - id String @id @default(cuid()) @map("id") - userId String @map("user_id") - spaceId String @map("space_id") - date DateTime @map("date") - startTime DateTime @map("start_time") - endTime DateTime @map("end_time") - status BookingStatus @default(PENDING) @map("status") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") +model Appointment { + id String @id @default(cuid()) @map("id") + userId String @map("user_id") + spaceId String @map("space_id") + date DateTime @map("date") + startTime DateTime @map("start_time") + endTime DateTime @map("end_time") + status AppointmentStatus @default(PENDING) @map("status") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) + space Space @relation(fields: [spaceId], references: [id]) - @@map("bookings") + @@map("appointments") } model Rating { diff --git a/src/config/swagger.ts b/src/config/swagger.ts index b8d6cee..18d8b19 100644 --- a/src/config/swagger.ts +++ b/src/config/swagger.ts @@ -5,9 +5,37 @@ import { Express } from 'express'; const options: swaggerJsdoc.Options = { definition: { openapi: '3.0.0', + basePath: '/api', + host: 'localhost:3000', + schemes: ['http'], info: { title: 'Pool Appointments API', version: '1.0.0', + description: 'API for managing appointments', + contact: { + name: 'Antonio Silva', + url: 'https://www.linkedin.com/in/tony-silva/', + email: 'contato@antoniobsilva.com.br', + }, + license: { + name: 'MIT', + url: 'https://opensource.org/licenses/MIT', + }, + servers: [ + { + url: 'http://localhost:3000', + description: 'Local server', + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + }, + }, }, }, apis: ['./src/modules/**/presentation/docs/*.ts'], diff --git a/src/core/routes/index.routes.ts b/src/core/routes/index.routes.ts index 050103b..eaa1842 100644 --- a/src/core/routes/index.routes.ts +++ b/src/core/routes/index.routes.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import { userRoutes } from '@/modules/users/presentation/routes/user-routes'; import { spaceRoutes } from '@/modules/spaces/presentation/routes/space-routes'; import { ratingRoutes } from '@/modules/ratings/presentation/routes/rating-routes'; +import { appointmentRoutes } from '@/modules/appointments/presentation/routes/appointment-routes'; export const routes = Router(); @@ -11,4 +12,5 @@ routes.get('/status', (req, res) => { routes.use('/users', userRoutes); routes.use('/spaces', spaceRoutes); -routes.use('/ratings', ratingRoutes); \ No newline at end of file +routes.use('/ratings', ratingRoutes); +routes.use('/appointments', appointmentRoutes); \ No newline at end of file diff --git a/src/modules/appointments/README.md b/src/modules/appointments/README.md new file mode 100644 index 0000000..195157c --- /dev/null +++ b/src/modules/appointments/README.md @@ -0,0 +1,65 @@ +# Módulo de Appointments + +Este módulo gerencia os agendamentos de espaços, permitindo que usuários agendem o uso de um espaço em uma data e horário específicos. + +## Funcionalidades + +- **Criar agendamento**: Cria um novo agendamento para um espaço +- **Buscar agendamento por ID**: Retorna um agendamento específico +- **Listar agendamentos por usuário**: Lista todos os agendamentos de um usuário +- **Listar agendamentos por espaço**: Lista todos os agendamentos de um espaço +- **Atualizar agendamento**: Atualiza dados de um agendamento existente +- **Cancelar agendamento**: Remove um agendamento + +## Regras de Negócio + +1. **Validação de data**: Não é possível agendar para datas passadas +2. **Validação de horário**: O horário de início deve ser menor que o horário de fim +3. **Conflito de horários**: Não é possível agendar um espaço que já possui agendamento no mesmo horário +4. **Status do agendamento**: Pode ser PENDING, CONFIRMED ou CANCELLED + +## Estrutura do Módulo + +``` +appointments/ +├── domain/ +│ ├── entities/ +│ │ └── appointment.ts +│ └── repositories/ +│ └── appointment-repository.ts +├── application/ +│ └── use-cases/ +│ ├── create-appointment/ +│ ├── find-appointment/ +│ ├── update-appointment/ +│ └── delete-appointment/ +├── infra/ +│ └── repositories/ +│ └── prisma-appointment-repository.ts +└── presentation/ + ├── controllers/ + ├── validators/ + ├── routes/ + └── docs/ +``` + +## Endpoints + +- `POST /api/appointments` - Criar agendamento +- `GET /api/appointments/:id` - Buscar agendamento por ID +- `GET /api/appointments/user/:userId` - Listar agendamentos por usuário +- `GET /api/appointments/space/:spaceId` - Listar agendamentos por espaço +- `PUT /api/appointments/:id` - Atualizar agendamento +- `DELETE /api/appointments/:id` - Deletar agendamento + +## Exemplo de Uso + +```json +{ + "userId": "user-uuid", + "spaceId": "space-uuid", + "date": "2024-01-15T00:00:00.000Z", + "startTime": "2024-01-15T10:00:00.000Z", + "endTime": "2024-01-15T12:00:00.000Z" +} +``` \ No newline at end of file diff --git a/src/modules/appointments/application/use-cases/create-appointment/create-appointment.use-case.spec.ts b/src/modules/appointments/application/use-cases/create-appointment/create-appointment.use-case.spec.ts new file mode 100644 index 0000000..5fc1a7d --- /dev/null +++ b/src/modules/appointments/application/use-cases/create-appointment/create-appointment.use-case.spec.ts @@ -0,0 +1,461 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { CreateAppointmentUseCase } from './create-appointment.use-case'; +import { InMemoryAppointmentRepository } from 'test/repositories/in-memory-appointments-repository'; +import { InMemoryUserRepository } from 'test/repositories/in-memory-users-repository'; +import { InMemorySpacesRepository } from 'test/repositories/in-memory-spaces-repository'; +import { makeUser } from 'test/factories/make-user'; +import { makeSpace } from 'test/factories/make-space'; +import { Appointment } from '@/modules/appointments/domain/entities/appointment'; +import { AppointmentStatus } from '@prisma/client'; + +describe('CreateAppointmentUseCase', () => { + let appointmentRepository: InMemoryAppointmentRepository; + let userRepository: InMemoryUserRepository; + let spaceRepository: InMemorySpacesRepository; + let createAppointmentUseCase: CreateAppointmentUseCase; + + beforeEach(() => { + appointmentRepository = new InMemoryAppointmentRepository(); + userRepository = new InMemoryUserRepository(); + spaceRepository = new InMemorySpacesRepository(); + createAppointmentUseCase = new CreateAppointmentUseCase( + appointmentRepository, + spaceRepository, + userRepository, + ); + }); + + it('should create a new appointment', async () => { + const user = await makeUser(); + const space = makeSpace(); + + await userRepository.create(user); + await spaceRepository.create(space); + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const startTime = new Date(tomorrow); + startTime.setHours(10, 0, 0, 0); + + const endTime = new Date(tomorrow); + endTime.setHours(12, 0, 0, 0); + + const appointment = await createAppointmentUseCase.execute({ + userId: user.id.toString(), + spaceId: space.id.toString(), + date: tomorrow, + startTime, + endTime, + }); + + expect(appointment).toBeInstanceOf(Appointment); + expect(appointment.userId).toBe(user.id.toString()); + expect(appointment.spaceId).toBe(space.id.toString()); + expect(appointment.status).toBe(AppointmentStatus.PENDING); + expect(appointment.date).toEqual(tomorrow); + expect(appointment.startTime).toEqual(startTime); + expect(appointment.endTime).toEqual(endTime); + + const total = await appointmentRepository.count(); + expect(total).toBe(1); + }); + + it('should throw error if user does not exist', async () => { + const space = makeSpace(); + await spaceRepository.create(space); + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const startTime = new Date(tomorrow); + startTime.setHours(10, 0, 0, 0); + + const endTime = new Date(tomorrow); + endTime.setHours(12, 0, 0, 0); + + await expect(() => + createAppointmentUseCase.execute({ + userId: 'non-existent-user', + spaceId: space.id.toString(), + date: tomorrow, + startTime, + endTime, + }), + ).rejects.toThrow('User not found'); + }); + + it('should throw error if space does not exist', async () => { + const user = await makeUser(); + await userRepository.create(user); + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const startTime = new Date(tomorrow); + startTime.setHours(10, 0, 0, 0); + + const endTime = new Date(tomorrow); + endTime.setHours(12, 0, 0, 0); + + await expect(() => + createAppointmentUseCase.execute({ + userId: user.id.toString(), + spaceId: 'non-existent-space', + date: tomorrow, + startTime, + endTime, + }), + ).rejects.toThrow('Space not found'); + }); + + it('should throw error if appointment date is in the past', async () => { + const user = await makeUser(); + const space = makeSpace(); + + await userRepository.create(user); + await spaceRepository.create(space); + + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + const startTime = new Date(yesterday); + startTime.setHours(10, 0, 0, 0); + + const endTime = new Date(yesterday); + endTime.setHours(12, 0, 0, 0); + + await expect(() => + createAppointmentUseCase.execute({ + userId: user.id.toString(), + spaceId: space.id.toString(), + date: yesterday, + startTime, + endTime, + }), + ).rejects.toThrow('Não é possível agendar para datas passadas'); + }); + + it('should throw error if start time is greater than or equal to end time', async () => { + const user = await makeUser(); + const space = makeSpace(); + + await userRepository.create(user); + await spaceRepository.create(space); + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const startTime = new Date(tomorrow); + startTime.setHours(12, 0, 0, 0); + + const endTime = new Date(tomorrow); + endTime.setHours(10, 0, 0, 0); + + await expect(() => + createAppointmentUseCase.execute({ + userId: user.id.toString(), + spaceId: space.id.toString(), + date: tomorrow, + startTime, + endTime, + }), + ).rejects.toThrow('O horário de início deve ser menor que o horário de fim'); + }); + + it('should throw error if there is a time conflict for the same space', async () => { + const user = await makeUser(); + const space = makeSpace(); + + await userRepository.create(user); + await spaceRepository.create(space); + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const startTime1 = new Date(tomorrow); + startTime1.setHours(10, 0, 0, 0); + + const endTime1 = new Date(tomorrow); + endTime1.setHours(12, 0, 0, 0); + + await createAppointmentUseCase.execute({ + userId: user.id.toString(), + spaceId: space.id.toString(), + date: tomorrow, + startTime: startTime1, + endTime: endTime1, + }); + + const startTime2 = new Date(tomorrow); + startTime2.setHours(11, 0, 0, 0); + + const endTime2 = new Date(tomorrow); + endTime2.setHours(13, 0, 0, 0); + + await expect(() => + createAppointmentUseCase.execute({ + userId: user.id.toString(), + spaceId: space.id.toString(), + date: tomorrow, + startTime: startTime2, + endTime: endTime2, + }), + ).rejects.toThrow('Já existe um agendamento para este horário neste espaço'); + }); + + it('should allow appointments for different spaces at the same time', async () => { + const user = await makeUser(); + const space1 = makeSpace({ id: 'space-1' }); + const space2 = makeSpace({ id: 'space-2' }); + + await userRepository.create(user); + await spaceRepository.create(space1); + await spaceRepository.create(space2); + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const startTime = new Date(tomorrow); + startTime.setHours(10, 0, 0, 0); + + const endTime = new Date(tomorrow); + endTime.setHours(12, 0, 0, 0); + + await createAppointmentUseCase.execute({ + userId: user.id.toString(), + spaceId: space1.id.toString(), + date: tomorrow, + startTime, + endTime, + }); + + await createAppointmentUseCase.execute({ + userId: user.id.toString(), + spaceId: space2.id.toString(), + date: tomorrow, + startTime, + endTime, + }); + + const total = await appointmentRepository.count(); + expect(total).toBe(2); + }); +}); + +describe('CreateAppointmentUseCase Concurrency', () => { + let appointmentRepository: InMemoryAppointmentRepository; + let userRepository: InMemoryUserRepository; + let spaceRepository: InMemorySpacesRepository; + let createAppointmentUseCase: CreateAppointmentUseCase; + + beforeEach(() => { + appointmentRepository = new InMemoryAppointmentRepository(); + userRepository = new InMemoryUserRepository(); + spaceRepository = new InMemorySpacesRepository(); + createAppointmentUseCase = new CreateAppointmentUseCase( + appointmentRepository, + spaceRepository, + userRepository, + ); + }); + + it('should handle concurrent appointments for different users in same space', async () => { + const user1 = await makeUser({ id: 'user-1' }); + const user2 = await makeUser({ id: 'user-2' }); + const space = makeSpace(); + + await userRepository.create(user1); + await userRepository.create(user2); + await spaceRepository.create(space); + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const startTime1 = new Date(tomorrow); + startTime1.setHours(10, 0, 0, 0); + const endTime1 = new Date(tomorrow); + endTime1.setHours(12, 0, 0, 0); + + const startTime2 = new Date(tomorrow); + startTime2.setHours(14, 0, 0, 0); + const endTime2 = new Date(tomorrow); + endTime2.setHours(16, 0, 0, 0); + + const promises = [ + createAppointmentUseCase.execute({ + userId: user1.id.toString(), + spaceId: space.id.toString(), + date: tomorrow, + startTime: startTime1, + endTime: endTime1, + }), + createAppointmentUseCase.execute({ + userId: user2.id.toString(), + spaceId: space.id.toString(), + date: tomorrow, + startTime: startTime2, + endTime: endTime2, + }), + ]; + + const results = await Promise.all(promises); + + expect(results).toHaveLength(2); + expect(results[0]).toBeInstanceOf(Appointment); + expect(results[1]).toBeInstanceOf(Appointment); + expect(results[0].userId).toBe(user1.id.toString()); + expect(results[1].userId).toBe(user2.id.toString()); + + const total = await appointmentRepository.count(); + expect(total).toBe(2); + }); + + it('should handle concurrent conflicting appointments', async () => { + const user1 = await makeUser({ id: 'user-1' }); + const user2 = await makeUser({ id: 'user-2' }); + const space = makeSpace(); + + await userRepository.create(user1); + await userRepository.create(user2); + await spaceRepository.create(space); + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const startTime = new Date(tomorrow); + startTime.setHours(10, 0, 0, 0); + const endTime = new Date(tomorrow); + endTime.setHours(12, 0, 0, 0); + + const promises = [ + createAppointmentUseCase.execute({ + userId: user1.id.toString(), + spaceId: space.id.toString(), + date: tomorrow, + startTime, + endTime, + }), + createAppointmentUseCase.execute({ + userId: user2.id.toString(), + spaceId: space.id.toString(), + date: tomorrow, + startTime, + endTime, + }), + ]; + + const results = await Promise.allSettled(promises); + + expect(results[0].status).toBe('fulfilled'); + expect(results[1].status).toBe('rejected'); + + if (results[1].status === 'rejected') { + expect(results[1].reason.message).toBe('Já existe um agendamento para este horário neste espaço'); + } + + const total = await appointmentRepository.count(); + expect(total).toBe(1); + }); + + it('should handle concurrent appointments for different spaces', async () => { + const user = await makeUser(); + const space1 = makeSpace({ id: 'space-1' }); + const space2 = makeSpace({ id: 'space-2' }); + const space3 = makeSpace({ id: 'space-3' }); + + await userRepository.create(user); + await spaceRepository.create(space1); + await spaceRepository.create(space2); + await spaceRepository.create(space3); + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const startTime = new Date(tomorrow); + startTime.setHours(10, 0, 0, 0); + const endTime = new Date(tomorrow); + endTime.setHours(12, 0, 0, 0); + + const promises = [ + createAppointmentUseCase.execute({ + userId: user.id.toString(), + spaceId: space1.id.toString(), + date: tomorrow, + startTime, + endTime, + }), + createAppointmentUseCase.execute({ + userId: user.id.toString(), + spaceId: space2.id.toString(), + date: tomorrow, + startTime, + endTime, + }), + createAppointmentUseCase.execute({ + userId: user.id.toString(), + spaceId: space3.id.toString(), + date: tomorrow, + startTime, + endTime, + }), + ]; + + const results = await Promise.all(promises); + + expect(results).toHaveLength(3); + results.forEach(result => { + expect(result).toBeInstanceOf(Appointment); + }); + + const total = await appointmentRepository.count(); + expect(total).toBe(3); + }); + + it('should handle race condition with multiple users trying to book same slot', async () => { + const users = []; + const space = makeSpace(); + + for (let i = 0; i < 5; i++) { + const user = await makeUser({ id: `user-${i}` }); + await userRepository.create(user); + users.push(user); + } + await spaceRepository.create(space); + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const startTime = new Date(tomorrow); + startTime.setHours(10, 0, 0, 0); + const endTime = new Date(tomorrow); + endTime.setHours(12, 0, 0, 0); + + const promises = users.map(user => + createAppointmentUseCase.execute({ + userId: user.id.toString(), + spaceId: space.id.toString(), + date: tomorrow, + startTime, + endTime, + }) + ); + + const results = await Promise.allSettled(promises); + + const successful = results.filter(result => result.status === 'fulfilled'); + const failed = results.filter(result => result.status === 'rejected'); + + expect(successful).toHaveLength(1); + expect(failed).toHaveLength(4); + + failed.forEach(result => { + if (result.status === 'rejected') { + expect(result.reason.message).toBe('Já existe um agendamento para este horário neste espaço'); + } + }); + + const total = await appointmentRepository.count(); + expect(total).toBe(1); + }); +}); \ No newline at end of file diff --git a/src/modules/appointments/application/use-cases/create-appointment/create-appointment.use-case.ts b/src/modules/appointments/application/use-cases/create-appointment/create-appointment.use-case.ts new file mode 100644 index 0000000..faa1f42 --- /dev/null +++ b/src/modules/appointments/application/use-cases/create-appointment/create-appointment.use-case.ts @@ -0,0 +1,44 @@ +import { AppointmentRepository } from '../../../domain/repositories/appointment-repository'; +import { SpaceRepository } from '@/modules/spaces/domain/repositories/space-repository'; +import { UserRepository } from '@/modules/users/domain/repositories/user-repository'; +import { Appointment } from '../../../domain/entities/appointment'; +import { ResourceNotFoundError } from '@/core/errors/resource-not-found-error'; + +interface CreateAppointmentDTO { + userId: string; + spaceId: string; + date: Date; + startTime: Date; + endTime: Date; +} + +export class CreateAppointmentUseCase { + constructor( + private readonly appointmentRepository: AppointmentRepository, + private readonly spaceRepository: SpaceRepository, + private readonly userRepository: UserRepository, + ) { } + + async execute(data: CreateAppointmentDTO): Promise { + const user = await this.userRepository.findById(data.userId); + if (!user) { + throw new ResourceNotFoundError('User'); + } + + const space = await this.spaceRepository.findById(data.spaceId); + if (!space) { + throw new ResourceNotFoundError('Space'); + } + + const appointment = Appointment.create({ + userId: data.userId, + spaceId: data.spaceId, + date: data.date, + startTime: data.startTime, + endTime: data.endTime, + }); + + await this.appointmentRepository.create(appointment); + return appointment; + } +} \ No newline at end of file diff --git a/src/modules/appointments/application/use-cases/delete-appointment/delete-appointment.use-case.spec.ts b/src/modules/appointments/application/use-cases/delete-appointment/delete-appointment.use-case.spec.ts new file mode 100644 index 0000000..a30501d --- /dev/null +++ b/src/modules/appointments/application/use-cases/delete-appointment/delete-appointment.use-case.spec.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { DeleteAppointmentUseCase } from './delete-appointment.use-case'; +import { InMemoryAppointmentRepository } from 'test/repositories/in-memory-appointments-repository'; +import { makeAppointment } from 'test/factories/make-appointment'; +import { AppointmentStatus } from '@prisma/client'; + +describe('DeleteAppointmentUseCase', () => { + let appointmentRepository: InMemoryAppointmentRepository; + let deleteAppointmentUseCase: DeleteAppointmentUseCase; + + beforeEach(() => { + appointmentRepository = new InMemoryAppointmentRepository(); + deleteAppointmentUseCase = new DeleteAppointmentUseCase(appointmentRepository); + }); + + it('should delete an appointment', async () => { + const appointment = makeAppointment(); + await appointmentRepository.create(appointment); + + await deleteAppointmentUseCase.execute(appointment.id.toString()); + + const deletedAppointment = await appointmentRepository.findById(appointment.id.toString()); + expect(deletedAppointment).toBeNull(); + + const total = await appointmentRepository.count(); + expect(total).toBe(0); + }); + + it('should throw error when appointment not found', async () => { + await expect(() => + deleteAppointmentUseCase.execute('non-existent-id'), + ).rejects.toThrow('Appointment not found'); + }); + + it('should delete appointment with different statuses', async () => { + const pendingAppointment = makeAppointment({ status: AppointmentStatus.PENDING }); + const confirmedAppointment = makeAppointment({ status: AppointmentStatus.CONFIRMED }); + const cancelledAppointment = makeAppointment({ status: AppointmentStatus.CANCELLED }); + + await appointmentRepository.create(pendingAppointment); + await appointmentRepository.create(confirmedAppointment); + await appointmentRepository.create(cancelledAppointment); + + await deleteAppointmentUseCase.execute(pendingAppointment.id.toString()); + await deleteAppointmentUseCase.execute(confirmedAppointment.id.toString()); + await deleteAppointmentUseCase.execute(cancelledAppointment.id.toString()); + + const total = await appointmentRepository.count(); + expect(total).toBe(0); + }); + + it('should delete multiple appointments', async () => { + const appointment1 = makeAppointment(); + const appointment2 = makeAppointment(); + const appointment3 = makeAppointment(); + + await appointmentRepository.create(appointment1); + await appointmentRepository.create(appointment2); + await appointmentRepository.create(appointment3); + + expect(await appointmentRepository.count()).toBe(3); + + await deleteAppointmentUseCase.execute(appointment1.id.toString()); + await deleteAppointmentUseCase.execute(appointment2.id.toString()); + + expect(await appointmentRepository.count()).toBe(1); + + const remainingAppointment = await appointmentRepository.findById(appointment3.id.toString()); + expect(remainingAppointment).toBeDefined(); + expect(remainingAppointment?.id.toString()).toBe(appointment3.id.toString()); + }); +}); \ No newline at end of file diff --git a/src/modules/appointments/application/use-cases/delete-appointment/delete-appointment.use-case.ts b/src/modules/appointments/application/use-cases/delete-appointment/delete-appointment.use-case.ts new file mode 100644 index 0000000..d1a4685 --- /dev/null +++ b/src/modules/appointments/application/use-cases/delete-appointment/delete-appointment.use-case.ts @@ -0,0 +1,16 @@ +import { AppointmentRepository } from '../../../domain/repositories/appointment-repository'; +import { ResourceNotFoundError } from '@/core/errors/resource-not-found-error'; + +export class DeleteAppointmentUseCase { + constructor(private readonly appointmentRepository: AppointmentRepository) { } + + async execute(id: string): Promise { + const appointment = await this.appointmentRepository.findById(id); + + if (!appointment) { + throw new ResourceNotFoundError('Appointment'); + } + + await this.appointmentRepository.delete(id); + } +} \ No newline at end of file diff --git a/src/modules/appointments/application/use-cases/find-appointment/find-appointment-by-id.use-case.spec.ts b/src/modules/appointments/application/use-cases/find-appointment/find-appointment-by-id.use-case.spec.ts new file mode 100644 index 0000000..d3f8e05 --- /dev/null +++ b/src/modules/appointments/application/use-cases/find-appointment/find-appointment-by-id.use-case.spec.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { FindAppointmentByIdUseCase } from './find-appointment-by-id.use-case'; +import { InMemoryAppointmentRepository } from 'test/repositories/in-memory-appointments-repository'; +import { makeAppointment } from 'test/factories/make-appointment'; +import { AppointmentStatus } from '@prisma/client'; + +describe('FindAppointmentByIdUseCase', () => { + let appointmentRepository: InMemoryAppointmentRepository; + let findAppointmentByIdUseCase: FindAppointmentByIdUseCase; + + beforeEach(() => { + appointmentRepository = new InMemoryAppointmentRepository(); + findAppointmentByIdUseCase = new FindAppointmentByIdUseCase(appointmentRepository); + }); + + it('should find appointment by ID', async () => { + const appointment = makeAppointment(); + await appointmentRepository.create(appointment); + + const result = await findAppointmentByIdUseCase.execute(appointment.id.toString()); + + expect(result).toBeDefined(); + expect(result?.id.toString()).toBe(appointment.id.toString()); + expect(result?.userId).toBe(appointment.userId); + expect(result?.spaceId).toBe(appointment.spaceId); + expect(result?.status).toBe(appointment.status); + expect(result?.date).toEqual(appointment.date); + expect(result?.startTime).toEqual(appointment.startTime); + expect(result?.endTime).toEqual(appointment.endTime); + }); + + it('should return null when appointment not found', async () => { + const result = await findAppointmentByIdUseCase.execute('non-existent-id'); + expect(result).toBeNull(); + }); + + it('should find appointment with different statuses', async () => { + const pendingAppointment = makeAppointment({ status: AppointmentStatus.PENDING }); + const confirmedAppointment = makeAppointment({ status: AppointmentStatus.CONFIRMED }); + const cancelledAppointment = makeAppointment({ status: AppointmentStatus.CANCELLED }); + + await appointmentRepository.create(pendingAppointment); + await appointmentRepository.create(confirmedAppointment); + await appointmentRepository.create(cancelledAppointment); + + const pendingResult = await findAppointmentByIdUseCase.execute(pendingAppointment.id.toString()); + const confirmedResult = await findAppointmentByIdUseCase.execute(confirmedAppointment.id.toString()); + const cancelledResult = await findAppointmentByIdUseCase.execute(cancelledAppointment.id.toString()); + + expect(pendingResult?.status).toBe(AppointmentStatus.PENDING); + expect(confirmedResult?.status).toBe(AppointmentStatus.CONFIRMED); + expect(cancelledResult?.status).toBe(AppointmentStatus.CANCELLED); + }); + + it('should find appointment with all fields', async () => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const startTime = new Date(tomorrow); + startTime.setHours(10, 0, 0, 0); + + const endTime = new Date(tomorrow); + endTime.setHours(12, 0, 0, 0); + + const appointment = makeAppointment({ + userId: 'user-123', + spaceId: 'space-456', + date: tomorrow, + startTime, + endTime, + status: AppointmentStatus.CONFIRMED, + }); + + await appointmentRepository.create(appointment); + + const result = await findAppointmentByIdUseCase.execute(appointment.id.toString()); + + expect(result).toBeDefined(); + expect(result?.userId).toBe('user-123'); + expect(result?.spaceId).toBe('space-456'); + expect(result?.date).toEqual(tomorrow); + expect(result?.startTime).toEqual(startTime); + expect(result?.endTime).toEqual(endTime); + expect(result?.status).toBe(AppointmentStatus.CONFIRMED); + expect(result?.createdAt).toBeInstanceOf(Date); + expect(result?.updatedAt).toBeInstanceOf(Date); + }); +}); \ No newline at end of file diff --git a/src/modules/appointments/application/use-cases/find-appointment/find-appointment-by-id.use-case.ts b/src/modules/appointments/application/use-cases/find-appointment/find-appointment-by-id.use-case.ts new file mode 100644 index 0000000..d3fc6b3 --- /dev/null +++ b/src/modules/appointments/application/use-cases/find-appointment/find-appointment-by-id.use-case.ts @@ -0,0 +1,10 @@ +import { AppointmentRepository } from '../../../domain/repositories/appointment-repository'; +import { Appointment } from '../../../domain/entities/appointment'; + +export class FindAppointmentByIdUseCase { + constructor(private readonly appointmentRepository: AppointmentRepository) { } + + async execute(id: string): Promise { + return this.appointmentRepository.findById(id); + } +} \ No newline at end of file diff --git a/src/modules/appointments/application/use-cases/find-appointment/find-appointments-by-space-id.use-case.spec.ts b/src/modules/appointments/application/use-cases/find-appointment/find-appointments-by-space-id.use-case.spec.ts new file mode 100644 index 0000000..1b65fac --- /dev/null +++ b/src/modules/appointments/application/use-cases/find-appointment/find-appointments-by-space-id.use-case.spec.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { FindAppointmentsBySpaceIdUseCase } from './find-appointments-by-space-id.use-case'; +import { InMemoryAppointmentRepository } from 'test/repositories/in-memory-appointments-repository'; +import { makeAppointment } from 'test/factories/make-appointment'; +import { AppointmentStatus } from '@prisma/client'; + +describe('FindAppointmentsBySpaceIdUseCase', () => { + let appointmentRepository: InMemoryAppointmentRepository; + let findAppointmentsBySpaceIdUseCase: FindAppointmentsBySpaceIdUseCase; + + beforeEach(() => { + appointmentRepository = new InMemoryAppointmentRepository(); + findAppointmentsBySpaceIdUseCase = new FindAppointmentsBySpaceIdUseCase(appointmentRepository); + }); + + it('should return paginated appointments with total', async () => { + const spaceId = 'space-123'; + + await appointmentRepository.create(makeAppointment({ spaceId })); + await appointmentRepository.create(makeAppointment({ spaceId })); + await appointmentRepository.create(makeAppointment({ spaceId: 'other-space' })); + + const result = await findAppointmentsBySpaceIdUseCase.execute(spaceId, { page: 1, perPage: 10 }); + + expect(result.total).toBe(2); + expect(result.appointments).toHaveLength(2); + expect(result.appointments[0].spaceId).toBe(spaceId); + expect(result.appointments[1].spaceId).toBe(spaceId); + }); + + it('should return empty list when no appointments exist for space', async () => { + const result = await findAppointmentsBySpaceIdUseCase.execute('space-123', { page: 1, perPage: 10 }); + + expect(result.total).toBe(0); + expect(result.appointments).toHaveLength(0); + }); + + it('should return correct page of appointments', async () => { + const spaceId = 'space-123'; + + for (let i = 0; i < 5; i++) { + const startTime = new Date(); + startTime.setDate(startTime.getDate() + 1); + startTime.setHours(9 + i * 3, 0, 0, 0); // 3 horas de intervalo + const endTime = new Date(startTime); + endTime.setHours(startTime.getHours() + 2, 0, 0, 0); // 2 horas de duração + + await appointmentRepository.create(makeAppointment({ spaceId, startTime, endTime })); + } + + const result = await findAppointmentsBySpaceIdUseCase.execute(spaceId, { page: 2, perPage: 2 }); + + expect(result.total).toBe(5); + expect(result.appointments).toHaveLength(2); + }); + + it('should return appointments with different statuses', async () => { + const spaceId = 'space-123'; + + await appointmentRepository.create(makeAppointment({ spaceId, status: AppointmentStatus.PENDING })); + await appointmentRepository.create(makeAppointment({ spaceId, status: AppointmentStatus.CONFIRMED })); + await appointmentRepository.create(makeAppointment({ spaceId, status: AppointmentStatus.CANCELLED })); + + const result = await findAppointmentsBySpaceIdUseCase.execute(spaceId, { page: 1, perPage: 10 }); + + expect(result.total).toBe(3); + expect(result.appointments).toHaveLength(3); + + const statuses = result.appointments.map(a => a.status); + expect(statuses).toContain(AppointmentStatus.PENDING); + expect(statuses).toContain(AppointmentStatus.CONFIRMED); + expect(statuses).toContain(AppointmentStatus.CANCELLED); + }); + + it('should return last page correctly', async () => { + const spaceId = 'space-123'; + + for (let i = 0; i < 3; i++) { + await appointmentRepository.create(makeAppointment({ spaceId })); + } + + const result = await findAppointmentsBySpaceIdUseCase.execute(spaceId, { page: 2, perPage: 2 }); + + expect(result.total).toBe(3); + expect(result.appointments).toHaveLength(1); + }); + + it('should handle pagination with default values', async () => { + const spaceId = 'space-123'; + + await appointmentRepository.create(makeAppointment({ spaceId })); + await appointmentRepository.create(makeAppointment({ spaceId })); + + const result = await findAppointmentsBySpaceIdUseCase.execute(spaceId, {}); + + expect(result.total).toBe(2); + expect(result.appointments).toHaveLength(2); + }); +}); \ No newline at end of file diff --git a/src/modules/appointments/application/use-cases/find-appointment/find-appointments-by-space-id.use-case.ts b/src/modules/appointments/application/use-cases/find-appointment/find-appointments-by-space-id.use-case.ts new file mode 100644 index 0000000..a377ce3 --- /dev/null +++ b/src/modules/appointments/application/use-cases/find-appointment/find-appointments-by-space-id.use-case.ts @@ -0,0 +1,10 @@ +import { AppointmentRepository } from '../../../domain/repositories/appointment-repository'; +import { PaginationParams } from '@/core/repositories/pagination-params'; + +export class FindAppointmentsBySpaceIdUseCase { + constructor(private readonly appointmentRepository: AppointmentRepository) { } + + async execute(spaceId: string, params: PaginationParams) { + return await this.appointmentRepository.findBySpaceId(spaceId, params); + } +} \ No newline at end of file diff --git a/src/modules/appointments/application/use-cases/find-appointment/find-appointments-by-user-id.use-case.spec.ts b/src/modules/appointments/application/use-cases/find-appointment/find-appointments-by-user-id.use-case.spec.ts new file mode 100644 index 0000000..7afa90b --- /dev/null +++ b/src/modules/appointments/application/use-cases/find-appointment/find-appointments-by-user-id.use-case.spec.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { FindAppointmentsByUserIdUseCase } from './find-appointments-by-user-id.use-case'; +import { InMemoryAppointmentRepository } from 'test/repositories/in-memory-appointments-repository'; +import { makeAppointment } from 'test/factories/make-appointment'; +import { AppointmentStatus } from '@prisma/client'; + +describe('FindAppointmentsByUserIdUseCase', () => { + let appointmentRepository: InMemoryAppointmentRepository; + let findAppointmentsByUserIdUseCase: FindAppointmentsByUserIdUseCase; + + beforeEach(() => { + appointmentRepository = new InMemoryAppointmentRepository(); + findAppointmentsByUserIdUseCase = new FindAppointmentsByUserIdUseCase(appointmentRepository); + }); + + it('should return paginated appointments with total', async () => { + const userId = 'user-123'; + + await appointmentRepository.create(makeAppointment({ userId })); + await appointmentRepository.create(makeAppointment({ userId })); + await appointmentRepository.create(makeAppointment({ userId: 'other-user' })); + + const result = await findAppointmentsByUserIdUseCase.execute(userId, { page: 1, perPage: 10 }); + + expect(result.total).toBe(2); + expect(result.appointments).toHaveLength(2); + expect(result.appointments[0].userId).toBe(userId); + expect(result.appointments[1].userId).toBe(userId); + }); + + it('should return empty list when no appointments exist for user', async () => { + const result = await findAppointmentsByUserIdUseCase.execute('user-123', { page: 1, perPage: 10 }); + + expect(result.total).toBe(0); + expect(result.appointments).toHaveLength(0); + }); + + it('should return correct page of appointments', async () => { + const userId = 'user-123'; + + for (let i = 0; i < 5; i++) { + await appointmentRepository.create(makeAppointment({ userId })); + } + + const result = await findAppointmentsByUserIdUseCase.execute(userId, { page: 2, perPage: 2 }); + + expect(result.total).toBe(5); + expect(result.appointments).toHaveLength(2); + }); + + it('should return appointments with different statuses', async () => { + const userId = 'user-123'; + + await appointmentRepository.create(makeAppointment({ userId, status: AppointmentStatus.PENDING })); + await appointmentRepository.create(makeAppointment({ userId, status: AppointmentStatus.CONFIRMED })); + await appointmentRepository.create(makeAppointment({ userId, status: AppointmentStatus.CANCELLED })); + + const result = await findAppointmentsByUserIdUseCase.execute(userId, { page: 1, perPage: 10 }); + + expect(result.total).toBe(3); + expect(result.appointments).toHaveLength(3); + + const statuses = result.appointments.map(a => a.status); + expect(statuses).toContain(AppointmentStatus.PENDING); + expect(statuses).toContain(AppointmentStatus.CONFIRMED); + expect(statuses).toContain(AppointmentStatus.CANCELLED); + }); + + it('should return last page correctly', async () => { + const userId = 'user-123'; + + for (let i = 0; i < 3; i++) { + await appointmentRepository.create(makeAppointment({ userId })); + } + + const result = await findAppointmentsByUserIdUseCase.execute(userId, { page: 2, perPage: 2 }); + + expect(result.total).toBe(3); + expect(result.appointments).toHaveLength(1); + }); + + it('should handle pagination with default values', async () => { + const userId = 'user-123'; + + await appointmentRepository.create(makeAppointment({ userId })); + await appointmentRepository.create(makeAppointment({ userId })); + + const result = await findAppointmentsByUserIdUseCase.execute(userId, {}); + + expect(result.total).toBe(2); + expect(result.appointments).toHaveLength(2); + }); +}); \ No newline at end of file diff --git a/src/modules/appointments/application/use-cases/find-appointment/find-appointments-by-user-id.use-case.ts b/src/modules/appointments/application/use-cases/find-appointment/find-appointments-by-user-id.use-case.ts new file mode 100644 index 0000000..78e108a --- /dev/null +++ b/src/modules/appointments/application/use-cases/find-appointment/find-appointments-by-user-id.use-case.ts @@ -0,0 +1,10 @@ +import { AppointmentRepository } from '../../../domain/repositories/appointment-repository'; +import { PaginationParams } from '@/core/repositories/pagination-params'; + +export class FindAppointmentsByUserIdUseCase { + constructor(private readonly appointmentRepository: AppointmentRepository) { } + + async execute(userId: string, params: PaginationParams) { + return await this.appointmentRepository.findByUserId(userId, params); + } +} \ No newline at end of file diff --git a/src/modules/appointments/application/use-cases/update-appointment/update-appointment.use-case.spec.ts b/src/modules/appointments/application/use-cases/update-appointment/update-appointment.use-case.spec.ts new file mode 100644 index 0000000..8eff6ce --- /dev/null +++ b/src/modules/appointments/application/use-cases/update-appointment/update-appointment.use-case.spec.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { UpdateAppointmentUseCase } from './update-appointment.use-case'; +import { InMemoryAppointmentRepository } from 'test/repositories/in-memory-appointments-repository'; +import { makeAppointment } from 'test/factories/make-appointment'; +import { AppointmentStatus } from '@prisma/client'; + +describe('UpdateAppointmentUseCase', () => { + let appointmentRepository: InMemoryAppointmentRepository; + let updateAppointmentUseCase: UpdateAppointmentUseCase; + + beforeEach(() => { + appointmentRepository = new InMemoryAppointmentRepository(); + updateAppointmentUseCase = new UpdateAppointmentUseCase(appointmentRepository); + }); + + it('should update appointment status', async () => { + const appointment = makeAppointment({ status: AppointmentStatus.PENDING }); + await appointmentRepository.create(appointment); + + const updatedAppointment = await updateAppointmentUseCase.execute({ + id: appointment.id.toString(), + status: AppointmentStatus.CONFIRMED, + }); + + expect(updatedAppointment.status).toBe(AppointmentStatus.CONFIRMED); + expect(updatedAppointment.updatedAt).toBeInstanceOf(Date); + }); + + it('should update appointment date and time', async () => { + const appointment = makeAppointment(); + await appointmentRepository.create(appointment); + + const newDate = new Date(); + newDate.setDate(newDate.getDate() + 2); + + const newStartTime = new Date(newDate); + newStartTime.setHours(14, 0, 0, 0); + + const newEndTime = new Date(newDate); + newEndTime.setHours(16, 0, 0, 0); + + const updatedAppointment = await updateAppointmentUseCase.execute({ + id: appointment.id.toString(), + date: newDate, + startTime: newStartTime, + endTime: newEndTime, + }); + + expect(updatedAppointment.date).toEqual(newDate); + expect(updatedAppointment.startTime).toEqual(newStartTime); + expect(updatedAppointment.endTime).toEqual(newEndTime); + expect(updatedAppointment.updatedAt).toBeInstanceOf(Date); + }); + + it('should update multiple fields at once', async () => { + const appointment = makeAppointment({ status: AppointmentStatus.PENDING }); + await appointmentRepository.create(appointment); + + const newDate = new Date(); + newDate.setDate(newDate.getDate() + 2); + + const newStartTime = new Date(newDate); + newStartTime.setHours(14, 0, 0, 0); + + const newEndTime = new Date(newDate); + newEndTime.setHours(16, 0, 0, 0); + + const updatedAppointment = await updateAppointmentUseCase.execute({ + id: appointment.id.toString(), + date: newDate, + startTime: newStartTime, + endTime: newEndTime, + status: AppointmentStatus.CONFIRMED, + }); + + expect(updatedAppointment.date).toEqual(newDate); + expect(updatedAppointment.startTime).toEqual(newStartTime); + expect(updatedAppointment.endTime).toEqual(newEndTime); + expect(updatedAppointment.status).toBe(AppointmentStatus.CONFIRMED); + expect(updatedAppointment.updatedAt).toBeInstanceOf(Date); + }); + + it('should throw error when appointment not found', async () => { + await expect(() => + updateAppointmentUseCase.execute({ + id: 'non-existent-id', + status: AppointmentStatus.CONFIRMED, + }), + ).rejects.toThrow('Appointment not found'); + }); + + it('should throw error if new date is in the past', async () => { + const appointment = makeAppointment(); + await appointmentRepository.create(appointment); + + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + const startTime = new Date(yesterday); + startTime.setHours(10, 0, 0, 0); + + const endTime = new Date(yesterday); + endTime.setHours(12, 0, 0, 0); + + await expect(() => + updateAppointmentUseCase.execute({ + id: appointment.id.toString(), + date: yesterday, + startTime, + endTime, + }), + ).rejects.toThrow('Não é possível agendar para datas passadas'); + }); + + it('should throw error if start time is greater than or equal to end time', async () => { + const appointment = makeAppointment(); + await appointmentRepository.create(appointment); + + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + const startTime = new Date(tomorrow); + startTime.setHours(12, 0, 0, 0); + + const endTime = new Date(tomorrow); + endTime.setHours(10, 0, 0, 0); + + await expect(() => + updateAppointmentUseCase.execute({ + id: appointment.id.toString(), + date: tomorrow, + startTime, + endTime, + }), + ).rejects.toThrow('O horário de início deve ser menor que o horário de fim'); + }); + + it('should maintain unchanged fields when only status is updated', async () => { + const originalDate = new Date(); + originalDate.setDate(originalDate.getDate() + 1); + + const originalStartTime = new Date(originalDate); + originalStartTime.setHours(10, 0, 0, 0); + + const originalEndTime = new Date(originalDate); + originalEndTime.setHours(12, 0, 0, 0); + + const appointment = makeAppointment({ + date: originalDate, + startTime: originalStartTime, + endTime: originalEndTime, + status: AppointmentStatus.PENDING, + }); + await appointmentRepository.create(appointment); + + const updatedAppointment = await updateAppointmentUseCase.execute({ + id: appointment.id.toString(), + status: AppointmentStatus.CONFIRMED, + }); + + expect(updatedAppointment.date).toEqual(originalDate); + expect(updatedAppointment.startTime).toEqual(originalStartTime); + expect(updatedAppointment.endTime).toEqual(originalEndTime); + expect(updatedAppointment.status).toBe(AppointmentStatus.CONFIRMED); + }); +}); \ No newline at end of file diff --git a/src/modules/appointments/application/use-cases/update-appointment/update-appointment.use-case.ts b/src/modules/appointments/application/use-cases/update-appointment/update-appointment.use-case.ts new file mode 100644 index 0000000..3ca90c2 --- /dev/null +++ b/src/modules/appointments/application/use-cases/update-appointment/update-appointment.use-case.ts @@ -0,0 +1,35 @@ +import { AppointmentRepository } from '../../../domain/repositories/appointment-repository'; +import { Appointment } from '../../../domain/entities/appointment'; +import { ResourceNotFoundError } from '@/core/errors/resource-not-found-error'; +import { AppointmentStatus } from '@prisma/client'; + +interface UpdateAppointmentDTO { + id: string; + date?: Date; + startTime?: Date; + endTime?: Date; + status?: AppointmentStatus; +} + +export class UpdateAppointmentUseCase { + constructor(private readonly appointmentRepository: AppointmentRepository) { } + + async execute(data: UpdateAppointmentDTO): Promise { + const appointment = await this.appointmentRepository.findById(data.id); + + if (!appointment) { + throw new ResourceNotFoundError('Appointment'); + } + + if (data.status) { + appointment.updateStatus(data.status); + } + + if (data.date && data.startTime && data.endTime) { + appointment.updateDateTime(data.date, data.startTime, data.endTime); + } + + await this.appointmentRepository.update(appointment); + return appointment; + } +} \ No newline at end of file diff --git a/src/modules/appointments/domain/entities/appointment.ts b/src/modules/appointments/domain/entities/appointment.ts new file mode 100644 index 0000000..17757b5 --- /dev/null +++ b/src/modules/appointments/domain/entities/appointment.ts @@ -0,0 +1,100 @@ +import { Entity } from '@/core/entities/entity'; +import { UniqueEntityID } from '@/core/entities/unique-entity-id'; +import { Optional } from '@/core/types/optional'; +import { AppointmentStatus } from '@prisma/client'; + +export interface AppointmentProps { + userId: string; + spaceId: string; + date: Date; + startTime: Date; + endTime: Date; + status: AppointmentStatus; + createdAt: Date; + updatedAt: Date; +} + +export class Appointment extends Entity { + get userId(): string { + return this.props.userId; + } + + get spaceId(): string { + return this.props.spaceId; + } + + get date(): Date { + return this.props.date; + } + + get startTime(): Date { + return this.props.startTime; + } + + get endTime(): Date { + return this.props.endTime; + } + + get status(): AppointmentStatus { + return this.props.status; + } + + get createdAt(): Date { + return this.props.createdAt; + } + + get updatedAt(): Date { + return this.props.updatedAt; + } + + private touch() { + this.props.updatedAt = new Date(); + } + + updateStatus(status: AppointmentStatus): void { + this.props.status = status; + this.touch(); + } + + updateDateTime(date: Date, startTime: Date, endTime: Date): void { + const now = new Date(); + if (date < now) { + throw new Error('Não é possível agendar para datas passadas'); + } + + if (startTime >= endTime) { + throw new Error('O horário de início deve ser menor que o horário de fim'); + } + + this.props.date = date; + this.props.startTime = startTime; + this.props.endTime = endTime; + this.touch(); + } + + static create( + props: Optional, + id?: UniqueEntityID, + ): Appointment { + const now = new Date(); + if (props.date < now) { + throw new Error('Não é possível agendar para datas passadas'); + } + + if (props.startTime >= props.endTime) { + throw new Error('O horário de início deve ser menor que o horário de fim'); + } + + const appointment = new Appointment( + { + ...props, + status: props.status ?? 'PENDING', + createdAt: props.createdAt ?? new Date(), + updatedAt: props.updatedAt ?? new Date(), + }, + id, + ); + + return appointment; + } +} \ No newline at end of file diff --git a/src/modules/appointments/domain/repositories/appointment-repository.ts b/src/modules/appointments/domain/repositories/appointment-repository.ts new file mode 100644 index 0000000..3d07108 --- /dev/null +++ b/src/modules/appointments/domain/repositories/appointment-repository.ts @@ -0,0 +1,13 @@ +import { PaginationParams } from '@/core/repositories/pagination-params'; +import { Appointment } from '../entities/appointment'; + +export interface AppointmentRepository { + create(appointment: Appointment): Promise; + findById(id: string): Promise; + findByUserId(userId: string, params: PaginationParams): Promise<{ total: number; appointments: Appointment[] }>; + findBySpaceId(spaceId: string, params: PaginationParams): Promise<{ total: number; appointments: Appointment[] }>; + findByDateRange(spaceId: string, startDate: Date, endDate: Date): Promise; + update(appointment: Appointment): Promise; + delete(id: string): Promise; + count(): Promise; +} \ No newline at end of file diff --git a/src/modules/appointments/index.ts b/src/modules/appointments/index.ts new file mode 100644 index 0000000..070e841 --- /dev/null +++ b/src/modules/appointments/index.ts @@ -0,0 +1 @@ +export { appointmentRoutes } from './presentation/routes/appointment-routes'; \ No newline at end of file diff --git a/src/modules/appointments/infra/repositories/prisma-appointment-repository.ts b/src/modules/appointments/infra/repositories/prisma-appointment-repository.ts new file mode 100644 index 0000000..2b868ed --- /dev/null +++ b/src/modules/appointments/infra/repositories/prisma-appointment-repository.ts @@ -0,0 +1,221 @@ +import { PrismaClient, AppointmentStatus } from '@prisma/client'; +import { AppointmentRepository } from '../../domain/repositories/appointment-repository'; +import { Appointment } from '../../domain/entities/appointment'; +import { PaginationParams } from '@/core/repositories/pagination-params'; +import { UniqueEntityID } from '@/core/entities/unique-entity-id'; + +export class PrismaAppointmentRepository implements AppointmentRepository { + constructor(private prisma: PrismaClient) { } + + async create(appointment: Appointment): Promise { + // Verificar conflitos antes de criar o agendamento + const conflictingAppointments = await this.findByDateRange( + appointment.spaceId, + appointment.startTime, + appointment.endTime + ); + + if (conflictingAppointments.length > 0) { + throw new Error('Já existe um agendamento para este horário neste espaço'); + } + + await this.prisma.appointment.create({ + data: { + id: appointment.id.toString(), + userId: appointment.userId, + spaceId: appointment.spaceId, + date: appointment.date, + startTime: appointment.startTime, + endTime: appointment.endTime, + status: appointment.status, + createdAt: appointment.createdAt, + updatedAt: appointment.updatedAt, + }, + }); + } + + async findById(id: string): Promise { + const appointment = await this.prisma.appointment.findUnique({ + where: { id }, + }); + + if (!appointment) { + return null; + } + + return Appointment.create( + { + userId: appointment.userId, + spaceId: appointment.spaceId, + date: appointment.date, + startTime: appointment.startTime, + endTime: appointment.endTime, + status: appointment.status, + createdAt: appointment.createdAt, + updatedAt: appointment.updatedAt, + }, + new UniqueEntityID(appointment.id), + ); + } + + async findByUserId( + userId: string, + params: PaginationParams, + ): Promise<{ total: number; appointments: Appointment[] }> { + const skip = ((params.page ?? 1) - 1) * (params.perPage ?? 10); + + const [appointments, total] = await Promise.all([ + this.prisma.appointment.findMany({ + where: { userId }, + skip, + take: params.perPage ?? 10, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.appointment.count({ + where: { userId }, + }), + ]); + + return { + total, + appointments: appointments.map( + (appointment) => + Appointment.create( + { + userId: appointment.userId, + spaceId: appointment.spaceId, + date: appointment.date, + startTime: appointment.startTime, + endTime: appointment.endTime, + status: appointment.status, + createdAt: appointment.createdAt, + updatedAt: appointment.updatedAt, + }, + new UniqueEntityID(appointment.id), + ), + ), + }; + } + + async findBySpaceId( + spaceId: string, + params: PaginationParams, + ): Promise<{ total: number; appointments: Appointment[] }> { + const skip = ((params.page ?? 1) - 1) * (params.perPage ?? 10); + + const [appointments, total] = await Promise.all([ + this.prisma.appointment.findMany({ + where: { spaceId }, + skip, + take: params.perPage ?? 10, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.appointment.count({ + where: { spaceId }, + }), + ]); + + return { + total, + appointments: appointments.map( + (appointment) => + Appointment.create( + { + userId: appointment.userId, + spaceId: appointment.spaceId, + date: appointment.date, + startTime: appointment.startTime, + endTime: appointment.endTime, + status: appointment.status, + createdAt: appointment.createdAt, + updatedAt: appointment.updatedAt, + }, + new UniqueEntityID(appointment.id), + ), + ), + }; + } + + async findByDateRange( + spaceId: string, + startDate: Date, + endDate: Date, + ): Promise { + const appointments = await this.prisma.appointment.findMany({ + where: { + spaceId, + AND: [ + { + OR: [ + { + startTime: { + gte: startDate, + lt: endDate, + }, + }, + { + endTime: { + gt: startDate, + lte: endDate, + }, + }, + { + AND: [ + { startTime: { lte: startDate } }, + { endTime: { gte: endDate } }, + ], + }, + ], + }, + { + status: { + not: AppointmentStatus.CANCELLED, + }, + }, + ], + }, + }); + + return appointments.map( + (appointment) => + Appointment.create( + { + userId: appointment.userId, + spaceId: appointment.spaceId, + date: appointment.date, + startTime: appointment.startTime, + endTime: appointment.endTime, + status: appointment.status, + createdAt: appointment.createdAt, + updatedAt: appointment.updatedAt, + }, + new UniqueEntityID(appointment.id), + ), + ); + } + + async update(appointment: Appointment): Promise { + await this.prisma.appointment.update({ + where: { id: appointment.id.toString() }, + data: { + userId: appointment.userId, + spaceId: appointment.spaceId, + date: appointment.date, + startTime: appointment.startTime, + endTime: appointment.endTime, + status: appointment.status, + updatedAt: appointment.updatedAt, + }, + }); + } + + async delete(id: string): Promise { + await this.prisma.appointment.delete({ + where: { id }, + }); + } + + async count(): Promise { + return await this.prisma.appointment.count(); + } +} \ No newline at end of file diff --git a/src/modules/appointments/presentation/controllers/create-appointment.controller.ts b/src/modules/appointments/presentation/controllers/create-appointment.controller.ts new file mode 100644 index 0000000..c4c7eaf --- /dev/null +++ b/src/modules/appointments/presentation/controllers/create-appointment.controller.ts @@ -0,0 +1,25 @@ +import { Request, Response } from 'express'; +import { PrismaAppointmentRepository } from '../../infra/repositories/prisma-appointment-repository'; +import { PrismaSpaceRepository } from '@/modules/spaces/infra/repositories/prisma-space-repository'; +import { PrismaUserRepository } from '@/modules/users/infra/repositories/prisma-user-repository'; +import { CreateAppointmentUseCase } from '../../application/use-cases/create-appointment/create-appointment.use-case'; +import { prisma } from '@/config/prisma'; + +export async function createAppointmentController(req: Request, res: Response) { + const { userId, spaceId, date, startTime, endTime } = req.body; + + const appointmentRepo = new PrismaAppointmentRepository(prisma); + const spaceRepo = new PrismaSpaceRepository(); + const userRepo = new PrismaUserRepository(); + const useCase = new CreateAppointmentUseCase(appointmentRepo, spaceRepo, userRepo); + + const appointment = await useCase.execute({ + userId, + spaceId, + date: new Date(date), + startTime: new Date(startTime), + endTime: new Date(endTime), + }); + + return res.status(201).json(appointment); +} \ No newline at end of file diff --git a/src/modules/appointments/presentation/controllers/delete-appointment.controller.ts b/src/modules/appointments/presentation/controllers/delete-appointment.controller.ts new file mode 100644 index 0000000..d351987 --- /dev/null +++ b/src/modules/appointments/presentation/controllers/delete-appointment.controller.ts @@ -0,0 +1,14 @@ +import { Request, Response } from 'express'; +import { PrismaAppointmentRepository } from '../../infra/repositories/prisma-appointment-repository'; +import { DeleteAppointmentUseCase } from '../../application/use-cases/delete-appointment/delete-appointment.use-case'; +import { prisma } from '@/config/prisma'; + +export async function deleteAppointmentController(req: Request, res: Response) { + const { id } = req.params; + + const appointmentRepo = new PrismaAppointmentRepository(prisma); + const useCase = new DeleteAppointmentUseCase(appointmentRepo); + + await useCase.execute(id); + return res.status(204).send(); +} \ No newline at end of file diff --git a/src/modules/appointments/presentation/controllers/find-appointment-by-id.controller.ts b/src/modules/appointments/presentation/controllers/find-appointment-by-id.controller.ts new file mode 100644 index 0000000..f53ad5d --- /dev/null +++ b/src/modules/appointments/presentation/controllers/find-appointment-by-id.controller.ts @@ -0,0 +1,14 @@ +import { Request, Response } from 'express'; +import { PrismaAppointmentRepository } from '../../infra/repositories/prisma-appointment-repository'; +import { FindAppointmentByIdUseCase } from '../../application/use-cases/find-appointment/find-appointment-by-id.use-case'; +import { prisma } from '@/config/prisma'; + +export async function findAppointmentByIdController(req: Request, res: Response) { + const { id } = req.params; + + const appointmentRepo = new PrismaAppointmentRepository(prisma); + const useCase = new FindAppointmentByIdUseCase(appointmentRepo); + + const appointment = await useCase.execute(id); + return res.status(200).json(appointment); +} \ No newline at end of file diff --git a/src/modules/appointments/presentation/controllers/find-appointments-by-space-id.controller.ts b/src/modules/appointments/presentation/controllers/find-appointments-by-space-id.controller.ts new file mode 100644 index 0000000..b27df2a --- /dev/null +++ b/src/modules/appointments/presentation/controllers/find-appointments-by-space-id.controller.ts @@ -0,0 +1,16 @@ +import { Request, Response } from 'express'; +import { PrismaAppointmentRepository } from '../../infra/repositories/prisma-appointment-repository'; +import { FindAppointmentsBySpaceIdUseCase } from '../../application/use-cases/find-appointment/find-appointments-by-space-id.use-case'; +import { prisma } from '@/config/prisma'; + +export async function findAppointmentsBySpaceIdController(req: Request, res: Response) { + const { spaceId } = req.params; + const pagination = req.pagination; + + const appointmentRepo = new PrismaAppointmentRepository(prisma); + const useCase = new FindAppointmentsBySpaceIdUseCase(appointmentRepo); + + const result = await useCase.execute(spaceId, pagination ?? {}); + + return res.status(200).json(result); +} \ No newline at end of file diff --git a/src/modules/appointments/presentation/controllers/find-appointments-by-user-id.controller.ts b/src/modules/appointments/presentation/controllers/find-appointments-by-user-id.controller.ts new file mode 100644 index 0000000..28d21c5 --- /dev/null +++ b/src/modules/appointments/presentation/controllers/find-appointments-by-user-id.controller.ts @@ -0,0 +1,16 @@ +import { Request, Response } from 'express'; +import { PrismaAppointmentRepository } from '../../infra/repositories/prisma-appointment-repository'; +import { FindAppointmentsByUserIdUseCase } from '../../application/use-cases/find-appointment/find-appointments-by-user-id.use-case'; +import { prisma } from '@/config/prisma'; + +export async function findAppointmentsByUserIdController(req: Request, res: Response) { + const { userId } = req.params; + const pagination = req.pagination; + + const appointmentRepo = new PrismaAppointmentRepository(prisma); + const useCase = new FindAppointmentsByUserIdUseCase(appointmentRepo); + + const result = await useCase.execute(userId, pagination ?? {}); + + return res.status(200).json(result); +} \ No newline at end of file diff --git a/src/modules/appointments/presentation/controllers/index.ts b/src/modules/appointments/presentation/controllers/index.ts new file mode 100644 index 0000000..c6fe32a --- /dev/null +++ b/src/modules/appointments/presentation/controllers/index.ts @@ -0,0 +1,6 @@ +export { createAppointmentController } from './create-appointment.controller'; +export { findAppointmentByIdController } from './find-appointment-by-id.controller'; +export { findAppointmentsByUserIdController } from './find-appointments-by-user-id.controller'; +export { findAppointmentsBySpaceIdController } from './find-appointments-by-space-id.controller'; +export { updateAppointmentController } from './update-appointment.controller'; +export { deleteAppointmentController } from './delete-appointment.controller'; \ No newline at end of file diff --git a/src/modules/appointments/presentation/controllers/update-appointment.controller.ts b/src/modules/appointments/presentation/controllers/update-appointment.controller.ts new file mode 100644 index 0000000..02ec8c8 --- /dev/null +++ b/src/modules/appointments/presentation/controllers/update-appointment.controller.ts @@ -0,0 +1,22 @@ +import { Request, Response } from 'express'; +import { PrismaAppointmentRepository } from '../../infra/repositories/prisma-appointment-repository'; +import { UpdateAppointmentUseCase } from '../../application/use-cases/update-appointment/update-appointment.use-case'; +import { prisma } from '@/config/prisma'; + +export async function updateAppointmentController(req: Request, res: Response) { + const { id } = req.params; + const { date, startTime, endTime, status } = req.body; + + const appointmentRepo = new PrismaAppointmentRepository(prisma); + const useCase = new UpdateAppointmentUseCase(appointmentRepo); + + const appointment = await useCase.execute({ + id, + date: date ? new Date(date) : undefined, + startTime: startTime ? new Date(startTime) : undefined, + endTime: endTime ? new Date(endTime) : undefined, + status, + }); + + return res.status(200).json(appointment); +} \ No newline at end of file diff --git a/src/modules/appointments/presentation/docs/create-appointment.doc.ts b/src/modules/appointments/presentation/docs/create-appointment.doc.ts new file mode 100644 index 0000000..247de9f --- /dev/null +++ b/src/modules/appointments/presentation/docs/create-appointment.doc.ts @@ -0,0 +1,77 @@ +/** + * @swagger + * /appointments: + * post: + * summary: Criar um novo agendamento + * description: Cria um novo agendamento para um espaço em uma data e horário específicos + * tags: [Appointments] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - userId + * - spaceId + * - date + * - startTime + * - endTime + * properties: + * userId: + * type: string + * format: uuid + * description: ID do usuário que está fazendo o agendamento + * spaceId: + * type: string + * format: uuid + * description: ID do espaço que está sendo agendado + * date: + * type: string + * format: date-time + * description: Data do agendamento + * startTime: + * type: string + * format: date-time + * description: Horário de início do agendamento + * endTime: + * type: string + * format: date-time + * description: Horário de fim do agendamento + * responses: + * 201: + * description: Agendamento criado com sucesso + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * userId: + * type: string + * spaceId: + * type: string + * date: + * type: string + * format: date-time + * startTime: + * type: string + * format: date-time + * endTime: + * type: string + * format: date-time + * status: + * type: string + * enum: [PENDING, CONFIRMED, CANCELLED] + * createdAt: + * type: string + * format: date-time + * updatedAt: + * type: string + * format: date-time + * 400: + * description: Dados inválidos ou conflito de horário + * 404: + * description: Usuário ou espaço não encontrado + */ \ No newline at end of file diff --git a/src/modules/appointments/presentation/docs/delete-appointment.doc.ts b/src/modules/appointments/presentation/docs/delete-appointment.doc.ts new file mode 100644 index 0000000..a766015 --- /dev/null +++ b/src/modules/appointments/presentation/docs/delete-appointment.doc.ts @@ -0,0 +1,21 @@ +/** + * @swagger + * /appointments/{id}: + * delete: + * summary: Deletar agendamento + * description: Remove um agendamento existente + * tags: [Appointments] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * description: ID do agendamento + * responses: + * 204: + * description: Agendamento deletado com sucesso + * 404: + * description: Agendamento não encontrado + */ \ No newline at end of file diff --git a/src/modules/appointments/presentation/docs/find-appointment-by-id.doc.ts b/src/modules/appointments/presentation/docs/find-appointment-by-id.doc.ts new file mode 100644 index 0000000..fe2df89 --- /dev/null +++ b/src/modules/appointments/presentation/docs/find-appointment-by-id.doc.ts @@ -0,0 +1,50 @@ +/** + * @swagger + * /appointments/{id}: + * get: + * summary: Buscar agendamento por ID + * description: Retorna um agendamento específico pelo seu ID + * tags: [Appointments] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * description: ID do agendamento + * responses: + * 200: + * description: Agendamento encontrado com sucesso + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * userId: + * type: string + * spaceId: + * type: string + * date: + * type: string + * format: date-time + * startTime: + * type: string + * format: date-time + * endTime: + * type: string + * format: date-time + * status: + * type: string + * enum: [PENDING, CONFIRMED, CANCELLED] + * createdAt: + * type: string + * format: date-time + * updatedAt: + * type: string + * format: date-time + * 404: + * description: Agendamento não encontrado + */ \ No newline at end of file diff --git a/src/modules/appointments/presentation/docs/find-appointments-by-space-id.doc.ts b/src/modules/appointments/presentation/docs/find-appointments-by-space-id.doc.ts new file mode 100644 index 0000000..d2c847a --- /dev/null +++ b/src/modules/appointments/presentation/docs/find-appointments-by-space-id.doc.ts @@ -0,0 +1,85 @@ +/** + * @swagger + * /appointments/space/{spaceId}: + * get: + * summary: Listar agendamentos por espaço + * description: Retorna uma lista paginada de agendamentos de um espaço específico + * tags: [Appointments] + * parameters: + * - in: path + * name: spaceId + * required: true + * schema: + * type: string + * format: uuid + * description: ID do espaço + * - in: query + * name: page + * schema: + * type: integer + * minimum: 1 + * default: 1 + * description: Número da página + * - in: query + * name: perPage + * schema: + * type: integer + * minimum: 1 + * default: 10 + * description: Quantidade de itens por página + * - in: query + * name: orderBy + * schema: + * type: string + * default: createdAt + * description: Campo para ordenação + * - in: query + * name: orderDirection + * schema: + * type: string + * enum: [asc, desc] + * default: desc + * description: Direção da ordenação + * responses: + * 200: + * description: Lista de agendamentos retornada com sucesso + * content: + * application/json: + * schema: + * type: object + * properties: + * total: + * type: integer + * description: Total de agendamentos + * appointments: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * userId: + * type: string + * spaceId: + * type: string + * date: + * type: string + * format: date-time + * startTime: + * type: string + * format: date-time + * endTime: + * type: string + * format: date-time + * status: + * type: string + * enum: [PENDING, CONFIRMED, CANCELLED] + * createdAt: + * type: string + * format: date-time + * updatedAt: + * type: string + * format: date-time + * 400: + * description: Parâmetros de paginação inválidos + */ \ No newline at end of file diff --git a/src/modules/appointments/presentation/docs/find-appointments-by-user-id.doc.ts b/src/modules/appointments/presentation/docs/find-appointments-by-user-id.doc.ts new file mode 100644 index 0000000..7cf678c --- /dev/null +++ b/src/modules/appointments/presentation/docs/find-appointments-by-user-id.doc.ts @@ -0,0 +1,85 @@ +/** + * @swagger + * /appointments/user/{userId}: + * get: + * summary: Listar agendamentos por usuário + * description: Retorna uma lista paginada de agendamentos de um usuário específico + * tags: [Appointments] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: string + * format: uuid + * description: ID do usuário + * - in: query + * name: page + * schema: + * type: integer + * minimum: 1 + * default: 1 + * description: Número da página + * - in: query + * name: perPage + * schema: + * type: integer + * minimum: 1 + * default: 10 + * description: Quantidade de itens por página + * - in: query + * name: orderBy + * schema: + * type: string + * default: createdAt + * description: Campo para ordenação + * - in: query + * name: orderDirection + * schema: + * type: string + * enum: [asc, desc] + * default: desc + * description: Direção da ordenação + * responses: + * 200: + * description: Lista de agendamentos retornada com sucesso + * content: + * application/json: + * schema: + * type: object + * properties: + * total: + * type: integer + * description: Total de agendamentos + * appointments: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * userId: + * type: string + * spaceId: + * type: string + * date: + * type: string + * format: date-time + * startTime: + * type: string + * format: date-time + * endTime: + * type: string + * format: date-time + * status: + * type: string + * enum: [PENDING, CONFIRMED, CANCELLED] + * createdAt: + * type: string + * format: date-time + * updatedAt: + * type: string + * format: date-time + * 400: + * description: Parâmetros de paginação inválidos + */ \ No newline at end of file diff --git a/src/modules/appointments/presentation/docs/index.ts b/src/modules/appointments/presentation/docs/index.ts new file mode 100644 index 0000000..d49bfe1 --- /dev/null +++ b/src/modules/appointments/presentation/docs/index.ts @@ -0,0 +1,7 @@ +// Importar todas as documentações para que sejam incluídas no Swagger +import './create-appointment.doc'; +import './find-appointment-by-id.doc'; +import './find-appointments-by-user-id.doc'; +import './find-appointments-by-space-id.doc'; +import './update-appointment.doc'; +import './delete-appointment.doc'; \ No newline at end of file diff --git a/src/modules/appointments/presentation/docs/update-appointment.doc.ts b/src/modules/appointments/presentation/docs/update-appointment.doc.ts new file mode 100644 index 0000000..314253c --- /dev/null +++ b/src/modules/appointments/presentation/docs/update-appointment.doc.ts @@ -0,0 +1,75 @@ +/** + * @swagger + * /appointments/{id}: + * put: + * summary: Atualizar agendamento + * description: Atualiza um agendamento existente + * tags: [Appointments] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * format: uuid + * description: ID do agendamento + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * date: + * type: string + * format: date-time + * description: Nova data do agendamento + * startTime: + * type: string + * format: date-time + * description: Novo horário de início + * endTime: + * type: string + * format: date-time + * description: Novo horário de fim + * status: + * type: string + * enum: [PENDING, CONFIRMED, CANCELLED] + * description: Novo status do agendamento + * responses: + * 200: + * description: Agendamento atualizado com sucesso + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * userId: + * type: string + * spaceId: + * type: string + * date: + * type: string + * format: date-time + * startTime: + * type: string + * format: date-time + * endTime: + * type: string + * format: date-time + * status: + * type: string + * enum: [PENDING, CONFIRMED, CANCELLED] + * createdAt: + * type: string + * format: date-time + * updatedAt: + * type: string + * format: date-time + * 400: + * description: Dados inválidos ou conflito de horário + * 404: + * description: Agendamento não encontrado + */ \ No newline at end of file diff --git a/src/modules/appointments/presentation/routes/appointment-routes.ts b/src/modules/appointments/presentation/routes/appointment-routes.ts new file mode 100644 index 0000000..44086a4 --- /dev/null +++ b/src/modules/appointments/presentation/routes/appointment-routes.ts @@ -0,0 +1,20 @@ +import { Router } from 'express'; +import { validateCreateAppointment, validateUpdateAppointment } from '../validators'; +import { validatePagination, validateParamsId } from '@/core/validators'; +import { + createAppointmentController, + findAppointmentByIdController, + findAppointmentsByUserIdController, + findAppointmentsBySpaceIdController, + updateAppointmentController, + deleteAppointmentController, +} from '../controllers'; + +export const appointmentRoutes = Router(); + +appointmentRoutes.post('/', validateCreateAppointment, createAppointmentController); +appointmentRoutes.get('/:id', validateParamsId, findAppointmentByIdController); +appointmentRoutes.get('/user/:id', validateParamsId, validatePagination, findAppointmentsByUserIdController); +appointmentRoutes.get('/space/:id', validateParamsId, validatePagination, findAppointmentsBySpaceIdController); +appointmentRoutes.put('/:id', validateParamsId, validateUpdateAppointment, updateAppointmentController); +appointmentRoutes.delete('/:id', validateParamsId, deleteAppointmentController); \ No newline at end of file diff --git a/src/modules/appointments/presentation/validators/create-appointment-validator.ts b/src/modules/appointments/presentation/validators/create-appointment-validator.ts new file mode 100644 index 0000000..49c85cc --- /dev/null +++ b/src/modules/appointments/presentation/validators/create-appointment-validator.ts @@ -0,0 +1,12 @@ +import { baseValidator } from '@/core/validators'; +import { z } from 'zod'; + +const createAppointmentSchema = z.object({ + userId: z.string().uuid(), + spaceId: z.string().uuid(), + date: z.string().datetime(), + startTime: z.string().datetime(), + endTime: z.string().datetime(), +}); + +export const validateCreateAppointment = baseValidator(createAppointmentSchema, 'body'); \ No newline at end of file diff --git a/src/modules/appointments/presentation/validators/index.ts b/src/modules/appointments/presentation/validators/index.ts new file mode 100644 index 0000000..8cf2d9e --- /dev/null +++ b/src/modules/appointments/presentation/validators/index.ts @@ -0,0 +1,2 @@ +export { validateCreateAppointment } from './create-appointment-validator'; +export { validateUpdateAppointment } from './update-appointment-validator'; \ No newline at end of file diff --git a/src/modules/appointments/presentation/validators/update-appointment-validator.ts b/src/modules/appointments/presentation/validators/update-appointment-validator.ts new file mode 100644 index 0000000..9654909 --- /dev/null +++ b/src/modules/appointments/presentation/validators/update-appointment-validator.ts @@ -0,0 +1,12 @@ +import { baseValidator } from '@/core/validators'; +import { z } from 'zod'; +import { AppointmentStatus } from '@prisma/client'; + +const updateAppointmentSchema = z.object({ + date: z.string().datetime().optional(), + startTime: z.string().datetime().optional(), + endTime: z.string().datetime().optional(), + status: z.nativeEnum(AppointmentStatus).optional(), +}); + +export const validateUpdateAppointment = baseValidator(updateAppointmentSchema, 'body'); \ No newline at end of file diff --git a/src/modules/bookings/applications/use-cases/create-booking-use-case.ts b/src/modules/bookings/applications/use-cases/create-booking-use-case.ts deleted file mode 100644 index 3ea6b59..0000000 --- a/src/modules/bookings/applications/use-cases/create-booking-use-case.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Booking, BookingStatus } from '../../domain/entities/booking'; -import { BookingRepository } from '../../domain/repositories/booking-repository'; -import { randomUUID } from 'crypto'; - -interface CreateBookingDTO { - userId: string; - spaceId: string; - date: Date; - startTime: Date; - endTime: Date; -} - -export class CreateBookingUseCase { - constructor(private readonly bookingRepository: BookingRepository) { } - - async execute(data: CreateBookingDTO): Promise { - const overlapping = await this.bookingRepository.findOverlapping( - data.spaceId, - data.startTime, - data.endTime - ); - - if (overlapping.length > 0) { - throw new Error('Time slot is already booked'); - } - - const booking = new Booking( - randomUUID(), - data.userId, - data.spaceId, - data.date, - data.startTime, - data.endTime - ); - - await this.bookingRepository.create(booking); - return booking; - } -} \ No newline at end of file diff --git a/src/modules/bookings/domain/entities/booking.ts b/src/modules/bookings/domain/entities/booking.ts deleted file mode 100644 index b23d6e5..0000000 --- a/src/modules/bookings/domain/entities/booking.ts +++ /dev/null @@ -1,17 +0,0 @@ -export enum BookingStatus { - PENDING = 'PENDING', - CONFIRMED = 'CONFIRMED', - CANCELLED = 'CANCELLED', -} - -export class Booking { - constructor( - public readonly id: string, - public readonly userId: string, - public readonly spaceId: string, - public date: Date, - public startTime: Date, - public endTime: Date, - public status: BookingStatus = BookingStatus.PENDING - ) { } -} \ No newline at end of file diff --git a/src/modules/bookings/domain/repositories/booking-repository.ts b/src/modules/bookings/domain/repositories/booking-repository.ts deleted file mode 100644 index d9ccef1..0000000 --- a/src/modules/bookings/domain/repositories/booking-repository.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Booking } from "../entities/booking"; - -export interface BookingRepository { - create(booking: Booking): Promise; - findOverlapping(spaceId: string, startTime: Date, endTime: Date): Promise; -} \ No newline at end of file diff --git a/src/modules/ratings/presentation/docs/create-rating.doc.ts b/src/modules/ratings/presentation/docs/create-rating.doc.ts index b67d0af..0d3c107 100644 --- a/src/modules/ratings/presentation/docs/create-rating.doc.ts +++ b/src/modules/ratings/presentation/docs/create-rating.doc.ts @@ -1,13 +1,33 @@ /** - * @route POST /api/ratings - * @summary Cria uma nova avaliação para um espaço - * @tags Ratings - * @param {string} spaceId.body.required - ID do espaço - * @param {string} userId.body.required - ID do usuário - * @param {number} score.body.required - Nota (1 a 5) - * @param {string} comment.body.optional - Comentário - * @response 201 - Avaliação criada com sucesso - * @response 400 - Dados inválidos + * @swagger + * /ratings: + * post: + * summary: Cria uma nova avaliação para um espaço + * tags: [Ratings] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * spaceId: + * type: string + * description: ID do espaço + * userId: + * type: string + * description: ID do usuário + * score: + * type: number + * description: Nota (1 a 5) + * comment: + * type: string + * description: Comentário + * responses: + * 201: + * description: Avaliação criada com sucesso + * 400: + * description: Dados inválidos */ // Exemplo de payload: // { diff --git a/src/modules/ratings/presentation/docs/delete-rating.doc.ts b/src/modules/ratings/presentation/docs/delete-rating.doc.ts index e28bbad..7b88453 100644 --- a/src/modules/ratings/presentation/docs/delete-rating.doc.ts +++ b/src/modules/ratings/presentation/docs/delete-rating.doc.ts @@ -1,8 +1,19 @@ /** - * @route DELETE /api/ratings/:id - * @summary Remove uma avaliação - * @tags Ratings - * @param {string} id.path.required - ID da avaliação - * @response 204 - Avaliação removida com sucesso - * @response 404 - Avaliação não encontrada + * @swagger + * /ratings/{id}: + * delete: + * summary: Remove uma avaliação + * tags: [Ratings] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: ID da avaliação + * responses: + * 204: + * description: Avaliação removida com sucesso + * 404: + * description: Avaliação não encontrada */ \ No newline at end of file diff --git a/src/modules/ratings/presentation/docs/find-rating-by-id.doc.ts b/src/modules/ratings/presentation/docs/find-rating-by-id.doc.ts index aaee79e..57cf349 100644 --- a/src/modules/ratings/presentation/docs/find-rating-by-id.doc.ts +++ b/src/modules/ratings/presentation/docs/find-rating-by-id.doc.ts @@ -1,8 +1,19 @@ /** - * @route GET /api/ratings/:id - * @summary Busca uma avaliação pelo ID - * @tags Ratings - * @param {string} id.path.required - ID da avaliação - * @response 200 - Avaliação encontrada - * @response 404 - Avaliação não encontrada + * @swagger + * /ratings/{id}: + * get: + * summary: Busca uma avaliação pelo ID + * tags: [Ratings] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: ID da avaliação + * responses: + * 200: + * description: Avaliação encontrada + * 404: + * description: Avaliação não encontrada */ \ No newline at end of file diff --git a/src/modules/ratings/presentation/docs/find-ratings-by-space-id.doc.ts b/src/modules/ratings/presentation/docs/find-ratings-by-space-id.doc.ts index 11b2a62..5a4f517 100644 --- a/src/modules/ratings/presentation/docs/find-ratings-by-space-id.doc.ts +++ b/src/modules/ratings/presentation/docs/find-ratings-by-space-id.doc.ts @@ -1,9 +1,27 @@ /** - * @route GET /api/ratings/space/:spaceId - * @summary Lista avaliações de um espaço - * @tags Ratings - * @param {string} spaceId.path.required - ID do espaço - * @param {number} page.query.optional - Página (default: 1) - * @param {number} perPage.query.optional - Itens por página (default: 10) - * @response 200 - Lista de avaliações - */ \ No newline at end of file + * @swagger + * /ratings/space/{spaceId}: + * get: + * summary: Lista avaliações de um espaço + * tags: [Ratings] + * parameters: + * - in: path + * name: spaceId + * required: true + * schema: + * type: string + * description: ID do espaço + * - in: query + * name: page + * schema: + * type: number + * description: "Página (default: 1)" + * - in: query + * name: perPage + * schema: + * type: number + * description: "Itens por página (default: 10)" + * responses: + * 200: + * description: Lista de avaliações + */ \ No newline at end of file diff --git a/src/modules/ratings/presentation/docs/find-ratings-by-user-id.doc.ts b/src/modules/ratings/presentation/docs/find-ratings-by-user-id.doc.ts index 1b53314..a982aa0 100644 --- a/src/modules/ratings/presentation/docs/find-ratings-by-user-id.doc.ts +++ b/src/modules/ratings/presentation/docs/find-ratings-by-user-id.doc.ts @@ -1,9 +1,27 @@ /** - * @route GET /api/ratings/user/:userId - * @summary Lista avaliações feitas por um usuário - * @tags Ratings - * @param {string} userId.path.required - ID do usuário - * @param {number} page.query.optional - Página (default: 1) - * @param {number} perPage.query.optional - Itens por página (default: 10) - * @response 200 - Lista de avaliações - */ \ No newline at end of file + * @swagger + * /ratings/user/{userId}: + * get: + * summary: Lista avaliações de um usuário + * tags: [Ratings] + * parameters: + * - in: path + * name: userId + * required: true + * schema: + * type: string + * description: ID do usuário + * - in: query + * name: page + * schema: + * type: number + * description: "Página (default: 1)" + * - in: query + * name: perPage + * schema: + * type: number + * description: "Itens por página (default: 10)" + * responses: + * 200: + * description: Lista de avaliações + */ \ No newline at end of file diff --git a/src/modules/ratings/presentation/docs/update-rating.doc.ts b/src/modules/ratings/presentation/docs/update-rating.doc.ts index 0223030..a859107 100644 --- a/src/modules/ratings/presentation/docs/update-rating.doc.ts +++ b/src/modules/ratings/presentation/docs/update-rating.doc.ts @@ -1,13 +1,36 @@ /** - * @route PUT /api/ratings/:id - * @summary Atualiza uma avaliação existente - * @tags Ratings - * @param {string} id.path.required - ID da avaliação - * @param {number} score.body.optional - Nota (1 a 5) - * @param {string} comment.body.optional - Comentário - * @response 200 - Avaliação atualizada com sucesso - * @response 400 - Dados inválidos - * @response 404 - Avaliação não encontrada + * @swagger + * /ratings/{id}: + * put: + * summary: Atualiza uma avaliação existente + * tags: [Ratings] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: ID da avaliação + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * score: + * type: number + * description: Nota (1 a 5) + * comment: + * type: string + * description: Comentário + * responses: + * 200: + * description: Avaliação atualizada com sucesso + * 400: + * description: Dados inválidos + * 404: + * description: Avaliação não encontrada */ // Exemplo de payload: // { diff --git a/src/modules/ratings/presentation/routes/rating-routes.ts b/src/modules/ratings/presentation/routes/rating-routes.ts index f5c9fa6..79e118b 100644 --- a/src/modules/ratings/presentation/routes/rating-routes.ts +++ b/src/modules/ratings/presentation/routes/rating-routes.ts @@ -20,9 +20,9 @@ import { validateParamsId } from '@/core/validators'; */ export const ratingRoutes = Router(); -ratingRoutes.post('/', validateCreateRating, createRatingController); // Cria uma nova avaliação -ratingRoutes.put('/:id', validateParamsId, validateUpdateRating, updateRatingController); // Atualiza uma avaliação existente -ratingRoutes.delete('/:id', validateParamsId, deleteRatingController); // Remove uma avaliação -ratingRoutes.get('/:id', validateParamsId, findRatingByIdController); // Busca uma avaliação por ID -ratingRoutes.get('/space/:spaceId', validateParamsId, findRatingsBySpaceIdController); // Lista avaliações de um espaço -ratingRoutes.get('/user/:userId', validateParamsId, findRatingsByUserIdController); // Lista avaliações de um usuário \ No newline at end of file +ratingRoutes.post('/', validateCreateRating, createRatingController); +ratingRoutes.put('/:id', validateParamsId, validateUpdateRating, updateRatingController); +ratingRoutes.delete('/:id', validateParamsId, deleteRatingController); +ratingRoutes.get('/:id', validateParamsId, findRatingByIdController); +ratingRoutes.get('/space/:spaceId', validateParamsId, findRatingsBySpaceIdController); +ratingRoutes.get('/user/:userId', validateParamsId, findRatingsByUserIdController); \ No newline at end of file diff --git a/src/modules/spaces/application/use-cases/create-rating/create-rating.use-case.spec.ts b/src/modules/spaces/application/use-cases/create-rating/create-rating.use-case.spec.ts deleted file mode 100644 index 2da3c92..0000000 --- a/src/modules/spaces/application/use-cases/create-rating/create-rating.use-case.spec.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { InMemoryRatingRepository } from 'test/repositories/in-memory-ratings-repository'; -import { CreateRatingUseCase } from './create-rating.use-case'; -import { makeSpace } from 'test/factories/make-space'; -import { makeUser } from 'test/factories/make-user'; - -describe('CreateRatingUseCase', () => { - let repo: InMemoryRatingRepository; - let useCase: CreateRatingUseCase; - - beforeEach(() => { - repo = new InMemoryRatingRepository(); - useCase = new CreateRatingUseCase(repo); - }); - - it('should create a new rating', async () => { - const space = await makeSpace(); - const user = await makeUser(); - - const result = await useCase.execute({ - spaceId: space.id.toString(), - userId: user.id.toString(), - score: 5, - comment: 'Ótimo espaço!', - }); - - expect(result).toBeDefined(); - expect(result.spaceId).toBe(space.id.toString()); - expect(result.userId).toBe(user.id.toString()); - expect(result.score).toBe(5); - expect(result.comment).toBe('Ótimo espaço!'); - expect(result.createdAt).toBeInstanceOf(Date); - expect(result.updatedAt).toBeInstanceOf(Date); - }); - - it('should create a rating without comment', async () => { - const space = await makeSpace(); - const user = await makeUser(); - - const result = await useCase.execute({ - spaceId: space.id.toString(), - userId: user.id.toString(), - score: 4, - }); - - expect(result).toBeDefined(); - expect(result.comment).toBeUndefined(); - }); - - it('should throw error if score is less than 1', async () => { - const space = await makeSpace(); - const user = await makeUser(); - - await expect(useCase.execute({ - spaceId: space.id.toString(), - userId: user.id.toString(), - score: 0, - })).rejects.toThrow('Score must be between 1 and 5'); - }); - - it('should throw error if score is greater than 5', async () => { - const space = await makeSpace(); - const user = await makeUser(); - - await expect(useCase.execute({ - spaceId: space.id.toString(), - userId: user.id.toString(), - score: 6, - })).rejects.toThrow('Score must be between 1 and 5'); - }); -}); \ No newline at end of file diff --git a/src/modules/spaces/application/use-cases/create-rating/create-rating.use-case.ts b/src/modules/spaces/application/use-cases/create-rating/create-rating.use-case.ts deleted file mode 100644 index bfb10c2..0000000 --- a/src/modules/spaces/application/use-cases/create-rating/create-rating.use-case.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { RatingRepository } from "../../../domain/repositories/rating-repository"; -import { Rating } from "../../../domain/entities/rating"; - -interface CreateRatingDTO { - spaceId: string; - userId: string; - score: number; - comment?: string; -} - -export class CreateRatingUseCase { - constructor(private readonly ratingRepository: RatingRepository) { } - - async execute(data: CreateRatingDTO): Promise { - const rating = Rating.create({ - spaceId: data.spaceId, - userId: data.userId, - score: data.score, - comment: data.comment, - }); - - await this.ratingRepository.create(rating); - return rating; - } -} \ No newline at end of file diff --git a/src/modules/spaces/application/use-cases/delete-rating/delete-rating.use-case.spec.ts b/src/modules/spaces/application/use-cases/delete-rating/delete-rating.use-case.spec.ts deleted file mode 100644 index 2851e52..0000000 --- a/src/modules/spaces/application/use-cases/delete-rating/delete-rating.use-case.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { InMemoryRatingRepository } from 'test/repositories/in-memory-ratings-repository'; -import { DeleteRatingUseCase } from './delete-rating.use-case'; -import { makeRating } from 'test/factories/make-rating'; - -describe('DeleteRatingUseCase', () => { - let repo: InMemoryRatingRepository; - let useCase: DeleteRatingUseCase; - - beforeEach(() => { - repo = new InMemoryRatingRepository(); - useCase = new DeleteRatingUseCase(repo); - }); - - it('should delete a rating', async () => { - const rating = makeRating(); - await repo.create(rating); - - await useCase.execute(rating.id.toString()); - - const deletedRating = await repo.findById(rating.id.toString()); - expect(deletedRating).toBeNull(); - }); - - it('should throw error if rating not found', async () => { - await expect(useCase.execute('non-existent-id')).rejects.toThrow('Rating not found'); - }); -}); \ No newline at end of file diff --git a/src/modules/spaces/application/use-cases/delete-rating/delete-rating.use-case.ts b/src/modules/spaces/application/use-cases/delete-rating/delete-rating.use-case.ts deleted file mode 100644 index 960b346..0000000 --- a/src/modules/spaces/application/use-cases/delete-rating/delete-rating.use-case.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { RatingRepository } from "../../../domain/repositories/rating-repository"; - -export class DeleteRatingUseCase { - constructor(private readonly ratingRepository: RatingRepository) { } - - async execute(id: string): Promise { - const rating = await this.ratingRepository.findById(id); - - if (!rating) { - throw new Error('Rating not found'); - } - - await this.ratingRepository.delete(id); - } -} \ No newline at end of file diff --git a/src/modules/spaces/application/use-cases/find-rating-by-id/find-rating-by-id.use-case.spec.ts b/src/modules/spaces/application/use-cases/find-rating-by-id/find-rating-by-id.use-case.spec.ts deleted file mode 100644 index c04cafd..0000000 --- a/src/modules/spaces/application/use-cases/find-rating-by-id/find-rating-by-id.use-case.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { InMemoryRatingRepository } from 'test/repositories/in-memory-ratings-repository'; -import { FindRatingByIdUseCase } from './find-rating-by-id.use-case'; -import { makeRating } from 'test/factories/make-rating'; - -describe('FindRatingByIdUseCase', () => { - let repo: InMemoryRatingRepository; - let useCase: FindRatingByIdUseCase; - - beforeEach(() => { - repo = new InMemoryRatingRepository(); - useCase = new FindRatingByIdUseCase(repo); - }); - - it('should find rating by id', async () => { - const rating = makeRating(); - await repo.create(rating); - - const result = await useCase.execute(rating.id.toString()); - - expect(result).toBeDefined(); - expect(result?.id.toString()).toBe(rating.id.toString()); - expect(result?.spaceId).toBe(rating.spaceId); - expect(result?.userId).toBe(rating.userId); - expect(result?.score).toBe(rating.score); - expect(result?.comment).toBe(rating.comment); - }); - - it('should return null when rating not found', async () => { - const result = await useCase.execute('non-existent-id'); - expect(result).toBeNull(); - }); -}); \ No newline at end of file diff --git a/src/modules/spaces/application/use-cases/find-rating-by-id/find-rating-by-id.use-case.ts b/src/modules/spaces/application/use-cases/find-rating-by-id/find-rating-by-id.use-case.ts deleted file mode 100644 index 0e5db0a..0000000 --- a/src/modules/spaces/application/use-cases/find-rating-by-id/find-rating-by-id.use-case.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { RatingRepository } from "../../../domain/repositories/rating-repository"; -import { Rating } from "../../../domain/entities/rating"; - -export class FindRatingByIdUseCase { - constructor(private readonly ratingRepository: RatingRepository) { } - - async execute(id: string): Promise { - return this.ratingRepository.findById(id); - } -} \ No newline at end of file diff --git a/src/modules/spaces/application/use-cases/find-ratings-by-space-id/find-ratings-by-space-id.use-case.spec.ts b/src/modules/spaces/application/use-cases/find-ratings-by-space-id/find-ratings-by-space-id.use-case.spec.ts deleted file mode 100644 index 80a3433..0000000 --- a/src/modules/spaces/application/use-cases/find-ratings-by-space-id/find-ratings-by-space-id.use-case.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { InMemoryRatingRepository } from 'test/repositories/in-memory-ratings-repository'; -import { FindRatingsBySpaceIdUseCase } from './find-ratings-by-space-id.use-case'; -import { makeRating } from 'test/factories/make-rating'; - -describe('FindRatingsBySpaceIdUseCase', () => { - let repo: InMemoryRatingRepository; - let useCase: FindRatingsBySpaceIdUseCase; - - beforeEach(() => { - repo = new InMemoryRatingRepository(); - useCase = new FindRatingsBySpaceIdUseCase(repo); - }); - - it('should list ratings by space id', async () => { - const spaceId = 'space-123'; - const ratings = [ - makeRating({ spaceId }), - makeRating({ spaceId }), - makeRating({ spaceId }), - ]; - - for (const rating of ratings) { - await repo.create(rating); - } - - const result = await useCase.execute(spaceId, { page: 1, perPage: 10 }); - - expect(result.total).toBe(3); - expect(result.ratings).toHaveLength(3); - expect(result.ratings[0].spaceId).toBe(spaceId); - }); - - it('should return empty list when no ratings found', async () => { - const result = await useCase.execute('non-existent-space', { page: 1, perPage: 10 }); - - expect(result.total).toBe(0); - expect(result.ratings).toHaveLength(0); - }); - - it('should return correct page of ratings', async () => { - const spaceId = 'space-123'; - const ratings = [ - makeRating({ spaceId }), - makeRating({ spaceId }), - makeRating({ spaceId }), - ]; - - for (const rating of ratings) { - await repo.create(rating); - } - - const result = await useCase.execute(spaceId, { page: 1, perPage: 2 }); - - expect(result.total).toBe(3); - expect(result.ratings).toHaveLength(2); - }); -}); \ No newline at end of file diff --git a/src/modules/spaces/application/use-cases/find-ratings-by-space-id/find-ratings-by-space-id.use-case.ts b/src/modules/spaces/application/use-cases/find-ratings-by-space-id/find-ratings-by-space-id.use-case.ts deleted file mode 100644 index 9741d0f..0000000 --- a/src/modules/spaces/application/use-cases/find-ratings-by-space-id/find-ratings-by-space-id.use-case.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { RatingRepository } from "../../../domain/repositories/rating-repository"; -import { Rating } from "../../../domain/entities/rating"; -import { PaginationParams } from "@/core/repositories/pagination-params"; - -export class FindRatingsBySpaceIdUseCase { - constructor(private readonly ratingRepository: RatingRepository) { } - - async execute(spaceId: string, params: PaginationParams): Promise<{ total: number; ratings: Rating[] }> { - return this.ratingRepository.findBySpaceId(spaceId, params); - } -} \ No newline at end of file diff --git a/src/modules/spaces/application/use-cases/find-ratings-by-user-id/find-ratings-by-user-id.use-case.spec.ts b/src/modules/spaces/application/use-cases/find-ratings-by-user-id/find-ratings-by-user-id.use-case.spec.ts deleted file mode 100644 index 69a15be..0000000 --- a/src/modules/spaces/application/use-cases/find-ratings-by-user-id/find-ratings-by-user-id.use-case.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { InMemoryRatingRepository } from 'test/repositories/in-memory-ratings-repository'; -import { FindRatingsByUserIdUseCase } from './find-ratings-by-user-id.use-case'; -import { makeRating } from 'test/factories/make-rating'; - -describe('FindRatingsByUserIdUseCase', () => { - let repo: InMemoryRatingRepository; - let useCase: FindRatingsByUserIdUseCase; - - beforeEach(() => { - repo = new InMemoryRatingRepository(); - useCase = new FindRatingsByUserIdUseCase(repo); - }); - - it('should list ratings by user id', async () => { - const userId = 'user-123'; - const ratings = [ - makeRating({ userId }), - makeRating({ userId }), - makeRating({ userId }), - ]; - - for (const rating of ratings) { - await repo.create(rating); - } - - const result = await useCase.execute(userId, { page: 1, perPage: 10 }); - - expect(result.total).toBe(3); - expect(result.ratings).toHaveLength(3); - expect(result.ratings[0].userId).toBe(userId); - }); - - it('should return empty list when no ratings found', async () => { - const result = await useCase.execute('non-existent-user', { page: 1, perPage: 10 }); - - expect(result.total).toBe(0); - expect(result.ratings).toHaveLength(0); - }); - - it('should return correct page of ratings', async () => { - const userId = 'user-123'; - const ratings = [ - makeRating({ userId }), - makeRating({ userId }), - makeRating({ userId }), - ]; - - for (const rating of ratings) { - await repo.create(rating); - } - - const result = await useCase.execute(userId, { page: 1, perPage: 2 }); - - expect(result.total).toBe(3); - expect(result.ratings).toHaveLength(2); - }); -}); \ No newline at end of file diff --git a/src/modules/spaces/application/use-cases/find-ratings-by-user-id/find-ratings-by-user-id.use-case.ts b/src/modules/spaces/application/use-cases/find-ratings-by-user-id/find-ratings-by-user-id.use-case.ts deleted file mode 100644 index 9a20d10..0000000 --- a/src/modules/spaces/application/use-cases/find-ratings-by-user-id/find-ratings-by-user-id.use-case.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { RatingRepository } from "../../../domain/repositories/rating-repository"; -import { Rating } from "../../../domain/entities/rating"; -import { PaginationParams } from "@/core/repositories/pagination-params"; - -export class FindRatingsByUserIdUseCase { - constructor(private readonly ratingRepository: RatingRepository) { } - - async execute(userId: string, params: PaginationParams): Promise<{ total: number; ratings: Rating[] }> { - return this.ratingRepository.findByUserId(userId, params); - } -} \ No newline at end of file diff --git a/src/modules/spaces/application/use-cases/get-space-average-rating/get-space-average-rating.use-case.spec.ts b/src/modules/spaces/application/use-cases/get-space-average-rating/get-space-average-rating.use-case.spec.ts deleted file mode 100644 index b2d8b0b..0000000 --- a/src/modules/spaces/application/use-cases/get-space-average-rating/get-space-average-rating.use-case.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { InMemoryRatingRepository } from 'test/repositories/in-memory-ratings-repository'; -import { GetSpaceAverageRatingUseCase } from './get-space-average-rating.use-case'; -import { makeRating } from 'test/factories/make-rating'; - -describe('GetSpaceAverageRatingUseCase', () => { - let repo: InMemoryRatingRepository; - let useCase: GetSpaceAverageRatingUseCase; - - beforeEach(() => { - repo = new InMemoryRatingRepository(); - useCase = new GetSpaceAverageRatingUseCase(repo); - }); - - it('should return average score for a space', async () => { - const spaceId = 'space-123'; - await repo.create(makeRating({ spaceId, score: 5 })); - await repo.create(makeRating({ spaceId, score: 3 })); - await repo.create(makeRating({ spaceId, score: 4 })); - - const avg = await useCase.execute(spaceId); - expect(avg).toBeCloseTo(4); - }); - - it('should return 0 if no ratings for space', async () => { - const avg = await useCase.execute('non-existent-space'); - expect(avg).toBe(0); - }); -}); \ No newline at end of file diff --git a/src/modules/spaces/application/use-cases/get-space-average-rating/get-space-average-rating.use-case.ts b/src/modules/spaces/application/use-cases/get-space-average-rating/get-space-average-rating.use-case.ts deleted file mode 100644 index 0be86f2..0000000 --- a/src/modules/spaces/application/use-cases/get-space-average-rating/get-space-average-rating.use-case.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { RatingRepository } from "../../../domain/repositories/rating-repository"; - -export class GetSpaceAverageRatingUseCase { - constructor(private readonly ratingRepository: RatingRepository) { } - - async execute(spaceId: string): Promise { - return this.ratingRepository.getAverageScoreBySpaceId(spaceId); - } -} \ No newline at end of file diff --git a/src/modules/spaces/application/use-cases/update-rating/update-rating.use-case.spec.ts b/src/modules/spaces/application/use-cases/update-rating/update-rating.use-case.spec.ts deleted file mode 100644 index fd556cb..0000000 --- a/src/modules/spaces/application/use-cases/update-rating/update-rating.use-case.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { InMemoryRatingRepository } from 'test/repositories/in-memory-ratings-repository'; -import { UpdateRatingUseCase } from './update-rating.use-case'; -import { makeRating } from 'test/factories/make-rating'; - -describe('UpdateRatingUseCase', () => { - let repo: InMemoryRatingRepository; - let useCase: UpdateRatingUseCase; - - beforeEach(() => { - repo = new InMemoryRatingRepository(); - useCase = new UpdateRatingUseCase(repo); - }); - - it('should update rating score', async () => { - const rating = makeRating(); - await repo.create(rating); - - const result = await useCase.execute({ - id: rating.id.toString(), - score: 4, - }); - - expect(result.score).toBe(4); - expect(result.updatedAt).toBeInstanceOf(Date); - }); - - it('should update rating comment', async () => { - const rating = makeRating(); - await repo.create(rating); - - const result = await useCase.execute({ - id: rating.id.toString(), - comment: 'Novo comentário', - }); - - expect(result.comment).toBe('Novo comentário'); - expect(result.updatedAt).toBeInstanceOf(Date); - }); - - it('should throw error if score is less than 1', async () => { - const rating = makeRating(); - await repo.create(rating); - - await expect(useCase.execute({ - id: rating.id.toString(), - score: 0, - })).rejects.toThrow('Score must be between 1 and 5'); - }); - - it('should throw error if score is greater than 5', async () => { - const rating = makeRating(); - await repo.create(rating); - - await expect(useCase.execute({ - id: rating.id.toString(), - score: 6, - })).rejects.toThrow('Score must be between 1 and 5'); - }); - - it('should throw error if rating not found', async () => { - await expect(useCase.execute({ - id: 'non-existent-id', - score: 4, - })).rejects.toThrow('Rating not found'); - }); -}); \ No newline at end of file diff --git a/src/modules/spaces/application/use-cases/update-rating/update-rating.use-case.ts b/src/modules/spaces/application/use-cases/update-rating/update-rating.use-case.ts deleted file mode 100644 index be9db55..0000000 --- a/src/modules/spaces/application/use-cases/update-rating/update-rating.use-case.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { RatingRepository } from "../../../domain/repositories/rating-repository"; -import { Rating } from "../../../domain/entities/rating"; - -interface UpdateRatingDTO { - id: string; - score?: number; - comment?: string; -} - -export class UpdateRatingUseCase { - constructor(private readonly ratingRepository: RatingRepository) { } - - async execute(data: UpdateRatingDTO): Promise { - const rating = await this.ratingRepository.findById(data.id); - - if (!rating) { - throw new Error('Rating not found'); - } - - if (data.score !== undefined) { - rating.updateScore(data.score); - } - - if (data.comment !== undefined) { - rating.updateComment(data.comment); - } - - await this.ratingRepository.update(rating); - return rating; - } -} \ No newline at end of file diff --git a/src/modules/spaces/domain/entities/rating.ts b/src/modules/spaces/domain/entities/rating.ts deleted file mode 100644 index 277b8e3..0000000 --- a/src/modules/spaces/domain/entities/rating.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Entity } from "@/core/entities/entity"; -import { UniqueEntityID } from "@/core/entities/unique-entity-id"; -import { Optional } from "@/core/types/optional"; - -export type RatingProps = { - spaceId: string; - userId: string; - score: number; - comment?: string; - createdAt?: Date; - updatedAt?: Date; -} - -export class Rating extends Entity { - get spaceId() { - return this.props.spaceId; - } - - get userId() { - return this.props.userId; - } - - get score() { - return this.props.score; - } - - get comment() { - return this.props.comment; - } - - get createdAt() { - return this.props.createdAt; - } - - get updatedAt() { - return this.props.updatedAt; - } - - updateScore(score: number) { - if (score < 1 || score > 5) { - throw new Error('Score must be between 1 and 5'); - } - this.props.score = score; - this.touch(); - } - - updateComment(comment: string) { - this.props.comment = comment; - this.touch(); - } - - private touch() { - this.props.updatedAt = new Date(); - } - - static create( - props: Optional, - id?: UniqueEntityID, - ) { - if (props.score < 1 || props.score > 5) { - throw new Error('Score must be between 1 and 5'); - } - - const rating = new Rating( - { - ...props, - createdAt: new Date(), - updatedAt: new Date(), - }, - id, - ); - - return rating; - } -} \ No newline at end of file diff --git a/src/modules/spaces/domain/repositories/rating-repository.ts b/src/modules/spaces/domain/repositories/rating-repository.ts deleted file mode 100644 index 2a64830..0000000 --- a/src/modules/spaces/domain/repositories/rating-repository.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Rating } from "../entities/rating"; -import { PaginationParams } from "@/core/repositories/pagination-params"; - -export interface RatingRepository { - create(rating: Rating): Promise; - findById(id: string): Promise; - findBySpaceId(spaceId: string, params: PaginationParams): Promise<{ total: number; ratings: Rating[] }>; - findByUserId(userId: string, params: PaginationParams): Promise<{ total: number; ratings: Rating[] }>; - update(rating: Rating): Promise; - delete(id: string): Promise; - countBySpaceId(spaceId: string): Promise; - getAverageScoreBySpaceId(spaceId: string): Promise; -} \ No newline at end of file diff --git a/src/modules/spaces/infra/repositories/prisma-rating-repository.ts b/src/modules/spaces/infra/repositories/prisma-rating-repository.ts deleted file mode 100644 index f1ef236..0000000 --- a/src/modules/spaces/infra/repositories/prisma-rating-repository.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { PrismaClient } from '@prisma/client'; -import { RatingRepository } from '../../domain/repositories/rating-repository'; -import { Rating } from '../../domain/entities/rating'; -import { PaginationParams } from '@/core/repositories/pagination-params'; -import { UniqueEntityID } from '@/core/entities/unique-entity-id'; - -export class PrismaRatingRepository implements RatingRepository { - constructor(private readonly prisma: PrismaClient) { } - - async create(rating: Rating): Promise { - await this.prisma.rating.create({ - data: { - id: rating.id.toString(), - spaceId: rating.spaceId, - userId: rating.userId, - score: rating.score, - comment: rating.comment, - createdAt: rating.createdAt, - updatedAt: rating.updatedAt, - }, - }); - } - - async findById(id: string): Promise { - const data = await this.prisma.rating.findUnique({ where: { id } }); - if (!data) return null; - return Rating.create({ - spaceId: data.spaceId, - userId: data.userId, - score: data.score, - comment: data.comment || undefined, - createdAt: data.createdAt, - updatedAt: data.updatedAt, - }, new UniqueEntityID(data.id)); - } - - async findBySpaceId(spaceId: string, { page = 1, perPage = 10 }: PaginationParams) { - const [ratings, total] = await Promise.all([ - this.prisma.rating.findMany({ - where: { spaceId }, - skip: (page - 1) * perPage, - take: perPage, - orderBy: { createdAt: 'desc' }, - }), - this.prisma.rating.count({ where: { spaceId } }), - ]); - return { - total, - ratings: ratings.map(data => Rating.create({ - spaceId: data.spaceId, - userId: data.userId, - score: data.score, - comment: data.comment || undefined, - createdAt: data.createdAt, - updatedAt: data.updatedAt, - }, new UniqueEntityID(data.id))), - }; - } - - async findByUserId(userId: string, { page = 1, perPage = 10 }: PaginationParams) { - const [ratings, total] = await Promise.all([ - this.prisma.rating.findMany({ - where: { userId }, - skip: (page - 1) * perPage, - take: perPage, - orderBy: { createdAt: 'desc' }, - }), - this.prisma.rating.count({ where: { userId } }), - ]); - return { - total, - ratings: ratings.map(data => Rating.create({ - spaceId: data.spaceId, - userId: data.userId, - score: data.score, - comment: data.comment || undefined, - createdAt: data.createdAt, - updatedAt: data.updatedAt, - }, new UniqueEntityID(data.id))), - }; - } - - async update(rating: Rating): Promise { - await this.prisma.rating.update({ - where: { id: rating.id.toString() }, - data: { - score: rating.score, - comment: rating.comment, - updatedAt: rating.updatedAt, - }, - }); - } - - async delete(id: string): Promise { - await this.prisma.rating.delete({ where: { id } }); - } - - async countBySpaceId(spaceId: string): Promise { - return this.prisma.rating.count({ where: { spaceId } }); - } - - async getAverageScoreBySpaceId(spaceId: string): Promise { - const result = await this.prisma.rating.aggregate({ - where: { spaceId }, - _avg: { score: true }, - }); - return result._avg.score ?? 0; - } -} \ No newline at end of file diff --git a/src/modules/users/application/use-cases/create-user/create-user.use-case.spec.ts b/src/modules/users/application/use-cases/create-user/create-user.use-case.spec.ts index 14b7e4b..bf839ea 100644 --- a/src/modules/users/application/use-cases/create-user/create-user.use-case.spec.ts +++ b/src/modules/users/application/use-cases/create-user/create-user.use-case.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { User } from '@/modules/users/domain/entities/user'; import { CreateUserUseCase } from './create-user.use-case'; import { InMemoryUserRepository } from 'test/repositories/in-memory-users-repository'; +import { makeUser } from 'test/factories/make-user'; import { UserStatus, UserRole } from '@prisma/client'; describe('CreateUserUseCase', () => { @@ -17,100 +17,97 @@ describe('CreateUserUseCase', () => { const user = await createUserUseCase.execute({ name: 'John Doe', email: 'john@example.com', - password: '123456', - status: 'ACTIVE', - role: 'USER', + password: 'password123', + status: UserStatus.ACTIVE, + role: UserRole.USER, }); - expect(user).toBeInstanceOf(User); - expect(user.email).toBe('john@example.com'); + expect(user).toBeDefined(); expect(user.name).toBe('John Doe'); - expect(user.status).toBe('ACTIVE'); - expect(user.role).toBe('USER'); - expect(user.createdAt).toBeInstanceOf(Date); - expect(user.updatedAt).toBeInstanceOf(Date); - - const total = await userRepository.count(); - expect(total).toBe(1); - }); - - it('should not allow duplicate emails', async () => { - await createUserUseCase.execute({ - name: 'John Doe', - email: 'john@example.com', - password: '123456', - status: 'ACTIVE', - role: 'USER', - }); - - await expect(() => - createUserUseCase.execute({ - name: 'Jane Doe', - email: 'john@example.com', - password: 'abcdef', - status: 'ACTIVE', - role: 'USER', - }), - ).rejects.toThrow('User already exists'); + expect(user.email).toBe('john@example.com'); + expect(user.status).toBe(UserStatus.ACTIVE); + expect(user.role).toBe(UserRole.USER); + expect(user.password.value).toMatch(/^\$2[aby]\$\d+\$/); const total = await userRepository.count(); expect(total).toBe(1); }); - it('should create admin user', async () => { + it('should create user with custom role', async () => { const user = await createUserUseCase.execute({ name: 'Admin User', email: 'admin@example.com', - password: '123456', - status: 'ACTIVE', - role: 'ADMIN', + password: 'password123', + status: UserStatus.ACTIVE, + role: UserRole.ADMIN, }); - expect(user.role).toBe('ADMIN'); + expect(user.role).toBe(UserRole.ADMIN); }); - it('should create inactive user', async () => { + it('should create user with custom status', async () => { const user = await createUserUseCase.execute({ name: 'Inactive User', email: 'inactive@example.com', - password: '123456', - status: 'INACTIVE', - role: 'USER', + password: 'password123', + status: UserStatus.INACTIVE, + role: UserRole.USER, }); - expect(user.status).toBe('INACTIVE'); + expect(user.status).toBe(UserStatus.INACTIVE); }); - it('should hash password', async () => { - const user = await createUserUseCase.execute({ + it('should throw error if email already exists', async () => { + await createUserUseCase.execute({ name: 'John Doe', email: 'john@example.com', - password: '123456', - status: 'ACTIVE', - role: 'USER', + password: 'password123', + status: UserStatus.ACTIVE, + role: UserRole.USER, }); - expect(user.password.value).not.toBe('123456'); - expect(user.password.value).toMatch(/^\$2[aby]\$\d+\$/); // Verifica se é um hash bcrypt + await expect(() => + createUserUseCase.execute({ + name: 'Jane Doe', + email: 'john@example.com', + password: 'password456', + status: UserStatus.ACTIVE, + role: UserRole.USER, + }), + ).rejects.toThrow('User already exists'); }); - it('should be case insensitive for email uniqueness', async () => { + it('should create multiple users with different emails', async () => { await createUserUseCase.execute({ + name: 'User 1', + email: 'user1@example.com', + password: 'password123', + status: UserStatus.ACTIVE, + role: UserRole.USER, + }); + + await createUserUseCase.execute({ + name: 'User 2', + email: 'user2@example.com', + password: 'password456', + status: UserStatus.ACTIVE, + role: UserRole.USER, + }); + + const total = await userRepository.count(); + expect(total).toBe(2); + }); + + it('should hash password correctly', async () => { + const user = await createUserUseCase.execute({ name: 'John Doe', - email: 'John.Doe@Example.com', - password: '123456', - status: 'ACTIVE', - role: 'USER', + email: 'john@example.com', + password: 'password123', + status: UserStatus.ACTIVE, + role: UserRole.USER, }); - await expect(() => - createUserUseCase.execute({ - name: 'Jane Doe', - email: 'john.doe@example.com', - password: 'abcdef', - status: 'ACTIVE', - role: 'USER', - }), - ).rejects.toThrow('User already exists'); + expect(user.password.value).toMatch(/^\$2[aby]\$\d+\$/); + expect(user.password.value).not.toBe('password123'); }); }); \ No newline at end of file diff --git a/src/modules/users/application/use-cases/delete-user/delete-user-use-case.spec.ts b/src/modules/users/application/use-cases/delete-user/delete-user-use-case.spec.ts index df670b4..2c76bb6 100644 --- a/src/modules/users/application/use-cases/delete-user/delete-user-use-case.spec.ts +++ b/src/modules/users/application/use-cases/delete-user/delete-user-use-case.spec.ts @@ -1,68 +1,84 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { DeleteUserUseCase } from './delete-user.use-case'; -import { makeUser } from 'test/factories/make-user'; import { InMemoryUserRepository } from 'test/repositories/in-memory-users-repository'; +import { makeUser } from 'test/factories/make-user'; import { UserStatus } from '@prisma/client'; describe('DeleteUserUseCase', () => { - let repo: InMemoryUserRepository; - let useCase: DeleteUserUseCase; + let userRepository: InMemoryUserRepository; + let deleteUserUseCase: DeleteUserUseCase; beforeEach(() => { - repo = new InMemoryUserRepository(); - useCase = new DeleteUserUseCase(repo); + userRepository = new InMemoryUserRepository(); + deleteUserUseCase = new DeleteUserUseCase(userRepository); }); it('should delete a user', async () => { const user = await makeUser(); - await repo.create(user); + await userRepository.create(user); - await useCase.execute(user.id.toString()); + await deleteUserUseCase.execute(user.id.toString()); - const deletedUser = await repo.findById(user.id.toString()); + const deletedUser = await userRepository.findById(user.id.toString()); expect(deletedUser).toBeDefined(); - expect(deletedUser?.status).toBe('INACTIVE'); + expect(deletedUser?.status).toBe(UserStatus.INACTIVE); expect(deletedUser?.deletedAt).toBeInstanceOf(Date); - expect(deletedUser?.updatedAt).toBeInstanceOf(Date); + + const total = await userRepository.count(); + expect(total).toBe(0); }); it('should throw error when user not found', async () => { await expect(() => - useCase.execute('non-existent-id') + deleteUserUseCase.execute('non-existent-id'), ).rejects.toThrow('User not found'); }); - it('should maintain user data after deletion', async () => { - const user = await makeUser({ - name: 'John Doe', - email: 'john@example.com', - status: 'ACTIVE' as UserStatus, - }); - await repo.create(user); + it('should delete user with different statuses', async () => { + const activeUser = await makeUser({ status: UserStatus.ACTIVE }); + const inactiveUser = await makeUser({ status: UserStatus.INACTIVE }); - await useCase.execute(user.id.toString()); + await userRepository.create(activeUser); + await userRepository.create(inactiveUser); - const deletedUser = await repo.findById(user.id.toString()); - expect(deletedUser?.name).toBe('John Doe'); - expect(deletedUser?.email).toBe('john@example.com'); - expect(deletedUser?.status).toBe('INACTIVE'); - expect(deletedUser?.deletedAt).toBeInstanceOf(Date); + await deleteUserUseCase.execute(activeUser.id.toString()); + await deleteUserUseCase.execute(inactiveUser.id.toString()); + + const total = await userRepository.count(); + expect(total).toBe(0); }); - it('should not allow deleting an already deleted user', async () => { + it('should delete multiple users', async () => { + const user1 = await makeUser(); + const user2 = await makeUser(); + const user3 = await makeUser(); + + await userRepository.create(user1); + await userRepository.create(user2); + await userRepository.create(user3); + + expect(await userRepository.count()).toBe(3); + + await deleteUserUseCase.execute(user1.id.toString()); + await deleteUserUseCase.execute(user2.id.toString()); + + expect(await userRepository.count()).toBe(1); + }); + + it('should handle multiple deletions of the same user', async () => { const user = await makeUser(); - await repo.create(user); + await userRepository.create(user); + + await deleteUserUseCase.execute(user.id.toString()); - // Primeira deleção - await useCase.execute(user.id.toString()); - const firstDeletedUser = await repo.findById(user.id.toString()); - const firstDeletedAt = firstDeletedUser?.deletedAt; + const deletedUser = await userRepository.findById(user.id.toString()); + expect(deletedUser?.status).toBe(UserStatus.INACTIVE); + expect(deletedUser?.deletedAt).toBeInstanceOf(Date); - // Segunda deleção - await useCase.execute(user.id.toString()); - const secondDeletedUser = await repo.findById(user.id.toString()); + await deleteUserUseCase.execute(user.id.toString()); - expect(secondDeletedUser?.deletedAt).toEqual(firstDeletedAt); - expect(secondDeletedUser?.status).toBe('INACTIVE'); + const deletedUser2 = await userRepository.findById(user.id.toString()); + expect(deletedUser2?.status).toBe(UserStatus.INACTIVE); + expect(deletedUser2?.deletedAt).toBeInstanceOf(Date); }); }); \ No newline at end of file diff --git a/src/modules/users/application/use-cases/find-user/find-all-users-use-case.spec.ts b/src/modules/users/application/use-cases/find-user/find-all-users-use-case.spec.ts index 6f84f40..adaa683 100644 --- a/src/modules/users/application/use-cases/find-user/find-all-users-use-case.spec.ts +++ b/src/modules/users/application/use-cases/find-user/find-all-users-use-case.spec.ts @@ -1,71 +1,92 @@ import { describe, it, expect, beforeEach } from 'vitest'; +import { FindAllUsersUseCase } from './find-all-users.use-case'; import { InMemoryUserRepository } from 'test/repositories/in-memory-users-repository'; import { makeUser } from 'test/factories/make-user'; -import { FindAllUsersUseCase } from './find-all-users.use-case'; import { UserStatus } from '@prisma/client'; describe('FindAllUsersUseCase', () => { - let repo: InMemoryUserRepository; - let useCase: FindAllUsersUseCase; + let userRepository: InMemoryUserRepository; + let findAllUsersUseCase: FindAllUsersUseCase; beforeEach(() => { - repo = new InMemoryUserRepository(); - useCase = new FindAllUsersUseCase(repo); + userRepository = new InMemoryUserRepository(); + findAllUsersUseCase = new FindAllUsersUseCase(userRepository); }); it('should return paginated users with total', async () => { - await repo.create(await makeUser()); - await repo.create(await makeUser()); + await userRepository.create(await makeUser()); + await userRepository.create(await makeUser()); + await userRepository.create(await makeUser()); - const result = await useCase.execute({ page: 1, perPage: 10 }); + const result = await findAllUsersUseCase.execute({ page: 1, perPage: 10 }); - expect(result.total).toBe(2); - expect(result.users).toHaveLength(2); + expect(result.total).toBe(3); + expect(result.users).toHaveLength(3); }); it('should return empty list when no users exist', async () => { - const result = await useCase.execute({ page: 1, perPage: 10 }); + const result = await findAllUsersUseCase.execute({ page: 1, perPage: 10 }); expect(result.total).toBe(0); expect(result.users).toHaveLength(0); }); it('should return correct page of users', async () => { - // Criar 5 usuários for (let i = 0; i < 5; i++) { - await repo.create(await makeUser()); + await userRepository.create(await makeUser()); } - const result = await useCase.execute({ page: 2, perPage: 2 }); + const result = await findAllUsersUseCase.execute({ page: 2, perPage: 2 }); expect(result.total).toBe(5); expect(result.users).toHaveLength(2); }); - it('should return active users even with deletedAt', async () => { - const activeUser = await makeUser({ status: 'ACTIVE' as UserStatus }); - activeUser.delete(); // Adiciona deletedAt mas mantém status ACTIVE - await repo.create(activeUser); + it('should return only active users', async () => { + await userRepository.create(await makeUser({ status: UserStatus.ACTIVE })); + await userRepository.create(await makeUser({ status: UserStatus.ACTIVE })); + await userRepository.create(await makeUser({ status: UserStatus.INACTIVE })); + + const result = await findAllUsersUseCase.execute({ page: 1, perPage: 10 }); + + expect(result.total).toBe(2); + expect(result.users).toHaveLength(2); + expect(result.users[0].status).toBe(UserStatus.ACTIVE); + expect(result.users[1].status).toBe(UserStatus.ACTIVE); + }); + + it('should not return deleted users', async () => { + const activeUser = await makeUser({ status: UserStatus.ACTIVE }); + await userRepository.create(activeUser); - const inactiveUser = await makeUser({ status: 'INACTIVE' as UserStatus }); + const inactiveUser = await makeUser({ status: UserStatus.INACTIVE }); inactiveUser.delete(); - await repo.create(inactiveUser); + await userRepository.create(inactiveUser); - const result = await useCase.execute({ page: 1, perPage: 10 }); + const result = await findAllUsersUseCase.execute({ page: 1, perPage: 10 }); - expect(result.total).toBe(1); // Apenas o usuário ativo deve aparecer - expect(result.users[0].status).toBe('ACTIVE'); + expect(result.total).toBe(1); + expect(result.users[0].status).toBe(UserStatus.ACTIVE); }); it('should return last page correctly', async () => { - // Criar 5 usuários for (let i = 0; i < 5; i++) { - await repo.create(await makeUser()); + await userRepository.create(await makeUser()); } - const result = await useCase.execute({ page: 3, perPage: 2 }); + const result = await findAllUsersUseCase.execute({ page: 3, perPage: 2 }); expect(result.total).toBe(5); - expect(result.users).toHaveLength(1); // Última página deve ter apenas 1 usuário + expect(result.users).toHaveLength(1); + }); + + it('should handle pagination with default values', async () => { + await userRepository.create(await makeUser()); + await userRepository.create(await makeUser()); + + const result = await findAllUsersUseCase.execute({}); + + expect(result.total).toBe(2); + expect(result.users).toHaveLength(2); }); }); \ No newline at end of file diff --git a/src/modules/users/application/use-cases/update-user/update-user-use-case.spec.ts b/src/modules/users/application/use-cases/update-user/update-user-use-case.spec.ts index a8c2777..4637a66 100644 --- a/src/modules/users/application/use-cases/update-user/update-user-use-case.spec.ts +++ b/src/modules/users/application/use-cases/update-user/update-user-use-case.spec.ts @@ -59,7 +59,7 @@ describe('UpdateUserUseCase', () => { it('should clear deletedAt when status changes from INACTIVE to ACTIVE', async () => { const user = await makeUser({ status: 'INACTIVE' as UserStatus }); - user.delete(); // Adiciona deletedAt + user.delete(); await repo.create(user); await useCase.execute({ id: user.id.toString(), status: 'ACTIVE' as UserStatus }); diff --git a/src/modules/users/domain/entities/user.ts b/src/modules/users/domain/entities/user.ts index 4dee3ff..d3b12fe 100644 --- a/src/modules/users/domain/entities/user.ts +++ b/src/modules/users/domain/entities/user.ts @@ -78,6 +78,7 @@ export class User extends Entity { } delete() { + this.props.status = UserStatus.INACTIVE; this.props.deletedAt = new Date(); this.touch(); } diff --git a/test/TEST.MD b/test/TEST.MD index bea1daf..a0afa8a 100644 --- a/test/TEST.MD +++ b/test/TEST.MD @@ -120,6 +120,56 @@ - ✅ Deve retornar média das avaliações de um espaço - ✅ Deve retornar 0 quando não existem avaliações +## Módulo de Agendamentos + +### Casos de Uso + +#### CreateAppointmentUseCase +- ✅ Deve criar um novo agendamento +- ✅ Deve retornar erro se usuário não existir +- ✅ Deve retornar erro se espaço não existir +- ✅ Deve retornar erro se data for no passado +- ✅ Deve retornar erro se horário de início for maior que fim +- ✅ Deve retornar erro se houver conflito de horário no mesmo espaço +- ✅ Deve permitir agendamentos em espaços diferentes no mesmo horário + +#### FindAppointmentByIdUseCase +- ✅ Deve encontrar agendamento por ID +- ✅ Deve retornar null quando agendamento não encontrado +- ✅ Deve encontrar agendamentos com diferentes status +- ✅ Deve retornar todos os campos do agendamento + +#### FindAppointmentsByUserIdUseCase +- ✅ Deve retornar agendamentos paginados com total +- ✅ Deve retornar lista vazia quando não existem agendamentos +- ✅ Deve retornar página correta de agendamentos +- ✅ Deve retornar agendamentos com diferentes status +- ✅ Deve retornar última página corretamente +- ✅ Deve lidar com paginação com valores padrão + +#### FindAppointmentsBySpaceIdUseCase +- ✅ Deve retornar agendamentos paginados com total +- ✅ Deve retornar lista vazia quando não existem agendamentos +- ✅ Deve retornar página correta de agendamentos +- ✅ Deve retornar agendamentos com diferentes status +- ✅ Deve retornar última página corretamente +- ✅ Deve lidar com paginação com valores padrão + +#### UpdateAppointmentUseCase +- ✅ Deve atualizar status do agendamento +- ✅ Deve atualizar data e horário do agendamento +- ✅ Deve atualizar múltiplos campos de uma vez +- ✅ Deve retornar erro se agendamento não existir +- ✅ Deve retornar erro se nova data for no passado +- ✅ Deve retornar erro se horário de início for maior que fim +- ✅ Deve manter campos não modificados + +#### DeleteAppointmentUseCase +- ✅ Deve deletar um agendamento +- ✅ Deve retornar erro se agendamento não existir +- ✅ Deve deletar agendamentos com diferentes status +- ✅ Deve deletar múltiplos agendamentos + ## Entidades ### User @@ -144,16 +194,32 @@ - ✅ Deve lançar erro se nota for inválida - ✅ Deve criar avaliação com props customizadas +### Appointment +- ✅ Deve criar um agendamento +- ✅ Deve criar agendamento com status customizado +- ✅ Deve lançar erro se data for no passado +- ✅ Deve lançar erro se horário de início for maior que fim +- ✅ Deve atualizar status +- ✅ Deve atualizar data e horário +- ✅ Deve lançar erro ao atualizar data para passado +- ✅ Deve lançar erro ao atualizar horário com range inválido + ## Repositórios ### InMemoryUserRepository - ✅ Deve criar usuário - ✅ Deve encontrar usuário por email (case insensitive) - ✅ Deve encontrar usuário por ID +- ✅ Deve retornar null quando usuário não encontrado - ✅ Deve listar usuários com paginação +- ✅ Deve retornar lista vazia quando não existem usuários +- ✅ Deve retornar página correta de usuários - ✅ Deve atualizar usuário -- ✅ Deve deletar usuário +- ✅ Deve deletar usuário (marcar como inativo e setar deletedAt) - ✅ Deve contar usuários ativos e não deletados +- ✅ Deve não contar usuários deletados +- ✅ Deve lidar com paginação com valores padrão +- ✅ Deve retornar usuários ativos mesmo com deletedAt ### PrismaUserRepository - ✅ Deve criar usuário @@ -164,12 +230,43 @@ - ✅ Deve deletar usuário - ✅ Deve contar usuários ativos e não deletados +### InMemorySpacesRepository +- ✅ Deve criar espaço +- ✅ Deve encontrar espaço por ID +- ✅ Deve retornar null quando espaço não encontrado +- ✅ Deve listar espaços com paginação +- ✅ Deve retornar lista vazia quando não existem espaços +- ✅ Deve atualizar espaço +- ✅ Deve deletar espaço +- ✅ Deve lidar com múltiplos espaços +- ✅ Deve atualizar espaço com propriedades customizadas +- ✅ Deve lidar com paginação corretamente + ### InMemoryRatingRepository - ✅ Deve criar avaliação - ✅ Deve encontrar avaliação por ID -- ✅ Deve listar avaliações por espaço -- ✅ Deve listar avaliações por usuário +- ✅ Deve retornar null quando avaliação não encontrada +- ✅ Deve listar avaliações por espaço com paginação +- ✅ Deve listar avaliações por usuário com paginação +- ✅ Deve retornar lista vazia quando não existem avaliações - ✅ Deve atualizar avaliação - ✅ Deve deletar avaliação - ✅ Deve contar avaliações por espaço -- ✅ Deve calcular média de avaliações por espaço \ No newline at end of file +- ✅ Deve calcular média de score por espaço +- ✅ Deve retornar 0 quando não existem avaliações +- ✅ Deve lidar com paginação corretamente para avaliações de espaço +- ✅ Deve lidar com paginação corretamente para avaliações de usuário +- ✅ Deve lidar com paginação com valores padrão + +### InMemoryAppointmentRepository +- ✅ Deve criar agendamento +- ✅ Deve encontrar agendamento por ID +- ✅ Deve retornar null quando agendamento não encontrado +- ✅ Deve listar agendamentos por usuário com paginação +- ✅ Deve listar agendamentos por espaço com paginação +- ✅ Deve encontrar agendamentos por range de data +- ✅ Deve não retornar agendamentos cancelados no range de data +- ✅ Deve atualizar agendamento +- ✅ Deve deletar agendamento +- ✅ Deve contar agendamentos +- ✅ Deve lidar com paginação corretamente \ No newline at end of file diff --git a/test/factories/make-appointment.ts b/test/factories/make-appointment.ts new file mode 100644 index 0000000..a13ffbf --- /dev/null +++ b/test/factories/make-appointment.ts @@ -0,0 +1,55 @@ +import { Appointment } from '@/modules/appointments/domain/entities/appointment'; +import { UniqueEntityID } from '@/core/entities/unique-entity-id'; +import { AppointmentStatus } from '@prisma/client'; + +interface MakeAppointmentProps { + id?: string; + userId?: string; + spaceId?: string; + date?: Date; + startTime?: Date; + endTime?: Date; + status?: AppointmentStatus; +} + +let appointmentCounter = 0; + +export function makeAppointment(overrides: MakeAppointmentProps = {}): Appointment { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + + appointmentCounter++; + + // Use o spaceId (ou um valor default) para garantir unicidade por espaço + const spaceKey = overrides.spaceId ? parseInt(overrides.spaceId.replace(/\D/g, ''), 10) || 0 : appointmentCounter; + const baseHour = 9 + ((appointmentCounter + spaceKey * 5) * 3) % 12; // espaçamento maior + const baseMinute = ((appointmentCounter + spaceKey * 7) * 15) % 60; + + const startTime = new Date(tomorrow); + startTime.setHours(baseHour, baseMinute, 0, 0); + + const endTime = new Date(tomorrow); + endTime.setHours(baseHour + 2, baseMinute, 0, 0); + + const { + id = new UniqueEntityID().toString(), + userId = 'user-123', + spaceId = `space-${appointmentCounter}`, + date = tomorrow, + startTime: customStartTime = startTime, + endTime: customEndTime = endTime, + status = 'PENDING', + } = overrides; + + return Appointment.create( + { + userId, + spaceId, + date, + startTime: customStartTime, + endTime: customEndTime, + status, + }, + new UniqueEntityID(id), + ); +} \ No newline at end of file diff --git a/test/repositories/in-memory-appointments-repository.ts b/test/repositories/in-memory-appointments-repository.ts new file mode 100644 index 0000000..5795b18 --- /dev/null +++ b/test/repositories/in-memory-appointments-repository.ts @@ -0,0 +1,105 @@ +import { PaginationParams } from '@/core/repositories/pagination-params'; +import { Appointment } from '@/modules/appointments/domain/entities/appointment'; +import { AppointmentRepository } from '@/modules/appointments/domain/repositories/appointment-repository'; +import { AppointmentStatus } from '@prisma/client'; + +export class InMemoryAppointmentRepository implements AppointmentRepository { + private appointments: Appointment[] = []; + + async create(appointment: Appointment): Promise { + // Verificar conflitos antes de criar o agendamento + // Usar uma verificação mais robusta que simule o comportamento de um banco de dados + const conflictingAppointments = this.appointments.filter(existing => { + if (existing.spaceId !== appointment.spaceId) return false; + if (existing.status === AppointmentStatus.CANCELLED) return false; + + const existingStart = existing.startTime; + const existingEnd = existing.endTime; + const newStart = appointment.startTime; + const newEnd = appointment.endTime; + + // Verificar sobreposição de horários + return ( + (newStart >= existingStart && newStart < existingEnd) || + (newEnd > existingStart && newEnd <= existingEnd) || + (newStart <= existingStart && newEnd >= existingEnd) + ); + }); + + if (conflictingAppointments.length > 0) { + throw new Error('Já existe um agendamento para este horário neste espaço'); + } + + this.appointments.push(appointment); + } + + async findById(id: string): Promise { + return this.appointments.find(a => a.id.toString() === id) || null; + } + + async findByUserId( + userId: string, + params: PaginationParams, + ): Promise<{ total: number; appointments: Appointment[] }> { + const userAppointments = this.appointments.filter(a => a.userId === userId); + const skip = ((params.page ?? 1) - 1) * (params.perPage ?? 10); + const appointments = userAppointments.slice(skip, skip + (params.perPage ?? 10)); + + return { + total: userAppointments.length, + appointments, + }; + } + + async findBySpaceId( + spaceId: string, + params: PaginationParams, + ): Promise<{ total: number; appointments: Appointment[] }> { + const spaceAppointments = this.appointments.filter(a => a.spaceId === spaceId); + const skip = ((params.page ?? 1) - 1) * (params.perPage ?? 10); + const appointments = spaceAppointments.slice(skip, skip + (params.perPage ?? 10)); + + return { + total: spaceAppointments.length, + appointments, + }; + } + + async findByDateRange( + spaceId: string, + startDate: Date, + endDate: Date, + ): Promise { + return this.appointments.filter(appointment => { + if (appointment.spaceId !== spaceId) return false; + if (appointment.status === AppointmentStatus.CANCELLED) return false; + + const appointmentStart = appointment.startTime; + const appointmentEnd = appointment.endTime; + + return ( + (appointmentStart >= startDate && appointmentStart < endDate) || + (appointmentEnd > startDate && appointmentEnd <= endDate) || + (appointmentStart <= startDate && appointmentEnd >= endDate) + ); + }); + } + + async update(appointment: Appointment): Promise { + const index = this.appointments.findIndex(a => a.id.toString() === appointment.id.toString()); + if (index >= 0) { + this.appointments[index] = appointment; + } + } + + async delete(id: string): Promise { + const index = this.appointments.findIndex(a => a.id.toString() === id); + if (index >= 0) { + this.appointments.splice(index, 1); + } + } + + async count(): Promise { + return this.appointments.length; + } +} \ No newline at end of file diff --git a/test/repositories/in-memory-ratings-repository.ts b/test/repositories/in-memory-ratings-repository.ts index a12bdc4..740aa86 100644 --- a/test/repositories/in-memory-ratings-repository.ts +++ b/test/repositories/in-memory-ratings-repository.ts @@ -14,7 +14,7 @@ export class InMemoryRatingRepository implements RatingRepository { return rating ?? null; } - async findBySpaceId(spaceId: string, { page, perPage }: PaginationParams) { + async findBySpaceId(spaceId: string, { page = 1, perPage = 10 }: PaginationParams) { const ratings = this.items .filter(item => item.spaceId === spaceId) .slice((page - 1) * perPage, page * perPage); @@ -27,7 +27,7 @@ export class InMemoryRatingRepository implements RatingRepository { }; } - async findByUserId(userId: string, { page, perPage }: PaginationParams) { + async findByUserId(userId: string, { page = 1, perPage = 10 }: PaginationParams) { const ratings = this.items .filter(item => item.userId === userId) .slice((page - 1) * perPage, page * perPage); diff --git a/test/repositories/in-memory-users-repository.ts b/test/repositories/in-memory-users-repository.ts index 3231495..78b8bb8 100644 --- a/test/repositories/in-memory-users-repository.ts +++ b/test/repositories/in-memory-users-repository.ts @@ -19,7 +19,7 @@ export class InMemoryUserRepository implements UserRepository { async findAll({ page = 1, perPage = 10 }: PaginationParams): Promise<{ total: number; users: User[] }> { const filteredUsers = this.users.filter(user => - user.status === 'ACTIVE' || user.deletedAt === null + user.status === 'ACTIVE' && user.deletedAt === null ); const start = (page - 1) * perPage; From 4794222f25b8a0301edd9015564aa60c70e9ea8f Mon Sep 17 00:00:00 2001 From: Antonio Bernardino da Silva Date: Wed, 18 Jun 2025 20:20:47 -0300 Subject: [PATCH 2/2] chore: remove debug test --- debug-test.ts | 46 ---------------------------------------------- 1 file changed, 46 deletions(-) delete mode 100644 debug-test.ts diff --git a/debug-test.ts b/debug-test.ts deleted file mode 100644 index 1e03bdd..0000000 --- a/debug-test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { makeAppointment } from './test/factories/make-appointment'; - -// Teste para debugar a factory -console.log('Testando factory de agendamentos atualizada...'); - -const appointment1 = makeAppointment({ spaceId: 'space-123' }); -const appointment2 = makeAppointment({ spaceId: 'space-123' }); -const appointment3 = makeAppointment({ spaceId: 'space-123' }); - -console.log('Appointment 1:', { - spaceId: appointment1.spaceId, - startTime: appointment1.startTime.toLocaleTimeString(), - endTime: appointment1.endTime.toLocaleTimeString() -}); - -console.log('Appointment 2:', { - spaceId: appointment2.spaceId, - startTime: appointment2.startTime.toLocaleTimeString(), - endTime: appointment2.endTime.toLocaleTimeString() -}); - -console.log('Appointment 3:', { - spaceId: appointment3.spaceId, - startTime: appointment3.startTime.toLocaleTimeString(), - endTime: appointment3.endTime.toLocaleTimeString() -}); - -// Verificar se há sobreposição -const hasOverlap1_2 = ( - appointment1.startTime < appointment2.endTime && - appointment2.startTime < appointment1.endTime -); - -const hasOverlap2_3 = ( - appointment2.startTime < appointment3.endTime && - appointment3.startTime < appointment2.endTime -); - -const hasOverlap1_3 = ( - appointment1.startTime < appointment3.endTime && - appointment3.startTime < appointment1.endTime -); - -console.log('Há sobreposição entre 1 e 2?', hasOverlap1_2); -console.log('Há sobreposição entre 2 e 3?', hasOverlap2_3); -console.log('Há sobreposição entre 1 e 3?', hasOverlap1_3); \ No newline at end of file