diff --git a/prisma/migrations/20240515001139_/migration.sql b/prisma/migrations/20240515001139_/migration.sql new file mode 100644 index 00000000..02617b70 --- /dev/null +++ b/prisma/migrations/20240515001139_/migration.sql @@ -0,0 +1,47 @@ +-- CreateTable +CREATE TABLE "transports" ( + "id" TEXT NOT NULL, + "vehicle_type" TEXT NOT NULL, + "vehicle_registration_plate" TEXT, + "contact" TEXT, + "created_at" VARCHAR(32) NOT NULL, + "updated_at" VARCHAR(32), + + CONSTRAINT "transports_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "transport_managers" ( + "transport_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" VARCHAR(32) NOT NULL, + "updated_at" VARCHAR(32), + + CONSTRAINT "transport_managers_pkey" PRIMARY KEY ("transport_id","user_id") +); + +-- CreateTable +CREATE TABLE "trips" ( + "id" TEXT NOT NULL, + "transport_id" TEXT NOT NULL, + "shelter_id" TEXT NOT NULL, + "departure_city" TEXT NOT NULL, + "departure_datetime" TIMESTAMP(3) NOT NULL, + "contact" TEXT NOT NULL, + "created_at" VARCHAR(32) NOT NULL, + "updated_at" VARCHAR(32), + + CONSTRAINT "trips_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "transport_managers" ADD CONSTRAINT "transport_managers_transport_id_fkey" FOREIGN KEY ("transport_id") REFERENCES "transports"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "transport_managers" ADD CONSTRAINT "transport_managers_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "trips" ADD CONSTRAINT "trips_transport_id_fkey" FOREIGN KEY ("transport_id") REFERENCES "transports"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "trips" ADD CONSTRAINT "trips_shelter_id_fkey" FOREIGN KEY ("shelter_id") REFERENCES "shelters"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20240515003358_/migration.sql b/prisma/migrations/20240515003358_/migration.sql new file mode 100644 index 00000000..572a4e8e --- /dev/null +++ b/prisma/migrations/20240515003358_/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "trips" ALTER COLUMN "departure_datetime" SET DATA TYPE TEXT; diff --git a/prisma/migrations/20240515022401_/migration.sql b/prisma/migrations/20240515022401_/migration.sql new file mode 100644 index 00000000..a69c57ee --- /dev/null +++ b/prisma/migrations/20240515022401_/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "trips" ADD COLUMN "canceled" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20240515233229_/migration.sql b/prisma/migrations/20240515233229_/migration.sql new file mode 100644 index 00000000..5c7b23d6 --- /dev/null +++ b/prisma/migrations/20240515233229_/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "AccessLevel" ADD VALUE 'TransportManager'; diff --git a/prisma/migrations/20240516172116_/migration.sql b/prisma/migrations/20240516172116_/migration.sql new file mode 100644 index 00000000..2adc5435 --- /dev/null +++ b/prisma/migrations/20240516172116_/migration.sql @@ -0,0 +1,9 @@ +/* + Warnings: + + - Changed the type of `departure_datetime` on the `trips` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + +*/ +-- AlterTable +ALTER TABLE "trips" DROP COLUMN "departure_datetime", +ADD COLUMN "departure_datetime" TIMESTAMP(3) NOT NULL; diff --git a/prisma/migrations/20240516184748_/migration.sql b/prisma/migrations/20240516184748_/migration.sql new file mode 100644 index 00000000..81be3ce5 --- /dev/null +++ b/prisma/migrations/20240516184748_/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "trips" ADD COLUMN "departure_neighborhood" TEXT; diff --git a/prisma/migrations/20240516195001_/migration.sql b/prisma/migrations/20240516195001_/migration.sql new file mode 100644 index 00000000..f905a1a6 --- /dev/null +++ b/prisma/migrations/20240516195001_/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `departure_state` to the `trips` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "State" AS ENUM ('AC', 'AL', 'AP', 'AM', 'BA', 'CE', 'DF', 'ES', 'GO', 'MA', 'MT', 'MS', 'MG', 'PA', 'PB', 'PR', 'PE', 'PI', 'RJ', 'RN', 'RS', 'RO', 'RR', 'SC', 'SP', 'SE', 'TO'); + +-- AlterTable +ALTER TABLE "trips" ADD COLUMN "departure_state" "State" NOT NULL; diff --git a/prisma/migrations/20240516234942_/migration.sql b/prisma/migrations/20240516234942_/migration.sql new file mode 100644 index 00000000..c85e08f8 --- /dev/null +++ b/prisma/migrations/20240516234942_/migration.sql @@ -0,0 +1,11 @@ +-- ALTER TABLE +ALTER TABLE "transport_managers" ALTER COLUMN "created_at" TYPE TIMESTAMP(3) WITH TIME ZONE USING TO_TIMESTAMP("created_at", 'YYYY-MM-DD"T"HH24:MI:SS.FF3"Z"')::TIMESTAMP; +ALTER TABLE "transport_managers" ALTER COLUMN "updated_at" TYPE TIMESTAMP(3) WITH TIME ZONE USING TO_TIMESTAMP("updated_at", 'YYYY-MM-DD"T"HH24:MI:SS.FF3"Z"')::TIMESTAMP; + +-- ALTER TABLE +ALTER TABLE "transports" ALTER COLUMN "created_at" TYPE TIMESTAMP(3) WITH TIME ZONE USING TO_TIMESTAMP("created_at", 'YYYY-MM-DD"T"HH24:MI:SS.FF3"Z"')::TIMESTAMP; +ALTER TABLE "transports" ALTER COLUMN "updated_at" TYPE TIMESTAMP(3) WITH TIME ZONE USING TO_TIMESTAMP("updated_at", 'YYYY-MM-DD"T"HH24:MI:SS.FF3"Z"')::TIMESTAMP; + +-- ALTER TABLE +ALTER TABLE "trips" ALTER COLUMN "created_at" TYPE TIMESTAMP(3) WITH TIME ZONE USING TO_TIMESTAMP("created_at", 'YYYY-MM-DD"T"HH24:MI:SS.FF3"Z"')::TIMESTAMP; +ALTER TABLE "trips" ALTER COLUMN "updated_at" TYPE TIMESTAMP(3) WITH TIME ZONE USING TO_TIMESTAMP("updated_at", 'YYYY-MM-DD"T"HH24:MI:SS.FF3"Z"')::TIMESTAMP; diff --git a/prisma/migrations/20240517002749_/migration.sql b/prisma/migrations/20240517002749_/migration.sql new file mode 100644 index 00000000..6057a0fb --- /dev/null +++ b/prisma/migrations/20240517002749_/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable +ALTER TABLE "transport_managers" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "transports" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "trips" ALTER COLUMN "created_at" SET DEFAULT CURRENT_TIMESTAMP, +ALTER COLUMN "departure_datetime" SET DATA TYPE TEXT; diff --git a/prisma/migrations/20240517003522_/migration.sql b/prisma/migrations/20240517003522_/migration.sql new file mode 100644 index 00000000..273a8ca7 --- /dev/null +++ b/prisma/migrations/20240517003522_/migration.sql @@ -0,0 +1,2 @@ +-- ALTER TABLE +ALTER TABLE "trips" ALTER COLUMN "departure_datetime" TYPE TIMESTAMP(3) WITH TIME ZONE USING TO_TIMESTAMP("departure_datetime", 'YYYY-MM-DD"T"HH24:MI:SS.FF3"Z"')::TIMESTAMP; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 69d25067..c18e9686 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,11 +9,42 @@ datasource db { enum AccessLevel { User + TransportManager Staff DistributionCenter Admin } +enum State { + AC + AL + AP + AM + BA + CE + DF + ES + GO + MA + MT + MS + MG + PA + PB + PR + PE + PI + RJ + RN + RS + RO + RR + SC + SP + SE + TO +} + enum ShelterCategory { Shelter DistributionCenter @@ -30,9 +61,10 @@ model User { createdAt String @map("created_at") @db.VarChar(32) updatedAt String? @map("updated_at") @db.VarChar(32) - sessions Session[] - shelterManagers ShelterManagers[] - suppliesHistory SupplyHistory[] + sessions Session[] + shelterManagers ShelterManagers[] + transportManagers TransportManager[] + suppliesHistory SupplyHistory[] @@map("users") } @@ -116,6 +148,7 @@ model Shelter { shelterManagers ShelterManagers[] shelterSupplies ShelterSupply[] + trips Trip[] supplyHistories SupplyHistory[] @@map("shelters") @@ -155,6 +188,52 @@ model Supporters { @@map("supporters") } +model Transport { + id String @id @default(uuid()) + vehicleType String @map("vehicle_type") + vehicleRegistrationPlate String? @map("vehicle_registration_plate") + contact String? + createdAt DateTime @default(value: now()) @map("created_at") @db.Timestamptz() + updatedAt DateTime? @map("updated_at") @db.Timestamptz() + + transportManagers TransportManager[] + trips Trip[] + + @@map("transports") +} + +model TransportManager { + transportId String @map("transport_id") + userId String @map("user_id") + createdAt DateTime @default(value: now()) @map("created_at") @db.Timestamptz() + updatedAt DateTime? @map("updated_at") @db.Timestamptz() + + transport Transport @relation(fields: [transportId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@id([transportId, userId]) + @@map("transport_managers") +} + +model Trip { + id String @id @default(uuid()) + transportId String @map("transport_id") + shelterId String @map("shelter_id") + departureNeighborhood String? @map("departure_neighborhood") + departureCity String @map("departure_city") + departureState State @map("departure_state") + departureDatetime DateTime @map("departure_datetime") @db.Timestamptz() + contact String + canceled Boolean @default(value: false) + createdAt DateTime @default(value: now()) @map("created_at") @db.Timestamptz() + updatedAt DateTime? @map("updated_at") @db.Timestamptz() + + transport Transport @relation(fields: [transportId], references: [id]) + shelter Shelter @relation(fields: [shelterId], references: [id]) + + @@map("trips") +} + model SupplyHistory { id String @id @default(uuid()) predecessorId String? @unique @map("predecessor_id") diff --git a/src/app.module.ts b/src/app.module.ts index 3f814215..bda0dc9e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -14,6 +14,9 @@ import { ShelterSupplyModule } from './shelter-supply/shelter-supply.module'; import { PartnersModule } from './partners/partners.module'; import { DashboardModule } from './modules/dashboard/dashboard.module'; import { SupportersModule } from './supporters/supporters.module'; +import { TransportsModule } from './transports/transports.module'; +import { TransportManagersModule } from './transport-managers/transport-managers.module'; +import { TripsModule } from './trips/trips.module'; import { SuppliesHistoryModule } from './supplies-history/supplies-history.module'; @Module({ @@ -29,6 +32,9 @@ import { SuppliesHistoryModule } from './supplies-history/supplies-history.modul PartnersModule, DashboardModule, SupportersModule, + TransportsModule, + TransportManagersModule, + TripsModule, SuppliesHistoryModule, ], controllers: [], diff --git a/src/guards/transport-manager.guard.ts b/src/guards/transport-manager.guard.ts new file mode 100644 index 00000000..afe797a7 --- /dev/null +++ b/src/guards/transport-manager.guard.ts @@ -0,0 +1,23 @@ +import { ExecutionContext, HttpException, Injectable, UnauthorizedException } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { AccessLevel } from '@prisma/client'; + +import { canActivate } from './utils'; + +@Injectable() +export class TransportManagerGuard extends AuthGuard('jwt') { + constructor() { + super(); + } + + async canActivate(context: ExecutionContext): Promise { + await super.canActivate(context); + const ok = await canActivate(context, [ + AccessLevel.TransportManager, + AccessLevel.Staff, + AccessLevel.Admin, + ]); + if (ok) return true; + throw new UnauthorizedException('Acesso não autorizado'); + } +} diff --git a/src/transport-managers/transport-managers.controller.spec.ts b/src/transport-managers/transport-managers.controller.spec.ts new file mode 100644 index 00000000..2490d547 --- /dev/null +++ b/src/transport-managers/transport-managers.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TransportManagersController } from './transport-managers.controller'; + +describe('TransportManagersController', () => { + let controller: TransportManagersController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TransportManagersController], + }).compile(); + + controller = module.get(TransportManagersController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/transport-managers/transport-managers.controller.ts b/src/transport-managers/transport-managers.controller.ts new file mode 100644 index 00000000..6bbc4916 --- /dev/null +++ b/src/transport-managers/transport-managers.controller.ts @@ -0,0 +1,34 @@ +import { + Body, + Controller, + HttpException, + Logger, + Post, + UseGuards, +} from '@nestjs/common'; +import { TransportManagersService } from './transport-managers.service'; +import { ApiTags } from '@nestjs/swagger'; +import { ServerResponse } from '../utils'; +import { StaffGuard } from '@/guards/staff.guard'; + +@ApiTags('Transport Managers') +@Controller('transport/managers') +export class TransportManagersController { + private logger = new Logger(TransportManagersController.name); + + constructor( + private readonly transportManagersService: TransportManagersService, + ) {} + + @Post('') + @UseGuards(StaffGuard) + async store(@Body() body) { + try { + await this.transportManagersService.store(body); + return new ServerResponse(200, 'Successfully added manager to transport'); + } catch (err: any) { + this.logger.error(`Failed to added manager to transport: ${err}`); + throw new HttpException(err?.code ?? err?.name ?? `${err}`, 400); + } + } +} diff --git a/src/transport-managers/transport-managers.module.ts b/src/transport-managers/transport-managers.module.ts new file mode 100644 index 00000000..817bdbd5 --- /dev/null +++ b/src/transport-managers/transport-managers.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TransportManagersService } from './transport-managers.service'; +import { TransportManagersController } from './transport-managers.controller'; +import { PrismaModule } from 'src/prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + providers: [TransportManagersService], + controllers: [TransportManagersController], +}) +export class TransportManagersModule {} diff --git a/src/transport-managers/transport-managers.service.spec.ts b/src/transport-managers/transport-managers.service.spec.ts new file mode 100644 index 00000000..46ac66e5 --- /dev/null +++ b/src/transport-managers/transport-managers.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TransportManagersService } from './transport-managers.service'; + +describe('TransportManagersService', () => { + let service: TransportManagersService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [TransportManagersService], + }).compile(); + + service = module.get(TransportManagersService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/transport-managers/transport-managers.service.ts b/src/transport-managers/transport-managers.service.ts new file mode 100644 index 00000000..217ac28a --- /dev/null +++ b/src/transport-managers/transport-managers.service.ts @@ -0,0 +1,41 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { z } from 'zod'; +import { CreateTransportManagerSchema } from './types'; + +@Injectable() +export class TransportManagersService { + constructor(private readonly prismaService: PrismaService) {} + + async store(body: z.infer) { + const { transportId, userId } = CreateTransportManagerSchema.parse(body); + + let result = await this.prismaService.transport.findFirst({ + where: { + id: transportId, + }, + select: { + id: true, + }, + }); + if (!result) throw new NotFoundException('Transporte não encontrado.'); + + result = await this.prismaService.user.findFirst({ + where: { + id: userId, + }, + select: { + id: true, + }, + }); + if (!result) throw new NotFoundException('Usuário não encontrado.'); + + await this.prismaService.transportManager.create({ + data: { + transportId, + userId, + createdAt: new Date().toISOString(), + }, + }); + } +} diff --git a/src/transport-managers/types.ts b/src/transport-managers/types.ts new file mode 100644 index 00000000..78f201f3 --- /dev/null +++ b/src/transport-managers/types.ts @@ -0,0 +1,15 @@ +import z from 'zod'; + +const TransportManagerSchema = z.object({ + transportId: z.string(), + userId: z.string(), + createdAt: z.string(), + updatedAt: z.string().nullable().optional(), +}); + +const CreateTransportManagerSchema = TransportManagerSchema.omit({ + createdAt: true, + updatedAt: true, +}); + +export { CreateTransportManagerSchema }; diff --git a/src/transports/TransportsSearch.ts b/src/transports/TransportsSearch.ts new file mode 100644 index 00000000..940357b0 --- /dev/null +++ b/src/transports/TransportsSearch.ts @@ -0,0 +1,73 @@ +import { Prisma } from '@prisma/client'; +import { TransportSearchProps } from './types/search.types'; + +class TransportsSearch { + private formProps: Partial; + + constructor(props: Partial = {}) { + this.formProps = { ...props }; + } + + get vehicleType(): Prisma.TransportWhereInput { + if (!this.formProps.vehicleType) return {}; + + return { + vehicleType: { + contains: this.formProps.vehicleType, + mode: 'insensitive', + }, + }; + } + + get vehicleRegistrationPlate(): Prisma.TransportWhereInput { + if (!this.formProps.vehicleRegistrationPlate) return {}; + + return { + vehicleRegistrationPlate: { + contains: this.formProps.vehicleRegistrationPlate, + mode: 'insensitive', + }, + }; + } + + get userId(): Prisma.TransportWhereInput { + if (!this.formProps.userId) return {}; + + return { + transportManagers: { + some: { + userId: this.formProps.userId, + }, + }, + }; + } + + get tripId(): Prisma.TransportWhereInput { + if (!this.formProps.tripId) return {}; + + return { + trips: { + some: { + id: this.formProps.tripId, + }, + }, + }; + } + + get query(): Prisma.TransportWhereInput { + if (Object.keys(this.formProps).length === 0) return {}; + + const queryData = { + AND: [ + this.vehicleType, + this.vehicleRegistrationPlate, + this.userId, + this.tripId, + ], + }; + + return queryData; + } +} + +export { TransportsSearch }; diff --git a/src/transports/transports.controller.spec.ts b/src/transports/transports.controller.spec.ts new file mode 100644 index 00000000..569172e5 --- /dev/null +++ b/src/transports/transports.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TransportsController } from './transports.controller'; + +describe('TransportsController', () => { + let controller: TransportsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TransportsController], + }).compile(); + + controller = module.get(TransportsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/transports/transports.controller.ts b/src/transports/transports.controller.ts new file mode 100644 index 00000000..ddaad85c --- /dev/null +++ b/src/transports/transports.controller.ts @@ -0,0 +1,70 @@ +import { + Body, + Controller, + Get, + HttpException, + Logger, + Param, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { TransportsService } from './transports.service'; +import { ServerResponse } from '../utils'; +import { StaffGuard } from '@/guards/staff.guard'; + +@ApiTags('Transports') +@Controller('transports') +export class TransportsController { + private logger = new Logger(TransportsController.name); + + constructor(private readonly transportsService: TransportsService) {} + + @Get('') + async index(@Query() query) { + try { + const data = await this.transportsService.index(query); + return new ServerResponse(200, 'Successfully get transports', data); + } catch (err: any) { + this.logger.error(`Failed to get transports: ${err}`); + throw new HttpException(err?.code ?? err?.name ?? `${err}`, 400); + } + } + + @Get(':id') + async show(@Param('id') id: string) { + try { + const data = await this.transportsService.show(id); + return new ServerResponse(200, 'Successfully get transport', data); + } catch (err: any) { + this.logger.error(`Failed to get transport: ${err}`); + throw new HttpException(err?.code ?? err?.name ?? `${err}`, 400); + } + } + + @Post('') + @UseGuards(StaffGuard) + async store(@Body() body) { + try { + const data = await this.transportsService.store(body); + return new ServerResponse(200, 'Successfully created transport', data); + } catch (err: any) { + this.logger.error(`Failed to create transport: ${err}`); + throw new HttpException(err?.code ?? err?.name ?? `${err}`, 400); + } + } + + @Put(':id') + @UseGuards(StaffGuard) + async update(@Param('id') id: string, @Body() body) { + try { + const data = await this.transportsService.update(id, body); + return new ServerResponse(200, 'Successfully updated transport', data); + } catch (err: any) { + this.logger.error(`Failed update transport: ${err}`); + throw new HttpException(err?.code ?? err?.name ?? `${err}`, 400); + } + } +} diff --git a/src/transports/transports.module.ts b/src/transports/transports.module.ts new file mode 100644 index 00000000..5eb8c9f1 --- /dev/null +++ b/src/transports/transports.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TransportsService } from './transports.service'; +import { TransportsController } from './transports.controller'; +import { PrismaModule } from 'src/prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + providers: [TransportsService], + controllers: [TransportsController], +}) +export class TransportsModule {} diff --git a/src/transports/transports.service.spec.ts b/src/transports/transports.service.spec.ts new file mode 100644 index 00000000..2f45886b --- /dev/null +++ b/src/transports/transports.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TransportsService } from './transports.service'; + +describe('TransportsService', () => { + let service: TransportsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [TransportsService], + }).compile(); + + service = module.get(TransportsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/transports/transports.service.ts b/src/transports/transports.service.ts new file mode 100644 index 00000000..ce962d6f --- /dev/null +++ b/src/transports/transports.service.ts @@ -0,0 +1,76 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { z } from 'zod'; +import { CreateTransportSchema, UpdateTransportSchema } from './types/types'; +import { TransportSearchPropsSchema } from './types/search.types'; +import { Prisma } from '@prisma/client'; +import { DefaultArgs } from '@prisma/client/runtime/library'; +import { TransportsSearch } from './TransportsSearch'; + +@Injectable() +export class TransportsService { + constructor(private readonly prismaService: PrismaService) {} + + async index(query: any) { + const { order, orderBy, page, perPage, ...queryData } = + TransportSearchPropsSchema.parse(query); + + const { query: where } = new TransportsSearch(queryData); + const count = await this.prismaService.transport.count({ where }); + + const take = perPage; + const skip = perPage * (page - 1); + + const whereData: Prisma.TransportFindManyArgs = { + take, + skip, + orderBy: { [orderBy]: order }, + where, + }; + + const results = await this.prismaService.transport.findMany({ + ...whereData, + }); + + return { + page, + perPage, + count, + results, + }; + } + + async show(id: string) { + const result = await this.prismaService.transport.findFirst({ + where: { + id, + }, + }); + if (!result) throw new NotFoundException('Transporte não encontrado.'); + return result; + } + + async store(body: z.infer) { + const payload = CreateTransportSchema.parse(body); + + await this.prismaService.transport.create({ + data: { + ...payload, + createdAt: new Date().toISOString(), + }, + }); + } + + async update(id: string, body: z.infer) { + const payload = UpdateTransportSchema.parse(body); + await this.prismaService.transport.update({ + where: { + id, + }, + data: { + ...payload, + updatedAt: new Date().toISOString(), + }, + }); + } +} diff --git a/src/transports/types/search.types.ts b/src/transports/types/search.types.ts new file mode 100644 index 00000000..ea1b206d --- /dev/null +++ b/src/transports/types/search.types.ts @@ -0,0 +1,15 @@ +import { SearchSchema } from 'src/types'; +import { z } from 'zod'; + +export const TransportSearchPropsSchema = SearchSchema.omit({ + search: true, +}).merge( + z.object({ + vehicleType: z.string().optional(), + vehicleRegistrationPlate: z.string().optional(), + userId: z.string().optional(), + tripId: z.string().optional(), + }), +); + +export type TransportSearchProps = z.infer; diff --git a/src/transports/types/types.ts b/src/transports/types/types.ts new file mode 100644 index 00000000..a34b433c --- /dev/null +++ b/src/transports/types/types.ts @@ -0,0 +1,24 @@ +import z from 'zod'; + +const TransportSchema = z.object({ + id: z.string(), + vehicleType: z.string(), + vehicleRegistrationPlate: z.string().nullable().optional(), + contact: z.string().nullable().optional(), + createdAt: z.string(), + updatedAt: z.string().nullable().optional(), +}); + +const CreateTransportSchema = TransportSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, +}); + +const UpdateTransportSchema = TransportSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, +}).partial(); + +export { CreateTransportSchema, UpdateTransportSchema }; diff --git a/src/trips/TripsDao.ts b/src/trips/TripsDao.ts new file mode 100644 index 00000000..f4207d49 --- /dev/null +++ b/src/trips/TripsDao.ts @@ -0,0 +1,70 @@ +import { NotFoundException } from '@nestjs/common'; +import { PrismaService } from 'src/prisma/prisma.service'; + +export class TripsDao { + constructor(private readonly prismaService: PrismaService) {} + + async checkIfUserManagesTransport(transportId: string, userId: string) { + const result = await this.prismaService.transportManager.findFirst({ + where: { + userId, + transport: { + id: transportId, + }, + }, + select: { + transportId: true, + }, + }); + if (!result) throw new NotFoundException('Transporte não encontrado.'); + } + + async checkIfShelterExists(shelterId: string) { + const result = await this.prismaService.shelter.findFirst({ + where: { + id: shelterId, + }, + select: { + id: true, + }, + }); + if (!result) throw new NotFoundException('Abrigo não encontrado.'); + } + + async create(payload: any) { + await this.prismaService.trip.create({ + data: { + ...payload, + createdAt: new Date().toISOString(), + }, + }); + } + + async udpateOnlyIfUserManagesTrip( + tripId: string, + userId: string, + payload: any, + ) { + try { + await this.prismaService.trip.update({ + where: { + id: tripId, + canceled: false, + transport: { + transportManagers: { + some: { + userId, + }, + }, + }, + }, + data: { + ...payload, + updatedAt: new Date().toISOString(), + }, + }); + } catch (error) { + throw new NotFoundException('Viagem não encontrada.'); + } + } +} diff --git a/src/trips/TripsSearch.ts b/src/trips/TripsSearch.ts new file mode 100644 index 00000000..c256efcb --- /dev/null +++ b/src/trips/TripsSearch.ts @@ -0,0 +1,102 @@ +import { Prisma } from '@prisma/client'; +import { TripSearchProps } from './types/search.types'; +import { State } from 'src/types'; + +class TripsSearch { + private formProps: Partial; + + constructor(props: Partial = {}) { + this.formProps = { ...props }; + } + + get departureCity(): Prisma.TripWhereInput { + if (!this.formProps.departureCity) return {}; + + return { + departureCity: { + contains: this.formProps.departureCity, + mode: 'insensitive', + }, + }; + } + + get departureState(): Prisma.TripWhereInput { + if (!this.formProps.departureState) return {}; + + return { + departureState: State.parse(this.formProps.departureState), + }; + } + + get departureDatetimeStart(): Prisma.TripWhereInput { + if (!this.formProps.departureDatetimeStart) return {}; + + return { + departureDatetime: { + gte: new Date(this.formProps.departureDatetimeStart), + }, + }; + } + + get departureDatetimeEnd(): Prisma.TripWhereInput { + if (!this.formProps.departureDatetimeEnd) return {}; + + return { + departureDatetime: { + lte: new Date(this.formProps.departureDatetimeEnd), + }, + }; + } + + get transportId(): Prisma.TripWhereInput { + if (!this.formProps.transportId) return {}; + + return { + transportId: this.formProps.transportId, + }; + } + + get userId(): Prisma.TripWhereInput { + if (!this.formProps.userId) return {}; + + return { + transport: { + transportManagers: { + some: { + userId: this.formProps.userId, + }, + }, + }, + }; + } + + get shelterIds(): Prisma.TripWhereInput { + if (!this.formProps.shelterIds) return {}; + + return { + shelterId: { + in: this.formProps.shelterIds, + }, + }; + } + + get query(): Prisma.TripWhereInput { + if (Object.keys(this.formProps).length === 0) return {}; + + const queryData = { + AND: [ + this.departureCity, + this.departureState, + this.departureDatetimeStart, + this.departureDatetimeEnd, + this.transportId, + this.shelterIds, + this.userId, + ], + }; + + return queryData; + } +} + +export { TripsSearch }; diff --git a/src/trips/trips.controller.spec.ts b/src/trips/trips.controller.spec.ts new file mode 100644 index 00000000..1e9b2957 --- /dev/null +++ b/src/trips/trips.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TripsController } from './trips.controller'; + +describe('TripsController', () => { + let controller: TripsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TripsController], + }).compile(); + + controller = module.get(TripsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/trips/trips.controller.ts b/src/trips/trips.controller.ts new file mode 100644 index 00000000..8dca861b --- /dev/null +++ b/src/trips/trips.controller.ts @@ -0,0 +1,109 @@ +import { + Body, + Controller, + Delete, + Get, + HttpException, + Logger, + Param, + Post, + Put, + Query, + Request, + UseGuards, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { TripsService } from './trips.service'; +import { ServerResponse } from '../utils'; +import { TransportManagerGuard } from '@/guards/transport-manager.guard'; + +@ApiTags('Trips') +@Controller('trips') +export class TripsController { + private logger = new Logger(TripsController.name); + + constructor(private readonly tripsService: TripsService) {} + + @Get('') + async index(@Query() query) { + try { + const data = await this.tripsService.index(query); + return new ServerResponse(200, 'Successfully get trips', data); + } catch (err: any) { + this.logger.error(`Failed to get trips: ${err}`); + throw new HttpException(err?.code ?? err?.name ?? `${err}`, 400); + } + } + + @Get(':id') + async show(@Param('id') id: string) { + try { + const data = await this.tripsService.show(id); + return new ServerResponse(200, 'Successfully get trip', data); + } catch (err: any) { + this.logger.error(`Failed to get trip: ${err}`); + throw new HttpException(err?.code ?? err?.name ?? `${err}`, 400); + } + } + + @Get('cities') + async cities() { + try { + const data = await this.tripsService.getCities(); + return new ServerResponse(200, 'Successfully get trip cities', data); + } catch (err: any) { + this.logger.error(`Failed to get trip cities: ${err}`); + throw new HttpException(err?.code ?? err?.name ?? `${err}`, 400); + } + } + + @Get('states') + async states() { + try { + const data = await this.tripsService.getStates(); + return new ServerResponse(200, 'Successfully get trip states', data); + } catch (err: any) { + this.logger.error(`Failed to get trip states: ${err}`); + throw new HttpException(err?.code ?? err?.name ?? `${err}`, 400); + } + } + + @Post('') + @UseGuards(TransportManagerGuard) + async store(@Body() body, @Request() req) { + try { + const { userId } = req.user; + const data = await this.tripsService.store(body, userId); + return new ServerResponse(200, 'Successfully created trip', data); + } catch (err: any) { + this.logger.error(`Failed to create trip: ${err}`); + throw new HttpException(err?.code ?? err?.name ?? `${err}`, 400); + } + } + + @Put(':id') + @UseGuards(TransportManagerGuard) + async update(@Param('id') id: string, @Body() body, @Request() req) { + try { + const { userId } = req.user; + const data = await this.tripsService.update(id, body, userId); + return new ServerResponse(200, 'Successfully updated trip', data); + } catch (err: any) { + this.logger.error(`Failed update trip: ${err}`); + throw new HttpException(err?.code ?? err?.name ?? `${err}`, 400); + } + } + + @Delete(':id') + @UseGuards(TransportManagerGuard) + async cancel(@Param('id') id: string, @Request() req) { + try { + const { userId } = req.user; + const data = await this.tripsService.cancel(id, userId); + return new ServerResponse(200, 'Successfully canceled trip', data); + } catch (err: any) { + this.logger.error(`Failed canceled trip: ${err}`); + throw new HttpException(err?.code ?? err?.name ?? `${err}`, 400); + } + } +} diff --git a/src/trips/trips.module.ts b/src/trips/trips.module.ts new file mode 100644 index 00000000..646f23f5 --- /dev/null +++ b/src/trips/trips.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TripsController } from './trips.controller'; +import { TripsService } from './trips.service'; +import { PrismaModule } from 'src/prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + controllers: [TripsController], + providers: [TripsService], +}) +export class TripsModule {} diff --git a/src/trips/trips.service.spec.ts b/src/trips/trips.service.spec.ts new file mode 100644 index 00000000..b5233deb --- /dev/null +++ b/src/trips/trips.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TripsService } from './trips.service'; + +describe('TripsService', () => { + let service: TripsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [TripsService], + }).compile(); + + service = module.get(TripsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/trips/trips.service.ts b/src/trips/trips.service.ts new file mode 100644 index 00000000..b0dc8f6c --- /dev/null +++ b/src/trips/trips.service.ts @@ -0,0 +1,133 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { z } from 'zod'; +import { CreateTripSchema, UpdateTripSchema } from './types/types'; +import { TripsDao } from './TripsDao'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { TripSearchPropsSchema } from './types/search.types'; +import { TripsSearch } from './TripsSearch'; +import { Prisma } from '@prisma/client'; +import { DefaultArgs } from '@prisma/client/runtime/library'; +import * as qs from 'qs'; + +@Injectable() +export class TripsService { + private readonly prismaService: PrismaService; + private readonly tripsDao: TripsDao; + + constructor(prismaService: PrismaService) { + this.prismaService = prismaService; + this.tripsDao = new TripsDao(prismaService); + } + + async index(query: any) { + const { order, orderBy, page, perPage, ...queryData } = + TripSearchPropsSchema.parse(qs.parse(query)); + + const { query: where } = new TripsSearch(queryData); + const count = await this.prismaService.trip.count({ where }); + + const take = perPage; + const skip = perPage * (page - 1); + + const whereData: Prisma.TripFindManyArgs = { + take, + skip, + orderBy: { [orderBy]: order }, + where, + }; + + const results = await this.prismaService.trip.findMany({ + ...whereData, + include: { + transport: true, + shelter: true, + }, + }); + + return { + page, + perPage, + count, + results, + }; + } + + async show(id: string) { + const result = await this.prismaService.trip.findFirst({ + where: { + id, + }, + include: { + transport: true, + shelter: true, + }, + }); + if (!result) throw new NotFoundException('Viagem não encontrada.'); + return result; + } + + async getCities() { + const cities = await this.prismaService.trip.groupBy({ + by: ['departureCity'], + _count: { + id: true, + }, + orderBy: { + _count: { + id: 'desc', + }, + }, + }); + + return cities.map(({ departureCity, _count: { id: tripsCount } }) => ({ + departureCity: departureCity || 'Cidade não informada', + tripsCount, + })); + } + + async getStates() { + const states = await this.prismaService.trip.groupBy({ + by: ['departureState'], + _count: { + id: true, + }, + orderBy: { + _count: { + id: 'desc', + }, + }, + }); + + return states.map(({ departureState, _count: { id: tripsCount } }) => ({ + departureState: departureState || 'Estado não informado', + tripsCount, + })); + } + + async store(body: z.infer, userId: string) { + const payload = CreateTripSchema.parse(body); + await this.tripsDao.checkIfUserManagesTransport( + payload.transportId, + userId, + ); + await this.tripsDao.checkIfShelterExists(payload.shelterId); + await this.tripsDao.create(payload); + } + + async update( + id: string, + body: z.infer, + userId: string, + ) { + const payload = UpdateTripSchema.parse(body); + if (payload.shelterId) { + await this.tripsDao.checkIfShelterExists(payload.shelterId); + } + await this.tripsDao.udpateOnlyIfUserManagesTrip(id, userId, payload); + } + + async cancel(id: string, userId: string) { + const payload = { canceled: true }; + await this.tripsDao.udpateOnlyIfUserManagesTrip(id, userId, payload); + } +} diff --git a/src/trips/types/search.types.ts b/src/trips/types/search.types.ts new file mode 100644 index 00000000..27f47fc8 --- /dev/null +++ b/src/trips/types/search.types.ts @@ -0,0 +1,19 @@ +import { state } from '@/utils/utils'; +import { SearchSchema } from 'src/types'; +import { z } from 'zod'; + +export const TripSearchPropsSchema = SearchSchema.omit({ + search: true, +}).merge( + z.object({ + departureCity: z.string().optional(), + departureState: z.string().transform(state).optional(), + departureDatetimeStart: z.string().optional(), + departureDatetimeEnd: z.string().optional(), + transportId: z.string().optional(), + shelterIds: z.array(z.string()).optional(), + userId: z.string().optional(), + }), +); + +export type TripSearchProps = z.infer; diff --git a/src/trips/types/types.ts b/src/trips/types/types.ts new file mode 100644 index 00000000..51851859 --- /dev/null +++ b/src/trips/types/types.ts @@ -0,0 +1,31 @@ +import z from 'zod'; +import { capitalize } from '../../utils'; +import { state } from '@/utils/utils'; + +const TripSchema = z.object({ + id: z.string(), + transportId: z.string(), + shelterId: z.string(), + departureNeighborhood: z.string().transform(capitalize).optional(), + departureCity: z.string().transform(capitalize), + departureState: z.string().transform(state), + departureDatetime: z.string(), + contact: z.string(), + createdAt: z.string(), + updatedAt: z.string().nullable().optional(), +}); + +const CreateTripSchema = TripSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, +}); + +const UpdateTripSchema = TripSchema.omit({ + id: true, + createdAt: true, + updatedAt: true, + transportId: true, +}).partial(); + +export { CreateTripSchema, UpdateTripSchema }; diff --git a/src/types.ts b/src/types.ts index 9e16b1d1..010ce58d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,35 @@ import z from 'zod'; +const State = z.enum([ + 'AC', + 'AL', + 'AP', + 'AM', + 'BA', + 'CE', + 'DF', + 'ES', + 'GO', + 'MA', + 'MT', + 'MS', + 'MG', + 'PA', + 'PB', + 'PR', + 'PE', + 'PI', + 'RJ', + 'RN', + 'RS', + 'RO', + 'RR', + 'SC', + 'SP', + 'SE', + 'TO', +]); + const SearchSchema = z.object({ perPage: z.preprocess( (v) => +((v ?? '20') as string), @@ -11,4 +41,4 @@ const SearchSchema = z.object({ orderBy: z.string().default('createdAt'), }); -export { SearchSchema }; +export { SearchSchema, State }; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 378e9914..de6a2526 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,5 +1,6 @@ import { Logger } from '@nestjs/common'; import { GeolocationFilter } from 'src/shelter/types/search.types'; +import { State } from 'src/types'; class ServerResponse { readonly message: string; @@ -36,6 +37,10 @@ function capitalize(input: string): string { .join(' '); } +function state(input: string): string { + return State.parse(input.trim().toUpperCase()); +} + function getSessionData(token?: string): { userId: string; sessionId: string } { try { if (token) { @@ -121,6 +126,7 @@ export { ServerResponse, calculateGeolocationBounds, capitalize, + state, deepMerge, getSessionData, removeNotNumbers,