some progress

This commit is contained in:
Evert Prants 2022-12-06 17:47:52 +02:00
parent c4d2cba4cb
commit b8ff56847a
16 changed files with 312 additions and 55 deletions

View File

@ -3,7 +3,7 @@ services:
mongodb: mongodb:
image: mongo:6 image: mongo:6
container_name: mongo container_name: mongo
restart: unless-stopped restart: 'no'
ports: ports:
- 27017:27017 - 27017:27017
environment: environment:

View File

@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const Token = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const response = ctx.switchToHttp().getResponse();
return response.locals.token as string;
},
);

20
src/guards/auth.guard.ts Normal file
View File

@ -0,0 +1,20 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
import type { Response, Request } from 'express';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const response = context.switchToHttp().getResponse<Response>();
const authHeader = request.header('Authorization');
if (!authHeader) return true; // false;
const [, token] = authHeader.split(' ');
if (!token) return true; // false
// Validate `token` JWT here
response.locals.token = token;
return true;
}
}

View File

@ -0,0 +1,14 @@
import { Transform } from 'class-transformer';
import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { ApplicationStatus } from 'src/enums/status.enum';
export class ListQueryDto {
@IsOptional()
@Transform(({ value }) => value.split(','))
@IsEnum(ApplicationStatus, { each: true })
readonly statuses?: ApplicationStatus[];
@IsNotEmpty()
@IsString()
readonly residentSub: string;
}

View File

@ -1,43 +1,30 @@
import { Exclude, Expose } from 'class-transformer'; import {
IsBoolean,
IsEnum,
IsOptional,
IsString,
ValidateIf,
} from 'class-validator';
import { Industry } from 'src/enums/industry.enum'; import { Industry } from 'src/enums/industry.enum';
import { RegulatoryElection } from 'src/enums/regulatory-election.enums'; import { RegulatoryElection } from 'src/enums/regulatory-election.enums';
import { ApplicationStatus, ObjectStatus } from 'src/enums/status.enum';
import { Resident } from 'src/resident/schemas/Resident.schema';
export class ApplicationInformationDto { export class RegisterIndustryChangeApplicationDto {
@Exclude() @IsString()
_id: string; residentSub: string;
willWorkInPhysicalJuristiction: string; @IsBoolean()
willWorkInPhysicalJurisdiction: boolean;
@IsEnum(Industry)
@ValidateIf((o) => o.willWorkInPhysicalJurisdiction === true)
industry: Industry; industry: Industry;
@IsEnum(RegulatoryElection)
@ValidateIf((o) => o.willWorkInPhysicalJurisdiction === true)
regulatoryElection: RegulatoryElection; regulatoryElection: RegulatoryElection;
@IsOptional()
@IsString()
@ValidateIf((o) => o.willWorkInPhysicalJurisdiction === true)
regulatoryElectionSub: string; regulatoryElectionSub: string;
} }
export class DecisionDto {
@Exclude()
_id: string;
decidedAt: Date;
rejctionReason: string;
}
export class GetRegisterIndustryChangeApplicationDto {
@Expose({ name: 'id' })
_id: string;
@Exclude()
resident: Resident;
@Expose({ name: 'residentSub' })
getResidentSub() {
return this.resident.sub;
}
current: ApplicationInformationDto;
requested: ApplicationInformationDto;
status: ApplicationStatus;
submittedAt: Date;
decision: DecisionDto;
objectStatus: ObjectStatus;
}

View File

@ -1,24 +1,61 @@
import { Controller, Get } from '@nestjs/common'; import {
import { plainToInstance } from 'class-transformer'; Body,
import { GetRegisterIndustryChangeApplicationDto } from './dtos/register-industry-change-application.dto'; Controller,
Delete,
Get,
Param,
Post,
Query,
UseGuards,
ValidationPipe,
} from '@nestjs/common';
import { Token } from 'src/decorators/token.decorator';
import { AuthGuard } from 'src/guards/auth.guard';
import { ListQueryDto } from './dtos/list-query.dto';
import { RegisterIndustryChangeApplicationDto } from './dtos/register-industry-change-application.dto';
import { IndustryChangeApplicationService } from './industry-change-application.service'; import { IndustryChangeApplicationService } from './industry-change-application.service';
@Controller({ @Controller({
path: '/resident-register/industry-change-applications', path: '/resident-register/industry-change-applications',
}) })
@UseGuards(AuthGuard)
export class IndustryChangeApplicationController { export class IndustryChangeApplicationController {
constructor( constructor(
private readonly applicationsSerivce: IndustryChangeApplicationService, private readonly applicationsSerivce: IndustryChangeApplicationService,
) {} ) {}
@Get() @Get()
async getList() { async getList(
const allApplications = await this.applicationsSerivce.getAll(); @Query(new ValidationPipe({ transform: true })) queryOpts: ListQueryDto,
const transformed = plainToInstance( ) {
GetRegisterIndustryChangeApplicationDto, const allApplications = await this.applicationsSerivce.getAll(queryOpts);
allApplications, return this.applicationsSerivce.makeReadable(allApplications);
{ excludeExtraneousValues: true }, }
@Get(':id')
async getSingle(@Param('id') id: string) {
const findInstance = await this.applicationsSerivce.getById(id);
if (!findInstance) return null;
return this.applicationsSerivce.makeReadable(findInstance);
}
@Delete(':id')
async delete(@Param('id') id: string, @Token() authToken: string) {
return this.applicationsSerivce.makeReadable(
await this.applicationsSerivce.markDeleted(id, authToken),
); );
return transformed; }
@Post()
async create(
@Body() application: RegisterIndustryChangeApplicationDto,
@Token() authToken: string,
) {
const newApplication = await this.applicationsSerivce.create(
application,
authToken,
);
return this.applicationsSerivce.makeReadable(newApplication);
} }
} }

View File

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose'; import { MongooseModule } from '@nestjs/mongoose';
import { ResidentModule } from 'src/resident/resident.module';
import { IndustryChangeApplicationController } from './industry-change-application.controller'; import { IndustryChangeApplicationController } from './industry-change-application.controller';
import { IndustryChangeApplicationService } from './industry-change-application.service'; import { IndustryChangeApplicationService } from './industry-change-application.service';
import { Decision, DecisionSchema } from './schemas/Decision.schema'; import { Decision, DecisionSchema } from './schemas/Decision.schema';
@ -28,6 +29,7 @@ import {
schema: IndustryChangeApplicationSchema, schema: IndustryChangeApplicationSchema,
}, },
]), ]),
ResidentModule,
], ],
controllers: [IndustryChangeApplicationController], controllers: [IndustryChangeApplicationController],
providers: [IndustryChangeApplicationService], providers: [IndustryChangeApplicationService],

View File

@ -1,19 +1,156 @@
import { Injectable } from '@nestjs/common'; import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose'; import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose'; import { Model } from 'mongoose';
import {
ApplicationStatus,
ObjectStatus,
ResidentStatus,
} from 'src/enums/status.enum';
import { TypeOfRegistration } from 'src/enums/type-of-registration.enums';
import { ResidentService } from 'src/resident/resident.service';
import { takeMongoObject, equate, take } from 'src/utility';
import { ListQueryDto } from './dtos/list-query.dto';
import { RegisterIndustryChangeApplicationDto } from './dtos/register-industry-change-application.dto';
import { import {
IndustryChangeApplication, IndustryChangeApplication,
IndustryChangeApplicationDocument, IndustryChangeApplicationDocument,
} from './schemas/IndustryChangeApplication.schema'; } from './schemas/IndustryChangeApplication.schema';
const requestedFields = [
'industry',
'willWorkInPhysicalJurisdiction',
'regulatoryElection',
'regulatoryElectionSub',
];
const fieldsToExpose = [
'id',
'residentSub',
'current',
'requested',
'status',
'submittedAt',
'decision',
'objectStatus',
];
@Injectable() @Injectable()
export class IndustryChangeApplicationService { export class IndustryChangeApplicationService {
constructor( constructor(
@InjectModel(IndustryChangeApplication.name) @InjectModel(IndustryChangeApplication.name)
private applicationModel: Model<IndustryChangeApplicationDocument>, private applicationModel: Model<IndustryChangeApplicationDocument>,
private resident: ResidentService,
) {} ) {}
async getAll() { async getAll(options: ListQueryDto) {
return this.applicationModel.find(); const getResident = await this.resident.getResidentBySub(
options.residentSub,
);
if (!getResident) {
throw new NotFoundException('Resident not found');
}
return this.applicationModel.find({
residentSub: options.residentSub,
status: {
$in: options.statuses || Object.values(ApplicationStatus),
},
});
}
async getById(id: string) {
return this.applicationModel.findById(id);
}
async create(data: RegisterIndustryChangeApplicationDto, token: string) {
// This might be possible to turn into a custom validator for class-validator
if (
data.willWorkInPhysicalJurisdiction === false &&
(!!data.industry ||
!!data.regulatoryElection ||
!!data.regulatoryElectionSub)
) {
throw new BadRequestException(
'industry, regulatoryElection and regulatoryElectionSub are not allowed when willWorkInPhysicalJurisdiction is false',
);
}
const getResident = await this.resident.getResidentBySub(data.residentSub);
if (!getResident) {
throw new BadRequestException('Resident not found');
}
if (getResident.status !== ResidentStatus.ACTIVE) {
throw new BadRequestException('Resident must be active!');
}
if (
![TypeOfRegistration.E_RESIDENCY, TypeOfRegistration.RESIDENCY].includes(
getResident.typeOfRegistration,
)
) {
throw new BadRequestException(
'Resident must be either an E-resident or a resident',
);
}
if (
equate(data, getResident, requestedFields).length ===
requestedFields.length
) {
throw new BadRequestException('Cannot request what is already the case.');
}
const status =
data.willWorkInPhysicalJurisdiction === true
? ApplicationStatus.IN_REVIEW
: ApplicationStatus.APPROVED;
const newApplication = new this.applicationModel({
residentSub: getResident.sub,
current: take(takeMongoObject(getResident), requestedFields),
requested: take(data, requestedFields),
status,
submittedAt: new Date(),
createdBy: token ?? 'no token provided for testing',
});
return newApplication.save();
}
async markDeleted(id: string, token: string) {
const findApplication = await this.applicationModel.findById(id);
if (!findApplication) {
throw new NotFoundException('Application was not found');
}
if (findApplication.status !== ApplicationStatus.IN_REVIEW) {
throw new BadRequestException(
'Only applications which are currently in review can be deleted.',
);
}
if (findApplication.objectStatus === ObjectStatus.DELETED) {
throw new BadRequestException('The object has already been deleted.');
}
findApplication.objectStatus = ObjectStatus.DELETED;
findApplication.updatedBy = token || 'no token provided for testing';
return findApplication.save();
}
// I wrote this because I could not for the life of me get class-transformer to
// play along with mongo documents. I do not have experience with either, so this
// was a last ditch effort.
makeReadable(input: IndustryChangeApplication | IndustryChangeApplication[]) {
return Array.isArray(input)
? input.map((object) => take(takeMongoObject(object), fieldsToExpose))
: take(takeMongoObject(input), fieldsToExpose);
} }
} }

View File

@ -1,6 +1,5 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import mongoose, { HydratedDocument } from 'mongoose'; import { HydratedDocument } from 'mongoose';
import { Resident } from 'src/resident/schemas/Resident.schema';
import { Decision } from './Decision.schema'; import { Decision } from './Decision.schema';
import { ApplicationStatus, ObjectStatus } from 'src/enums/status.enum'; import { ApplicationStatus, ObjectStatus } from 'src/enums/status.enum';
import { ICAInformation } from './ICAInformation.schema'; import { ICAInformation } from './ICAInformation.schema';
@ -14,10 +13,8 @@ export type IndustryChangeApplicationDocument =
export class IndustryChangeApplication { export class IndustryChangeApplication {
@Prop({ @Prop({
required: true, required: true,
ref: 'Resident',
type: mongoose.Schema.Types.ObjectId,
}) })
resident: Resident; residentSub: string;
@Prop({ @Prop({
required: true, required: true,
@ -58,6 +55,10 @@ export class IndustryChangeApplication {
required: true, required: true,
}) })
objectStatus: ObjectStatus; objectStatus: ObjectStatus;
createdAt: Date;
updatedAt: Date;
} }
export const IndustryChangeApplicationSchema = SchemaFactory.createForClass( export const IndustryChangeApplicationSchema = SchemaFactory.createForClass(

View File

@ -1,8 +1,10 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
await app.listen(3000); await app.listen(3000);
} }
bootstrap(); bootstrap();

View File

@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose'; import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose'; import { Model } from 'mongoose';
import { Resident, ResidentDocument } from './schemas/Resident.schema'; import { Resident, ResidentDocument } from './schemas/Resident.schema';
import { ResidentAddress } from './schemas/ResidentAddress.schema';
@Injectable() @Injectable()
export class ResidentService { export class ResidentService {
@ -14,4 +13,8 @@ export class ResidentService {
const createdResident = new this.residentModel(resident); const createdResident = new this.residentModel(resident);
return createdResident.save(); return createdResident.save();
} }
public async getResidentBySub(sub: string): Promise<Resident> {
return this.residentModel.findOne({ sub });
}
} }

17
src/utility/equate.ts Normal file
View File

@ -0,0 +1,17 @@
/**
* Equate keys of two objects
* @param object1 First object
* @param object2 Second object
* @param keys Keys to equate
* @returns Keys which are equal in both objects
*/
export default function equate<T, K>(
object1: T,
object2: K,
keys: string[],
): string[] {
return keys.reduce<string[]>((list, current) => {
if (object1[current] === object2[current]) return [...list, current];
return list;
}, []);
}

3
src/utility/index.ts Normal file
View File

@ -0,0 +1,3 @@
export { default as take } from './take';
export { default as equate } from './equate';
export { default as takeMongoObject } from './take-mongo-object';

View File

@ -0,0 +1,17 @@
/**
* Take the plain javascript object from a mongo document.
* I wrote this because I could not for the life of me get class-transformer to
* play along with mongo documents.
* @param mongoObj Mongo database response
* @returns Plain javascript object
*/
export default function takeMongoObject<T>(mongoObj: T): T {
const dirty = (mongoObj as Record<string, unknown>)._doc as Record<
string,
unknown
>;
dirty.id = dirty._id.toString();
delete dirty._id;
delete dirty.__v;
return dirty as T;
}

9
src/utility/take.ts Normal file
View File

@ -0,0 +1,9 @@
export default function take<T>(object: T, keys: string[]): Partial<T> {
if (!object) return null;
return Object.keys(object).reduce<Partial<T>>((obj, field) => {
if (keys.includes(field)) {
obj[field] = object[field];
}
return obj;
}, {});
}

View File