Compare commits

..

46 Commits

Author SHA1 Message Date
a7028fa64c feat: sms using twillio 2024-12-29 09:46:02 +03:00
0750509a85 Merge pull request #21 from HamzaSha1/feat/notifications
Feat/notifications
2024-12-24 13:08:29 +03:00
4d9ebe729e Merge branch 'mvp-1' of github.com:HamzaSha1/zod-backend into feat/notifications 2024-12-24 13:00:58 +03:00
bb8cc33d53 fix: register fcm token on enabling push notification 2024-12-24 13:00:54 +03:00
e933cacdcf Merge pull request #20 from HamzaSha1/feat/notifications
feat: working on push notifications journey
2024-12-24 12:18:32 +03:00
3719498c2f feat: working on push notifications journey 2024-12-24 12:10:49 +03:00
c7470302bd Merge pull request #19 from HamzaSha1/feat/gifts-journey
feat: gift journeys
2024-12-22 16:47:01 +03:00
5e9e83cb74 feat: gift journeys 2024-12-22 16:34:02 +03:00
4cef58580e Merge pull request #18 from HamzaSha1/feat/logout
Feat/logout
2024-12-22 10:55:24 +03:00
0ba09cbf8b Merge branch 'mvp-1' of github.com:HamzaSha1/zod-backend into feat/logout 2024-12-22 10:48:03 +03:00
28a2cb5d75 fix: fix exception in case of logout 2024-12-22 10:47:59 +03:00
4961a192ea Merge pull request #17 from HamzaSha1/feat/logout
Feat/logout
2024-12-19 16:59:16 +03:00
8ab47f3835 Merge branch 'mvp-1' of github.com:HamzaSha1/zod-backend into feat/logout 2024-12-19 16:50:44 +03:00
8112fb81a2 feat: logout 2024-12-19 16:50:39 +03:00
2c3c862c4a Merge pull request #16 from HamzaSha1/feat/refresh-token
feat: refresh token
2024-12-19 16:49:25 +03:00
93f5d83825 feat: refresh token 2024-12-19 16:25:39 +03:00
ea60ac3d7b Merge pull request #15 from HamzaSha1/feat/allowance-journey
feat: allowance journey
2024-12-19 15:27:00 +03:00
0748695f23 fix: remove comments from allowance entity 2024-12-19 15:21:44 +03:00
a201692c0c feat: allowance journey 2024-12-19 15:18:35 +03:00
fd6c1d1442 Merge pull request #14 from HamzaSha1/feat/money-requests
feat: working on money requests journrey
2024-12-18 13:17:29 +03:00
ed57ce6e91 feat: working on money requests jounrey 2024-12-18 12:57:23 +03:00
33453b193f Merge pull request #13 from HamzaSha1/feat/register-junior-via-qrcode
feat: onboard junior by qrcode
2024-12-15 17:00:42 +03:00
b0972f1a0a feat: onbard junior by qrcode 2024-12-15 16:46:49 +03:00
7437403756 Merge pull request #12 from HamzaSha1/feat/saving-goals
feat: working on saving goals journey for juniors
2024-12-15 12:53:27 +03:00
4d2f6f57f4 feat: working on saving goals jounrey for juniors 2024-12-15 12:44:59 +03:00
24d990592d fix: fix tasks submission journey 2024-12-12 15:22:04 +03:00
5b7b7ff689 fix: fix oci bucket name in generating signed url 2024-12-12 13:30:11 +03:00
6fccacd085 Merge pull request #11 from HamzaSha1/feat/customer-settings
feat: update customer profile picture and notifications settings
2024-12-12 13:23:28 +03:00
51fa61dbc6 feat: update customer profile picture and notifications settings 2024-12-12 13:15:47 +03:00
4867a5f858 Merge pull request #10 from HamzaSha1/feat/tasks-default-logo
feat: seed default task logos
2024-12-12 11:22:20 +03:00
687b6a5c6d feat: seed default task logos 2024-12-12 11:14:38 +03:00
e6ed1772f7 Merge pull request #9 from HamzaSha1/feat/signed-urls
Feat/signed urls
2024-12-12 10:01:52 +03:00
1f0a14fee4 fix: fix magic number lint issue 2024-12-12 09:47:49 +03:00
eb70828ae0 Merge branch 'mvp-1' of github.com:HamzaSha1/zod-backend into feat/signed-urls 2024-12-12 09:46:48 +03:00
220a03cc46 feat: working on signed url for private files 2024-12-12 09:46:38 +03:00
39b1e76bb5 fix: import tasks migration 2024-12-11 17:49:15 +03:00
83fc634d25 Merge pull request #8 from HamzaSha1/feat/tasks
feat: tasks journey
2024-12-11 11:14:21 +03:00
35b434bc3d fix: fix multiple submissions 2024-12-11 11:09:55 +03:00
749ee5457f feat: tasks jounrey 2024-12-11 10:27:51 +03:00
d539073f29 Merge pull request #7 from HamzaSha1/feat/roles-guard
feat: protecting endpoints by roles
2024-12-10 10:19:32 +03:00
66e1bb0f28 feat: protecting endpoint by roles 2024-12-10 10:11:47 +03:00
577f91b796 Merge pull request #6 from HamzaSha1/feat/junior-theme
feat: set theme for junior users
2024-12-10 09:30:42 +03:00
7ed37c30e1 feat: set theme for junior users 2024-12-10 09:23:30 +03:00
c2f63ccc72 Merge pull request #5 from HamzaSha1/feat/create-juniors
feat: create junior
2024-12-09 13:18:03 +03:00
970a41c895 feat: create junior 2024-12-09 13:11:18 +03:00
3fd29b3905 Merge pull request #4 from HamzaSha1/feat/forget-password
feat:forget password
2024-12-08 13:26:18 +03:00
234 changed files with 9829 additions and 2997 deletions

7225
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -28,6 +28,7 @@
"dependencies": { "dependencies": {
"@abdalhamid/hello": "^2.0.0", "@abdalhamid/hello": "^2.0.0",
"@hamid/hello": "file:../libraries/test-package", "@hamid/hello": "file:../libraries/test-package",
"@keyv/redis": "^4.0.2",
"@nestjs-modules/mailer": "^2.0.2", "@nestjs-modules/mailer": "^2.0.2",
"@nestjs/axios": "^3.1.2", "@nestjs/axios": "^3.1.2",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
@ -45,8 +46,10 @@
"amqp-connection-manager": "^4.1.14", "amqp-connection-manager": "^4.1.14",
"amqplib": "^0.10.4", "amqplib": "^0.10.4",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cacheable": "^1.8.5",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"firebase-admin": "^13.0.2",
"google-libphonenumber": "^3.2.39", "google-libphonenumber": "^3.2.39",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"ioredis": "^5.4.1", "ioredis": "^5.4.1",
@ -54,6 +57,7 @@
"moment": "^2.30.1", "moment": "^2.30.1",
"nestjs-i18n": "^10.4.9", "nestjs-i18n": "^10.4.9",
"nestjs-pino": "^4.1.0", "nestjs-pino": "^4.1.0",
"nestjs-twilio": "^4.4.0",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"oci-common": "^2.99.0", "oci-common": "^2.99.0",
"oci-sdk": "^2.99.0", "oci-sdk": "^2.99.0",
@ -62,9 +66,11 @@
"pg": "^8.13.1", "pg": "^8.13.1",
"pino-http": "^10.3.0", "pino-http": "^10.3.0",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"reflect-metadata": "^0.2.0", "qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.20" "typeorm": "^0.3.20",
"typeorm-transactional": "^0.5.0"
}, },
"devDependencies": { "devDependencies": {
"@golevelup/ts-jest": "^0.6.0", "@golevelup/ts-jest": "^0.6.0",
@ -80,15 +86,19 @@
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
"@types/nodemailer": "^6.4.16", "@types/nodemailer": "^6.4.16",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.5",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.0",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^5.59.2", "@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2", "@typescript-eslint/parser": "^5.59.2",
"eslint": "^8.39.0", "eslint": "^8.39.0",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-security": "^1.7.1", "eslint-plugin-security": "^1.7.1",
"i": "^0.3.7",
"jest": "^29.5.0", "jest": "^29.5.0",
"lint-staged": "^13.2.2", "lint-staged": "^13.2.2",
"npm": "^10.9.2",
"prettier": "^2.8.8", "prettier": "^2.8.8",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",

View File

@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JuniorModule } from '~/junior/junior.module';
import { AllowanceChangeRequestController, AllowancesController } from './controllers';
import { Allowance, AllowanceChangeRequest } from './entities';
import { AllowanceChangeRequestsRepository, AllowancesRepository } from './repositories';
import { AllowanceChangeRequestsService, AllowancesService } from './services';
@Module({
controllers: [AllowancesController, AllowanceChangeRequestController],
imports: [TypeOrmModule.forFeature([Allowance, AllowanceChangeRequest]), JuniorModule],
providers: [
AllowancesService,
AllowancesRepository,
AllowanceChangeRequestsService,
AllowanceChangeRequestsRepository,
],
exports: [],
})
export class AllowanceModule {}

View File

@ -0,0 +1,80 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces';
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
import { RolesGuard } from '~/common/guards';
import { ApiDataPageResponse, ApiDataResponse } from '~/core/decorators';
import { PageOptionsRequestDto } from '~/core/dtos';
import { CustomParseUUIDPipe } from '~/core/pipes';
import { ResponseFactory } from '~/core/utils';
import { CreateAllowanceChangeRequestDto } from '../dtos/request';
import { AllowanceChangeRequestResponseDto } from '../dtos/response';
import { AllowanceChangeRequestsService } from '../services';
@Controller('allowance-change-requests')
@ApiTags('Allowance Change Requests')
@ApiBearerAuth()
export class AllowanceChangeRequestController {
constructor(private readonly allowanceChangeRequestsService: AllowanceChangeRequestsService) {}
@Post()
@UseGuards(RolesGuard)
@AllowedRoles(Roles.JUNIOR)
@HttpCode(HttpStatus.NO_CONTENT)
requestAllowanceChange(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateAllowanceChangeRequestDto) {
return this.allowanceChangeRequestsService.createAllowanceChangeRequest(sub, body);
}
@Get()
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataPageResponse(AllowanceChangeRequestResponseDto)
async findAllowanceChangeRequests(@AuthenticatedUser() { sub }: IJwtPayload, @Query() query: PageOptionsRequestDto) {
const [requests, itemCount] = await this.allowanceChangeRequestsService.findAllowanceChangeRequests(sub, query);
return ResponseFactory.dataPage(
requests.map((request) => new AllowanceChangeRequestResponseDto(request)),
{
itemCount,
page: query.page,
size: query.size,
},
);
}
@Get('/:changeRequestId')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(AllowanceChangeRequestResponseDto)
async findAllowanceChangeRequestById(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('changeRequestId', CustomParseUUIDPipe) changeRequestId: string,
) {
const request = await this.allowanceChangeRequestsService.findAllowanceChangeRequestById(sub, changeRequestId);
return ResponseFactory.data(new AllowanceChangeRequestResponseDto(request));
}
@Patch(':changeRequestId/approve')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@HttpCode(HttpStatus.NO_CONTENT)
approveAllowanceChangeRequest(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('changeRequestId', CustomParseUUIDPipe) changeRequestId: string,
) {
return this.allowanceChangeRequestsService.approveAllowanceChangeRequest(sub, changeRequestId);
}
@Patch(':changeRequestId/reject')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@HttpCode(HttpStatus.NO_CONTENT)
rejectAllowanceChangeRequest(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('changeRequestId', CustomParseUUIDPipe) changeRequestId: string,
) {
return this.allowanceChangeRequestsService.rejectAllowanceChangeRequest(sub, changeRequestId);
}
}

View File

@ -0,0 +1,72 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces';
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
import { RolesGuard } from '~/common/guards';
import { ApiDataPageResponse, ApiDataResponse } from '~/core/decorators';
import { PageOptionsRequestDto } from '~/core/dtos';
import { CustomParseUUIDPipe } from '~/core/pipes';
import { ResponseFactory } from '~/core/utils';
import { CreateAllowanceRequestDto } from '../dtos/request';
import { AllowanceResponseDto } from '../dtos/response';
import { AllowancesService } from '../services';
@Controller('allowances')
@ApiTags('Allowances')
@ApiBearerAuth()
export class AllowancesController {
constructor(private readonly allowancesService: AllowancesService) {}
@Post()
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(AllowanceResponseDto)
async createAllowance(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateAllowanceRequestDto) {
const allowance = await this.allowancesService.createAllowance(sub, body);
return ResponseFactory.data(new AllowanceResponseDto(allowance));
}
@Get()
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataPageResponse(AllowanceResponseDto)
async findAllowances(@AuthenticatedUser() { sub }: IJwtPayload, @Query() query: PageOptionsRequestDto) {
const [allowances, itemCount] = await this.allowancesService.findAllowances(sub, query);
return ResponseFactory.dataPage(
allowances.map((allowance) => new AllowanceResponseDto(allowance)),
{
itemCount,
page: query.page,
size: query.size,
},
);
}
@Get(':allowanceId')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(AllowanceResponseDto)
async findAllowanceById(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('allowanceId', CustomParseUUIDPipe) allowanceId: string,
) {
const allowance = await this.allowancesService.findAllowanceById(allowanceId, sub);
return ResponseFactory.data(new AllowanceResponseDto(allowance));
}
@Delete(':allowanceId')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(AllowanceResponseDto)
@HttpCode(HttpStatus.NO_CONTENT)
deleteAllowance(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('allowanceId', CustomParseUUIDPipe) allowanceId: string,
) {
return this.allowancesService.deleteAllowance(sub, allowanceId);
}
}

View File

@ -0,0 +1,2 @@
export * from './allowance-change-request.controller';
export * from './allowances.controller';

View File

@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class CreateAllowanceChangeRequestDto {
@ApiProperty({ example: 'I want to change the amount of the allowance' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'allowanceChangeRequest.reason' }) })
@IsNotEmpty({
message: i18n('validation.IsNotEmpty', { path: 'general', property: 'allowanceChangeRequest.reason' }),
})
reason!: string;
@ApiProperty({ example: 100 })
@IsNumber(
{},
{ message: i18n('validation.IsNumber', { path: 'general', property: 'allowanceChangeRequest.amount' }) },
)
@IsPositive({
message: i18n('validation.IsPositive', { path: 'general', property: 'allowanceChangeRequest.amount' }),
})
amount!: number;
@ApiProperty({ example: 'd641bb71-2e7c-4e62-96fa-2785f0a651c6' })
@IsUUID('4', {
message: i18n('validation.IsUUID', { path: 'general', property: 'allowanceChangeRequest.allowanceId' }),
})
allowanceId!: string;
}

View File

@ -0,0 +1,52 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsDate, IsEnum, IsInt, IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID, ValidateIf } from 'class-validator';
import moment from 'moment';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { AllowanceFrequency, AllowanceType } from '~/allowance/enums';
export class CreateAllowanceRequestDto {
@ApiProperty({ example: 'Allowance name' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'allowance.name' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'allowance.name' }) })
name!: string;
@ApiProperty({ example: 100 })
@IsNumber({}, { message: i18n('validation.IsNumber', { path: 'general', property: 'allowance.amount' }) })
@IsPositive({ message: i18n('validation.IsPositive', { path: 'general', property: 'allowance.amount' }) })
amount!: number;
@ApiProperty({ example: AllowanceFrequency.WEEKLY })
@IsEnum(AllowanceFrequency, {
message: i18n('validation.IsEnum', { path: 'general', property: 'allowance.frequency' }),
})
frequency!: AllowanceFrequency;
@ApiProperty({ example: AllowanceType.BY_END_DATE })
@IsEnum(AllowanceType, { message: i18n('validation.IsEnum', { path: 'general', property: 'allowance.type' }) })
type!: AllowanceType;
@ApiProperty({ example: new Date() })
@IsDate({ message: i18n('validation.IsDate', { path: 'general', property: 'allowance.startDate' }) })
@Transform(({ value }) => moment(value).startOf('day').toDate())
startDate!: Date;
@ApiProperty({ example: new Date() })
@IsDate({ message: i18n('validation.IsDate', { path: 'general', property: 'allowance.endDate' }) })
@Transform(({ value }) => moment(value).endOf('day').toDate())
@ValidateIf((o) => o.type === AllowanceType.BY_END_DATE)
endDate?: Date;
@ApiProperty({ example: 10 })
@IsNumber(
{},
{ message: i18n('validation.IsNumber', { path: 'general', property: 'allowance.numberOfTransactions' }) },
)
@IsInt({ message: i18n('validation.IsInt', { path: 'general', property: 'allowance.amount' }) })
@IsPositive({ message: i18n('validation.IsPositive', { path: 'general', property: 'allowance.amount' }) })
@ValidateIf((o) => o.type === AllowanceType.BY_COUNT)
numberOfTransactions?: number;
@ApiProperty({ example: 'e7b1b3b4-4b3b-4b3b-4b3b-4b3b4b3b4b3b' })
@IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'allowance.juniorId' }) })
juniorId!: string;
}

View File

@ -0,0 +1,2 @@
export * from './create-allowance-change.request.dto';
export * from './create-allowance.request.dto';

View File

@ -0,0 +1,45 @@
import { ApiProperty } from '@nestjs/swagger';
import { AllowanceChangeRequest } from '~/allowance/entities';
import { AllowanceChangeRequestStatus } from '~/allowance/enums';
import { JuniorResponseDto } from '~/junior/dtos/response';
export class AllowanceChangeRequestResponseDto {
@ApiProperty({ example: 'd641bb71-2e7c-4e62-96fa-2785f0a651c6' })
id!: string;
@ApiProperty({ example: AllowanceChangeRequestStatus.APPROVED })
status!: AllowanceChangeRequestStatus;
@ApiProperty({ example: 'Allowance name' })
name!: string;
@ApiProperty({ example: '100' })
oldAmount!: number;
@ApiProperty({ example: '200' })
newAmount!: number;
@ApiProperty({ example: 'Some reason' })
reason!: string;
@ApiProperty({ example: 'd641bb71-2e7c-4e62-96fa-2785f0a651c6' })
allowanceId!: string;
@ApiProperty({ type: JuniorResponseDto })
junior!: JuniorResponseDto;
@ApiProperty({ example: new Date() })
createdAt!: Date;
constructor(allowanceChangeRequest: AllowanceChangeRequest) {
this.id = allowanceChangeRequest.id;
this.status = allowanceChangeRequest.status;
this.name = allowanceChangeRequest.allowance.name;
this.oldAmount = allowanceChangeRequest.allowance.amount;
this.newAmount = allowanceChangeRequest.amount;
this.reason = allowanceChangeRequest.reason;
this.allowanceId = allowanceChangeRequest.allowanceId;
this.junior = new JuniorResponseDto(allowanceChangeRequest.allowance.junior);
this.createdAt = allowanceChangeRequest.createdAt;
}
}

View File

@ -0,0 +1,53 @@
import { ApiProperty } from '@nestjs/swagger';
import { Allowance } from '~/allowance/entities';
import { AllowanceFrequency, AllowanceType } from '~/allowance/enums';
import { JuniorResponseDto } from '~/junior/dtos/response';
export class AllowanceResponseDto {
@ApiProperty({ example: 'd641bb71-2e7c-4e62-96fa-2785f0a651c6' })
id!: string;
@ApiProperty({ example: 'Allowance name' })
name!: string;
@ApiProperty({ example: 100 })
amount!: number;
@ApiProperty({ example: AllowanceFrequency.WEEKLY })
frequency!: AllowanceFrequency;
@ApiProperty({ example: AllowanceType.BY_END_DATE })
type!: AllowanceType;
@ApiProperty({ example: new Date() })
startDate!: Date;
@ApiProperty({ example: new Date() })
endDate?: Date;
@ApiProperty({ example: 10 })
numberOfTransactions?: number;
@ApiProperty({ type: JuniorResponseDto })
junior!: JuniorResponseDto;
@ApiProperty({ example: new Date() })
createdAt!: Date;
@ApiProperty({ example: new Date() })
updatedAt!: Date;
constructor(allowance: Allowance) {
this.id = allowance.id;
this.name = allowance.name;
this.amount = allowance.amount;
this.frequency = allowance.frequency;
this.type = allowance.type;
this.startDate = allowance.startDate;
this.endDate = allowance.endDate;
this.numberOfTransactions = allowance.numberOfTransactions;
this.junior = new JuniorResponseDto(allowance.junior);
this.createdAt = allowance.createdAt;
this.updatedAt = allowance.updatedAt;
}
}

View File

@ -0,0 +1,2 @@
export * from './allowance-change-request.response.dto';
export * from './allowance.response.dto';

View File

@ -0,0 +1,45 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { AllowanceChangeRequestStatus } from '../enums';
import { Allowance } from './allowance.entity';
@Entity('allowance_change_requests')
export class AllowanceChangeRequest {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'text', name: 'reason' })
reason!: string;
@Column({
type: 'decimal',
precision: 10,
scale: 2,
name: 'amount',
transformer: { to: (value: number) => value, from: (value: string) => parseFloat(value) },
})
amount!: number;
@Column({ type: 'varchar', length: 255, name: 'status', default: AllowanceChangeRequestStatus.PENDING })
status!: AllowanceChangeRequestStatus;
@Column({ type: 'uuid', name: 'allowance_id' })
allowanceId!: string;
@ManyToOne(() => Allowance, (allowance) => allowance.changeRequests)
@JoinColumn({ name: 'allowance_id' })
allowance!: Allowance;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date;
}

View File

@ -0,0 +1,74 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Guardian } from '~/guardian/entities/guradian.entity';
import { Junior } from '~/junior/entities';
import { AllowanceFrequency, AllowanceType } from '../enums';
import { AllowanceChangeRequest } from './allowance-change-request.entity';
@Entity('allowances')
export class Allowance {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 255, name: 'name' })
name!: string;
@Column({
type: 'decimal',
precision: 10,
scale: 2,
name: 'amount',
transformer: { to: (value: number) => value, from: (value: string) => parseFloat(value) },
})
amount!: number;
@Column({ type: 'varchar', length: 255, name: 'frequency' })
frequency!: AllowanceFrequency;
@Column({ type: 'varchar', length: 255, name: 'type' })
type!: AllowanceType;
@Column({ type: 'timestamp with time zone', name: 'start_date' })
startDate!: Date;
@Column({ type: 'timestamp with time zone', name: 'end_date', nullable: true })
endDate?: Date;
@Column({ type: 'int', name: 'number_of_transactions', nullable: true })
numberOfTransactions?: number;
@Column({ type: 'uuid', name: 'guardian_id' })
guardianId!: string;
@Column({ type: 'uuid', name: 'junior_id' })
juniorId!: string;
@ManyToOne(() => Guardian, (guardian) => guardian.allowances)
@JoinColumn({ name: 'guardian_id' })
guardian!: Guardian;
@ManyToOne(() => Junior, (junior) => junior.allowances)
@JoinColumn({ name: 'junior_id' })
junior!: Junior;
@OneToMany(() => AllowanceChangeRequest, (changeRequest) => changeRequest.allowance)
changeRequests!: AllowanceChangeRequest[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date;
@DeleteDateColumn({ name: 'deleted_at', type: 'timestamp with time zone', nullable: true })
deletedAt?: Date;
}

View File

@ -0,0 +1,2 @@
export * from './allowance-change-request.entity';
export * from './allowance.entity';

View File

@ -0,0 +1,5 @@
export enum AllowanceChangeRequestStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED',
}

View File

@ -0,0 +1,5 @@
export enum AllowanceFrequency {
DAILY = 'DAILY',
WEEKLY = 'WEEKLY',
MONTHLY = 'MONTHLY',
}

View File

@ -0,0 +1,4 @@
export enum AllowanceType {
BY_END_DATE = 'BY_END_DATE',
BY_COUNT = 'BY_COUNT',
}

View File

@ -0,0 +1,3 @@
export * from './allowance-change-request-status.enum';
export * from './allowance-frequency.enum';
export * from './allowance-type.enum';

View File

@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, Repository } from 'typeorm';
import { PageOptionsRequestDto } from '~/core/dtos';
import { CreateAllowanceChangeRequestDto } from '../dtos/request';
import { AllowanceChangeRequest } from '../entities';
import { AllowanceChangeRequestStatus } from '../enums';
const ONE = 1;
@Injectable()
export class AllowanceChangeRequestsRepository {
constructor(
@InjectRepository(AllowanceChangeRequest)
private readonly allowanceChangeRequestsRepository: Repository<AllowanceChangeRequest>,
) {}
createAllowanceChangeRequest(allowanceId: string, body: CreateAllowanceChangeRequestDto) {
return this.allowanceChangeRequestsRepository.save(
this.allowanceChangeRequestsRepository.create({
allowanceId,
amount: body.amount,
reason: body.reason,
}),
);
}
findAllowanceChangeRequestBy(where: FindOptionsWhere<AllowanceChangeRequest>, withRelations = false) {
const relations = withRelations
? ['allowance', 'allowance.junior', 'allowance.junior.customer', 'allowance.junior.customer.profilePicture']
: [];
return this.allowanceChangeRequestsRepository.findOne({ where, relations });
}
updateAllowanceChangeRequestStatus(requestId: string, status: AllowanceChangeRequestStatus) {
return this.allowanceChangeRequestsRepository.update({ id: requestId }, { status });
}
findAllowanceChangeRequests(guardianId: string, query: PageOptionsRequestDto) {
return this.allowanceChangeRequestsRepository.findAndCount({
where: { allowance: { guardianId } },
take: query.size,
skip: query.size * (query.page - ONE),
relations: [
'allowance',
'allowance.junior',
'allowance.junior.customer',
'allowance.junior.customer.profilePicture',
],
});
}
}

View File

@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PageOptionsRequestDto } from '~/core/dtos';
import { CreateAllowanceRequestDto } from '../dtos/request';
import { Allowance } from '../entities';
const ONE = 1;
@Injectable()
export class AllowancesRepository {
constructor(@InjectRepository(Allowance) private readonly allowancesRepository: Repository<Allowance>) {}
createAllowance(guardianId: string, body: CreateAllowanceRequestDto) {
return this.allowancesRepository.save(
this.allowancesRepository.create({
guardianId,
name: body.name,
amount: body.amount,
frequency: body.frequency,
type: body.type,
startDate: body.startDate,
endDate: body.endDate,
numberOfTransactions: body.numberOfTransactions,
juniorId: body.juniorId,
}),
);
}
findAllowanceById(allowanceId: string, guardianId?: string) {
return this.allowancesRepository.findOne({
where: { id: allowanceId, guardianId },
relations: ['junior', 'junior.customer', 'junior.customer.profilePicture'],
});
}
findAllowances(guardianId: string, query: PageOptionsRequestDto) {
return this.allowancesRepository.findAndCount({
where: { guardianId },
relations: ['junior', 'junior.customer', 'junior.customer.profilePicture'],
take: query.size,
skip: query.size * (query.page - ONE),
});
}
deleteAllowance(guardianId: string, allowanceId: string) {
return this.allowancesRepository.softDelete({ id: allowanceId, guardianId });
}
}

View File

@ -0,0 +1,2 @@
export * from './allowance-change-request.repository';
export * from './allowances.repository';

View File

@ -0,0 +1,115 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { PageOptionsRequestDto } from '~/core/dtos';
import { OciService } from '~/document/services';
import { CreateAllowanceChangeRequestDto } from '../dtos/request';
import { AllowanceChangeRequest } from '../entities';
import { AllowanceChangeRequestStatus } from '../enums';
import { AllowanceChangeRequestsRepository } from '../repositories';
import { AllowancesService } from './allowances.service';
@Injectable()
export class AllowanceChangeRequestsService {
constructor(
private readonly allowanceChangeRequestsRepository: AllowanceChangeRequestsRepository,
private readonly ociService: OciService,
private readonly allowanceService: AllowancesService,
) {}
async createAllowanceChangeRequest(juniorId: string, body: CreateAllowanceChangeRequestDto) {
const allowance = await this.allowanceService.validateAllowanceForJunior(juniorId, body.allowanceId);
if (allowance.amount === body.amount) {
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.SAME_AMOUNT');
}
const requestWithTheSameAmount = await this.findAllowanceChangeRequestBy({
allowanceId: body.allowanceId,
amount: body.amount,
status: AllowanceChangeRequestStatus.PENDING,
});
if (requestWithTheSameAmount) {
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.SAME_AMOUNT_PENDING');
}
return this.allowanceChangeRequestsRepository.createAllowanceChangeRequest(body.allowanceId, body);
}
findAllowanceChangeRequestBy(where: FindOptionsWhere<AllowanceChangeRequest>) {
return this.allowanceChangeRequestsRepository.findAllowanceChangeRequestBy(where);
}
async approveAllowanceChangeRequest(guardianId: string, requestId: string) {
const request = await this.findAllowanceChangeRequestBy({ id: requestId, allowance: { guardianId } });
if (!request) {
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.NOT_FOUND');
}
if (request.status === AllowanceChangeRequestStatus.APPROVED) {
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.ALREADY_APPROVED');
}
return this.allowanceChangeRequestsRepository.updateAllowanceChangeRequestStatus(
requestId,
AllowanceChangeRequestStatus.APPROVED,
);
}
async rejectAllowanceChangeRequest(guardianId: string, requestId: string) {
const request = await this.findAllowanceChangeRequestBy({ id: requestId, allowance: { guardianId } });
if (!request) {
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.NOT_FOUND');
}
if (request.status === AllowanceChangeRequestStatus.REJECTED) {
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.ALREADY_REJECTED');
}
return this.allowanceChangeRequestsRepository.updateAllowanceChangeRequestStatus(
requestId,
AllowanceChangeRequestStatus.REJECTED,
);
}
async findAllowanceChangeRequests(
guardianId: string,
query: PageOptionsRequestDto,
): Promise<[AllowanceChangeRequest[], number]> {
const [requests, itemCount] = await this.allowanceChangeRequestsRepository.findAllowanceChangeRequests(
guardianId,
query,
);
await this.prepareAllowanceChangeRequestsImages(requests);
return [requests, itemCount];
}
async findAllowanceChangeRequestById(guardianId: string, requestId: string) {
const request = await this.allowanceChangeRequestsRepository.findAllowanceChangeRequestBy(
{
id: requestId,
allowance: { guardianId },
},
true,
);
if (!request) {
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.NOT_FOUND');
}
await this.prepareAllowanceChangeRequestsImages([request]);
return request;
}
private prepareAllowanceChangeRequestsImages(requests: AllowanceChangeRequest[]) {
return Promise.all(
requests.map(async (request) => {
const profilePicture = request.allowance.junior.customer.profilePicture;
if (profilePicture) {
profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture);
}
}),
);
}
}

View File

@ -0,0 +1,85 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import moment from 'moment';
import { PageOptionsRequestDto } from '~/core/dtos';
import { OciService } from '~/document/services';
import { JuniorService } from '~/junior/services';
import { CreateAllowanceRequestDto } from '../dtos/request';
import { Allowance } from '../entities';
import { AllowancesRepository } from '../repositories';
@Injectable()
export class AllowancesService {
constructor(
private readonly allowancesRepository: AllowancesRepository,
private readonly juniorService: JuniorService,
private readonly ociService: OciService,
) {}
async createAllowance(guardianId: string, body: CreateAllowanceRequestDto) {
if (moment(body.startDate).isBefore(moment().startOf('day'))) {
throw new BadRequestException('ALLOWANCE.START_DATE_BEFORE_TODAY');
}
if (moment(body.startDate).isAfter(body.endDate)) {
throw new BadRequestException('ALLOWANCE.START_DATE_AFTER_END_DATE');
}
const doesJuniorBelongToGuardian = await this.juniorService.doesJuniorBelongToGuardian(guardianId, body.juniorId);
if (!doesJuniorBelongToGuardian) {
throw new BadRequestException('JUNIOR.DOES_NOT_BELONG_TO_GUARDIAN');
}
const allowance = await this.allowancesRepository.createAllowance(guardianId, body);
return this.findAllowanceById(allowance.id);
}
async findAllowanceById(allowanceId: string, guardianId?: string) {
const allowance = await this.allowancesRepository.findAllowanceById(allowanceId, guardianId);
if (!allowance) {
throw new BadRequestException('ALLOWANCE.NOT_FOUND');
}
await this.prepareAllowanceDocuments([allowance]);
return allowance;
}
async findAllowances(guardianId: string, query: PageOptionsRequestDto): Promise<[Allowance[], number]> {
const [allowances, itemCount] = await this.allowancesRepository.findAllowances(guardianId, query);
await this.prepareAllowanceDocuments(allowances);
return [allowances, itemCount];
}
async deleteAllowance(guardianId: string, allowanceId: string) {
const { affected } = await this.allowancesRepository.deleteAllowance(guardianId, allowanceId);
if (!affected) {
throw new BadRequestException('ALLOWANCE.NOT_FOUND');
}
}
async validateAllowanceForJunior(juniorId: string, allowanceId: string) {
const allowance = await this.allowancesRepository.findAllowanceById(allowanceId);
if (!allowance) {
throw new BadRequestException('ALLOWANCE.NOT_FOUND');
}
if (allowance.juniorId !== juniorId) {
throw new BadRequestException('ALLOWANCE.DOES_NOT_BELONG_TO_JUNIOR');
}
return allowance;
}
private async prepareAllowanceDocuments(allowance: Allowance[]) {
await Promise.all(
allowance.map(async (allowance) => {
const profilePicture = allowance.junior.customer.profilePicture;
if (profilePicture) {
profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture);
}
}),
);
}
}

View File

@ -0,0 +1,2 @@
export * from './allowance-change-requests.service';
export * from './allowances.service';

View File

@ -4,7 +4,13 @@ import { APP_FILTER, APP_PIPE } from '@nestjs/core';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { I18nMiddleware, I18nModule } from 'nestjs-i18n'; import { I18nMiddleware, I18nModule } from 'nestjs-i18n';
import { LoggerModule } from 'nestjs-pino'; import { LoggerModule } from 'nestjs-pino';
import { DataSource } from 'typeorm';
import { addTransactionalDataSource } from 'typeorm-transactional';
import { AllowanceModule } from './allowance/allowance.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { CacheModule } from './common/modules/cache/cache.module';
import { LookupModule } from './common/modules/lookup/lookup.module';
import { NotificationModule } from './common/modules/notification/notification.module';
import { OtpModule } from './common/modules/otp/otp.module'; import { OtpModule } from './common/modules/otp/otp.module';
import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters'; import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters';
import { buildConfigOptions, buildLoggerOptions, buildTypeormOptions } from './core/module-options'; import { buildConfigOptions, buildLoggerOptions, buildTypeormOptions } from './core/module-options';
@ -13,7 +19,14 @@ import { buildValidationPipe } from './core/pipes';
import { CustomerModule } from './customer/customer.module'; import { CustomerModule } from './customer/customer.module';
import { migrations } from './db'; import { migrations } from './db';
import { DocumentModule } from './document/document.module'; import { DocumentModule } from './document/document.module';
import { GiftModule } from './gift/gift.module';
import { GuardianModule } from './guardian/guardian.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { JuniorModule } from './junior/junior.module';
import { MoneyRequestModule } from './money-request/money-request.module';
import { SavingGoalsModule } from './saving-goals/saving-goals.module';
import { TaskModule } from './task/task.module';
@Module({ @Module({
controllers: [], controllers: [],
imports: [ imports: [
@ -21,19 +34,46 @@ import { HealthModule } from './health/health.module';
TypeOrmModule.forRootAsync({ TypeOrmModule.forRootAsync({
imports: [], imports: [],
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService) => buildTypeormOptions(config, migrations), useFactory: (config: ConfigService) => {
return buildTypeormOptions(config, migrations);
},
/* eslint-disable require-await */
async dataSourceFactory(options) {
if (!options) {
throw new Error('Invalid options passed');
}
return addTransactionalDataSource(new DataSource(options));
},
/* eslint-enable require-await */
}), }),
LoggerModule.forRootAsync({ LoggerModule.forRootAsync({
useFactory: (config: ConfigService) => buildLoggerOptions(config), useFactory: (config: ConfigService) => buildLoggerOptions(config),
inject: [ConfigService], inject: [ConfigService],
}), }),
I18nModule.forRoot(buildI18nOptions()), I18nModule.forRoot(buildI18nOptions()),
CacheModule,
// App modules // App modules
AuthModule, AuthModule,
CustomerModule, CustomerModule,
DocumentModule, JuniorModule,
HealthModule,
TaskModule,
GuardianModule,
SavingGoalsModule,
AllowanceModule,
MoneyRequestModule,
GiftModule,
OtpModule, OtpModule,
DocumentModule,
LookupModule,
HealthModule,
NotificationModule,
], ],
providers: [ providers: [
// Global Pipes // Global Pipes

View File

@ -1,17 +1,24 @@
import { Module } from '@nestjs/common'; import { forwardRef, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { CustomerModule } from '~/customer/customer.module';
import { JuniorModule } from '~/junior/junior.module';
import { AuthController } from './controllers'; import { AuthController } from './controllers';
import { Device, User, UserNotificationSettings } from './entities'; import { Device, User } from './entities';
import { DeviceRepository, UserRepository } from './repositories'; import { DeviceRepository, UserRepository } from './repositories';
import { AuthService, DeviceService } from './services'; import { AuthService, DeviceService } from './services';
import { UserService } from './services/user.service'; import { UserService } from './services/user.service';
import { AccessTokenStrategy } from './strategies'; import { AccessTokenStrategy } from './strategies';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([User, UserNotificationSettings, Device]), JwtModule.register({})], imports: [
TypeOrmModule.forFeature([User, Device]),
JwtModule.register({}),
forwardRef(() => CustomerModule),
forwardRef(() => JuniorModule),
],
providers: [AuthService, UserRepository, UserService, DeviceService, DeviceRepository, AccessTokenStrategy], providers: [AuthService, UserRepository, UserService, DeviceService, DeviceRepository, AccessTokenStrategy],
controllers: [AuthController], controllers: [AuthController],
exports: [UserService], exports: [UserService, DeviceService],
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -1,7 +1,8 @@
import { Body, Controller, Headers, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Headers, HttpCode, HttpStatus, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { DEVICE_ID_HEADER } from '~/common/constants'; import { DEVICE_ID_HEADER } from '~/common/constants';
import { AuthenticatedUser } from '~/common/decorators'; import { AuthenticatedUser, Public } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards'; import { AccessTokenGuard } from '~/common/guards';
import { ResponseFactory } from '~/core/utils'; import { ResponseFactory } from '~/core/utils';
import { import {
@ -10,8 +11,10 @@ import {
EnableBiometricRequestDto, EnableBiometricRequestDto,
ForgetPasswordRequestDto, ForgetPasswordRequestDto,
LoginRequestDto, LoginRequestDto,
RefreshTokenRequestDto,
SendForgetPasswordOtpRequestDto, SendForgetPasswordOtpRequestDto,
SetEmailRequestDto, SetEmailRequestDto,
setJuniorPasswordRequestDto,
SetPasscodeRequestDto, SetPasscodeRequestDto,
VerifyUserRequestDto, VerifyUserRequestDto,
} from '../dtos/request'; } from '../dtos/request';
@ -77,9 +80,30 @@ export class AuthController {
return this.authService.verifyForgetPasswordOtp(forgetPasswordDto); return this.authService.verifyForgetPasswordOtp(forgetPasswordDto);
} }
@Post('junior/set-passcode')
@HttpCode(HttpStatus.NO_CONTENT)
@Public()
setJuniorPasscode(@Body() setPasscodeDto: setJuniorPasswordRequestDto) {
return this.authService.setJuniorPasscode(setPasscodeDto);
}
@Post('refresh-token')
@Public()
async refreshToken(@Body() { refreshToken }: RefreshTokenRequestDto) {
const [res, user] = await this.authService.refreshToken(refreshToken);
return ResponseFactory.data(new LoginResponseDto(res, user));
}
@Post('login') @Post('login')
async login(@Body() loginDto: LoginRequestDto, @Headers(DEVICE_ID_HEADER) deviceId: string) { async login(@Body() loginDto: LoginRequestDto, @Headers(DEVICE_ID_HEADER) deviceId: string) {
const [res, user] = await this.authService.login(loginDto, deviceId); const [res, user] = await this.authService.login(loginDto, deviceId);
return ResponseFactory.data(new LoginResponseDto(res, user)); return ResponseFactory.data(new LoginResponseDto(res, user));
} }
@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(AccessTokenGuard)
async logout(@Req() request: Request) {
await this.authService.logout(request);
}
} }

View File

@ -3,7 +3,9 @@ export * from './disable-biometric.request.dto';
export * from './enable-biometric.request.dto'; export * from './enable-biometric.request.dto';
export * from './forget-password.request.dto'; export * from './forget-password.request.dto';
export * from './login.request.dto'; export * from './login.request.dto';
export * from './refresh-token.request.dto';
export * from './send-forget-password-otp.request.dto'; export * from './send-forget-password-otp.request.dto';
export * from './set-email.request.dto'; export * from './set-email.request.dto';
export * from './set-junior-password.request.dto';
export * from './set-passcode.request.dto'; export * from './set-passcode.request.dto';
export * from './verify-user.request.dto'; export * from './verify-user.request.dto';

View File

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsEnum, IsString, ValidateIf } from 'class-validator'; import { IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, ValidateIf } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n'; import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { GrantType } from '~/auth/enums'; import { GrantType } from '~/auth/enums';
export class LoginRequestDto { export class LoginRequestDto {
@ -17,8 +17,14 @@ export class LoginRequestDto {
@ValidateIf((o) => o.grantType === GrantType.PASSWORD) @ValidateIf((o) => o.grantType === GrantType.PASSWORD)
password!: string; password!: string;
@ApiProperty({ example: 'device-token' }) @ApiProperty({ example: 'fcm-device-token' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceToken' }) }) @IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.fcmToken' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.fcmToken' }) })
@IsOptional()
fcmToken?: string;
@ApiProperty({ example: 'Login signature' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.signature' }) })
@ValidateIf((o) => o.grantType === GrantType.BIOMETRIC) @ValidateIf((o) => o.grantType === GrantType.BIOMETRIC)
deviceToken!: string; signature!: string;
} }

View File

@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class RefreshTokenRequestDto {
@ApiProperty()
@IsString({ message: i18n('validation.isString', { path: 'general', property: 'auth.refreshToken' }) })
@IsNotEmpty({ message: i18n('validation.required', { path: 'general', property: 'auth.refreshToken' }) })
refreshToken!: string;
}

View File

@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { SetPasscodeRequestDto } from './set-passcode.request.dto';
export class setJuniorPasswordRequestDto extends SetPasscodeRequestDto {
@ApiProperty()
@IsString({ message: i18n('validation.IsNumberString', { path: 'general', property: 'auth.qrToken' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'auth.qrToken' }) })
qrToken!: string;
}

View File

@ -15,6 +15,9 @@ export class Device {
@Column('varchar', { name: 'public_key', nullable: true }) @Column('varchar', { name: 'public_key', nullable: true })
publicKey?: string | null; publicKey?: string | null;
@Column('varchar', { name: 'fcm_token', nullable: true })
fcmToken?: string | null;
@Column('timestamp with time zone', { name: 'last_access_on', default: () => 'CURRENT_TIMESTAMP' }) @Column('timestamp with time zone', { name: 'last_access_on', default: () => 'CURRENT_TIMESTAMP' })
lastAccessOn!: Date; lastAccessOn!: Date;

View File

@ -1,3 +1,2 @@
export * from './device.entity'; export * from './device.entity';
export * from './user-notification-settings.entity';
export * from './user.entity'; export * from './user.entity';

View File

@ -3,18 +3,16 @@ import {
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
JoinColumn,
OneToMany, OneToMany,
OneToOne, OneToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { Notification } from '~/common/modules/notification/entities';
import { Otp } from '~/common/modules/otp/entities'; import { Otp } from '~/common/modules/otp/entities';
import { Customer } from '~/customer/entities/customer.entity'; import { Customer } from '~/customer/entities/customer.entity';
import { Document } from '~/document/entities';
import { Roles } from '../enums'; import { Roles } from '../enums';
import { Device } from './device.entity'; import { Device } from './device.entity';
import { UserNotificationSettings } from './user-notification-settings.entity';
@Entity('users') @Entity('users')
export class User extends BaseEntity { export class User extends BaseEntity {
@ -48,28 +46,18 @@ export class User extends BaseEntity {
@Column('text', { nullable: true, array: true, name: 'roles' }) @Column('text', { nullable: true, array: true, name: 'roles' })
roles!: Roles[]; roles!: Roles[];
@Column('varchar', { name: 'profile_picture_id', nullable: true })
profilePictureId!: string;
@OneToOne(() => Document, (document) => document.user, { cascade: true, nullable: true })
@JoinColumn({ name: 'profile_picture_id' })
profilePicture!: Document;
@OneToMany(() => Otp, (otp) => otp.user) @OneToMany(() => Otp, (otp) => otp.user)
otp!: Otp[]; otp!: Otp[];
@OneToOne(() => UserNotificationSettings, (notificationSettings) => notificationSettings.user, {
cascade: true,
eager: true,
})
notificationSettings!: UserNotificationSettings;
@OneToOne(() => Customer, (customer) => customer.user, { cascade: true, eager: true }) @OneToOne(() => Customer, (customer) => customer.user, { cascade: true, eager: true })
customer!: Customer; customer!: Customer;
@OneToMany(() => Device, (device) => device.user) @OneToMany(() => Device, (device) => device.user)
devices!: Device[]; devices!: Device[];
@OneToMany(() => Notification, (notification) => notification.user)
notifications!: Notification[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt!: Date; createdAt!: Date;

View File

@ -1,4 +1,6 @@
import { Roles } from '../enums';
export interface IJwtPayload { export interface IJwtPayload {
sub: string; sub: string;
roles: string[]; roles: Roles[];
} }

View File

@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { IsNull, Not, Repository } from 'typeorm';
import { Device } from '../entities'; import { Device } from '../entities';
@Injectable() @Injectable()
@ -16,6 +16,10 @@ export class DeviceRepository {
} }
updateDevice(deviceId: string, data: Partial<Device>) { updateDevice(deviceId: string, data: Partial<Device>) {
return this.deviceRepository.update({ deviceId }, data); return this.deviceRepository.save({ deviceId, ...data });
}
getTokens(userId: string) {
return this.deviceRepository.find({ where: { userId, fcmToken: Not(IsNull()) }, select: ['fcmToken'] });
} }
} }

View File

@ -1,9 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, Repository } from 'typeorm'; import { FindOptionsWhere, Repository } from 'typeorm';
import { UpdateNotificationsSettingsRequestDto } from '~/customer/dtos/request'; import { User } from '../entities';
import { Customer } from '~/customer/entities';
import { User, UserNotificationSettings } from '../entities';
@Injectable() @Injectable()
export class UserRepository { export class UserRepository {
@ -15,27 +13,23 @@ export class UserRepository {
phoneNumber: data.phoneNumber, phoneNumber: data.phoneNumber,
countryCode: data.countryCode, countryCode: data.countryCode,
roles: data.roles, roles: data.roles,
notificationSettings: UserNotificationSettings.create(),
}), }),
); );
} }
findOne(where: FindOptionsWhere<User>) { findOne(where: FindOptionsWhere<User> | FindOptionsWhere<User>[]) {
return this.userRepository.findOne({ where }); return this.userRepository.findOne({ where });
} }
updateNotificationSettings(user: User, body: UpdateNotificationsSettingsRequestDto) {
user.notificationSettings = UserNotificationSettings.create({ ...user.notificationSettings, ...body });
return this.userRepository.save(user);
}
verifyUserAndCreateCustomer(user: User) {
user.customer = Customer.create({ ...user.customer, id: user.id, isGuardian: true });
return this.userRepository.save(user);
}
update(userId: string, data: Partial<User>) { update(userId: string, data: Partial<User>) {
return this.userRepository.update(userId, data); return this.userRepository.update(userId, data);
} }
createUser(data: Partial<User>) {
const user = this.userRepository.create({
...data,
});
return this.userRepository.save(user);
}
} }

View File

@ -2,8 +2,11 @@ import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { Request } from 'express';
import { CacheService } from '~/common/modules/cache/services';
import { OtpScope, OtpType } from '~/common/modules/otp/enums'; import { OtpScope, OtpType } from '~/common/modules/otp/enums';
import { OtpService } from '~/common/modules/otp/services'; import { OtpService } from '~/common/modules/otp/services';
import { JuniorTokenService } from '~/junior/services';
import { PASSCODE_REGEX, PASSWORD_REGEX } from '../constants'; import { PASSCODE_REGEX, PASSWORD_REGEX } from '../constants';
import { import {
CreateUnverifiedUserRequestDto, CreateUnverifiedUserRequestDto,
@ -13,11 +16,12 @@ import {
LoginRequestDto, LoginRequestDto,
SendForgetPasswordOtpRequestDto, SendForgetPasswordOtpRequestDto,
SetEmailRequestDto, SetEmailRequestDto,
setJuniorPasswordRequestDto,
} from '../dtos/request'; } from '../dtos/request';
import { VerifyUserRequestDto } from '../dtos/request/verify-user.request.dto'; import { VerifyUserRequestDto } from '../dtos/request/verify-user.request.dto';
import { User } from '../entities'; import { User } from '../entities';
import { GrantType, Roles } from '../enums'; import { GrantType, Roles } from '../enums';
import { ILoginResponse } from '../interfaces'; import { IJwtPayload, ILoginResponse } from '../interfaces';
import { removePadding, verifySignature } from '../utils'; import { removePadding, verifySignature } from '../utils';
import { DeviceService } from './device.service'; import { DeviceService } from './device.service';
import { UserService } from './user.service'; import { UserService } from './user.service';
@ -32,6 +36,8 @@ export class AuthService {
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly userService: UserService, private readonly userService: UserService,
private readonly deviceService: DeviceService, private readonly deviceService: DeviceService,
private readonly juniorTokenService: JuniorTokenService,
private readonly cacheService: CacheService,
) {} ) {}
async sendRegisterOtp({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) { async sendRegisterOtp({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) {
const user = await this.userService.findOrCreateUser({ phoneNumber, countryCode }); const user = await this.userService.findOrCreateUser({ phoneNumber, countryCode });
@ -181,11 +187,45 @@ export class AuthService {
tokens = await this.loginWithBiometric(loginDto, user, deviceId); tokens = await this.loginWithBiometric(loginDto, user, deviceId);
} }
this.deviceService.updateDevice(deviceId, { lastAccessOn: new Date() }); this.deviceService.updateDevice(deviceId, {
lastAccessOn: new Date(),
fcmToken: loginDto.fcmToken,
userId: user.id,
});
return [tokens, user]; return [tokens, user];
} }
async setJuniorPasscode(body: setJuniorPasswordRequestDto) {
const juniorId = await this.juniorTokenService.validateToken(body.qrToken);
const salt = bcrypt.genSaltSync(SALT_ROUNDS);
const hashedPasscode = bcrypt.hashSync(body.passcode, salt);
await this.userService.setPasscode(juniorId, hashedPasscode, salt);
await this.juniorTokenService.invalidateToken(body.qrToken);
}
async refreshToken(refreshToken: string): Promise<[ILoginResponse, User]> {
try {
const isValid = await this.jwtService.verifyAsync<IJwtPayload>(refreshToken, {
secret: this.configService.getOrThrow('JWT_REFRESH_TOKEN_SECRET'),
});
const user = await this.userService.findUserOrThrow({ id: isValid.sub });
const tokens = await this.generateAuthToken(user);
return [tokens, user];
} catch (error) {
throw new BadRequestException('AUTH.INVALID_REFRESH_TOKEN');
}
}
logout(req: Request) {
const accessToken = req.headers.authorization?.split(' ')[1] as string;
const expiryInTtl = this.jwtService.decode(accessToken).exp - Date.now() / ONE_THOUSAND;
return this.cacheService.set(accessToken, 'LOGOUT', expiryInTtl);
}
private async loginWithPassword(loginDto: LoginRequestDto, user: User): Promise<ILoginResponse> { private async loginWithPassword(loginDto: LoginRequestDto, user: User): Promise<ILoginResponse> {
const isPasswordValid = bcrypt.compareSync(loginDto.password, user.password); const isPasswordValid = bcrypt.compareSync(loginDto.password, user.password);
@ -209,7 +249,7 @@ export class AuthService {
throw new UnauthorizedException('AUTH.BIOMETRIC_NOT_ENABLED'); throw new UnauthorizedException('AUTH.BIOMETRIC_NOT_ENABLED');
} }
const cleanToken = removePadding(loginDto.deviceToken); const cleanToken = removePadding(loginDto.signature);
const isValidToken = await verifySignature( const isValidToken = await verifySignature(
device.publicKey, device.publicKey,
cleanToken, cleanToken,

View File

@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Device } from '../entities'; import { Device } from '../entities';
import { DeviceRepository } from '../repositories'; import { DeviceRepository } from '../repositories';
@Injectable() @Injectable()
export class DeviceService { export class DeviceService {
constructor(private readonly deviceRepository: DeviceRepository) {} constructor(private readonly deviceRepository: DeviceRepository) {}
@ -16,4 +15,10 @@ export class DeviceService {
updateDevice(deviceId: string, data: Partial<Device>) { updateDevice(deviceId: string, data: Partial<Device>) {
return this.deviceRepository.updateDevice(deviceId, data); return this.deviceRepository.updateDevice(deviceId, data);
} }
async getTokens(userId: string): Promise<string[]> {
const devices = await this.deviceRepository.getTokens(userId);
return devices.map((device) => device.fcmToken!);
}
} }

View File

@ -1,6 +1,8 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm'; import { FindOptionsWhere } from 'typeorm';
import { UpdateNotificationsSettingsRequestDto } from '~/customer/dtos/request'; import { CustomerNotificationSettings } from '~/customer/entities/customer-notification-settings.entity';
import { CustomerService } from '~/customer/services';
import { Guardian } from '~/guardian/entities/guradian.entity';
import { CreateUnverifiedUserRequestDto } from '../dtos/request'; import { CreateUnverifiedUserRequestDto } from '../dtos/request';
import { User } from '../entities'; import { User } from '../entities';
import { Roles } from '../enums'; import { Roles } from '../enums';
@ -8,18 +10,12 @@ import { UserRepository } from '../repositories';
@Injectable() @Injectable()
export class UserService { export class UserService {
constructor(private readonly userRepository: UserRepository) {} constructor(
private readonly userRepository: UserRepository,
@Inject(forwardRef(() => CustomerService)) private readonly customerService: CustomerService,
) {}
async updateNotificationSettings(userId: string, body: UpdateNotificationsSettingsRequestDto) { findUser(where: FindOptionsWhere<User> | FindOptionsWhere<User>[]) {
const user = await this.findUserOrThrow({ id: userId });
const notificationSettings = (await this.userRepository.updateNotificationSettings(user, body))
.notificationSettings;
return notificationSettings;
}
findUser(where: FindOptionsWhere<User>) {
return this.userRepository.findOne(where); return this.userRepository.findOne(where);
} }
@ -44,12 +40,19 @@ export class UserService {
} }
if (user && user.roles.includes(Roles.JUNIOR)) { if (user && user.roles.includes(Roles.JUNIOR)) {
throw new BadRequestException('USERS.JUNIOR_UPGRADE_NOT_SUPPORTED_YET');
//TODO add role Guardian to the existing user and send OTP //TODO add role Guardian to the existing user and send OTP
} }
return user; return user;
} }
async createUser(data: Partial<User>) {
const user = await this.userRepository.createUser(data);
return user;
}
setEmail(userId: string, email: string) { setEmail(userId: string, email: string) {
return this.userRepository.update(userId, { email }); return this.userRepository.update(userId, { email });
} }
@ -58,7 +61,15 @@ export class UserService {
return this.userRepository.update(userId, { password: passcode, salt, isProfileCompleted: true }); return this.userRepository.update(userId, { password: passcode, salt, isProfileCompleted: true });
} }
verifyUserAndCreateCustomer(user: User) { async verifyUserAndCreateCustomer(user: User) {
return this.userRepository.verifyUserAndCreateCustomer(user); await this.customerService.createCustomer(
{
guardian: Guardian.create({ id: user.id }),
notificationSettings: new CustomerNotificationSettings(),
},
user,
);
return this.findUserOrThrow({ id: user.id });
} }
} }

View File

@ -0,0 +1,5 @@
import { SetMetadata } from '@nestjs/common';
import { Roles } from '~/auth/enums';
export const ROLE_METADATA_KEY = 'roles';
export const AllowedRoles = (...roles: Roles[]) => SetMetadata(ROLE_METADATA_KEY, roles);

View File

@ -1,2 +1,3 @@
export * from './allowed-roles.decorator';
export * from './public.decorator'; export * from './public.decorator';
export * from './user.decorator'; export * from './user.decorator';

View File

@ -1,15 +1,15 @@
import { ExecutionContext, Injectable } from '@nestjs/common'; import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';
import { IS_PUBLIC_KEY } from '../decorators'; import { IS_PUBLIC_KEY } from '../decorators';
import { CacheService } from '../modules/cache/services';
@Injectable() @Injectable()
export class AccessTokenGuard extends AuthGuard('access-token') { export class AccessTokenGuard extends AuthGuard('access-token') {
constructor(private reflector: Reflector) { constructor(protected reflector: Reflector, private readonly cacheService: CacheService) {
super(); super();
} }
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> { async canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(), context.getHandler(),
context.getClass(), context.getClass(),
@ -18,6 +18,17 @@ export class AccessTokenGuard extends AuthGuard('access-token') {
if (isPublic) { if (isPublic) {
return true; return true;
} }
return super.canActivate(context);
await super.canActivate(context);
const token = context.switchToHttp().getRequest().headers['authorization']?.split(' ')[1];
const isRevoked = await this.cacheService.get(token);
if (isRevoked) {
throw new UnauthorizedException();
}
return true;
} }
} }

View File

@ -1 +1,2 @@
export * from './access-token.guard'; export * from './access-token.guard';
export * from './roles-guard';

View File

@ -0,0 +1,28 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Roles } from '~/auth/enums';
import { ROLE_METADATA_KEY } from '../decorators';
import { AccessTokenGuard } from './access-token.guard';
@Injectable()
export class RolesGuard extends AccessTokenGuard {
async canActivate(context: ExecutionContext): Promise<boolean> {
await super.canActivate(context);
const request = context.switchToHttp().getRequest();
const user = request.user;
if (!user) {
return false;
}
const allowedRoles = this.reflector.getAllAndOverride<Roles[]>(ROLE_METADATA_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!allowedRoles) {
return true;
}
return allowedRoles.some((role) => user.roles.includes(role));
}
}

View File

@ -0,0 +1,18 @@
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { buildKeyvOptions } from '~/core/module-options';
import { CacheService } from './services';
@Module({
providers: [
{
provide: 'CACHE_INSTANCE',
useFactory: (config: ConfigService) => buildKeyvOptions(config),
inject: [ConfigService],
},
CacheService,
],
exports: ['CACHE_INSTANCE', CacheService],
})
@Global()
export class CacheModule {}

View File

@ -0,0 +1,19 @@
import { Inject, Injectable } from '@nestjs/common';
import { Cacheable } from 'cacheable';
@Injectable()
export class CacheService {
constructor(@Inject('CACHE_INSTANCE') private readonly cache: Cacheable) {}
get<T>(key: string): Promise<T | undefined> {
return this.cache.get(key);
}
async set<T>(key: string, value: T, ttl?: number | string): Promise<void> {
await this.cache.set(key, value, ttl);
}
async delete(key: string): Promise<void> {
await this.cache.delete(key);
}
}

View File

@ -0,0 +1 @@
export * from './cache.services';

View File

@ -0,0 +1 @@
export * from './lookup.controller';

View File

@ -0,0 +1,32 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { AccessTokenGuard } from '~/common/guards';
import { ApiDataArrayResponse } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import { DocumentMetaResponseDto } from '~/document/dtos/response';
import { LookupService } from '../services';
@Controller('lookup')
@ApiTags('Lookups')
@ApiBearerAuth()
export class LookupController {
constructor(private readonly lookupService: LookupService) {}
@UseGuards(AccessTokenGuard)
@Get('default-avatars')
@ApiDataArrayResponse(DocumentMetaResponseDto)
async findDefaultAvatars() {
const avatars = await this.lookupService.findDefaultAvatar();
return ResponseFactory.dataArray(avatars.map((avatar) => new DocumentMetaResponseDto(avatar)));
}
@UseGuards(AccessTokenGuard)
@Get('default-task-logos')
@ApiDataArrayResponse(DocumentMetaResponseDto)
async findDefaultTaskLogos() {
const avatars = await this.lookupService.findDefaultTasksLogo();
return ResponseFactory.dataArray(avatars.map((avatar) => new DocumentMetaResponseDto(avatar)));
}
}

View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { DocumentModule } from '~/document/document.module';
import { LookupController } from './controllers';
import { LookupService } from './services';
@Module({
controllers: [LookupController],
providers: [LookupService],
imports: [DocumentModule],
})
export class LookupModule {}

View File

@ -0,0 +1 @@
export * from './lookup.service';

View File

@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import { DocumentType } from '~/document/enums';
import { DocumentService, OciService } from '~/document/services';
@Injectable()
export class LookupService {
constructor(private readonly documentService: DocumentService, private readonly ociService: OciService) {}
async findDefaultAvatar() {
const documents = await this.documentService.findDocuments({ documentType: DocumentType.DEFAULT_AVATAR });
await Promise.all(
documents.map(async (document) => {
document.url = await this.ociService.generatePreSignedUrl(document);
}),
);
return documents;
}
async findDefaultTasksLogo() {
const documents = await this.documentService.findDocuments({ documentType: DocumentType.DEFAULT_TASKS_LOGO });
await Promise.all(
documents.map(async (document) => {
document.url = await this.ociService.generatePreSignedUrl(document);
}),
);
return documents;
}
}

View File

@ -0,0 +1 @@
export * from './notifications.controller';

View File

@ -0,0 +1,36 @@
import { Controller, Get, HttpCode, HttpStatus, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiResponse, ApiTags } from '@nestjs/swagger';
import { IJwtPayload } from '~/auth/interfaces';
import { AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards';
import { PageOptionsRequestDto } from '~/core/dtos';
import { NotificationsPageResponseDto } from '../dtos/response';
import { NotificationsService } from '../services/notifications.service';
@Controller('notifications')
@ApiTags('Notifications')
@ApiBearerAuth()
export class NotificationsController {
constructor(private readonly notificationsService: NotificationsService) {}
@Get()
@UseGuards(AccessTokenGuard)
@ApiResponse({ type: NotificationsPageResponseDto })
async getNotifications(@AuthenticatedUser() { sub }: IJwtPayload, @Query() pageOptionsDto: PageOptionsRequestDto) {
const { notifications, count, unreadCount } = await this.notificationsService.getNotifications(sub, pageOptionsDto);
return new NotificationsPageResponseDto(notifications, {
itemCount: count,
unreadCount,
page: pageOptionsDto.page,
size: pageOptionsDto.size,
});
}
@Post('mark-as-read')
@UseGuards(AccessTokenGuard)
@HttpCode(HttpStatus.NO_CONTENT)
markAsRead(@AuthenticatedUser() { sub }: IJwtPayload) {
return this.notificationsService.markAsRead(sub);
}
}

View File

@ -0,0 +1,2 @@
export * from './notifications-page.response.dto';
export * from './notifications.response.dto';

View File

@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { PageMetaResponseDto } from '~/core/dtos';
import { INotificationPageMeta } from '../../interfaces';
export class NotificationMetaResponseDto extends PageMetaResponseDto {
@ApiProperty({ example: 4 })
readonly unreadCount!: number;
constructor(meta: INotificationPageMeta) {
super(meta);
this.unreadCount = meta.unreadCount;
}
}

View File

@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { Notification } from '../../entities';
import { INotificationPageMeta } from '../../interfaces';
import { NotificationMetaResponseDto } from './notifications-meta.response.dto';
import { NotificationsResponseDto } from './notifications.response.dto';
export class NotificationsPageResponseDto {
@ApiProperty({ type: [NotificationsResponseDto] })
data!: NotificationsResponseDto[];
@ApiProperty({ type: NotificationMetaResponseDto })
meta: INotificationPageMeta;
constructor(data: Notification[], meta: INotificationPageMeta) {
this.data = data.map((notification) => new NotificationsResponseDto(notification));
this.meta = new NotificationMetaResponseDto(meta);
}
}

View File

@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { Notification } from '../../entities';
import { NotificationStatus } from '../../enums';
export class NotificationsResponseDto {
@ApiProperty({ example: 'f1b1b9b1-1b1b-1b1b-1b1b-1b1b1b1b1b1b' })
id!: string;
@ApiProperty({ example: 'Test' })
title: string;
@ApiProperty({ example: 'TestBody' })
body: string;
@ApiProperty({ example: NotificationStatus.UNREAD })
status!: NotificationStatus;
@ApiProperty({ example: '2021-09-01T00:00:00.000Z' })
createdAt!: Date;
constructor(notification: Notification) {
this.id = notification.id;
this.title = notification.title;
this.body = notification.message;
this.status = notification.status!;
this.createdAt = notification.createdAt;
}
}

View File

@ -0,0 +1 @@
export * from './notification.entity';

View File

@ -0,0 +1,48 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from '~/auth/entities';
import { NotificationChannel, NotificationScope, NotificationStatus } from '../enums';
@Entity('notifications')
export class Notification {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column('varchar', { name: 'title' })
title!: string;
@Column('varchar', { name: 'message' })
message!: string;
@Column('varchar', { name: 'recipient', nullable: true })
recipient?: string | null;
@Column('varchar', { name: 'scope' })
scope!: NotificationScope;
@Column('varchar', { name: 'status', nullable: true })
status!: NotificationStatus | null;
@Column('varchar', { name: 'channel' })
channel!: NotificationChannel;
@Column('uuid', { name: 'user_id', nullable: true })
userId!: string;
@ManyToOne(() => User, (user) => user.notifications, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'user_id' })
user!: User;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date;
}

View File

@ -0,0 +1,3 @@
export * from './notification-channel.enum';
export * from './notification-scope.enum';
export * from './notification-status.enum';

View File

@ -0,0 +1,5 @@
export enum NotificationChannel {
EMAIL = 'EMAIL',
SMS = 'SMS',
PUSH = 'PUSH',
}

View File

@ -0,0 +1,5 @@
export enum NotificationScope {
USER_REGISTERED = 'USER_REGISTERED',
TASK_COMPLETED = 'TASK_COMPLETED',
GIFT_RECEIVED = 'GIFT_RECEIVED',
}

View File

@ -0,0 +1,4 @@
export enum NotificationStatus {
READ = 'READ',
UNREAD = 'UNREAD',
}

View File

@ -0,0 +1 @@
export * from './notification-page-meta.interface';

View File

@ -0,0 +1,5 @@
import { IPageMeta } from '~/core/dtos';
export interface INotificationPageMeta extends IPageMeta {
unreadCount: number;
}

View File

@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TwilioModule } from 'nestjs-twilio';
import { AuthModule } from '~/auth/auth.module';
import { buildTwilioOptions } from '~/core/module-options';
import { NotificationsController } from './controllers';
import { Notification } from './entities';
import { NotificationsRepository } from './repositories';
import { FirebaseService } from './services/firebase.service';
import { NotificationsService } from './services/notifications.service';
import { TwilioService } from './services/twilio.service';
@Module({
imports: [
TypeOrmModule.forFeature([Notification]),
AuthModule,
TwilioModule.forRootAsync({
useFactory: buildTwilioOptions,
inject: [ConfigService],
}),
],
providers: [NotificationsService, FirebaseService, NotificationsRepository, TwilioService],
exports: [NotificationsService],
controllers: [NotificationsController],
})
export class NotificationModule {}

View File

@ -0,0 +1 @@
export * from './notifications.repository';

View File

@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { PageOptionsRequestDto } from '~/core/dtos';
import { Notification } from '../entities';
import { NotificationChannel, NotificationStatus } from '../enums';
const ONE = 1;
@Injectable()
export class NotificationsRepository {
constructor(@InjectRepository(Notification) private readonly notificationsRepository: Repository<Notification>) {}
getNotifications(userId: string, pageOptionsDto: PageOptionsRequestDto) {
const readQuery = this.notificationsRepository.createQueryBuilder('notification');
readQuery.where('notification.userId = :userId', { userId });
readQuery.andWhere('notification.channel = :channel', { channel: NotificationChannel.PUSH });
readQuery.orderBy('notification.createdAt', 'DESC');
readQuery.skip((pageOptionsDto.page - ONE) * pageOptionsDto.size);
readQuery.take(pageOptionsDto.size);
return readQuery.getManyAndCount();
}
getUnreadNotificationsCount(userId: string) {
return this.notificationsRepository.count({ where: { userId, status: NotificationStatus.UNREAD } });
}
markAsRead(userId: string) {
return this.notificationsRepository.update(
{ userId, status: NotificationStatus.UNREAD },
{ status: NotificationStatus.READ },
);
}
createNotification(notification: Partial<Notification>) {
return this.notificationsRepository.save(notification);
}
}

View File

@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as admin from 'firebase-admin';
@Injectable()
export class FirebaseService {
constructor(private readonly configService: ConfigService) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: this.configService.get('FIREBASE_PROJECT_ID'),
clientEmail: this.configService.get('FIREBASE_CLIENT_EMAIL'),
privateKey: this.configService.get('FIREBASE_PRIVATE_KEY').replace(/\\n/g, '\n'),
}),
});
}
sendNotification(tokens: string | string[], title: string, body: string) {
const message = {
notification: {
title,
body,
},
tokens: Array.isArray(tokens) ? tokens : [tokens],
};
admin.messaging().sendEachForMulticast(message);
}
}

View File

@ -0,0 +1,50 @@
import { Injectable } from '@nestjs/common';
import { DeviceService } from '~/auth/services';
import { PageOptionsRequestDto } from '~/core/dtos';
import { Notification } from '../entities';
import { NotificationsRepository } from '../repositories';
import { FirebaseService } from './firebase.service';
import { TwilioService } from './twilio.service';
@Injectable()
export class NotificationsService {
constructor(
private readonly deviceService: DeviceService,
private readonly firebaseService: FirebaseService,
private readonly notificationRepository: NotificationsRepository,
private readonly twilioService: TwilioService,
) {}
async sendPushNotification(userId: string, title: string, body: string) {
// Get the device tokens for the user
const tokens = await this.deviceService.getTokens(userId);
if (!tokens.length) {
return;
}
// Send the notification
return this.firebaseService.sendNotification(tokens, title, body);
}
async sendSMS(to: string, body: string) {
await this.twilioService.sendSMS(to, body);
}
async getNotifications(userId: string, pageOptionsDto: PageOptionsRequestDto) {
const [[notifications, count], unreadCount] = await Promise.all([
this.notificationRepository.getNotifications(userId, pageOptionsDto),
this.notificationRepository.getUnreadNotificationsCount(userId),
]);
return { notifications, count, unreadCount };
}
createNotification(notification: Partial<Notification>) {
return this.notificationRepository.createNotification(notification);
}
markAsRead(userId: string) {
return this.notificationRepository.markAsRead(userId);
}
}

View File

@ -0,0 +1,21 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TwilioService as TwilioApiService } from 'nestjs-twilio';
import { Environment } from '~/core/enums';
@Injectable()
export class TwilioService {
private logger = new Logger(TwilioService.name);
constructor(private readonly twilioService: TwilioApiService, private readonly configService: ConfigService) {}
private from = this.configService.getOrThrow('TWILIO_PHONE_NUMBER');
sendSMS(to: string, body: string) {
if (this.configService.get('NODE_ENV') === Environment.DEV) {
this.logger.log(`Skipping SMS sending in DEV environment. Message: ${body} to: ${to}`);
return;
}
return this.twilioService.client.messages.create({
body,
to,
from: this.from,
});
}
}

View File

@ -24,7 +24,6 @@ export class AllExceptionsFilter implements ExceptionFilter {
const httpCtx: HttpArgumentsHost = host.switchToHttp(); const httpCtx: HttpArgumentsHost = host.switchToHttp();
const res = httpCtx.getResponse<ExpressResponse>(); const res = httpCtx.getResponse<ExpressResponse>();
const i18n = new I18nContextWrapper(I18nContext.current()); const i18n = new I18nContextWrapper(I18nContext.current());
try { try {
const status = this.extractStatusCode(exception); const status = this.extractStatusCode(exception);

View File

@ -1,3 +1,5 @@
export * from '././keyv-options';
export * from './config-options'; export * from './config-options';
export * from './typeorm-options';
export * from './logger-options'; export * from './logger-options';
export * from './twilio-options';
export * from './typeorm-options';

View File

@ -0,0 +1,8 @@
import KeyvRedis from '@keyv/redis';
import { ConfigService } from '@nestjs/config';
import { Cacheable } from 'cacheable';
export function buildKeyvOptions(config: ConfigService) {
const secondary = new KeyvRedis(config.get('REDIS_URL'));
return new Cacheable({ secondary, ttl: config.get('REDIS_TTL') });
}

View File

@ -0,0 +1,9 @@
import { ConfigService } from '@nestjs/config';
import { TwilioModuleOptions } from 'nestjs-twilio';
export function buildTwilioOptions(config: ConfigService): TwilioModuleOptions {
return {
accountSid: config.get('TWILIO_ACCOUNT_SID'),
authToken: config.get('TWILIO_AUTH_TOKEN'),
};
}

1
src/core/types/index.ts Normal file
View File

@ -0,0 +1 @@
export * from './object-values.type';

View File

@ -0,0 +1,13 @@
/**
* Converts an object's values to a union type.
* Should be used in conjunction with constant objects to define enums.
*
* @example
* const Status = { SUCCEEDED: 'Success', FAILED: 'Failure' } as const;
* type Status = ObjectValues<typeof Status>; // 'Success' | 'Failure'
* const foo: Status = Status.SUCCEEDED;
* const bar: Status = 'Failure';
*
* @see https://youtu.be/jjMbPt_H3RQ
*/
export type ObjectValues<T> = T[keyof T];

View File

@ -1,8 +1,10 @@
import { Body, Controller, Patch, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Headers, Patch, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { IJwtPayload } from '~/auth/interfaces'; import { IJwtPayload } from '~/auth/interfaces';
import { DEVICE_ID_HEADER } from '~/common/constants';
import { AuthenticatedUser } from '~/common/decorators'; import { AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards'; import { AccessTokenGuard } from '~/common/guards';
import { ApiDataResponse } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils'; import { ResponseFactory } from '~/core/utils';
import { UpdateCustomerRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request'; import { UpdateCustomerRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request';
import { CustomerResponseDto, NotificationSettingsResponseDto } from '../dtos/response'; import { CustomerResponseDto, NotificationSettingsResponseDto } from '../dtos/response';
@ -11,11 +13,21 @@ import { CustomerService } from '../services';
@Controller('customers') @Controller('customers')
@ApiTags('Customers') @ApiTags('Customers')
@ApiBearerAuth() @ApiBearerAuth()
@UseGuards(AccessTokenGuard)
export class CustomerController { export class CustomerController {
constructor(private readonly customerService: CustomerService) {} constructor(private readonly customerService: CustomerService) {}
@Get('/profile')
@UseGuards(AccessTokenGuard)
@ApiDataResponse(CustomerResponseDto)
async getCustomerProfile(@AuthenticatedUser() { sub }: IJwtPayload) {
const customer = await this.customerService.findCustomerById(sub);
return ResponseFactory.data(new CustomerResponseDto(customer));
}
@Patch('') @Patch('')
@UseGuards(AccessTokenGuard)
@ApiDataResponse(CustomerResponseDto)
async updateCustomer(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: UpdateCustomerRequestDto) { async updateCustomer(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: UpdateCustomerRequestDto) {
const customer = await this.customerService.updateCustomer(sub, body); const customer = await this.customerService.updateCustomer(sub, body);
@ -23,11 +35,14 @@ export class CustomerController {
} }
@Patch('settings/notifications') @Patch('settings/notifications')
@UseGuards(AccessTokenGuard)
@ApiDataResponse(NotificationSettingsResponseDto)
async updateNotificationSettings( async updateNotificationSettings(
@AuthenticatedUser() { sub }: IJwtPayload, @AuthenticatedUser() { sub }: IJwtPayload,
@Body() body: UpdateNotificationsSettingsRequestDto, @Body() body: UpdateNotificationsSettingsRequestDto,
@Headers(DEVICE_ID_HEADER) deviceId: string,
) { ) {
const notificationSettings = await this.customerService.updateNotificationSettings(sub, body); const notificationSettings = await this.customerService.updateNotificationSettings(sub, body, deviceId);
return ResponseFactory.data(new NotificationSettingsResponseDto(notificationSettings)); return ResponseFactory.data(new NotificationSettingsResponseDto(notificationSettings));
} }

View File

@ -1,14 +1,16 @@
import { Module } from '@nestjs/common'; import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from '~/auth/auth.module'; import { AuthModule } from '~/auth/auth.module';
import { CustomerController } from './controllers'; import { CustomerController } from './controllers';
import { Customer } from './entities'; import { Customer } from './entities';
import { CustomerNotificationSettings } from './entities/customer-notification-settings.entity';
import { CustomerRepository } from './repositories/customer.repository'; import { CustomerRepository } from './repositories/customer.repository';
import { CustomerService } from './services'; import { CustomerService } from './services';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Customer]), AuthModule], imports: [TypeOrmModule.forFeature([Customer, CustomerNotificationSettings]), forwardRef(() => AuthModule)],
controllers: [CustomerController], controllers: [CustomerController],
providers: [CustomerService, CustomerRepository], providers: [CustomerService, CustomerRepository],
exports: [CustomerService],
}) })
export class CustomerModule {} export class CustomerModule {}

View File

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsDateString, IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { IsDateString, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n'; import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { IsAbove18 } from '~/core/decorators/validations'; import { IsAbove18 } from '~/core/decorators/validations';
export class UpdateCustomerRequestDto { export class UpdateCustomerRequestDto {
@ -26,4 +26,8 @@ export class UpdateCustomerRequestDto {
@IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) }) @IsAbove18({ message: i18n('validation.IsAbove18', { path: 'general', property: 'customer.dateOfBirth' }) })
@IsOptional() @IsOptional()
dateOfBirth!: Date; dateOfBirth!: Date;
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' })
@IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'customer.profilePictureId' }) })
profilePictureId!: string;
} }

View File

@ -1,19 +1,24 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional } from 'class-validator'; import { IsBoolean, IsOptional, IsString, ValidateIf } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class UpdateNotificationsSettingsRequestDto { export class UpdateNotificationsSettingsRequestDto {
@ApiProperty() @ApiProperty()
@IsBoolean() @IsBoolean({ message: i18n('validation.isBoolean', { path: 'general', property: 'customer.isEmailEnabled' }) })
@IsOptional() @IsOptional()
isEmailEnabled!: boolean; isEmailEnabled!: boolean;
@ApiProperty() @ApiProperty()
@IsBoolean() @IsBoolean({ message: i18n('validation.isBoolean', { path: 'general', property: 'customer.isPushEnabled' }) })
@IsOptional() @IsOptional()
isPushEnabled!: boolean; isPushEnabled!: boolean;
@ApiProperty() @ApiProperty()
@IsBoolean() @IsBoolean({ message: i18n('validation.isBoolean', { path: 'general', property: 'customer.isSmsEnabled' }) })
@IsOptional() @IsOptional()
isSmsEnabled!: boolean; isSmsEnabled!: boolean;
@ApiPropertyOptional()
@IsString({ message: i18n('validation.isString', { path: 'general', property: 'customer.fcmToken' }) })
@ValidateIf((o) => o.isPushEnabled)
fcmToken?: string;
} }

View File

@ -1,5 +1,7 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Customer } from '~/customer/entities'; import { Customer } from '~/customer/entities';
import { DocumentMetaResponseDto } from '~/document/dtos/response';
import { NotificationSettingsResponseDto } from './notification-settings.response.dto';
export class CustomerResponseDto { export class CustomerResponseDto {
@ApiProperty() @ApiProperty()
@ -50,6 +52,12 @@ export class CustomerResponseDto {
@ApiProperty() @ApiProperty()
isGuardian!: boolean; isGuardian!: boolean;
@ApiProperty()
notificationSettings!: NotificationSettingsResponseDto;
@ApiPropertyOptional({ type: DocumentMetaResponseDto })
profilePicture!: DocumentMetaResponseDto | null;
constructor(customer: Customer) { constructor(customer: Customer) {
this.id = customer.id; this.id = customer.id;
this.customerStatus = customer.customerStatus; this.customerStatus = customer.customerStatus;
@ -67,5 +75,7 @@ export class CustomerResponseDto {
this.gender = customer.gender; this.gender = customer.gender;
this.isJunior = customer.isJunior; this.isJunior = customer.isJunior;
this.isGuardian = customer.isGuardian; this.isGuardian = customer.isGuardian;
this.notificationSettings = new NotificationSettingsResponseDto(customer.notificationSettings);
this.profilePicture = customer.profilePicture ? new DocumentMetaResponseDto(customer.profilePicture) : null;
} }
} }

View File

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { UserNotificationSettings } from '~/auth/entities'; import { CustomerNotificationSettings } from '~/customer/entities/customer-notification-settings.entity';
export class NotificationSettingsResponseDto { export class NotificationSettingsResponseDto {
@ApiProperty() @ApiProperty()
@ -11,7 +11,7 @@ export class NotificationSettingsResponseDto {
@ApiProperty() @ApiProperty()
isSmsEnabled!: boolean; isSmsEnabled!: boolean;
constructor(notificationSettings: UserNotificationSettings) { constructor(notificationSettings: CustomerNotificationSettings) {
this.isEmailEnabled = notificationSettings.isEmailEnabled; this.isEmailEnabled = notificationSettings.isEmailEnabled;
this.isPushEnabled = notificationSettings.isPushEnabled; this.isPushEnabled = notificationSettings.isPushEnabled;
this.isSmsEnabled = notificationSettings.isSmsEnabled; this.isSmsEnabled = notificationSettings.isSmsEnabled;

View File

@ -8,10 +8,10 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { User } from './user.entity'; import { Customer } from '~/customer/entities';
@Entity('user_notification_settings') @Entity('cutsomer_notification_settings')
export class UserNotificationSettings extends BaseEntity { export class CustomerNotificationSettings extends BaseEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id!: string; id!: string;
@ -24,9 +24,9 @@ export class UserNotificationSettings extends BaseEntity {
@Column({ name: 'is_sms_enabled', default: false }) @Column({ name: 'is_sms_enabled', default: false })
isSmsEnabled!: boolean; isSmsEnabled!: boolean;
@OneToOne(() => User, (user) => user.notificationSettings, { onDelete: 'CASCADE' }) @OneToOne(() => Customer, (customer) => customer.notificationSettings, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' }) @JoinColumn({ name: 'customer_id' })
user!: User; customer!: Customer;
@CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' }) @CreateDateColumn({ name: 'created_at', type: 'timestamp with time zone' })
createdAt!: Date; createdAt!: Date;

View File

@ -9,8 +9,12 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { User } from '~/auth/entities'; import { User } from '~/auth/entities';
import { Document } from '~/document/entities';
import { Guardian } from '~/guardian/entities/guradian.entity';
import { Junior } from '~/junior/entities';
import { CustomerNotificationSettings } from './customer-notification-settings.entity';
@Entity() @Entity('customers')
export class Customer extends BaseEntity { export class Customer extends BaseEntity {
@PrimaryColumn('uuid') @PrimaryColumn('uuid')
id!: string; id!: string;
@ -63,10 +67,29 @@ export class Customer extends BaseEntity {
@Column('varchar', { name: 'user_id' }) @Column('varchar', { name: 'user_id' })
userId!: string; userId!: string;
@Column('varchar', { name: 'profile_picture_id', nullable: true })
profilePictureId!: string;
@OneToOne(() => CustomerNotificationSettings, (notificationSettings) => notificationSettings.customer, {
cascade: true,
eager: true,
})
notificationSettings!: CustomerNotificationSettings;
@OneToOne(() => Document, (document) => document.customerPicture, { cascade: true, nullable: true })
@JoinColumn({ name: 'profile_picture_id' })
profilePicture!: Document;
@OneToOne(() => User, (user) => user.customer, { onDelete: 'CASCADE' }) @OneToOne(() => User, (user) => user.customer, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' }) @JoinColumn({ name: 'user_id' })
user!: User; user!: User;
@OneToOne(() => Junior, (junior) => junior.customer, { cascade: true })
junior!: Junior;
@OneToOne(() => Guardian, (guardian) => guardian.customer, { cascade: true })
guardian!: Guardian;
@CreateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' }) @CreateDateColumn({ type: 'timestamp with time zone', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date; createdAt!: Date;

View File

@ -1,18 +1,41 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, Repository } from 'typeorm'; import { FindOptionsWhere, Repository } from 'typeorm';
import { UpdateCustomerRequestDto } from '../dtos/request'; import { User } from '~/auth/entities';
import { Roles } from '~/auth/enums';
import { UpdateNotificationsSettingsRequestDto } from '../dtos/request';
import { Customer } from '../entities'; import { Customer } from '../entities';
import { CustomerNotificationSettings } from '../entities/customer-notification-settings.entity';
@Injectable() @Injectable()
export class CustomerRepository { export class CustomerRepository {
constructor(@InjectRepository(Customer) private readonly customerRepository: Repository<Customer>) {} constructor(@InjectRepository(Customer) private readonly customerRepository: Repository<Customer>) {}
updateCustomer(id: string, data: UpdateCustomerRequestDto) { updateCustomer(id: string, data: Partial<Customer>) {
return this.customerRepository.update(id, data); return this.customerRepository.update(id, data);
} }
findOne(where: FindOptionsWhere<Customer>) { findOne(where: FindOptionsWhere<Customer>) {
return this.customerRepository.findOne({ where }); return this.customerRepository.findOne({ where, relations: ['profilePicture'] });
}
createCustomer(customerData: Partial<Customer>, user: User) {
return this.customerRepository.save(
this.customerRepository.create({
...customerData,
id: user.id,
user,
isGuardian: user.roles.includes(Roles.GUARDIAN),
isJunior: user.roles.includes(Roles.JUNIOR),
}),
);
}
updateNotificationSettings(customer: Customer, body: UpdateNotificationsSettingsRequestDto) {
customer.notificationSettings = CustomerNotificationSettings.create({
...customer.notificationSettings,
...body,
});
return this.customerRepository.save(customer);
} }
} }

View File

@ -1,14 +1,32 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
import { UserService } from '~/auth/services/user.service'; import { User } from '~/auth/entities';
import { DeviceService } from '~/auth/services';
import { OciService } from '~/document/services';
import { UpdateCustomerRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request'; import { UpdateCustomerRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request';
import { Customer } from '../entities'; import { Customer } from '../entities';
import { CustomerRepository } from '../repositories/customer.repository'; import { CustomerRepository } from '../repositories/customer.repository';
@Injectable() @Injectable()
export class CustomerService { export class CustomerService {
constructor(private readonly userService: UserService, private readonly customerRepository: CustomerRepository) {} constructor(
updateNotificationSettings(userId: string, data: UpdateNotificationsSettingsRequestDto) { private readonly customerRepository: CustomerRepository,
return this.userService.updateNotificationSettings(userId, data); private readonly ociService: OciService,
@Inject(forwardRef(() => DeviceService)) private readonly deviceService: DeviceService,
) {}
async updateNotificationSettings(userId: string, data: UpdateNotificationsSettingsRequestDto, deviceId: string) {
const customer = await this.findCustomerById(userId);
const notificationSettings = (await this.customerRepository.updateNotificationSettings(customer, data))
.notificationSettings;
if (data.isPushEnabled && deviceId) {
await this.deviceService.updateDevice(deviceId, {
fcmToken: data.fcmToken,
userId: userId,
});
}
return notificationSettings;
} }
async updateCustomer(userId: string, data: UpdateCustomerRequestDto): Promise<Customer> { async updateCustomer(userId: string, data: UpdateCustomerRequestDto): Promise<Customer> {
@ -16,11 +34,20 @@ export class CustomerService {
return this.findCustomerById(userId); return this.findCustomerById(userId);
} }
createCustomer(customerData: Partial<Customer>, user: User) {
return this.customerRepository.createCustomer(customerData, user);
}
async findCustomerById(id: string) { async findCustomerById(id: string) {
const customer = await this.customerRepository.findOne({ id }); const customer = await this.customerRepository.findOne({ id });
if (!customer) { if (!customer) {
throw new BadRequestException('CUSTOMER.NOT_FOUND'); throw new BadRequestException('CUSTOMER.NOT_FOUND');
} }
if (customer.profilePicture) {
customer.profilePicture.url = await this.ociService.generatePreSignedUrl(customer.profilePicture);
}
return customer; return customer;
} }
} }

View File

@ -9,7 +9,7 @@ export class CreateDocumentEntity1732434281561 implements MigrationInterface {
"id" uuid NOT NULL DEFAULT uuid_generate_v4(), "id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"name" character varying(255) NOT NULL, "name" character varying(255) NOT NULL,
"extension" character varying(255) NOT NULL, "extension" character varying(255) NOT NULL,
"documentType" character varying(255) NOT NULL, "document_type" character varying(255) NOT NULL,
"updated_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(),
"created_at" TIMESTAMP NOT NULL DEFAULT now(), "created_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_ac51aa5181ee2036f5ca482857c" PRIMARY KEY ("id"))`, CONSTRAINT "PK_ac51aa5181ee2036f5ca482857c" PRIMARY KEY ("id"))`,

View File

@ -16,16 +16,10 @@ export class CreateUserEntity1733206728721 implements MigrationInterface {
"apple_id" character varying(255), "apple_id" character varying(255),
"is_profile_completed" boolean NOT NULL DEFAULT false, "is_profile_completed" boolean NOT NULL DEFAULT false,
"roles" text array, "roles" text array,
"profile_picture_id" uuid,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "REL_02ec15de199e79a0c46869895f" UNIQUE ("profile_picture_id"),
CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`, CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`,
); );
await queryRunner.query(
`ALTER TABLE "users" ADD CONSTRAINT "FK_02ec15de199e79a0c46869895f4" FOREIGN KEY ("profile_picture_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {

View File

@ -1,29 +0,0 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateNotificationSettingsTable1733231692252 implements MigrationInterface {
name = 'CreateNotificationSettingsTable1733231692252';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "user_notification_settings"
("id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"is_email_enabled" boolean NOT NULL DEFAULT false,
"is_push_enabled" boolean NOT NULL DEFAULT false,
"is_sms_enabled" boolean NOT NULL DEFAULT false,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"user_id" uuid, CONSTRAINT "REL_52182ffd0f785e8256f8fcb4fd" UNIQUE ("user_id"),
CONSTRAINT "PK_a195de67d093e096152f387afbd" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "user_notification_settings" ADD CONSTRAINT "FK_52182ffd0f785e8256f8fcb4fd6" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_notification_settings" DROP CONSTRAINT "FK_52182ffd0f785e8256f8fcb4fd6"`,
);
await queryRunner.query(`DROP TABLE "user_notification_settings"`);
}
}

View File

@ -5,7 +5,7 @@ export class CreateCustomerEntity1733298524771 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> { public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query( await queryRunner.query(
`CREATE TABLE "customer" `CREATE TABLE "customers"
("id" uuid NOT NULL, ("id" uuid NOT NULL,
"customer_status" character varying(255) NOT NULL DEFAULT 'PENDING', "customer_status" character varying(255) NOT NULL DEFAULT 'PENDING',
"rejection_reason" text, "rejection_reason" text,
@ -22,19 +22,25 @@ export class CreateCustomerEntity1733298524771 implements MigrationInterface {
"gender" character varying(255), "gender" character varying(255),
"is_junior" boolean NOT NULL DEFAULT false, "is_junior" boolean NOT NULL DEFAULT false,
"is_guardian" boolean NOT NULL DEFAULT false, "is_guardian" boolean NOT NULL DEFAULT false,
"profile_picture_id" uuid,
"user_id" uuid NOT NULL, "user_id" uuid NOT NULL,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "REL_5d1f609371a285123294fddcf3" UNIQUE ("user_id"), CONSTRAINT "REL_5d1f609371a285123294fddcf3" UNIQUE ("user_id"),
CONSTRAINT "REL_02ec15de199e79a0c46869895f" UNIQUE ("profile_picture_id"),
CONSTRAINT "PK_a7a13f4cacb744524e44dfdad32" PRIMARY KEY ("id"))`, CONSTRAINT "PK_a7a13f4cacb744524e44dfdad32" PRIMARY KEY ("id"))`,
); );
await queryRunner.query( await queryRunner.query(
`ALTER TABLE "customer" ADD CONSTRAINT "FK_5d1f609371a285123294fddcf3a" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, `ALTER TABLE "customers" ADD CONSTRAINT "FK_e7574892da11dd01de5cfc46499" FOREIGN KEY ("profile_picture_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "customers" ADD CONSTRAINT "FK_11d81cd7be87b6f8865b0cf7661" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
); );
} }
public async down(queryRunner: QueryRunner): Promise<void> { public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "customer" DROP CONSTRAINT "FK_5d1f609371a285123294fddcf3a"`); await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_11d81cd7be87b6f8865b0cf7661"`);
await queryRunner.query(`DROP TABLE "customer"`); await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_e7574892da11dd01de5cfc46499"`);
await queryRunner.query(`DROP TABLE "customers"`);
} }
} }

View File

@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateJuniorEntity1733731507261 implements MigrationInterface {
name = 'CreateJuniorEntity1733731507261';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "juniors"
("id" uuid NOT NULL,
"relationship" character varying(255) NOT NULL,
"civil_id_front_id" uuid NOT NULL,
"civil_id_back_id" uuid NOT NULL,
"customer_id" uuid NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "REL_6a72e1a5758643737cc563b96c" UNIQUE ("civil_id_front_id"),
CONSTRAINT "REL_4662c4433223c01fe69fc1382f" UNIQUE ("civil_id_back_id"),
CONSTRAINT "REL_dfbf64ede1ff823a489902448a" UNIQUE ("customer_id"), CONSTRAINT "PK_2d273092322c1f8bf26296fa608" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "juniors" ADD CONSTRAINT "FK_6a72e1a5758643737cc563b96c7" FOREIGN KEY ("civil_id_front_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "juniors" ADD CONSTRAINT "FK_4662c4433223c01fe69fc1382f5" FOREIGN KEY ("civil_id_back_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "juniors" ADD CONSTRAINT "FK_dfbf64ede1ff823a489902448a2" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "juniors" DROP CONSTRAINT "FK_dfbf64ede1ff823a489902448a2"`);
await queryRunner.query(`ALTER TABLE "juniors" DROP CONSTRAINT "FK_4662c4433223c01fe69fc1382f5"`);
await queryRunner.query(`ALTER TABLE "juniors" DROP CONSTRAINT "FK_6a72e1a5758643737cc563b96c7"`);
await queryRunner.query(`DROP TABLE "juniors"`);
}
}

View File

@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateGuardianEntity1733732021622 implements MigrationInterface {
name = 'CreateGuardianEntity1733732021622';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "guardians"
("id" uuid NOT NULL,
"customer_id" uuid NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "REL_6c46a1b6af00e6457cb1b70f7e" UNIQUE ("customer_id"), CONSTRAINT "PK_3dcf02f3dc96a2c017106f280be" PRIMARY KEY ("id"))`,
);
await queryRunner.query(`ALTER TABLE "juniors" ADD "guardian_id" uuid NOT NULL`);
await queryRunner.query(
`ALTER TABLE "juniors" ADD CONSTRAINT "FK_0b11aa56264184690e2220da4a0" FOREIGN KEY ("guardian_id") REFERENCES "guardians"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "guardians" ADD CONSTRAINT "FK_6c46a1b6af00e6457cb1b70f7e7" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "guardians" DROP CONSTRAINT "FK_6c46a1b6af00e6457cb1b70f7e7"`);
await queryRunner.query(`ALTER TABLE "juniors" DROP CONSTRAINT "FK_0b11aa56264184690e2220da4a0"`);
await queryRunner.query(`ALTER TABLE "juniors" DROP COLUMN "guardian_id"`);
await queryRunner.query(`DROP TABLE "guardians"`);
}
}

View File

@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateThemeEntity1733748083604 implements MigrationInterface {
name = 'CreateThemeEntity1733748083604';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "themes"
("id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"color" character varying(255) NOT NULL,
"avatar_id" uuid, "junior_id" uuid NOT NULL,
CONSTRAINT "REL_73fcb76399a308cdd2d431a8f2" UNIQUE ("junior_id"),
CONSTRAINT "PK_ddbeaab913c18682e5c88155592" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "themes" ADD CONSTRAINT "FK_169b672cc28cc757e1f4464864d" FOREIGN KEY ("avatar_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "themes" ADD CONSTRAINT "FK_73fcb76399a308cdd2d431a8f2e" FOREIGN KEY ("junior_id") REFERENCES "juniors"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "themes" DROP CONSTRAINT "FK_73fcb76399a308cdd2d431a8f2e"`);
await queryRunner.query(`ALTER TABLE "themes" DROP CONSTRAINT "FK_169b672cc28cc757e1f4464864d"`);
await queryRunner.query(`DROP TABLE "themes"`);
}
}

View File

@ -0,0 +1,73 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { v4 as uuid } from 'uuid';
import { Document } from '../../document/entities';
import { DocumentType } from '../../document/enums';
const DEFAULT_AVATARS = [
{
id: uuid(),
name: 'vacation',
extension: '.jpg',
documentType: DocumentType.DEFAULT_AVATAR,
},
{
id: uuid(),
name: 'colors',
extension: '.jpg',
documentType: DocumentType.DEFAULT_AVATAR,
},
{
id: uuid(),
name: 'astronaut',
extension: '.jpg',
documentType: DocumentType.DEFAULT_AVATAR,
},
{
id: uuid(),
name: 'pet',
extension: '.jpg',
documentType: DocumentType.DEFAULT_AVATAR,
},
{
id: uuid(),
name: 'disney',
extension: '.jpg',
documentType: DocumentType.DEFAULT_AVATAR,
},
{
id: uuid(),
name: 'clothes',
extension: '.jpg',
documentType: DocumentType.DEFAULT_AVATAR,
},
{
id: uuid(),
name: 'playstation',
extension: '.jpg',
documentType: DocumentType.DEFAULT_AVATAR,
},
{
id: uuid(),
name: 'football',
extension: '.jpg',
documentType: DocumentType.DEFAULT_AVATAR,
},
{
id: uuid(),
name: 'cars',
extension: '.jpg',
documentType: DocumentType.DEFAULT_AVATAR,
},
];
export class SeedDefaultAvatar1733750228289 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.manager.getRepository(Document).save(DEFAULT_AVATARS);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await DEFAULT_AVATARS.forEach(async (avatar) => {
await queryRunner.manager
.getRepository(Document)
.delete({ name: avatar.name, documentType: avatar.documentType });
});
}
}

View File

@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateTaskEntities1733904556416 implements MigrationInterface {
name = 'CreateTaskEntities1733904556416';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "task_submissions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "status" character varying NOT NULL, "submitted_at" TIMESTAMP WITH TIME ZONE NOT NULL, "task_id" uuid NOT NULL, "proof_of_completion_id" uuid, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "REL_d6cfaee118a0300d652e28ee16" UNIQUE ("task_id"), CONSTRAINT "PK_8d19d6b5dd776e373113de50018" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "tasks" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying(255) NOT NULL, "description" character varying(255) NOT NULL, "reward_amount" numeric(12,3) NOT NULL, "image_id" uuid NOT NULL, "task_frequency" character varying NOT NULL, "start_date" date NOT NULL, "due_date" date NOT NULL, "is_proof_required" boolean NOT NULL, "assigned_to_id" uuid NOT NULL, "assigned_by_id" uuid NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_8d12ff38fcc62aaba2cab748772" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "task_submissions" ADD CONSTRAINT "FK_d6cfaee118a0300d652e28ee166" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "task_submissions" ADD CONSTRAINT "FK_87876dfe440de7aafce216e9f58" FOREIGN KEY ("proof_of_completion_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_f1f00d41b1e95d0bbda2710a62b" FOREIGN KEY ("image_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_9430f12c5a1604833f64595a57f" FOREIGN KEY ("assigned_to_id") REFERENCES "juniors"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_3e08a7ca125a175cf899b09f71a" FOREIGN KEY ("assigned_by_id") REFERENCES "guardians"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "tasks" DROP CONSTRAINT "FK_3e08a7ca125a175cf899b09f71a"`);
await queryRunner.query(`ALTER TABLE "tasks" DROP CONSTRAINT "FK_9430f12c5a1604833f64595a57f"`);
await queryRunner.query(`ALTER TABLE "tasks" DROP CONSTRAINT "FK_f1f00d41b1e95d0bbda2710a62b"`);
await queryRunner.query(`ALTER TABLE "task_submissions" DROP CONSTRAINT "FK_87876dfe440de7aafce216e9f58"`);
await queryRunner.query(`ALTER TABLE "task_submissions" DROP CONSTRAINT "FK_d6cfaee118a0300d652e28ee166"`);
await queryRunner.query(`DROP TABLE "tasks"`);
await queryRunner.query(`DROP TABLE "task_submissions"`);
}
}

Some files were not shown because too many files have changed in this diff Show More