From b1d1fca8e61ed5d4524494ddbf0ca108fa39e6b3 Mon Sep 17 00:00:00 2001 From: Jason Chua Date: Thu, 7 Sep 2023 11:27:48 +0800 Subject: [PATCH] [M1_TR-210] Backend for M1_TR-4 --- server/package.json | 22 ++- .../api/customer/customer.controller.spec.ts | 115 +++++++++++ .../src/api/customer/customer.controller.ts | 61 ++++++ server/src/api/customer/customer.module.ts | 13 ++ .../src/api/customer/customer.service.spec.ts | 73 +++++++ server/src/api/customer/customer.service.ts | 12 ++ .../api/customer/dtos/create-customer.dto.ts | 32 ++++ server/src/api/job/dtos/create-job-dto.ts | 49 +++++ ...eate-job-with-customer-and-schedule.dto.ts | 24 +++ ...job-without-customer-id-and-user-id.dto.ts | 36 ++++ server/src/api/job/job.controller.spec.ts | 111 +++++++++++ server/src/api/job/job.controller.ts | 48 +++++ server/src/api/job/job.module.ts | 15 ++ server/src/api/job/job.service.spec.ts | 181 ++++++++++++++++++ server/src/api/job/job.service.ts | 115 +++++++++++ .../src/api/sample/sample.controller.spec.ts | 18 -- server/src/api/sample/sample.controller.ts | 9 - server/src/api/sample/sample.dto.ts | 11 -- server/src/api/sample/sample.entities.ts | 7 - .../create-schedule-without-job-id.dto.ts | 28 +++ .../api/schedule/dtos/create-schedule.dto.ts | 32 ++++ .../api/schedule/schedule.controller.spec.ts | 78 ++++++++ .../src/api/schedule/schedule.controller.ts | 54 ++++++ server/src/api/schedule/schedule.module.ts | 4 + .../src/api/schedule/schedule.service.spec.ts | 93 +++++++++ server/src/api/schedule/schedule.service.ts | 12 ++ server/src/api/user/dtos/create-user.dto.ts | 31 +++ server/src/api/user/seeds/user.seed.ts | 22 +++ server/src/api/user/user.controller.spec.ts | 103 ++++++++++ server/src/api/user/user.controller.ts | 48 +++++ server/src/api/user/user.module.ts | 14 ++ server/src/api/user/user.service.spec.ts | 66 +++++++ server/src/api/user/user.service.ts | 12 ++ server/src/app.module.ts | 19 +- server/src/main.ts | 16 ++ .../20230904071055_init/migration.sql | 80 ++++++++ .../src/models/migrations/migration_lock.toml | 3 + server/src/models/schema.prisma | 82 ++++++-- server/src/models/seed.ts | 21 ++ server/src/shared/abstract-service.ts | 29 +++ server/yarn.lock | 59 +++++- 41 files changed, 1787 insertions(+), 71 deletions(-) create mode 100644 server/src/api/customer/customer.controller.spec.ts create mode 100644 server/src/api/customer/customer.controller.ts create mode 100644 server/src/api/customer/customer.module.ts create mode 100644 server/src/api/customer/customer.service.spec.ts create mode 100644 server/src/api/customer/customer.service.ts create mode 100644 server/src/api/customer/dtos/create-customer.dto.ts create mode 100644 server/src/api/job/dtos/create-job-dto.ts create mode 100644 server/src/api/job/dtos/create-job-with-customer-and-schedule.dto.ts create mode 100644 server/src/api/job/dtos/create-job-without-customer-id-and-user-id.dto.ts create mode 100644 server/src/api/job/job.controller.spec.ts create mode 100644 server/src/api/job/job.controller.ts create mode 100644 server/src/api/job/job.module.ts create mode 100644 server/src/api/job/job.service.spec.ts create mode 100644 server/src/api/job/job.service.ts delete mode 100644 server/src/api/sample/sample.controller.spec.ts delete mode 100644 server/src/api/sample/sample.controller.ts delete mode 100644 server/src/api/sample/sample.dto.ts delete mode 100644 server/src/api/sample/sample.entities.ts create mode 100644 server/src/api/schedule/dtos/create-schedule-without-job-id.dto.ts create mode 100644 server/src/api/schedule/dtos/create-schedule.dto.ts create mode 100644 server/src/api/schedule/schedule.controller.spec.ts create mode 100644 server/src/api/schedule/schedule.controller.ts create mode 100644 server/src/api/schedule/schedule.module.ts create mode 100644 server/src/api/schedule/schedule.service.spec.ts create mode 100644 server/src/api/schedule/schedule.service.ts create mode 100644 server/src/api/user/dtos/create-user.dto.ts create mode 100644 server/src/api/user/seeds/user.seed.ts create mode 100644 server/src/api/user/user.controller.spec.ts create mode 100644 server/src/api/user/user.controller.ts create mode 100644 server/src/api/user/user.module.ts create mode 100644 server/src/api/user/user.service.spec.ts create mode 100644 server/src/api/user/user.service.ts create mode 100644 server/src/models/migrations/20230904071055_init/migration.sql create mode 100644 server/src/models/migrations/migration_lock.toml create mode 100644 server/src/models/seed.ts create mode 100644 server/src/shared/abstract-service.ts diff --git a/server/package.json b/server/package.json index 0661ea4..8ff0f0c 100644 --- a/server/package.json +++ b/server/package.json @@ -17,7 +17,8 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "jest --config ./test/jest-e2e.json", + "seed": "ts-node src/models/seed.ts" }, "dependencies": { "@nestjs/common": "^10.0.0", @@ -27,14 +28,18 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "jest-junit": "^16.0.0", + "nestjs-seeder": "^0.3.2", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1" }, "devDependencies": { + "@faker-js/faker": "^8.0.2", "@nestjs/cli": "^10.0.0", "@nestjs/schematics": "^10.0.0", + "@nestjs/swagger": "^7.1.10", "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.17", + "@types/faker": "^6.6.9", "@types/jest": "^29.5.2", "@types/node": "^20.3.1", "@types/supertest": "^2.0.12", @@ -70,11 +75,22 @@ "**/*.{js,ts}" ], "coverageDirectory": "../test/coverage", - "coverageReporters": ["clover", "json", "lcov", "text"], + "coverageReporters": [ + "clover", + "json", + "lcov", + "text" + ], "testEnvironment": "node", "reporters": [ "default", - ["jest-junit", {"outputDirectory": "./test/result", "outputName": "report.xml"}] + [ + "jest-junit", + { + "outputDirectory": "./test/result", + "outputName": "report.xml" + } + ] ] }, "prisma": { diff --git a/server/src/api/customer/customer.controller.spec.ts b/server/src/api/customer/customer.controller.spec.ts new file mode 100644 index 0000000..d08754f --- /dev/null +++ b/server/src/api/customer/customer.controller.spec.ts @@ -0,0 +1,115 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../database/connection.service'; + +import { CustomerController } from './customer.controller'; +import { CustomerService } from './customer.service'; + +describe('CustomerController', () => { + let customerController: CustomerController; + let customerService: CustomerService; + let prisma: PrismaService + + beforeEach(() => { + customerService = new CustomerService(prisma); + customerController = new CustomerController(customerService); + }); + + describe('create', () => { + it('should return a customer', async () => { + const newCustomer = { + firstName: "John", + lastName: "Doe", + email: "johndoe@gmail.com", + contact: "0123456", + address: "ABC St, DEF Ave, GHI City" + } + + jest.spyOn(customerService, 'create').mockResolvedValue(newCustomer) + jest.spyOn(customerController, 'checkIfCustomerAlreadyExists').mockResolvedValue(false) + + const createdCustomer = await customerController.create(newCustomer); + + expect(createdCustomer).toEqual(newCustomer); + }) + + it('should return BadRequestException if input is invalid', async () => { + const invalidInput = { + firstName: "", + lastName: "", + email: "invalidEmail", + contact: "012345", + address: "ABC City" + } + + jest.spyOn(customerController, 'checkIfCustomerAlreadyExists').mockResolvedValue(false) + jest.spyOn(customerService, 'create').mockRejectedValue(new BadRequestException('error')) + + const createCustomerPromise = async () => { + await customerController.create(invalidInput); + } + + expect(createCustomerPromise).rejects.toThrow(BadRequestException); + }) + }) + + describe('findOne', () => { + it('should return a single customer', async () => { + const customer = { + id: 1, + firstName: "John", + lastName: "Doe", + email: "johndoe@gmail.com", + contact: "0123456", + address: "ABC St, DEF Ave, GHI City" + } + + jest.spyOn(customerService, 'findOne').mockResolvedValue(customer); + + const foundCustomer = await customerController.findOne(1); + + expect(foundCustomer).toEqual(customer); + }) + + it('should throw a not found exception when no customer is found', async () => { + jest.spyOn(customerService, 'findOne').mockRejectedValue(new NotFoundException('No customer found.')); + + const invalidId = 99; + + const findOnePromise = customerController.findOne(invalidId); + + await expect(findOnePromise).rejects.toThrow('No customer found.'); + }) + }) + + describe('findAll', () => { + it('should return an array of customers', async () => { + const customers = [{ + firstName: "John", + lastName: "Doe", + email: "johndoe@gmail.com", + contact: "0123456", + address: "ABC St, DEF Ave, GHI City" + }, { + firstName: "Jane", + lastName: "Doe", + email: "janedoe@gmail.com", + contact: "4567890", + address: "DEF St, GHI Ave, ABC City" + }]; + + jest.spyOn(customerService, 'findAll').mockResolvedValue(customers) + + const foundCustomers = await customerController.findAll(); + + expect(foundCustomers).toEqual(customers); + }) + + it('should throw a not found exception when no customer is found', async () => { + jest.spyOn(customerService, 'findAll').mockRejectedValue(new NotFoundException('No customer found.')); + + const findAllPromise = customerController.findAll(); + + await expect(findAllPromise).rejects.toThrow('No customer found.'); + }) + }) +}); diff --git a/server/src/api/customer/customer.controller.ts b/server/src/api/customer/customer.controller.ts new file mode 100644 index 0000000..4e5a4c7 --- /dev/null +++ b/server/src/api/customer/customer.controller.ts @@ -0,0 +1,61 @@ +import { Customer } from '@prisma/client'; +import { ApiTags } from '@nestjs/swagger'; +import { BadRequestException, Body, Controller, Get, NotFoundException, Param, ParseIntPipe, Post, UsePipes, ValidationPipe } from '@nestjs/common'; + +import { CustomerService } from './customer.service'; + +import { CreateCustomerDto } from './dtos/create-customer.dto'; + +@ApiTags('customer') +@Controller('customer') +export class CustomerController { + constructor( + private readonly customerService: CustomerService + ) { } + + @UsePipes(new ValidationPipe()) + @Post() + async create(@Body() options: CreateCustomerDto): Promise { + const { email } = options + + const existingCustomer = await this.checkIfCustomerAlreadyExists(email) + + if (existingCustomer) { + return existingCustomer + } + + const newCustomer = await this.customerService.create(options) + + if (!newCustomer) { + throw new BadRequestException('Something went wrong. Customer not created.') + } + + return newCustomer + } + + async checkIfCustomerAlreadyExists(email: string) { + return await this.customerService.findOne({ email }) + } + + @Get('/:id') + async findOne(@Param('id', ParseIntPipe) id: number): Promise { + const customer = await this.customerService.findOne({ id }) + + if (!customer) { + throw new NotFoundException("No customer found.") + } + + return customer + } + + @Get() + async findAll(): Promise { + const customers = await this.customerService.findAll() + + if (customers.length == 0) { + throw new NotFoundException('No customer found.') + } + + return customers + } +} diff --git a/server/src/api/customer/customer.module.ts b/server/src/api/customer/customer.module.ts new file mode 100644 index 0000000..2ac9f55 --- /dev/null +++ b/server/src/api/customer/customer.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { PrismaService } from '../../database/connection.service'; + +import { CustomerService } from './customer.service'; +import { CustomerController } from './customer.controller'; + +@Module({ + providers: [CustomerService, PrismaService], + controllers: [CustomerController], + exports: [] +}) +export class CustomerModule {} diff --git a/server/src/api/customer/customer.service.spec.ts b/server/src/api/customer/customer.service.spec.ts new file mode 100644 index 0000000..07483d2 --- /dev/null +++ b/server/src/api/customer/customer.service.spec.ts @@ -0,0 +1,73 @@ +import { PrismaService } from '../../database/connection.service'; + +import { CustomerService } from './customer.service'; + +describe('CustomerService', () => { + let customerService: CustomerService; + let prisma: PrismaService + + beforeEach(async () => { + customerService = new CustomerService(prisma); + }); + + describe('create', () => { + it('should return a customer', async () => { + const newCustomer = { + firstName: "John", + lastName: "Doe", + email: "johndoe@gmail.com", + contact: "0123456", + address: "ABC St, DEF Ave, GHI City" + } + + jest.spyOn(customerService, 'create').mockResolvedValue(newCustomer) + + const createdCustomer = await customerService.create(newCustomer); + + expect(createdCustomer).toEqual(newCustomer); + }) + }) + + describe('findOne', () => { + it('should return a single customer', async () => { + const customer = { + id: 1, + firstName: "John", + lastName: "Doe", + email: "johndoe@gmail.com", + contact: "0123456", + address: "ABC St, DEF Ave, GHI City" + } + + jest.spyOn(customerService, 'findOne').mockResolvedValue(customer); + + const foundCustomer = await customerService.findOne(1); + + expect(foundCustomer).toEqual(customer); + }) + }) + + describe('findAll', () => { + it('should return an array of customers', async () => { + const customers = [{ + firstName: "John", + lastName: "Doe", + email: "johndoe@gmail.com", + contact: "0123456", + address: "ABC St, DEF Ave, GHI City" + }, { + firstName: "Jane", + lastName: "Doe", + email: "janedoe@gmail.com", + contact: "4567890", + address: "DEF St, GHI Ave, ABC City" + }]; + + jest.spyOn(customerService, 'findAll').mockResolvedValue(customers) + + const foundCustomers = await customerService.findAll(); + + expect(foundCustomers).toEqual(customers); + }) + }) +}); diff --git a/server/src/api/customer/customer.service.ts b/server/src/api/customer/customer.service.ts new file mode 100644 index 0000000..9687b48 --- /dev/null +++ b/server/src/api/customer/customer.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '../../database/connection.service'; + +import { AbstractService } from '../../shared/abstract-service'; + +@Injectable() +export class CustomerService extends AbstractService { + constructor(prisma: PrismaService) { + super(prisma, "Customer") + } +} diff --git a/server/src/api/customer/dtos/create-customer.dto.ts b/server/src/api/customer/dtos/create-customer.dto.ts new file mode 100644 index 0000000..dbf5d60 --- /dev/null +++ b/server/src/api/customer/dtos/create-customer.dto.ts @@ -0,0 +1,32 @@ +import { IsEmail, IsOptional, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateCustomerDto { + @ApiProperty() + @IsString() + firstName: string; + + @ApiProperty() + @IsString() + lastName: string; + + @ApiProperty() + @IsEmail() + email: string; + + @ApiProperty() + @IsString() + contact: string; + + @ApiProperty() + @IsString() + address: string; + + @ApiProperty() + @IsOptional() + createdAt?: string; + + @ApiProperty() + @IsOptional() + updatedAt?: string; +} diff --git a/server/src/api/job/dtos/create-job-dto.ts b/server/src/api/job/dtos/create-job-dto.ts new file mode 100644 index 0000000..988bffd --- /dev/null +++ b/server/src/api/job/dtos/create-job-dto.ts @@ -0,0 +1,49 @@ +import { $Enums, PaymentMethod, Tag } from "@prisma/client"; +import { ApiProperty } from "@nestjs/swagger"; +import { + IsIn, + IsNumber, + IsOptional, + IsString, +} from "class-validator"; + +export class CreateJobDto { + @ApiProperty() + @IsString() + title: string; + + @ApiProperty() + @IsString() + type: string; + + @ApiProperty() + @IsString() + @IsIn(Object.values(Tag)) + tags: $Enums.Tag; + + @ApiProperty() + @IsOptional() + @IsString() + remarks: string; + + @ApiProperty() + @IsString() + @IsIn(Object.values(PaymentMethod)) + paymentMethod: $Enums.PaymentMethod; + + @ApiProperty() + @IsNumber() + userId: number; + + @ApiProperty() + @IsNumber() + customerId: number; + + @ApiProperty() + @IsOptional() + createdAt?: string; + + @ApiProperty() + @IsOptional() + updatedAt?: string; +} diff --git a/server/src/api/job/dtos/create-job-with-customer-and-schedule.dto.ts b/server/src/api/job/dtos/create-job-with-customer-and-schedule.dto.ts new file mode 100644 index 0000000..66f43f6 --- /dev/null +++ b/server/src/api/job/dtos/create-job-with-customer-and-schedule.dto.ts @@ -0,0 +1,24 @@ +import { Type } from "class-transformer"; +import { ApiProperty } from "@nestjs/swagger"; +import { ValidateNested } from "class-validator"; + +import { CreateCustomerDto } from "../../customer/dtos/create-customer.dto"; +import { CreateJobWithoutCustomerIdAndUserIdDto } from "./create-job-without-customer-id-and-user-id.dto"; +import { CreateScheduleWithoutJobIdDto } from "../../schedule/dtos/create-schedule-without-job-id.dto"; + +export class CreateJobWithCustomerAndSchedulesDto { + @ApiProperty() + @Type(() => CreateCustomerDto) + @ValidateNested() + customer_registration: CreateCustomerDto + + @ApiProperty() + @Type(() => CreateJobWithoutCustomerIdAndUserIdDto) + @ValidateNested() + job_information: CreateJobWithoutCustomerIdAndUserIdDto + + @ApiProperty() + @Type(() => CreateScheduleWithoutJobIdDto) + @ValidateNested() + work_schedules: CreateScheduleWithoutJobIdDto[] +} diff --git a/server/src/api/job/dtos/create-job-without-customer-id-and-user-id.dto.ts b/server/src/api/job/dtos/create-job-without-customer-id-and-user-id.dto.ts new file mode 100644 index 0000000..3175049 --- /dev/null +++ b/server/src/api/job/dtos/create-job-without-customer-id-and-user-id.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Tag, $Enums, PaymentMethod } from "@prisma/client"; +import { IsString, IsIn, IsOptional } from "class-validator"; + +export class CreateJobWithoutCustomerIdAndUserIdDto { + @ApiProperty() + @IsString() + title: string; + + @ApiProperty() + @IsString() + type: string; + + @ApiProperty() + @IsString() + @IsIn(Object.values(Tag)) + tags: $Enums.Tag; + + @ApiProperty() + @IsOptional() + @IsString() + remarks: string; + + @ApiProperty() + @IsString() + @IsIn(Object.values(PaymentMethod)) + paymentMethod: $Enums.PaymentMethod; + + @ApiProperty() + @IsOptional() + createdAt?: string; + + @ApiProperty() + @IsOptional() + updatedAt?: string; +} diff --git a/server/src/api/job/job.controller.spec.ts b/server/src/api/job/job.controller.spec.ts new file mode 100644 index 0000000..b5cd9c6 --- /dev/null +++ b/server/src/api/job/job.controller.spec.ts @@ -0,0 +1,111 @@ +import { $Enums } from '@prisma/client'; + +import { PrismaService } from '../../database/connection.service'; + +import { JobService } from './job.service'; +import { JobController } from './job.controller'; +import { CustomerService } from '../customer/customer.service'; + +describe('JobController', () => { + let jobService: JobService; + let jobController: JobController; + let customerService: CustomerService + let prisma: PrismaService + + beforeEach(() => { + jobService = new JobService(prisma, customerService) + jobController = new JobController(jobService) + }); + + describe('create', () => { + it('should return a new job', async () => { + const jobInput = { + customer_registration: { + firstName: "John", + lastName: "Doe", + email: "johndoe@gmail.com", + contact: "012345", + address: "ABC St, DEF Ave, GHI City", + }, + job_information: { + title: "string", + type: "string", + tags: $Enums.Tag.TAG_A, + remarks: "string", + paymentMethod: $Enums.PaymentMethod.CASH, + }, + work_schedules: [] + } + + const newJob = { + id: 1, + title: "string", + type: "string", + tags: $Enums.Tag.TAG_A, + remarks: "string", + customerId: 1, + paymentMethod: $Enums.PaymentMethod.CASH, + userId: 1, + } + + const mockCreateJobWithCustomerAndSchedules = jest.fn().mockResolvedValue(newJob) + + jest.spyOn(jobService, 'createJobWithCustomerAndSchedules').mockImplementation(mockCreateJobWithCustomerAndSchedules) + + const createdJob = await jobController.create(jobInput) + + expect(createdJob).toEqual(newJob); + }) + }) + + describe('findAll', () => { + it('should return an array of jobs', async () => { + const jobs = [{ + id: 1, + title: "Sample title 1", + type: "A", + tags: "TAG_A", + remarks: "", + customerId: 1, + paymentMethod: "CARD", + userId: 1, + }, { + id: 2, + title: "Sample title 2", + type: "A", + tags: "TAG_A", + remarks: "", + customerId: 1, + paymentMethod: "CARD", + userId: 1, + }]; + + jest.spyOn(jobService, 'findAll').mockResolvedValue(jobs) + + const foundJobs = await jobController.findAll(); + + expect(foundJobs).toEqual(jobs); + }) + }) + + describe('findOne', () => { + it('should return a single job', async () => { + const job = { + id: 1, + title: "Sample title 1", + type: "A", + tags: "TAG_A", + remarks: "", + customerId: 1, + paymentMethod: "CARD", + userId: 1, + } + + jest.spyOn(jobService, 'findOne').mockResolvedValue(job); + + const foundJob = await jobController.findOne(1); + + expect(foundJob).toEqual(job); + }) + }) +}); diff --git a/server/src/api/job/job.controller.ts b/server/src/api/job/job.controller.ts new file mode 100644 index 0000000..04d1a9c --- /dev/null +++ b/server/src/api/job/job.controller.ts @@ -0,0 +1,48 @@ +import { Job } from '@prisma/client'; +import { ApiTags } from '@nestjs/swagger'; +import { BadRequestException, Body, Controller, Get, NotFoundException, Param, ParseIntPipe, Post, UsePipes, ValidationPipe } from '@nestjs/common'; + +import { JobService } from './job.service'; + +import { CreateJobWithCustomerAndSchedulesDto } from './dtos/create-job-with-customer-and-schedule.dto'; + +@ApiTags('job') +@Controller('job') +export class JobController { + constructor( + private readonly jobService: JobService + ) { } + + @Post() + @UsePipes(new ValidationPipe()) + async create(@Body() options: CreateJobWithCustomerAndSchedulesDto): Promise { + const job = await this.jobService.createJobWithCustomerAndSchedules(options); + + if (!job ) { + throw new BadRequestException("Something went wrong. Job not created.") + } + + return job; + } + + @Get() + async findAll(): Promise { + const jobs = await this.jobService.findAll() + + if (jobs == 0) { + throw new NotFoundException("No job found.") + } + return jobs + } + + @Get('/:id') + async findOne(@Param('id', ParseIntPipe) id: number): Promise { + const job = await this.jobService.findOne({ id }) + + if (!job) { + throw new NotFoundException("No job found.") + } + + return job + } +} diff --git a/server/src/api/job/job.module.ts b/server/src/api/job/job.module.ts new file mode 100644 index 0000000..f030f75 --- /dev/null +++ b/server/src/api/job/job.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; + +import { PrismaService } from '../../database/connection.service'; + +import { CustomerService } from '../customer/customer.service'; +import { ScheduleService } from '../schedule/schedule.service'; +import { JobService } from './job.service'; +import { JobController } from './job.controller'; + +@Module({ + controllers: [JobController], + providers: [JobService, PrismaService, CustomerService, ScheduleService], + exports: [], +}) +export class JobModule {} diff --git a/server/src/api/job/job.service.spec.ts b/server/src/api/job/job.service.spec.ts new file mode 100644 index 0000000..51dd2ff --- /dev/null +++ b/server/src/api/job/job.service.spec.ts @@ -0,0 +1,181 @@ +import { $Enums } from '@prisma/client'; +import { BadRequestException } from '@nestjs/common'; + +import { PrismaService } from '../../database/connection.service'; + +import { JobService } from './job.service'; +import { CustomerService } from '../customer/customer.service'; + +describe('JobController', () => { + let jobService: JobService; + let customerService: CustomerService + let prisma: PrismaService + + beforeEach(() => { + jobService = new JobService(prisma, customerService) + }); + + describe('createJobWithCustomerAndSchedules', () => { + it('should return a new job', async () => { + const date = new Date(); + const jobInput = { + customer_registration: { + firstName: "John", + lastName: "Doe", + email: "johndoe@gmail.com", + contact: "012345", + address: "ABC St, DEF Ave, GHI City", + }, + job_information: { + title: "string", + type: "string", + tags: $Enums.Tag.TAG_A, + remarks: "string", + paymentMethod: $Enums.PaymentMethod.CASH, + }, + work_schedules: [] + } + + const newJob = { + id: 1, + title: "string", + type: "string", + tags: $Enums.Tag.TAG_A, + remarks: "string", + customerId: 1, + paymentMethod: $Enums.PaymentMethod.CASH, + userId: 1, + createdAt: date, + updatedAt: date + } + + const mockCreateJobWithCustomerAndSchedules = jest.fn().mockResolvedValue(newJob) + + jest.spyOn(jobService, 'createJobWithCustomerAndSchedules').mockImplementation(mockCreateJobWithCustomerAndSchedules) + + const createdJob = await jobService.createJobWithCustomerAndSchedules(jobInput) + + expect(createdJob).toEqual(newJob); + }) + }) + + describe('findOne', () => { + it('should return a single job', async () => { + const date = new Date(); + const job = { + id: 1, + title: "Sample title 1", + type: "A", + tags: $Enums.Tag.TAG_A, + remarks: "", + customerId: 1, + paymentMethod: $Enums.PaymentMethod.CASH, + userId: 1, + createdAt: date, + updatedAt: date + } + + jest.spyOn(jobService, 'findOne').mockResolvedValue(job); + + const foundJob = await jobService.findOne(1); + + expect(foundJob).toEqual(job); + }) + }) + + describe('findAll', () => { + it('should return an array of jobs', async () => { + const date = new Date(); + const jobs = [{ + id: 1, + title: "Sample title 1", + type: "A", + tags: $Enums.Tag.TAG_A, + remarks: "", + customerId: 1, + paymentMethod: $Enums.PaymentMethod.CASH, + userId: 1, + createdAt: date, + updatedAt: date + }, { + id: 2, + title: "Sample title 2", + type: "A", + tags: $Enums.Tag.TAG_A, + remarks: "", + customerId: 1, + paymentMethod: $Enums.PaymentMethod.CARD, + userId: 1, + createdAt: date, + updatedAt: date + }]; + + jest.spyOn(jobService, 'findAll').mockResolvedValue(jobs) + + const foundJobs = await jobService.findAll(); + + expect(foundJobs).toEqual(jobs); + }) + }) + + describe('createJob', () => { + it('should return a new job', async () => { + const date = new Date(); + const jobInput = { + title: "Sample title 1", + type: "A", + tags: $Enums.Tag.TAG_A, + remarks: "", + paymentMethod: $Enums.PaymentMethod.CASH, + customerId: 1, + userId: 1, + } + + const newJob = { + id: 1, + title: "Sample title 1", + type: "A", + tags: $Enums.Tag.TAG_A, + remarks: "", + paymentMethod: $Enums.PaymentMethod.CASH, + customerId: 1, + userId: 1, + createdAt: date, + updatedAt: date + } + + jest.spyOn(jobService, 'createJob').mockResolvedValue(newJob) + + const createdJob = await jobService.createJob(jobInput) + + expect(createdJob).toEqual(newJob) + }) + }) + + describe('checkIfJobAlreadyExists', () => { + it('should return return a job', async () => { + const customerId = 1; + const validTitle = 'valid title' + + jest.spyOn(jobService, 'checkIfJobAlreadyExists').mockResolvedValue(true) + + const checkIfJobExists = await jobService.checkIfJobAlreadyExists(validTitle, customerId); + + expect(checkIfJobExists).toEqual(true) + }) + }) + + describe('createSchedules', () => { + it('should throw BadRequestException if schedules is an empty array', () => { + const schedules = [] + + jest.spyOn(jobService, 'createSchedules').mockRejectedValue(new BadRequestException('Something went wrong. Schedule not created')) + + const createSchedulesPromise = async () => { + await jobService.createSchedules(schedules, 1) + } + + expect(createSchedulesPromise).rejects.toThrow(BadRequestException) + }) + }) +}); diff --git a/server/src/api/job/job.service.ts b/server/src/api/job/job.service.ts new file mode 100644 index 0000000..5717d83 --- /dev/null +++ b/server/src/api/job/job.service.ts @@ -0,0 +1,115 @@ +import { Customer, Job } from '@prisma/client'; +import { UsePipes, ValidationPipe, BadRequestException, Injectable } from '@nestjs/common'; + +import { PrismaService } from '../../database/connection.service'; + +import { AbstractService } from '../../shared/abstract-service'; +import { CustomerService } from '../customer/customer.service'; +import { CreateJobDto } from './dtos/create-job-dto'; +import { CreateCustomerDto } from '../customer/dtos/create-customer.dto'; +import { CreateJobWithCustomerAndSchedulesDto } from './dtos/create-job-with-customer-and-schedule.dto'; +import { CreateScheduleWithoutJobIdDto } from '../schedule/dtos/create-schedule-without-job-id.dto'; + +@Injectable() +@UsePipes(new ValidationPipe()) +export class JobService extends AbstractService { + constructor( + prisma: PrismaService, + private readonly customerService: CustomerService, + ) { + super(prisma, "Job") + } + + async createJobWithCustomerAndSchedules(createJobWithCustomerAndSchedulesOptions: CreateJobWithCustomerAndSchedulesDto): Promise { + const { + customer_registration, + job_information, + work_schedules + } = createJobWithCustomerAndSchedulesOptions; + + const transaction = await this.prisma.$transaction(async () => { + + // Step 1: Create the customer + const customerInput = { ...customer_registration } + const customer = await this.createCustomer(customerInput) + + // Step 2: Create the job associated with the customer + // TODO: Update userId to be authUserId + const jobInput = { ...job_information, customerId: customer.id, userId: 1 } + const job = await this.createJob(jobInput) + + // Step 3: Create schedules for the job + await this.createSchedules(work_schedules, job.id) + + return job + }) + + return transaction; + } + + async createJob(options: CreateJobDto): Promise { + const { title, customerId } = options + await this.checkIfJobAlreadyExists(title, customerId) + + const newJob = await this.prisma.job.create({ + data: options + }) + + if (!newJob) { + throw new BadRequestException('Something went wrong. job not created.') + } + + return newJob + } + + async checkIfJobAlreadyExists(title: string, customerId: number): Promise { + const jobAlreadyExists = await this.prisma.job.findFirst({ + where: { + title, + customerId, + }, + }); + + if (jobAlreadyExists) { + throw new BadRequestException('Job with the same title and customer already exists.'); + } + + return !!jobAlreadyExists + } + + async createCustomer(options: CreateCustomerDto): Promise { + const { email } = options + const existingCustomer = await this.checkIfCustomerAlreadyExists(email) + + if (existingCustomer) { + return existingCustomer + } + + const newCustomer = await this.customerService.create(options) + + if (!newCustomer) { + throw new BadRequestException('Something went wrong. Customer not created.') + } + + return newCustomer + } + + async checkIfCustomerAlreadyExists(email: string) { + return await this.customerService.findOne({ email }) + } + + async createSchedules(schedulesData: CreateScheduleWithoutJobIdDto[], jobId: number): Promise { + if (schedulesData.length == 0) { + throw new BadRequestException('Something went wrong. Schedule not created.') + } + + const schedulesDataWithJobId = schedulesData.map((schedule) => ({ + ...schedule, + jobId, + })); + + await this.prisma.schedule.createMany({ + data: schedulesDataWithJobId + }) + } +} diff --git a/server/src/api/sample/sample.controller.spec.ts b/server/src/api/sample/sample.controller.spec.ts deleted file mode 100644 index 3164a8c..0000000 --- a/server/src/api/sample/sample.controller.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { SampleController } from './sample.controller'; - -describe('SampleController', () => { - let controller: SampleController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [SampleController], - }).compile(); - - controller = module.get(SampleController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/server/src/api/sample/sample.controller.ts b/server/src/api/sample/sample.controller.ts deleted file mode 100644 index 3308a83..0000000 --- a/server/src/api/sample/sample.controller.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Controller, Get } from '@nestjs/common'; - -@Controller() -export class SampleController { - @Get() - getHello(): string { - return '(SERVER) Hello World!'; - } -} diff --git a/server/src/api/sample/sample.dto.ts b/server/src/api/sample/sample.dto.ts deleted file mode 100644 index 42ff6bb..0000000 --- a/server/src/api/sample/sample.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { IsString } from 'class-validator'; - -export class SampleUserDTO { - id: number; - - @IsString() - name: string; - - @IsString() - email: string; -} diff --git a/server/src/api/sample/sample.entities.ts b/server/src/api/sample/sample.entities.ts deleted file mode 100644 index 43baf1a..0000000 --- a/server/src/api/sample/sample.entities.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { SampleUser } from '@prisma/client'; - -export class SampleUserEntity implements SampleUser { - id: number; - name: string; - email: string; -} diff --git a/server/src/api/schedule/dtos/create-schedule-without-job-id.dto.ts b/server/src/api/schedule/dtos/create-schedule-without-job-id.dto.ts new file mode 100644 index 0000000..406ac2a --- /dev/null +++ b/server/src/api/schedule/dtos/create-schedule-without-job-id.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsDateString, IsOptional } from "class-validator"; + +export class CreateScheduleWithoutJobIdDto { + @ApiProperty() + @IsDateString() + startDate: string; + + @ApiProperty() + @IsDateString() + endDate: string; + + @ApiProperty() + @IsDateString() + startTime: string; + + @ApiProperty() + @IsDateString() + endTime: string; + + @ApiProperty() + @IsOptional() + createdAt?: string; + + @ApiProperty() + @IsOptional() + updatedAt?: string; +} \ No newline at end of file diff --git a/server/src/api/schedule/dtos/create-schedule.dto.ts b/server/src/api/schedule/dtos/create-schedule.dto.ts new file mode 100644 index 0000000..9e07fe5 --- /dev/null +++ b/server/src/api/schedule/dtos/create-schedule.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsDateString, IsInt, IsOptional } from "class-validator"; + +export class CreateScheduleDto { + @ApiProperty() + @IsDateString() + startDate: string; + + @ApiProperty() + @IsDateString() + endDate: string; + + @ApiProperty() + @IsDateString() + startTime: string; + + @ApiProperty() + @IsDateString() + endTime: string; + + @ApiProperty() + @IsInt() + jobId: number + + @ApiProperty() + @IsOptional() + createdAt?: string; + + @ApiProperty() + @IsOptional() + updatedAt?: string; +} diff --git a/server/src/api/schedule/schedule.controller.spec.ts b/server/src/api/schedule/schedule.controller.spec.ts new file mode 100644 index 0000000..69676a4 --- /dev/null +++ b/server/src/api/schedule/schedule.controller.spec.ts @@ -0,0 +1,78 @@ +import { PrismaService } from '../../database/connection.service'; + +import { ScheduleService } from './schedule.service'; +import { ScheduleController } from './schedule.controller'; +import { JobService } from '../job/job.service'; + +describe('ScheduleController', () => { + let scheduleController: ScheduleController; + let scheduleService: ScheduleService; + let jobService: JobService; + let prisma: PrismaService; + + beforeEach(async () => { + scheduleService = new ScheduleService(prisma) + scheduleController = new ScheduleController(scheduleService, jobService) + }); + + describe('create', () => { + it('should return a schedule', async () => { + const date = new Date(); + + const scheduleInput = { + startDate: "2023-01-02T01:00:00.000Z", + endDate: "2023-01-02T01:00:00.000Z", + startTime: "2023-01-02T01:00:00.000Z", + endTime: "2023-01-02T01:00:00.000Z", + jobId: 1, + } + + const schedule = { + id: 1, + startDate: date, + endDate: date, + startTime: date, + endTime: date, + jobId: 1, + } + + jest.spyOn(scheduleController, 'checkIfJobExists').mockResolvedValue(true) + jest.spyOn(scheduleService, 'create').mockResolvedValue(schedule) + + const createdSchedule = await scheduleController.create(scheduleInput) + + expect(createdSchedule).toEqual(schedule); + }) + }) + + describe('findAll', () => { + it('should return an array of schedules', async () => { + const date = new Date(); + + const schedules = [ + { + id: 1, + startDate: date, + endDate: date, + startTime: date, + endTime: date, + jobId: 1, + }, + { + id: 2, + startDate: date, + endDate: date, + startTime: date, + endTime: date, + jobId: 1, + } + ] + + jest.spyOn(scheduleService, 'findAll').mockResolvedValue(schedules) + + const findAllPromise = await scheduleController.findAll() + + expect(findAllPromise).toEqual(schedules); + }) + }) +}); diff --git a/server/src/api/schedule/schedule.controller.ts b/server/src/api/schedule/schedule.controller.ts new file mode 100644 index 0000000..bbdde3c --- /dev/null +++ b/server/src/api/schedule/schedule.controller.ts @@ -0,0 +1,54 @@ +import { ApiTags } from '@nestjs/swagger'; +import { Schedule } from '@prisma/client'; +import { BadRequestException, Body, Controller, Get, NotFoundException, Post, UsePipes, ValidationPipe } from '@nestjs/common'; + +import { JobService } from '../job/job.service'; +import { ScheduleService } from './schedule.service'; + +import { CreateScheduleDto } from './dtos/create-schedule.dto'; + +@ApiTags('schedule') +@Controller('schedule') +export class ScheduleController { + constructor( + private readonly scheduleService: ScheduleService, + private readonly jobService: JobService + ) { } + + @Post() + @UsePipes(new ValidationPipe()) + async create(@Body() params: CreateScheduleDto): Promise { + const { jobId } = params + + await this.checkIfJobExists(jobId) + + const schedule = await this.scheduleService.create(params) + + if (!schedule) { + throw new BadRequestException("Something went wrong. Schedule not created.") + } + + return schedule + } + + async checkIfJobExists(id: number): Promise { + const job = await this.jobService.findOne({ id: Number(id) }) + + if (!job) { + throw new NotFoundException("No job found.") + } + + return !!job + } + + @Get() + async findAll(): Promise { + const schedules = await this.scheduleService.findAll(); + + if (schedules.length == 0) { + throw new NotFoundException("No schedule found.") + } + + return schedules + } +} diff --git a/server/src/api/schedule/schedule.module.ts b/server/src/api/schedule/schedule.module.ts new file mode 100644 index 0000000..6516cb1 --- /dev/null +++ b/server/src/api/schedule/schedule.module.ts @@ -0,0 +1,4 @@ +import { Module } from '@nestjs/common'; + +@Module({}) +export class ScheduleModule {} diff --git a/server/src/api/schedule/schedule.service.spec.ts b/server/src/api/schedule/schedule.service.spec.ts new file mode 100644 index 0000000..da3e70e --- /dev/null +++ b/server/src/api/schedule/schedule.service.spec.ts @@ -0,0 +1,93 @@ + +import { PrismaService } from '../../database/connection.service'; +import { ScheduleService } from './schedule.service'; + +describe('ScheduleService', () => { + let scheduleService: ScheduleService; + let prisma: PrismaService; + + beforeEach(async () => { + scheduleService = new ScheduleService(prisma) + }); + + describe('create', () => { + it('should return a schedule', async () => { + const date = new Date(); + + const scheduleInput = { + startDate: "2023-01-02T01:00:00.000Z", + endDate: "2023-01-02T01:00:00.000Z", + startTime: "2023-01-02T01:00:00.000Z", + endTime: "2023-01-02T01:00:00.000Z", + jobId: 1, + } + + const schedule = { + id: 1, + startDate: date, + endDate: date, + startTime: date, + endTime: date, + jobId: 1, + } + + jest.spyOn(scheduleService, 'create').mockResolvedValue(schedule) + + const createdSchedule = await scheduleService.create(scheduleInput) + + expect(createdSchedule).toEqual(schedule); + }) + }) + + describe('findOne', () => { + it('should return a single schedule', async () => { + const date = new Date(); + + const schedule = { + id: 1, + startDate: date, + endDate: date, + startTime: date, + endTime: date, + jobId: 1, + } + + jest.spyOn(scheduleService, 'findOne').mockResolvedValue(schedule) + + const findAllPromise = await scheduleService.findOne({ id: 1 }) + + expect(findAllPromise).toEqual(schedule); + }) + }) + + describe('findAll', () => { + it('should return an array of schedules', async () => { + const date = new Date(); + + const schedules = [ + { + id: 1, + startDate: date, + endDate: date, + startTime: date, + endTime: date, + jobId: 1, + }, + { + id: 2, + startDate: date, + endDate: date, + startTime: date, + endTime: date, + jobId: 1, + } + ] + + jest.spyOn(scheduleService, 'findAll').mockResolvedValue(schedules) + + const findAllPromise = await scheduleService.findAll() + + expect(findAllPromise).toEqual(schedules); + }) + }) +}); diff --git a/server/src/api/schedule/schedule.service.ts b/server/src/api/schedule/schedule.service.ts new file mode 100644 index 0000000..c72af1b --- /dev/null +++ b/server/src/api/schedule/schedule.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '../../database/connection.service'; + +import { AbstractService } from '../../shared/abstract-service'; + +@Injectable() +export class ScheduleService extends AbstractService { + constructor(prisma: PrismaService) { + super(prisma, "Schedule") + } +} diff --git a/server/src/api/user/dtos/create-user.dto.ts b/server/src/api/user/dtos/create-user.dto.ts new file mode 100644 index 0000000..43d18a2 --- /dev/null +++ b/server/src/api/user/dtos/create-user.dto.ts @@ -0,0 +1,31 @@ +import { Role } from "@prisma/client"; +import { ApiProperty } from "@nestjs/swagger"; +import { IsEmail, IsIn, IsOptional, IsString } from "class-validator"; + +export class CreateUserDto { + @ApiProperty() + @IsString() + firstName: string; + + @ApiProperty() + @IsString() + lastName: string; + + @ApiProperty() + @IsEmail() + email: string; + + @ApiProperty() + @IsOptional() + @IsString() + @IsIn(Object.values(Role)) + role: string; + + @ApiProperty() + @IsOptional() + createdAt?: string; + + @ApiProperty() + @IsOptional() + updatedAt?: string; +} diff --git a/server/src/api/user/seeds/user.seed.ts b/server/src/api/user/seeds/user.seed.ts new file mode 100644 index 0000000..062425c --- /dev/null +++ b/server/src/api/user/seeds/user.seed.ts @@ -0,0 +1,22 @@ +import { faker } from '@faker-js/faker'; +import { PrismaClient } from '@prisma/client'; + +export default async function seedUsers() { + const prisma = new PrismaClient(); + + const seedDataCount = 5; + let userData = []; + + for (let i = 0; i < seedDataCount; i++) { + const newUser = { + firstName: faker.person.firstName(), + lastName: faker.person.lastName(), + email: faker.internet.email(), + } + userData = [...userData, newUser] + } + + await prisma.user.createMany({ + data: userData + }) +} diff --git a/server/src/api/user/user.controller.spec.ts b/server/src/api/user/user.controller.spec.ts new file mode 100644 index 0000000..799d424 --- /dev/null +++ b/server/src/api/user/user.controller.spec.ts @@ -0,0 +1,103 @@ +import { PrismaService } from '../../database/connection.service'; +import { NotFoundException, BadRequestException } from '@nestjs/common'; + +import { UserService } from './user.service'; +import { UserController } from './user.controller'; + +describe('UserController', () => { + let userController: UserController; + let userService: UserService + let prisma: PrismaService + + beforeEach(async () => { + userService = new UserService(prisma) + userController = new UserController(userService) + }); + + describe('create', () => { + it('should return a user', async () => { + const newUser = { + firstName: "John", + lastName: "Doe", + email: "johndoe@gmail.com", + role: "USER" + } + + jest.spyOn(userService, 'create').mockResolvedValue(newUser) + + const createdUser = await userController.create(newUser); + + expect(createdUser).toEqual(newUser); + }) + + it('should throw a bad request exception when input is invalid', async () => { + const invalidInput = { + firstName: "", + lastName: "", + email: "invalid", + role: "invalid" + } + + jest.spyOn(userService, 'create').mockRejectedValue(new BadRequestException('Someting went wrong. Customer not created.')); + + const createPromise = userController.create(invalidInput) + + await expect(createPromise).rejects.toThrow('Someting went wrong. Customer not created.'); + }) + }) + + describe('findOne', () => { + it('should return a single user', async () => { + const user = { + id: 1, + firstName: "John", + lastName: "Doe", + role: "USER" + } + + jest.spyOn(userService, 'findOne').mockResolvedValue(user); + + const foundUser = await userController.findOne(1); + + expect(foundUser).toEqual(user); + }) + + it('should throw a not found exception when there is no user found', async () => { + jest.spyOn(userService, 'findOne').mockRejectedValue(new NotFoundException('No customer found.')); + + const invalidId = 99; + + const findOnePromise = userController.findOne(invalidId); + + await expect(findOnePromise).rejects.toThrow('No customer found.'); + }) + }) + + describe('findAll', () => { + it('should return an array of users', async () => { + const users = [{ + firstName: "John", + lastName: "Doe", + role: "USER" + }, { + firstName: "Jane", + lastName: "Doe", + role: "ADMIN" + }]; + + jest.spyOn(userService, 'findAll').mockResolvedValue(users) + + const foundUsers = await userController.findAll(); + + expect(foundUsers).toEqual(users); + }) + + it('should throw a not found exception when there is no user found', async () => { + jest.spyOn(userService, 'findAll').mockRejectedValue(new NotFoundException('No customer found.')); + + const findAllPromise = userController.findAll(); + + await expect(findAllPromise).rejects.toThrow('No customer found.'); + }) + }) +}); diff --git a/server/src/api/user/user.controller.ts b/server/src/api/user/user.controller.ts new file mode 100644 index 0000000..c287326 --- /dev/null +++ b/server/src/api/user/user.controller.ts @@ -0,0 +1,48 @@ +import { User } from '@prisma/client'; +import { ApiTags } from '@nestjs/swagger'; +import { BadRequestException, Body, Controller, Get, NotFoundException, Post, UsePipes, ValidationPipe, ParseIntPipe, Param } from '@nestjs/common'; + +import { UserService } from './user.service'; +import { CreateUserDto } from './dtos/create-user.dto'; + +@ApiTags('user') +@Controller('user') +export class UserController { + constructor( + private readonly userService: UserService, + ) { } + + @UsePipes(new ValidationPipe()) + @Post() + async create(@Body() params: CreateUserDto): Promise { + const user = await this.userService.create(params) + + if (!user) { + throw new BadRequestException('Something went wrong. User not created.') + } + + return user + } + + @Get('/:id') + async findOne(@Param('id', ParseIntPipe) id: number): Promise { + const user = await this.userService.findOne({ id }) + + if (!user) { + throw new NotFoundException('No user found.') + } + + return user + } + + @Get() + async findAll(): Promise { + const users = await this.userService.findAll() + + if (users.length == 0) { + throw new NotFoundException('No user found.') + } + + return users + } +} diff --git a/server/src/api/user/user.module.ts b/server/src/api/user/user.module.ts new file mode 100644 index 0000000..dbdcd63 --- /dev/null +++ b/server/src/api/user/user.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from '../../database/connection.service'; + +import { AbstractService } from '../../shared/abstract-service'; + +import { UserService } from './user.service'; +import { UserController } from './user.controller'; + +@Module({ + controllers: [UserController], + exports: [UserService], + providers: [PrismaService, UserService, AbstractService] +}) +export class UserModule {} diff --git a/server/src/api/user/user.service.spec.ts b/server/src/api/user/user.service.spec.ts new file mode 100644 index 0000000..b1eb87b --- /dev/null +++ b/server/src/api/user/user.service.spec.ts @@ -0,0 +1,66 @@ +import { PrismaService } from '../../database/connection.service'; + +import { UserService } from './user.service'; + +describe('UserService', () => { + let userService: UserService + let prisma: PrismaService + + beforeEach(async () => { + userService = new UserService(prisma) + }); + + describe('create', () => { + it('should return a user', async () => { + const newUser = { + firstName: "John", + lastName: "Doe", + email: "johndoe@gmail.com", + role: "USER" + } + + jest.spyOn(userService, 'create').mockResolvedValue(newUser) + + const createdUser = await userService.create(newUser); + + expect(createdUser).toEqual(newUser); + }) + }) + + describe('findOne', () => { + it('should return a single user', async () => { + const user = { + id: 1, + firstName: "John", + lastName: "Doe", + role: "USER" + } + + jest.spyOn(userService, 'findOne').mockResolvedValue(user); + + const foundUser = await userService.findOne(1); + + expect(foundUser).toEqual(user); + }) + }) + + describe('findAll', () => { + it('should return an array of users', async () => { + const users = [{ + firstName: "John", + lastName: "Doe", + role: "USER" + }, { + firstName: "Jane", + lastName: "Doe", + role: "ADMIN" + }]; + + jest.spyOn(userService, 'findAll').mockResolvedValue(users) + + const foundUsers = await userService.findAll(); + + expect(foundUsers).toEqual(users); + }) + }) +}); diff --git a/server/src/api/user/user.service.ts b/server/src/api/user/user.service.ts new file mode 100644 index 0000000..1bdd8a1 --- /dev/null +++ b/server/src/api/user/user.service.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; + +import { PrismaService } from '../../database/connection.service'; + +import { AbstractService } from '../../shared/abstract-service'; + +@Injectable() +export class UserService extends AbstractService { + constructor(prisma: PrismaService) { + super(prisma, "User") + } +} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 001246b..74313ed 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -1,10 +1,21 @@ import { Module } from '@nestjs/common'; + +import { JobService } from './api/job/job.service'; +import { UserService } from './api/user/user.service'; +import { ScheduleService } from './api/schedule/schedule.service'; +import { CustomerService } from './api/customer/customer.service'; +import { JobController } from './api/job/job.controller'; +import { UserController } from './api/user/user.controller'; +import { CustomerController } from './api/customer/customer.controller'; +import { ScheduleController } from './api/schedule/schedule.controller'; +import { JobModule } from './api/job/job.module'; import { DatabaseModule } from './database/database.module'; -import { SampleController } from './api/sample/sample.controller'; +import { ScheduleModule } from './api/schedule/schedule.module'; +import { CustomerModule } from './api/customer/customer.module'; @Module({ - imports: [DatabaseModule], - controllers: [SampleController], - providers: [], + imports: [DatabaseModule, CustomerModule, JobModule, ScheduleModule], + controllers: [UserController, CustomerController, JobController , ScheduleController], + providers: [UserService, CustomerService, JobService , ScheduleService], }) export class AppModule {} diff --git a/server/src/main.ts b/server/src/main.ts index 8e348dc..0626313 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,8 +1,24 @@ import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; + +import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger' + import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); + + app.useGlobalPipes(new ValidationPipe()); + app.setGlobalPrefix('api') + + const config = new DocumentBuilder() + .setTitle('sim-JMS') + .setDescription('This is the API documentation for sim-jms') + .setVersion('1.0') + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('api', app, document); + await app.listen(4000); } bootstrap(); diff --git a/server/src/models/migrations/20230904071055_init/migration.sql b/server/src/models/migrations/20230904071055_init/migration.sql new file mode 100644 index 0000000..b1fa559 --- /dev/null +++ b/server/src/models/migrations/20230904071055_init/migration.sql @@ -0,0 +1,80 @@ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN'); + +-- CreateEnum +CREATE TYPE "Tag" AS ENUM ('TAG_A', 'TAG_B', 'TAG_C'); + +-- CreateEnum +CREATE TYPE "PaymentMethod" AS ENUM ('CASH', 'CARD', 'BANK_TRANSFER'); + +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "role" "Role" NOT NULL DEFAULT 'USER', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Job" ( + "id" SERIAL NOT NULL, + "title" TEXT NOT NULL, + "type" TEXT NOT NULL, + "tags" "Tag" NOT NULL DEFAULT 'TAG_A', + "remarks" TEXT NOT NULL, + "customerId" INTEGER NOT NULL, + "paymentMethod" "PaymentMethod" NOT NULL DEFAULT 'CASH', + "userId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Job_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Customer" ( + "id" SERIAL NOT NULL, + "firstName" TEXT NOT NULL, + "lastName" TEXT NOT NULL, + "email" TEXT NOT NULL, + "contact" TEXT NOT NULL, + "address" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Customer_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Schedule" ( + "id" SERIAL NOT NULL, + "startDate" TIMESTAMP(3) NOT NULL, + "endDate" TIMESTAMP(3) NOT NULL, + "startTime" TIMESTAMP(3) NOT NULL, + "endTime" TIMESTAMP(3) NOT NULL, + "jobId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Schedule_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Job_title_customerId_key" ON "Job"("title", "customerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Customer_email_key" ON "Customer"("email"); + +-- AddForeignKey +ALTER TABLE "Job" ADD CONSTRAINT "Job_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Job" ADD CONSTRAINT "Job_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Schedule" ADD CONSTRAINT "Schedule_jobId_fkey" FOREIGN KEY ("jobId") REFERENCES "Job"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/server/src/models/migrations/migration_lock.toml b/server/src/models/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/server/src/models/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/server/src/models/schema.prisma b/server/src/models/schema.prisma index 108018e..0fbba02 100644 --- a/server/src/models/schema.prisma +++ b/server/src/models/schema.prisma @@ -1,18 +1,78 @@ -// This is your Prisma schema file, -// learn more about it in the docs: https://pris.ly/d/prisma-schema +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" } -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") +model User { + id Int @id @default(autoincrement()) + firstName String + lastName String + email String + role Role @default(USER) + jobs Job[] + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) +} + +model Job { + id Int @id @default(autoincrement()) + title String + type String + tags Tag @default(TAG_A) + remarks String + customer Customer @relation(fields: [customerId], references: [id]) + customerId Int + paymentMethod PaymentMethod @default(CASH) + personInCharge User @relation(fields: [userId], references: [id]) + userId Int + schedules Schedule[] + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) + + @@unique([title, customerId]) +} + +model Customer { + id Int @id @default(autoincrement()) + firstName String + lastName String + email String @unique + contact String + address String + jobs Job[] + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) +} + +model Schedule { + id Int @id @default(autoincrement()) + startDate DateTime + endDate DateTime + startTime DateTime + endTime DateTime + job Job @relation(fields: [jobId], references: [id]) + jobId Int + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) +} + +enum Role { + USER + ADMIN +} + +enum Tag { + TAG_A + TAG_B + TAG_C } -// Temporary model so that Prisma Client can initialize -model SampleUser { - id Int @id @default(autoincrement()) - name String - email String @unique +enum PaymentMethod { + CASH + CARD + BANK_TRANSFER } diff --git a/server/src/models/seed.ts b/server/src/models/seed.ts new file mode 100644 index 0000000..b496cb3 --- /dev/null +++ b/server/src/models/seed.ts @@ -0,0 +1,21 @@ +import { PrismaClient } from "@prisma/client"; + +import seedUsers from "../api/user/seeds/user.seed"; + +async function seed() { + const prisma = new PrismaClient(); + + try { + await prisma.$transaction(async () => { + await seedUsers(); + // add other seeder files here + }); + } catch (error) { + console.log('Error seeding database'); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +seed(); diff --git a/server/src/shared/abstract-service.ts b/server/src/shared/abstract-service.ts new file mode 100644 index 0000000..a9bdd6d --- /dev/null +++ b/server/src/shared/abstract-service.ts @@ -0,0 +1,29 @@ +/* eslint @typescript-eslint/no-explicit-any: "off" */ +import { Prisma } from "@prisma/client"; +import { Injectable } from "@nestjs/common"; + +import { PrismaService } from "../database/connection.service"; + +@Injectable() +export class AbstractService { + constructor( + protected prisma: PrismaService, + protected modelName: Prisma.ModelName + ) {} + + async create(options: any): Promise { + return await this.prisma[this.modelName].create({ + data: options + }) + } + + async findOne(options: any): Promise { + return await this.prisma[this.modelName].findUnique({ + where: options + }) + } + + async findAll(): Promise { + return await this.prisma[this.modelName].findMany() + } +} diff --git a/server/yarn.lock b/server/yarn.lock index d91c693..b86db84 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -402,6 +402,11 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.46.0.tgz#3f7802972e8b6fe3f88ed1aabc74ec596c456db6" integrity sha512-a8TLtmPi8xzPkCbp/OGFUo5yhRkHM2Ko9kOWP4znJr0WAhWyThaw3PnwX4vOTWOAMsV2uRt32PPDcEz63esSaA== +"@faker-js/faker@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@faker-js/faker/-/faker-8.0.2.tgz#bab698c5d3da9c52744e966e0e3eedb6c8b05c37" + integrity sha512-Uo3pGspElQW91PCvKSIAXoEgAUlRnH29sX2/p89kg7sP1m2PzCufHINd0FhTXQf6DYGiUlVncdSPa2F9wxed2A== + "@humanwhocodes/config-array@^0.11.10": version "0.11.10" resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.11.10.tgz#5a3ffe32cc9306365fb3fd572596cd602d5e12d2" @@ -731,6 +736,11 @@ path-to-regexp "3.2.0" tslib "2.6.1" +"@nestjs/mapped-types@2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@nestjs/mapped-types/-/mapped-types-2.0.2.tgz#c8a090a8d22145b85ed977414c158534210f2e4f" + integrity sha512-V0izw6tWs6fTp9+KiiPUbGHWALy563Frn8X6Bm87ANLRuE46iuBMD5acKBDP5lKL/75QFvrzSJT7HkCbB0jTpg== + "@nestjs/platform-express@^10.0.0": version "10.1.3" resolved "https://registry.yarnpkg.com/@nestjs/platform-express/-/platform-express-10.1.3.tgz#d1f644e86f2bc45c7529b9eed7669613f4392e99" @@ -753,6 +763,17 @@ jsonc-parser "3.2.0" pluralize "8.0.0" +"@nestjs/swagger@^7.1.10": + version "7.1.10" + resolved "https://registry.yarnpkg.com/@nestjs/swagger/-/swagger-7.1.10.tgz#955deb9c428fae779d2988a0d24a55977a7be11d" + integrity sha512-qreCcxgHFyFX1mOfK36pxiziy4xoa/XcxC0h4Zr9yH54WuqMqO9aaNFhFyuQ1iyd/3YBVQB21Un4gQnh9iGm0w== + dependencies: + "@nestjs/mapped-types" "2.0.2" + js-yaml "4.1.0" + lodash "4.17.21" + path-to-regexp "3.2.0" + swagger-ui-dist "5.4.2" + "@nestjs/testing@^10.0.0": version "10.1.3" resolved "https://registry.yarnpkg.com/@nestjs/testing/-/testing-10.1.3.tgz#596a1dc580373b3b0b070ac73bf70e45f80197dd" @@ -940,6 +961,13 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/faker@^6.6.9": + version "6.6.9" + resolved "https://registry.yarnpkg.com/@types/faker/-/faker-6.6.9.tgz#1064e7c46be58388fa326e2f918a4f02ab740a7a" + integrity sha512-Y9YYm5L//8ooiiknO++4Gr539zzdI0j3aXnOBjo1Vk+kTvffY10GuE2wn78AFPECwZ5MYGTjiDVw1naLLdDimw== + dependencies: + faker "*" + "@types/graceful-fs@^4.1.3": version "4.1.6" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.6.tgz#e14b2576a1c25026b7f02ede1de3b84c3a1efeae" @@ -2318,6 +2346,11 @@ external-editor@^3.0.3: iconv-lite "^0.4.24" tmp "^0.0.33" +faker@*: + version "6.6.6" + resolved "https://registry.yarnpkg.com/faker/-/faker-6.6.6.tgz#e9529da0109dca4c7c5dbfeaadbd9234af943033" + integrity sha512-9tCqYEDHI5RYFQigXFwF1hnCwcWCOJl/hmll0lr5D2Ljjb0o4wphb69wikeJDz5qCEzXCoPvG6ss5SDP6IfOdg== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -3290,6 +3323,13 @@ js-tokens@^4.0.0: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== +js-yaml@4.1.0, js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + js-yaml@^3.13.1: version "3.14.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" @@ -3298,13 +3338,6 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" -js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== - dependencies: - argparse "^2.0.1" - jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" @@ -3406,7 +3439,7 @@ lodash.merge@^4.6.2: resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== -lodash@^4.17.21: +lodash@4.17.21, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -3630,6 +3663,11 @@ neo-async@^2.6.2: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== +nestjs-seeder@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/nestjs-seeder/-/nestjs-seeder-0.3.2.tgz#71a96fa10f9b8ebdb6e82a3832f4882af5b435f4" + integrity sha512-dCuxmhWdjgAn1HT0K7ExuCmKQc7HCGihFgzYF4keVJkR94d+diVQQg9/K1aQFnBkH9XVuugUqi4OClfwt09XVQ== + node-abort-controller@^3.0.1: version "3.1.1" resolved "https://registry.yarnpkg.com/node-abort-controller/-/node-abort-controller-3.1.1.tgz#a94377e964a9a37ac3976d848cb5c765833b8548" @@ -4406,6 +4444,11 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +swagger-ui-dist@5.4.2: + version "5.4.2" + resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-5.4.2.tgz#ff7b936bdfc84673a1823a0f05f3a933ba7ccd4c" + integrity sha512-vT5QxP/NOr9m4gLZl+SpavWI3M9Fdh30+Sdw9rEtZbkqNmNNEPhjXas2xTD9rsJYYdLzAiMfwXvtooWH3xbLJA== + symbol-observable@4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-4.0.0.tgz#5b425f192279e87f2f9b937ac8540d1984b39205"