diff --git a/server/package.json b/server/package.json index dbb2156b..5f60444d 100644 --- a/server/package.json +++ b/server/package.json @@ -42,6 +42,7 @@ "cidr-matcher": "^2.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.13.2", + "date-fns": "^2.29.3", "jest-mock-extended": "2.0.4", "passport": "^0.5.2", "passport-jwt": "^4.0.0", @@ -52,6 +53,7 @@ }, "devDependencies": { "@nestjs/cli": "^8.0.0", + "@nestjs/mapped-types": "^1.2.0", "@nestjs/schematics": "^8.0.0", "@nestjs/testing": "^8.4.4", "@types/bcrypt": "^5.0.0", diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index b4e99613..048e63c9 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -61,6 +61,7 @@ model Staff { activityEntries ActivityEntry[] Ledger Ledger[] Audit Audit[] + Visit Visit[] } model Variant { @@ -168,6 +169,7 @@ model Customer { updatedAt DateTime @updatedAt activityEntries ActivityEntry[] Ledger Ledger[] + Visit Visit[] } model ActivityEntry { @@ -187,6 +189,8 @@ model ActivityEntry { // The reason why the charge is 1 to many because we can't have more than one charge that is // not linked to an activity entry (have activityEntryId as null). charge Ledger[] + Visit Visit? @relation(fields: [visitId], references: [id]) + visitId String? @db.ObjectId } model Ledger { @@ -200,6 +204,8 @@ model Ledger { customerId String @db.ObjectId staffId String @db.ObjectId activityEntryId String? @db.ObjectId + Visit Visit? @relation(fields: [visitId], references: [id]) + visitId String? @db.ObjectId } model Audit { @@ -212,3 +218,18 @@ model Audit { createdBy Staff @relation(fields: [staffId], references: [id]) createdAt DateTime @default(now()) } + +model Visit { + id String @id @default(auto()) @map("_id") @db.ObjectId + visitDate String + customer Customer @relation(fields: [customerId], references: [id]) + customerId String @db.ObjectId + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + createdBy Staff @relation(fields: [staffId], references: [id]) + staffId String @db.ObjectId + activityEntries ActivityEntry[] + charge Ledger[] + fees Int @default(0) + tip Int @default(0) +} diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 6aacce19..eae84241 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -20,6 +20,7 @@ import { ThrottlerModule } from '@nestjs/throttler'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { ThrottlerBehindProxyGuard } from './auth/throttle.guard'; import { AuditInterceptor } from './interceptors/audit.interceptor'; +import { VisitModule } from './models/visit/visit.module'; @Module({ imports: [ @@ -28,6 +29,7 @@ import { AuditInterceptor } from './interceptors/audit.interceptor'; AnalyticsModule, AuditModule, AuthModule, + VisitModule, CustomerModule, FeatureModule, FormModule, diff --git a/server/src/models/activity-entry/activity-entry.controller.ts b/server/src/models/activity-entry/activity-entry.controller.ts index e08336fa..89aecaa3 100644 --- a/server/src/models/activity-entry/activity-entry.controller.ts +++ b/server/src/models/activity-entry/activity-entry.controller.ts @@ -18,6 +18,7 @@ import { Auth } from 'src/auth/role.decorator'; import { GetUser } from 'src/auth/user.decorator'; import { TransformInterceptor } from 'src/interceptors/transform.interceptor'; import { PrismaService } from 'src/prisma.service'; +import { getTodayDateString } from 'src/utils/dates'; import { LedgerService } from '../ledger/ledger.service'; import { ActivityEntryChargeDto, ActivityEntryDto } from './activity-entry.dto'; import { ActivityEntryService } from './activity-entry.service'; @@ -135,15 +136,46 @@ export class ActivityEntryController { } @Auth(Actions.WRITE, [Features.Entry]) - @Post() @Auditable() - createActivityEntry(@Body() body: ActivityEntryDto, @Request() { user }) { + @Post() + async createActivityEntry( + @Body() body: ActivityEntryDto, + @Request() { user }, + ) { + const visitFromToday = await this.prisma.visit.findFirst({ + where: { + visitDate: { + equals: getTodayDateString(), + }, + customer: { + id: body.customerId, + }, + }, + }); return this.service.createActivityEntry({ author: { connect: { id: user.id, }, }, + Visit: { + connectOrCreate: { + where: { + id: visitFromToday.id, + }, + create: { + customer: { + connect: { + id: body.customerId, + }, + }, + visitDate: getTodayDateString(), + createdBy: { + connect: user.id, + }, + }, + }, + }, customer: { connect: { id: body.customerId, diff --git a/server/src/models/visit/dto/create-visit.dto.ts b/server/src/models/visit/dto/create-visit.dto.ts new file mode 100644 index 00000000..05e0d564 --- /dev/null +++ b/server/src/models/visit/dto/create-visit.dto.ts @@ -0,0 +1,11 @@ +import { IsISO8601, IsMongoId, IsOptional, IsString } from 'class-validator'; + +export class CreateVisitDto { + @IsString() + @IsMongoId() + customerId: string; + + @IsISO8601() + @IsOptional() + visitDate?: string; +} diff --git a/server/src/models/visit/dto/filter-visit.dto.ts b/server/src/models/visit/dto/filter-visit.dto.ts new file mode 100644 index 00000000..40574876 --- /dev/null +++ b/server/src/models/visit/dto/filter-visit.dto.ts @@ -0,0 +1,17 @@ +import { IsEnum, IsISO8601, IsMongoId, IsOptional } from 'class-validator'; + +export type VisitStatus = 'open' | 'closed'; + +export class FilterVisitDto { + @IsOptional() + @IsMongoId() + customerId?: string; + + @IsISO8601() + @IsOptional() + visitDate?: string; + + @IsOptional() + @IsEnum(['open', 'closed']) + status?: VisitStatus; +} diff --git a/server/src/models/visit/dto/update-visit.dto.ts b/server/src/models/visit/dto/update-visit.dto.ts new file mode 100644 index 00000000..7c0f336d --- /dev/null +++ b/server/src/models/visit/dto/update-visit.dto.ts @@ -0,0 +1,15 @@ +import { IsArray, IsISO8601, IsMongoId } from 'class-validator'; + +export class UpdateVisitDto { + @IsMongoId() + customerId: string; + + @IsArray() + @IsMongoId({ + each: true, + }) + activityEntryIds: string[]; + + @IsISO8601() + visitDate: string; +} diff --git a/server/src/models/visit/entities/visit.entity.ts b/server/src/models/visit/entities/visit.entity.ts new file mode 100644 index 00000000..1b29b814 --- /dev/null +++ b/server/src/models/visit/entities/visit.entity.ts @@ -0,0 +1,56 @@ +import { Prisma } from '@prisma/client'; + +export const VISIT_SELECT: Prisma.VisitSelect = { + id: true, + visitDate: true, + updatedAt: true, + createdAt: true, + activityEntries: { + select: { + id: true, + author: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, + activity: { + select: { + id: true, + name: true, + price: true, + }, + }, + products: { + select: { + id: true, + price: true, + product: { + select: { + name: true, + brand: true, + }, + }, + }, + }, + }, + }, + charge: { + select: { + id: true, + amount: true, + description: true, + createdDt: true, + }, + }, + customer: { + select: { + id: true, + firstName: true, + lastName: true, + email: true, + }, + }, +} as const; diff --git a/server/src/models/visit/visit.controller.spec.ts b/server/src/models/visit/visit.controller.spec.ts new file mode 100644 index 00000000..5073c3f9 --- /dev/null +++ b/server/src/models/visit/visit.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { VisitController } from './visit.controller'; +import { VisitService } from './visit.service'; + +describe('VisitController', () => { + let controller: VisitController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [VisitController], + providers: [VisitService], + }).compile(); + + controller = module.get(VisitController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/server/src/models/visit/visit.controller.ts b/server/src/models/visit/visit.controller.ts new file mode 100644 index 00000000..a22aee07 --- /dev/null +++ b/server/src/models/visit/visit.controller.ts @@ -0,0 +1,62 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Delete, + UseInterceptors, + Put, + Query, + ValidationPipe, +} from '@nestjs/common'; +import { VisitService } from './visit.service'; +import { CreateVisitDto } from './dto/create-visit.dto'; +import { UpdateVisitDto } from './dto/update-visit.dto'; +import { TransformInterceptor } from 'src/interceptors/transform.interceptor'; +import { Auth } from 'src/auth/role.decorator'; +import { Actions, Features } from 'src/auth/constants'; +import { GetUser } from 'src/auth/user.decorator'; +import { Staff } from '@prisma/client'; +import { Auditable } from 'src/auth/audit.decorator'; +import { VALIDATION_PIPE_OPTION } from 'src/utils/consts'; +import { FilterVisitDto } from './dto/filter-visit.dto'; + +@Controller('visits') +@UseInterceptors(TransformInterceptor) +export class VisitController { + constructor(private readonly visitService: VisitService) {} + + @Auth(Actions.WRITE, [Features.Entry]) + @Auditable() + @Post() + create(@Body() createVisitDto: CreateVisitDto, @GetUser() { id }: Staff) { + return this.visitService.create(createVisitDto, id); + } + + @Auth(Actions.READ, [Features.Entry]) + @Get() + findAll( + @Query(new ValidationPipe(VALIDATION_PIPE_OPTION)) params: FilterVisitDto, + ) { + return this.visitService.findAll(params); + } + + @Auth(Actions.READ, [Features.Entry]) + @Get(':id') + findOne(@Param('id') id: string) { + return this.visitService.findOne(id); + } + + @Auth(Actions.WRITE, [Features.Entry]) + @Auditable() + @Put(':id') + update(@Param('id') id: string, @Body() updateVisitDto: UpdateVisitDto) { + return this.visitService.update(id, updateVisitDto); + } + + @Delete(':id') + remove(@Param('id') id: string) { + return this.visitService.remove(id); + } +} diff --git a/server/src/models/visit/visit.module.ts b/server/src/models/visit/visit.module.ts new file mode 100644 index 00000000..ec529a48 --- /dev/null +++ b/server/src/models/visit/visit.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { VisitService } from './visit.service'; +import { VisitController } from './visit.controller'; +import { PrismaService } from 'src/prisma.service'; +import { AuthModule } from 'src/auth/auth.module'; + +@Module({ + controllers: [VisitController], + providers: [VisitService, PrismaService], + imports: [AuthModule], +}) +export class VisitModule {} diff --git a/server/src/models/visit/visit.service.spec.ts b/server/src/models/visit/visit.service.spec.ts new file mode 100644 index 00000000..0d229b0a --- /dev/null +++ b/server/src/models/visit/visit.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { VisitService } from './visit.service'; + +describe('VisitService', () => { + let service: VisitService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [VisitService], + }).compile(); + + service = module.get(VisitService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/server/src/models/visit/visit.service.ts b/server/src/models/visit/visit.service.ts new file mode 100644 index 00000000..66079e77 --- /dev/null +++ b/server/src/models/visit/visit.service.ts @@ -0,0 +1,116 @@ +import { Prisma } from '@prisma/client'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { PrismaService } from 'src/prisma.service'; +import { CreateVisitDto } from './dto/create-visit.dto'; +import { FilterVisitDto } from './dto/filter-visit.dto'; +import { UpdateVisitDto } from './dto/update-visit.dto'; +import { VISIT_SELECT } from './entities/visit.entity'; +import { getTodayDateString } from 'src/utils/dates'; + +@Injectable() +export class VisitService { + constructor(private readonly prisma: PrismaService) {} + create({ customerId, visitDate }: CreateVisitDto, staffId: string) { + return this.prisma.visit.create({ + data: { + customer: { + connect: { + id: customerId, + }, + }, + visitDate: visitDate ?? getTodayDateString(), + createdBy: { + connect: { + id: staffId, + }, + }, + }, + }); + } + + findAll({ customerId, status, visitDate }: FilterVisitDto) { + const filterOptions: Prisma.VisitWhereInput = {}; + if (customerId) { + filterOptions.customer = { + id: customerId, + }; + } + if (visitDate) { + filterOptions.visitDate = { + equals: visitDate, + }; + } + if (status === 'closed') { + filterOptions.charge = { + some: { + amount: { + not: 0, + }, + }, + }; + } + if (status === 'open') { + filterOptions.charge = { + none: { + amount: { + not: 0, + }, + }, + }; + } + return this.prisma.visit.findMany({ + where: filterOptions, + select: VISIT_SELECT, + }); + } + + findOne(id: string) { + return this.prisma.visit.findFirst({ + where: { + id, + }, + select: VISIT_SELECT, + }); + } + + update(id: string, updateVisitDto: UpdateVisitDto) { + const { activityEntryIds, customerId, ...cleaned } = updateVisitDto; + return this.prisma.visit.update({ + where: { + id, + }, + data: { + ...cleaned, + activityEntries: { + set: activityEntryIds.map((i) => ({ id: i })), + }, + customer: { + connect: { + id: customerId, + }, + }, + }, + }); + } + + async remove(id: string) { + if (await this.isVisitCharged(id)) { + throw new BadRequestException( + 'Visit cannot be deleted as it has already been charged', + ); + } + return this.prisma.visit.delete({ + where: { id }, + }); + } + + private async isVisitCharged(id: string) { + const { charge } = await this.prisma.visit.findFirst({ + where: { id }, + include: { + charge: true, + }, + }); + return !!charge.length; + } +} diff --git a/server/src/utils/dates.ts b/server/src/utils/dates.ts new file mode 100644 index 00000000..17b04c97 --- /dev/null +++ b/server/src/utils/dates.ts @@ -0,0 +1,3 @@ +import { format } from 'date-fns'; + +export const getTodayDateString = () => format(new Date(), 'yyyy-MM-dd'); diff --git a/server/test/visits.e2e-spec.ts b/server/test/visits.e2e-spec.ts new file mode 100644 index 00000000..3b8b8e25 --- /dev/null +++ b/server/test/visits.e2e-spec.ts @@ -0,0 +1,131 @@ +import { randomUUID } from 'crypto'; +import { testClient } from './testClient'; +import * as yup from 'yup'; + +describe('/visits', () => { + let createdCustomer = null; + let createdVisit = null; + let accessToken: string | null = null; + const findAllSchema = yup.object({ + customer: yup.object({ + id: yup.string(), + firstName: yup.string(), + lastName: yup.string(), + email: yup.string().email(), + }), + visitDate: yup.string(), + createdAt: yup.string(), + updatedAt: yup.string(), + charge: yup.array( + yup.object({ + id: yup.string(), + amount: yup.number().integer(), + description: yup.string(), + createdDt: yup.string(), + }), + ), + activityEntries: yup.array( + yup.object({ + author: yup.object({ + firstName: yup.string(), + lastName: yup.string(), + }), + }), + ), + }); + + beforeEach(async () => { + const { + body: { accessToken: token }, + } = await testClient.post('/auth/login').send({ + email: 'test@konomi.ai', + password: 'test', + }); + accessToken = token; + + const res = await testClient + .post('/customers') + .set('Authorization', `Bearer ${accessToken}`) + .send({ + firstName: 'Test', + lastName: 'Wang', + email: `test.visit.${randomUUID()}@test.konomi.ai`, + phone: '+14169671111', + dateOfBirth: '1999-07-29', + gender: 'MALE', + }) + .expect(201); + createdCustomer = res.body.data; + const visitRes = await testClient + .post('/visits') + .set('Authorization', `Bearer ${accessToken}`) + .send({ + customerId: createdCustomer.id, + }) + .expect(201); + createdVisit = visitRes.body.data; + }); + + it('/ (GET)', async () => { + await testClient + .get('/visits') + .set('Authorization', `Bearer ${accessToken}`) + .expect(200) + .then((res) => { + const { body } = res; + expect(body.data.length).toBeGreaterThan(0); + expect(findAllSchema.isValidSync(body.data[0])); + }); + }); + + it('/:id (GET)', async () => { + await testClient + .get(`/visits/${createdVisit.id}`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(200) + .then((res) => { + const { body } = res; + expect(findAllSchema.isValidSync(body.data)).toEqual(true); + }); + }); + + it('/:id (PUT)', async () => { + const { + body: { data: targetVisit }, + } = await testClient + .get(`/visits/${createdVisit.id}`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(200); + + await testClient + .put(`/visits/${createdVisit.id}`) + .set('Authorization', `Bearer ${accessToken}`) + .send({ + customerId: targetVisit.customer.id, + activityEntryIds: [], + visitDate: '2022-12-12', + }) + .expect(200); + await testClient + .get(`/visits/${createdVisit.id}`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(200) + .then((res) => { + const { body } = res; + expect(body.data.visitDate).toEqual('2022-12-12'); + }); + }); + + it('/:id (DELETE)', async () => { + const { + body: { data: targetVisit }, + } = await testClient + .get(`/visits/${createdVisit.id}`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(200); + await testClient + .delete(`/visits/${targetVisit.id}`) + .set('Authorization', `Bearer ${accessToken}`) + .expect(200); + }); +}); diff --git a/server/yarn.lock b/server/yarn.lock index 80793a36..d3c59148 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -681,6 +681,11 @@ "@types/jsonwebtoken" "8.5.4" jsonwebtoken "8.5.1" +"@nestjs/mapped-types@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@nestjs/mapped-types/-/mapped-types-1.2.0.tgz#1bbdbb5c956f0adb3fd76add929137bc6ad3183f" + integrity sha512-NTFwPZkQWsArQH8QSyFWGZvJ08gR+R4TofglqZoihn/vU+ktHEJjMqsIsADwb7XD97DhiD+TVv5ac+jG33BHrg== + "@nestjs/passport@^8.2.1": version "8.2.1" resolved "https://registry.yarnpkg.com/@nestjs/passport/-/passport-8.2.1.tgz#a2abff9f51b3857b3423f5380a00f475aa298fe7" @@ -2097,6 +2102,11 @@ date-fns@^2.16.1: resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.28.0.tgz#9570d656f5fc13143e50c975a3b6bbeb46cd08b2" integrity sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw== +date-fns@^2.29.3: + version "2.29.3" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8" + integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA== + debug@2.6.9: version "2.6.9" resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz"