Compare commits

..

61 Commits

Author SHA1 Message Date
1fd1704da2 hotfix: fix task completed filter 2025-01-07 14:44:58 +03:00
ee433a5c8c fix: remove send email endpoint 2025-01-06 16:47:43 +03:00
3ab0f179d8 feat: add smtp and fix dynamic link 2025-01-06 16:46:35 +03:00
25ef549417 feat: prefix all apis 2025-01-05 14:46:49 +03:00
084d39096c feat:integration brancjio dynamic links to junior qr code registration 2025-01-05 13:15:57 +03:00
eca84b4e75 fix: general fixes on date types and some typos 2025-01-05 10:38:19 +03:00
aefa866ae7 feat: add money request cronjob, and edit money request entity 2025-01-02 11:22:33 +03:00
557ef4cd33 Merge pull request #26 from HamzaSha1/feat/cron-lock
Feat/cron lock
2024-12-30 16:18:06 +03:00
eea6302dda Merge branch 'mvp-1' of github.com:HamzaSha1/zod-backend into feat/cron-lock 2024-12-30 16:10:28 +03:00
c0fafd3f7c feat: add cron job for allowance and implement cron lock 2024-12-30 16:10:19 +03:00
f7290419d2 Merge pull request #25 from HamzaSha1/feat/add-loggers
feat: add loggers to all services
2024-12-30 10:44:07 +03:00
0fd2066c4a feat: add loggers to all services 2024-12-30 10:35:36 +03:00
cb54311a7b Merge pull request #24 from HamzaSha1/refactor/seperate-auth-and-user
refactor: seperate user and auth modules
2024-12-29 14:30:15 +03:00
ca71632755 refactor: sepeare user and auth modules 2024-12-29 14:17:39 +03:00
ebf335eabd Merge pull request #23 from HamzaSha1/feat/use-event-emitter
feat: handle notification async using event emitter
2024-12-29 11:53:23 +03:00
f383f6d14d feat: handle notification async using event emitter 2024-12-29 11:44:51 +03:00
5663a287f9 Merge pull request #22 from HamzaSha1/feat/mobile-notification
feat: sms using twillio
2024-12-29 10:00:24 +03:00
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
257 changed files with 10205 additions and 3109 deletions

View File

@ -28,4 +28,13 @@ MAIL_HOST=smtp.gmail.com
MAIL_USER=aahalhmad@gmail.com
MAIL_PASSWORD=
MAIL_PORT=587
MAIL_FROM=UBA
MAIL_FROM=UBA
BRANCH_IO_URL=https://api2.branch.io/v1/url
BRANCH_IO_KEY=
ZOD_BASE_URL=http://localhost:5001
ANDROID_PACKAGE_NAME=com.zod
IOS_PACKAGE_NAME=com.zod
ANDRIOD_JUNIOR_DEEPLINK_PATH=zodbank://juniors/qr-code/validate
IOS_JUNIOR_DEEPLINK_PATH=zodbank://juniors/qr-code/validate

View File

@ -29,7 +29,7 @@ module.exports = {
'require-await': ['error'],
'no-console': ['error'],
'no-multi-assign': ['error'],
'no-magic-numbers': ['error', { ignoreArrayIndexes: true }],
'no-magic-numbers': ['error', { ignoreArrayIndexes: true}],
'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 1, maxBOF: 0 }],
'max-len': [
'error',

View File

@ -10,6 +10,8 @@
"include": "config",
"exclude": "**/*.md"
},
{ "include": "common/modules/**/templates/*", "watchAssets": true }
,
"i18n",
"files"
]

7222
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -28,6 +28,7 @@
"dependencies": {
"@abdalhamid/hello": "^2.0.0",
"@hamid/hello": "file:../libraries/test-package",
"@keyv/redis": "^4.0.2",
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/axios": "^3.1.2",
"@nestjs/common": "^10.0.0",
@ -38,6 +39,7 @@
"@nestjs/microservices": "^10.4.7",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.8",
"@nestjs/schedule": "^4.1.2",
"@nestjs/swagger": "^8.0.5",
"@nestjs/terminus": "^10.2.3",
"@nestjs/throttler": "^6.2.1",
@ -45,8 +47,10 @@
"amqp-connection-manager": "^4.1.14",
"amqplib": "^0.10.4",
"bcrypt": "^5.1.1",
"cacheable": "^1.8.5",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"firebase-admin": "^13.0.2",
"google-libphonenumber": "^3.2.39",
"handlebars": "^4.7.8",
"ioredis": "^5.4.1",
@ -54,6 +58,7 @@
"moment": "^2.30.1",
"nestjs-i18n": "^10.4.9",
"nestjs-pino": "^4.1.0",
"nestjs-twilio": "^4.4.0",
"nodemailer": "^6.9.16",
"oci-common": "^2.99.0",
"oci-sdk": "^2.99.0",
@ -62,6 +67,7 @@
"pg": "^8.13.1",
"pino-http": "^10.3.0",
"pino-pretty": "^13.0.0",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^0.3.20",
@ -81,15 +87,19 @@
"@types/node": "^20.3.1",
"@types/nodemailer": "^6.4.16",
"@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.5",
"@types/supertest": "^6.0.0",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^5.59.2",
"@typescript-eslint/parser": "^5.59.2",
"eslint": "^8.39.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-security": "^1.7.1",
"i": "^0.3.7",
"jest": "^29.5.0",
"lint-staged": "^13.2.2",
"npm": "^10.9.2",
"prettier": "^2.8.8",
"source-map-support": "^0.5.21",
"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: [AllowancesService],
})
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,107 @@
import moment from 'moment';
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;
get nextPaymentDate(): Date | null {
const startDate = moment(this.startDate).clone().startOf('day');
const endDate = this.endDate ? moment(this.endDate).endOf('day') : null;
const now = moment().startOf('day');
if (endDate && moment().isAfter(endDate)) {
return null;
}
const calculateNextDate = (unit: moment.unitOfTime.Diff) => {
const diff = now.diff(startDate, unit);
const nextDate = startDate.clone().add(diff, unit);
const adjustedDate = nextDate.isSameOrAfter(now) ? nextDate : nextDate.add('1', unit);
if (endDate && adjustedDate.isAfter(endDate)) {
return null;
}
return adjustedDate.toDate();
};
switch (this.frequency) {
case AllowanceFrequency.DAILY:
return calculateNextDate('days');
case AllowanceFrequency.WEEKLY:
return calculateNextDate('weeks');
case AllowanceFrequency.MONTHLY:
return calculateNextDate('months');
default:
return null;
}
}
}

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,64 @@
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 });
}
async *findAllowancesChunks(chunkSize: number) {
let offset = 0;
while (true) {
const allowances = await this.allowancesRepository.find({
take: chunkSize,
skip: offset,
});
if (!allowances.length) {
break;
}
yield allowances;
offset += chunkSize;
}
}
}

View File

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

View File

@ -0,0 +1,132 @@
import { BadRequestException, Injectable, Logger } 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 {
private readonly logger = new Logger(AllowanceChangeRequestsService.name);
constructor(
private readonly allowanceChangeRequestsRepository: AllowanceChangeRequestsRepository,
private readonly ociService: OciService,
private readonly allowanceService: AllowancesService,
) {}
async createAllowanceChangeRequest(juniorId: string, body: CreateAllowanceChangeRequestDto) {
this.logger.log(`Creating allowance change request for junior ${juniorId}`);
const allowance = await this.allowanceService.validateAllowanceForJunior(juniorId, body.allowanceId);
if (allowance.amount === body.amount) {
this.logger.error(`Amount is the same as the current allowance 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) {
this.logger.error(`There is a pending request with the same amount`);
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.SAME_AMOUNT_PENDING');
}
return this.allowanceChangeRequestsRepository.createAllowanceChangeRequest(body.allowanceId, body);
}
findAllowanceChangeRequestBy(where: FindOptionsWhere<AllowanceChangeRequest>) {
this.logger.log(`Finding allowance change request by ${JSON.stringify(where)}`);
return this.allowanceChangeRequestsRepository.findAllowanceChangeRequestBy(where);
}
async approveAllowanceChangeRequest(guardianId: string, requestId: string) {
this.logger.log(`Approving allowance change request ${requestId} by guardian ${guardianId}`);
const request = await this.findAllowanceChangeRequestBy({ id: requestId, allowance: { guardianId } });
if (!request) {
this.logger.error(`Allowance change request ${requestId} not found for guardian ${guardianId}`);
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.NOT_FOUND');
}
if (request.status === AllowanceChangeRequestStatus.APPROVED) {
this.logger.error(`Allowance change request ${requestId} already approved`);
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.ALREADY_APPROVED');
}
return this.allowanceChangeRequestsRepository.updateAllowanceChangeRequestStatus(
requestId,
AllowanceChangeRequestStatus.APPROVED,
);
}
async rejectAllowanceChangeRequest(guardianId: string, requestId: string) {
this.logger.log(`Rejecting allowance change request ${requestId} by guardian ${guardianId}`);
const request = await this.findAllowanceChangeRequestBy({ id: requestId, allowance: { guardianId } });
if (!request) {
this.logger.error(`Allowance change request ${requestId} not found for guardian ${guardianId}`);
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.NOT_FOUND');
}
if (request.status === AllowanceChangeRequestStatus.REJECTED) {
this.logger.error(`Allowance change request ${requestId} already 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]> {
this.logger.log(`Finding allowance change requests for guardian ${guardianId}`);
const [requests, itemCount] = await this.allowanceChangeRequestsRepository.findAllowanceChangeRequests(
guardianId,
query,
);
await this.prepareAllowanceChangeRequestsImages(requests);
this.logger.log(`Returning allowance change requests for guardian ${guardianId}`);
return [requests, itemCount];
}
async findAllowanceChangeRequestById(guardianId: string, requestId: string) {
this.logger.log(`Finding allowance change request ${requestId} for guardian ${guardianId}`);
const request = await this.allowanceChangeRequestsRepository.findAllowanceChangeRequestBy(
{
id: requestId,
allowance: { guardianId },
},
true,
);
if (!request) {
this.logger.error(`Allowance change request ${requestId} not found for guardian ${guardianId}`);
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.NOT_FOUND');
}
await this.prepareAllowanceChangeRequestsImages([request]);
this.logger.log(`Allowance change request ${requestId} found successfully`);
return request;
}
private prepareAllowanceChangeRequestsImages(requests: AllowanceChangeRequest[]) {
this.logger.log(`Preparing allowance change requests images`);
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,110 @@
import { BadRequestException, Injectable, Logger } 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 {
private readonly logger = new Logger(AllowancesService.name);
constructor(
private readonly allowancesRepository: AllowancesRepository,
private readonly juniorService: JuniorService,
private readonly ociService: OciService,
) {}
async createAllowance(guardianId: string, body: CreateAllowanceRequestDto) {
this.logger.log(`Creating allowance for junior ${body.juniorId} by guardian ${guardianId}`);
if (moment(body.startDate).isBefore(moment().startOf('day'))) {
this.logger.error(`Start date ${body.startDate} is before today`);
throw new BadRequestException('ALLOWANCE.START_DATE_BEFORE_TODAY');
}
if (moment(body.startDate).isAfter(body.endDate)) {
this.logger.error(`Start date ${body.startDate} is after end date ${body.endDate}`);
throw new BadRequestException('ALLOWANCE.START_DATE_AFTER_END_DATE');
}
const doesJuniorBelongToGuardian = await this.juniorService.doesJuniorBelongToGuardian(guardianId, body.juniorId);
if (!doesJuniorBelongToGuardian) {
this.logger.error(`Junior ${body.juniorId} does not belong to guardian ${guardianId}`);
throw new BadRequestException('JUNIOR.DOES_NOT_BELONG_TO_GUARDIAN');
}
const allowance = await this.allowancesRepository.createAllowance(guardianId, body);
this.logger.log(`Allowance ${allowance.id} created successfully`);
return this.findAllowanceById(allowance.id);
}
async findAllowanceById(allowanceId: string, guardianId?: string) {
this.logger.log(`Finding allowance ${allowanceId} ${guardianId ? `by guardian ${guardianId}` : ''}`);
const allowance = await this.allowancesRepository.findAllowanceById(allowanceId, guardianId);
if (!allowance) {
this.logger.error(`Allowance ${allowanceId} not found ${guardianId ? `for guardian ${guardianId}` : ''}`);
throw new BadRequestException('ALLOWANCE.NOT_FOUND');
}
await this.prepareAllowanceDocuments([allowance]);
this.logger.log(`Allowance ${allowanceId} found successfully`);
return allowance;
}
async findAllowances(guardianId: string, query: PageOptionsRequestDto): Promise<[Allowance[], number]> {
this.logger.log(`Finding allowances for guardian ${guardianId}`);
const [allowances, itemCount] = await this.allowancesRepository.findAllowances(guardianId, query);
await this.prepareAllowanceDocuments(allowances);
this.logger.log(`Returning allowances for guardian ${guardianId}`);
return [allowances, itemCount];
}
async deleteAllowance(guardianId: string, allowanceId: string) {
this.logger.log(`Deleting allowance ${allowanceId} for guardian ${guardianId}`);
const { affected } = await this.allowancesRepository.deleteAllowance(guardianId, allowanceId);
if (!affected) {
this.logger.error(`Allowance ${allowanceId} not found`);
throw new BadRequestException('ALLOWANCE.NOT_FOUND');
}
this.logger.log(`Allowance ${allowanceId} deleted successfully`);
}
async validateAllowanceForJunior(juniorId: string, allowanceId: string) {
this.logger.log(`Validating allowance ${allowanceId} for junior ${juniorId}`);
const allowance = await this.allowancesRepository.findAllowanceById(allowanceId);
if (!allowance) {
this.logger.error(`Allowance ${allowanceId} not found`);
throw new BadRequestException('ALLOWANCE.NOT_FOUND');
}
if (allowance.juniorId !== juniorId) {
this.logger.error(`Allowance ${allowanceId} does not belong to junior ${juniorId}`);
throw new BadRequestException('ALLOWANCE.DOES_NOT_BELONG_TO_JUNIOR');
}
return allowance;
}
async findAllowancesChunks(chunkSize: number) {
this.logger.log(`Finding allowances chunks`);
const allowances = await this.allowancesRepository.findAllowancesChunks(chunkSize);
this.logger.log(`Returning allowances chunks`);
return allowances;
}
private async prepareAllowanceDocuments(allowance: Allowance[]) {
this.logger.log(`Preparing document for allowances`);
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

@ -1,23 +1,36 @@
import { MiddlewareConsumer, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm';
import { I18nMiddleware, I18nModule } from 'nestjs-i18n';
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 { 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 { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters';
import { buildConfigOptions, buildLoggerOptions, buildTypeormOptions } from './core/module-options';
import { buildI18nOptions } from './core/module-options/i18n-options';
import { buildValidationPipe } from './core/pipes';
import { CronModule } from './cron/cron.module';
import { CustomerModule } from './customer/customer.module';
import { migrations } from './db';
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 { 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';
import { UserModule } from './user/user.module';
@Module({
controllers: [],
imports: [
@ -43,14 +56,30 @@ import { JuniorModule } from './junior/junior.module';
inject: [ConfigService],
}),
I18nModule.forRoot(buildI18nOptions()),
CacheModule,
EventEmitterModule.forRoot(),
ScheduleModule.forRoot(),
// App modules
AuthModule,
CustomerModule,
JuniorModule,
TaskModule,
GuardianModule,
SavingGoalsModule,
AllowanceModule,
MoneyRequestModule,
GiftModule,
NotificationModule,
OtpModule,
DocumentModule,
LookupModule,
HealthModule,
UserModule,
CronModule,
],
providers: [
// Global Pipes

View File

@ -1,22 +1,15 @@
import { forwardRef, Module } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CustomerModule } from '~/customer/customer.module';
import { JuniorModule } from '~/junior/junior.module';
import { UserModule } from '~/user/user.module';
import { AuthController } from './controllers';
import { Device, User, UserNotificationSettings } from './entities';
import { DeviceRepository, UserRepository } from './repositories';
import { AuthService, DeviceService } from './services';
import { UserService } from './services/user.service';
import { AuthService } from './services';
import { AccessTokenStrategy } from './strategies';
@Module({
imports: [
TypeOrmModule.forFeature([User, UserNotificationSettings, Device]),
JwtModule.register({}),
forwardRef(() => CustomerModule),
],
providers: [AuthService, UserRepository, UserService, DeviceService, DeviceRepository, AccessTokenStrategy],
imports: [JwtModule.register({}), JuniorModule, UserModule],
providers: [AuthService, AccessTokenStrategy],
controllers: [AuthController],
exports: [UserService],
exports: [],
})
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 { Request } from 'express';
import { DEVICE_ID_HEADER } from '~/common/constants';
import { AuthenticatedUser } from '~/common/decorators';
import { AuthenticatedUser, Public } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards';
import { ResponseFactory } from '~/core/utils';
import {
@ -10,8 +11,10 @@ import {
EnableBiometricRequestDto,
ForgetPasswordRequestDto,
LoginRequestDto,
RefreshTokenRequestDto,
SendForgetPasswordOtpRequestDto,
SetEmailRequestDto,
setJuniorPasswordRequestDto,
SetPasscodeRequestDto,
VerifyUserRequestDto,
} from '../dtos/request';
@ -77,9 +80,30 @@ export class AuthController {
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')
async login(@Body() loginDto: LoginRequestDto, @Headers(DEVICE_ID_HEADER) deviceId: string) {
const [res, user] = await this.authService.login(loginDto, deviceId);
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 './forget-password.request.dto';
export * from './login.request.dto';
export * from './refresh-token.request.dto';
export * from './send-forget-password-otp.request.dto';
export * from './set-email.request.dto';
export * from './set-junior-password.request.dto';
export * from './set-passcode.request.dto';
export * from './verify-user.request.dto';

View File

@ -1,5 +1,5 @@
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 { GrantType } from '~/auth/enums';
export class LoginRequestDto {
@ -17,8 +17,14 @@ export class LoginRequestDto {
@ValidateIf((o) => o.grantType === GrantType.PASSWORD)
password!: string;
@ApiProperty({ example: 'device-token' })
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'auth.deviceToken' }) })
@ApiProperty({ example: 'fcm-device-token' })
@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)
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

@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { User } from '~/auth/entities';
import { ILoginResponse } from '~/auth/interfaces';
import { CustomerResponseDto } from '~/customer/dtos/response';
import { User } from '~/user/entities';
import { UserResponseDto } from './user.response.dto';
export class LoginResponseDto {

View File

@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { User } from '~/auth/entities';
import { Roles } from '~/auth/enums';
import { User } from '~/user/entities';
export class UserResponseDto {
@ApiProperty()

View File

@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { User } from '~/auth/entities';
import { ILoginResponse } from '~/auth/interfaces';
import { User } from '~/user/entities';
export class VerifyUserResponseDto {
@ApiProperty()

View File

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

View File

@ -1,10 +1,15 @@
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { BadRequestException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
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 { OtpService } from '~/common/modules/otp/services';
import { PASSCODE_REGEX, PASSWORD_REGEX } from '../constants';
import { JuniorTokenService } from '~/junior/services';
import { DeviceService, UserService } from '~/user/services';
import { User } from '../../user/entities';
import { PASSCODE_REGEX } from '../constants';
import {
CreateUnverifiedUserRequestDto,
DisableBiometricRequestDto,
@ -13,41 +18,47 @@ import {
LoginRequestDto,
SendForgetPasswordOtpRequestDto,
SetEmailRequestDto,
setJuniorPasswordRequestDto,
VerifyUserRequestDto,
} from '../dtos/request';
import { VerifyUserRequestDto } from '../dtos/request/verify-user.request.dto';
import { User } from '../entities';
import { GrantType, Roles } from '../enums';
import { ILoginResponse } from '../interfaces';
import { GrantType } from '../enums';
import { IJwtPayload, ILoginResponse } from '../interfaces';
import { removePadding, verifySignature } from '../utils';
import { DeviceService } from './device.service';
import { UserService } from './user.service';
const ONE_THOUSAND = 1000;
const SALT_ROUNDS = 10;
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(
private readonly otpService: OtpService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private readonly userService: UserService,
private readonly deviceService: DeviceService,
private readonly juniorTokenService: JuniorTokenService,
private readonly cacheService: CacheService,
) {}
async sendRegisterOtp({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) {
this.logger.log(`Sending OTP to ${countryCode + phoneNumber}`);
const user = await this.userService.findOrCreateUser({ phoneNumber, countryCode });
return this.otpService.generateAndSendOtp({
userId: user.id,
recipient: user.phoneNumber,
recipient: user.countryCode + user.phoneNumber,
scope: OtpScope.VERIFY_PHONE,
otpType: OtpType.SMS,
});
}
async verifyUser(verifyUserDto: VerifyUserRequestDto): Promise<[ILoginResponse, User]> {
this.logger.log(`Verifying user with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber}`);
const user = await this.userService.findUserOrThrow({ phoneNumber: verifyUserDto.phoneNumber });
if (user.isPasswordSet) {
this.logger.error(
`User with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber} already verified`,
);
throw new BadRequestException('USERS.PHONE_ALREADY_VERIFIED');
}
@ -59,26 +70,34 @@ export class AuthService {
});
if (!isOtpValid) {
this.logger.error(
`Invalid OTP for user with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber}`,
);
throw new BadRequestException('USERS.INVALID_OTP');
}
const updatedUser = await this.userService.verifyUserAndCreateCustomer(user);
const tokens = await this.generateAuthToken(updatedUser);
this.logger.log(
`User with phone number ${verifyUserDto.countryCode + verifyUserDto.phoneNumber} verified successfully`,
);
return [tokens, updatedUser];
}
async setEmail(userId: string, { email }: SetEmailRequestDto) {
this.logger.log(`Setting email for user with id ${userId}`);
const user = await this.userService.findUserOrThrow({ id: userId });
if (user.email) {
this.logger.error(`Email already set for user with id ${userId}`);
throw new BadRequestException('USERS.EMAIL_ALREADY_SET');
}
const existingUser = await this.userService.findUser({ email });
if (existingUser) {
this.logger.error(`Email ${email} already taken`);
throw new BadRequestException('USERS.EMAIL_ALREADY_TAKEN');
}
@ -86,21 +105,26 @@ export class AuthService {
}
async setPasscode(userId: string, passcode: string) {
this.logger.log(`Setting passcode for user with id ${userId}`);
const user = await this.userService.findUserOrThrow({ id: userId });
if (user.password) {
this.logger.error(`Passcode already set for user with id ${userId}`);
throw new BadRequestException('USERS.PASSCODE_ALREADY_SET');
}
const salt = bcrypt.genSaltSync(SALT_ROUNDS);
const hashedPasscode = bcrypt.hashSync(passcode, salt);
await this.userService.setPasscode(userId, hashedPasscode, salt);
this.logger.log(`Passcode set successfully for user with id ${userId}`);
}
async enableBiometric(userId: string, { deviceId, publicKey }: EnableBiometricRequestDto) {
this.logger.log(`Enabling biometric for user with id ${userId}`);
const device = await this.deviceService.findUserDeviceById(deviceId, userId);
if (!device) {
this.logger.log(`Device not found, creating new device for user with id ${userId}`);
return this.deviceService.createDevice({
deviceId,
userId,
@ -109,6 +133,7 @@ export class AuthService {
}
if (device.publicKey) {
this.logger.error(`Biometric already enabled for user with id ${userId}`);
throw new BadRequestException('AUTH.BIOMETRIC_ALREADY_ENABLED');
}
@ -119,10 +144,12 @@ export class AuthService {
const device = await this.deviceService.findUserDeviceById(deviceId, userId);
if (!device) {
this.logger.error(`Device not found for user with id ${userId} and device id ${deviceId}`);
throw new BadRequestException('AUTH.DEVICE_NOT_FOUND');
}
if (!device.publicKey) {
this.logger.error(`Biometric already disabled for user with id ${userId}`);
throw new BadRequestException('AUTH.BIOMETRIC_ALREADY_DISABLED');
}
@ -130,9 +157,11 @@ export class AuthService {
}
async sendForgetPasswordOtp({ email }: SendForgetPasswordOtpRequestDto) {
this.logger.log(`Sending forget password OTP to ${email}`);
const user = await this.userService.findUserOrThrow({ email });
if (!user.isProfileCompleted) {
this.logger.error(`Profile not completed for user with email ${email}`);
throw new BadRequestException('USERS.PROFILE_NOT_COMPLETED');
}
@ -145,8 +174,10 @@ export class AuthService {
}
async verifyForgetPasswordOtp({ email, otp, password, confirmPassword }: ForgetPasswordRequestDto) {
this.logger.log(`Verifying forget password OTP for ${email}`);
const user = await this.userService.findUserOrThrow({ email });
if (!user.isProfileCompleted) {
this.logger.error(`Profile not completed for user with email ${email}`);
throw new BadRequestException('USERS.PROFILE_NOT_COMPLETED');
}
const isOtpValid = await this.otpService.verifyOtp({
@ -157,6 +188,7 @@ export class AuthService {
});
if (!isOtpValid) {
this.logger.error(`Invalid OTP for user with email ${email}`);
throw new BadRequestException('USERS.INVALID_OTP');
}
@ -165,51 +197,106 @@ export class AuthService {
const hashedPassword = bcrypt.hashSync(password, user.salt);
await this.userService.setPasscode(user.id, hashedPassword, user.salt);
this.logger.log(`Passcode updated successfully for user with email ${email}`);
}
async login(loginDto: LoginRequestDto, deviceId: string): Promise<[ILoginResponse, User]> {
this.logger.log(`Logging in user with email ${loginDto.email}`);
const user = await this.userService.findUser({ email: loginDto.email });
let tokens;
if (!user) {
this.logger.error(`User with email ${loginDto.email} not found`);
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
}
if (loginDto.grantType === GrantType.PASSWORD) {
this.logger.log(`Logging in user with email ${loginDto.email} using password`);
tokens = await this.loginWithPassword(loginDto, user);
} else {
this.logger.log(`Logging in user with email ${loginDto.email} using biometric`);
tokens = await this.loginWithBiometric(loginDto, user, deviceId);
}
this.deviceService.updateDevice(deviceId, { lastAccessOn: new Date() });
await this.deviceService.updateDevice(deviceId, {
lastAccessOn: new Date(),
fcmToken: loginDto.fcmToken,
userId: user.id,
});
this.logger.log(`User with email ${loginDto.email} logged in successfully`);
return [tokens, user];
}
async setJuniorPasscode(body: setJuniorPasswordRequestDto) {
this.logger.log(`Setting passcode for junior with qrToken ${body.qrToken}`);
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);
this.logger.log(`Passcode set successfully for junior with id ${juniorId}`);
}
async refreshToken(refreshToken: string): Promise<[ILoginResponse, User]> {
this.logger.log('Refreshing token');
try {
const isValid = await this.jwtService.verifyAsync<IJwtPayload>(refreshToken, {
secret: this.configService.getOrThrow('JWT_REFRESH_TOKEN_SECRET'),
});
this.logger.log(`Refreshing token for user with id ${isValid.sub}`);
const user = await this.userService.findUserOrThrow({ id: isValid.sub });
const tokens = await this.generateAuthToken(user);
this.logger.log(`Token refreshed successfully for user with id ${isValid.sub}`);
return [tokens, user];
} catch (error) {
this.logger.error('Invalid refresh token');
throw new BadRequestException('AUTH.INVALID_REFRESH_TOKEN');
}
}
logout(req: Request) {
this.logger.log('Logging out');
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> {
this.logger.log(`validating password for user with email ${loginDto.email}`);
const isPasswordValid = bcrypt.compareSync(loginDto.password, user.password);
if (!isPasswordValid) {
this.logger.error(`Invalid password for user with email ${loginDto.email}`);
throw new UnauthorizedException('AUTH.INVALID_CREDENTIALS');
}
const tokens = await this.generateAuthToken(user);
this.logger.log(`Password validated successfully for user with email ${loginDto.email}`);
return tokens;
}
private async loginWithBiometric(loginDto: LoginRequestDto, user: User, deviceId: string): Promise<ILoginResponse> {
this.logger.log(`validating biometric for user with email ${loginDto.email}`);
const device = await this.deviceService.findUserDeviceById(deviceId, user.id);
if (!device) {
this.logger.error(`Device not found for user with email ${loginDto.email} and device id ${deviceId}`);
throw new UnauthorizedException('AUTH.DEVICE_NOT_FOUND');
}
if (!device.publicKey) {
this.logger.error(`Biometric not enabled for user with email ${loginDto.email}`);
throw new UnauthorizedException('AUTH.BIOMETRIC_NOT_ENABLED');
}
const cleanToken = removePadding(loginDto.deviceToken);
const cleanToken = removePadding(loginDto.signature);
const isValidToken = await verifySignature(
device.publicKey,
cleanToken,
@ -218,15 +305,17 @@ export class AuthService {
);
if (!isValidToken) {
this.logger.error(`Invalid biometric for user with email ${loginDto.email}`);
throw new UnauthorizedException('AUTH.INVALID_BIOMETRIC');
}
const tokens = await this.generateAuthToken(user);
this.logger.log(`Biometric validated successfully for user with email ${loginDto.email}`);
return tokens;
}
private async generateAuthToken(user: User) {
this.logger.log(`Generating auth token for user with id ${user.id}`);
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.sign(
{ sub: user.id, roles: user.roles },
@ -244,22 +333,20 @@ export class AuthService {
),
]);
this.logger.log(`Auth token generated successfully for user with id ${user.id}`);
return { accessToken, refreshToken, expiresAt: new Date(this.jwtService.decode(accessToken).exp * ONE_THOUSAND) };
}
private validatePassword(password: string, confirmPassword: string, user: User) {
this.logger.log(`Validating password for user with id ${user.id}`);
if (password !== confirmPassword) {
this.logger.error(`Password mismatch for user with id ${user.id}`);
throw new BadRequestException('AUTH.PASSWORD_MISMATCH');
}
const roles = user.roles;
if (roles.includes(Roles.GUARDIAN) && !PASSCODE_REGEX.test(password)) {
if (!PASSCODE_REGEX.test(password)) {
this.logger.error(`Invalid password for user with id ${user.id}`);
throw new BadRequestException('AUTH.INVALID_PASSCODE');
}
if (roles.includes(Roles.JUNIOR) && !PASSWORD_REGEX.test(password)) {
throw new BadRequestException('AUTH.INVALID_PASSWORD');
}
}
}

View File

@ -1,19 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Device } from '../entities';
import { DeviceRepository } from '../repositories';
@Injectable()
export class DeviceService {
constructor(private readonly deviceRepository: DeviceRepository) {}
findUserDeviceById(deviceId: string, userId: string) {
return this.deviceRepository.findUserDeviceById(deviceId, userId);
}
createDevice(data: Partial<Device>) {
return this.deviceRepository.createDevice(data);
}
updateDevice(deviceId: string, data: Partial<Device>) {
return this.deviceRepository.updateDevice(deviceId, data);
}
}

View File

@ -1,3 +1 @@
export * from './auth.service';
export * from './device.service';
export * from './user.service';

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 './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 { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';
import { IS_PUBLIC_KEY } from '../decorators';
import { CacheService } from '../modules/cache/services';
@Injectable()
export class AccessTokenGuard extends AuthGuard('access-token') {
constructor(private reflector: Reflector) {
constructor(protected reflector: Reflector, private readonly cacheService: CacheService) {
super();
}
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
async canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
@ -18,6 +18,17 @@ export class AccessTokenGuard extends AuthGuard('access-token') {
if (isPublic) {
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 './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,23 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Cacheable } from 'cacheable';
@Injectable()
export class CacheService {
private readonly logger = new Logger(CacheService.name);
constructor(@Inject('CACHE_INSTANCE') private readonly cache: Cacheable) {}
get<T>(key: string): Promise<T | undefined> {
this.logger.log(`Getting value for key ${key}`);
return this.cache.get(key);
}
async set<T>(key: string, value: T, ttl?: number | string): Promise<void> {
this.logger.log(`Setting value for key ${key}`);
await this.cache.set(key, value, ttl);
}
async delete(key: string): Promise<void> {
this.logger.log(`Deleting value for key ${key}`);
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,36 @@
import { Injectable, Logger } from '@nestjs/common';
import { DocumentType } from '~/document/enums';
import { DocumentService, OciService } from '~/document/services';
@Injectable()
export class LookupService {
private readonly logger = new Logger(LookupService.name);
constructor(private readonly documentService: DocumentService, private readonly ociService: OciService) {}
async findDefaultAvatar() {
this.logger.log(`Finding default avatar`);
const documents = await this.documentService.findDocuments({ documentType: DocumentType.DEFAULT_AVATAR });
await Promise.all(
documents.map(async (document) => {
document.url = await this.ociService.generatePreSignedUrl(document);
}),
);
this.logger.log(`Default avatar returned successfully`);
return documents;
}
async findDefaultTasksLogo() {
this.logger.log(`Finding default tasks logos`);
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);
}),
);
this.logger.log(`Default tasks logos returned successfully`);
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 @@
export * from './send-email.request.dto';

View File

@ -0,0 +1,20 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsOptional, IsString } from 'class-validator';
export class SendEmailRequestDto {
@ApiProperty({ example: 'test@test.com' })
@IsEmail()
to!: string;
@ApiProperty({ example: 'Test Subject' })
@IsString()
subject!: string;
@ApiProperty({ example: 'test' })
@IsString()
template!: string;
@ApiProperty({ example: { name: 'John Doe' } })
@IsOptional()
data!: Record<string, any>;
}

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 '~/user/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 enum EventType {
NOTIFICATION_CREATED = 'NOTIFICATION_CREATED',
}

View File

@ -0,0 +1,4 @@
export * from './event-type.enum';
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,6 @@
export enum NotificationScope {
USER_REGISTERED = 'USER_REGISTERED',
TASK_COMPLETED = 'TASK_COMPLETED',
GIFT_RECEIVED = 'GIFT_RECEIVED',
OTP = 'OTP',
}

View File

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

View File

@ -0,0 +1,3 @@
export class NotificationEvent {
constructor(public readonly notification: Notification) {}
}

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,30 @@
import { MailerModule } from '@nestjs-modules/mailer';
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TwilioModule } from 'nestjs-twilio';
import { buildMailerOptions, buildTwilioOptions } from '~/core/module-options';
import { UserModule } from '~/user/user.module';
import { NotificationsController } from './controllers';
import { Notification } from './entities';
import { NotificationsRepository } from './repositories';
import { FirebaseService, NotificationsService, TwilioService } from './services';
@Module({
imports: [
TypeOrmModule.forFeature([Notification]),
TwilioModule.forRootAsync({
useFactory: buildTwilioOptions,
inject: [ConfigService],
}),
MailerModule.forRootAsync({
useFactory: buildMailerOptions,
inject: [ConfigService],
}),
UserModule,
],
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,29 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as admin from 'firebase-admin';
@Injectable()
export class FirebaseService {
private readonly logger = new Logger(FirebaseService.name);
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) {
this.logger.log(`Sending push notification to ${tokens}`);
const message = {
notification: {
title,
body,
},
tokens: Array.isArray(tokens) ? tokens : [tokens],
};
admin.messaging().sendEachForMulticast(message);
}
}

View File

@ -0,0 +1,3 @@
export * from './firebase.service';
export * from './notifications.service';
export * from './twilio.service';

View File

@ -0,0 +1,123 @@
import { MailerService } from '@nestjs-modules/mailer';
import { Injectable, Logger } from '@nestjs/common';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { PageOptionsRequestDto } from '~/core/dtos';
import { DeviceService } from '~/user/services';
import { OTP_BODY, OTP_TITLE } from '../../otp/constants';
import { OtpType } from '../../otp/enums';
import { ISendOtp } from '../../otp/interfaces';
import { SendEmailRequestDto } from '../dtos/request';
import { Notification } from '../entities';
import { EventType, NotificationChannel, NotificationScope } from '../enums';
import { NotificationsRepository } from '../repositories';
import { FirebaseService } from './firebase.service';
import { TwilioService } from './twilio.service';
@Injectable()
export class NotificationsService {
private readonly logger = new Logger(NotificationsService.name);
constructor(
private readonly firebaseService: FirebaseService,
private readonly notificationRepository: NotificationsRepository,
private readonly twilioService: TwilioService,
private readonly eventEmitter: EventEmitter2,
private readonly deviceService: DeviceService,
private readonly mailerService: MailerService,
) {}
async sendPushNotification(userId: string, title: string, body: string) {
this.logger.log(`Sending push notification to user ${userId}`);
// Get the device tokens for the user
const tokens = await this.deviceService.getTokens(userId);
if (!tokens.length) {
this.logger.log(`No device tokens found for user ${userId} but notification created in the database`);
return;
}
// Send the notification
return this.firebaseService.sendNotification(tokens, title, body);
}
async sendSMS(to: string, body: string) {
this.logger.log(`Sending SMS to ${to}`);
await this.twilioService.sendSMS(to, body);
}
async sendEmail({ to, subject, data, template }: SendEmailRequestDto) {
this.logger.log(`Sending email to ${to}`);
await this.mailerService.sendMail({
to,
subject,
template,
context: { ...data },
});
this.logger.log(`Email sent to ${to}`);
}
async getNotifications(userId: string, pageOptionsDto: PageOptionsRequestDto) {
this.logger.log(`Getting notifications for user ${userId}`);
const [[notifications, count], unreadCount] = await Promise.all([
this.notificationRepository.getNotifications(userId, pageOptionsDto),
this.notificationRepository.getUnreadNotificationsCount(userId),
]);
this.logger.log(`Returning notifications for user ${userId}`);
return { notifications, count, unreadCount };
}
createNotification(notification: Partial<Notification>) {
this.logger.log(`Creating notification for user ${notification.userId}`);
return this.notificationRepository.createNotification(notification);
}
markAsRead(userId: string) {
this.logger.log(`Marking notifications as read for user ${userId}`);
return this.notificationRepository.markAsRead(userId);
}
async sendOtpNotification(sendOtpRequest: ISendOtp, otp: string) {
this.logger.log(`Sending OTP to ${sendOtpRequest.recipient}`);
const notification = await this.createNotification({
recipient: sendOtpRequest.recipient,
title: OTP_TITLE,
message: OTP_BODY.replace('{otp}', otp),
scope: NotificationScope.OTP,
channel: sendOtpRequest.otpType === OtpType.EMAIL ? NotificationChannel.EMAIL : NotificationChannel.SMS,
});
this.logger.log(`emitting ${EventType.NOTIFICATION_CREATED} event`);
return this.eventEmitter.emit(EventType.NOTIFICATION_CREATED, notification);
}
private getTemplateFromNotification(notification: Notification) {
switch (notification.scope) {
case NotificationScope.OTP:
return 'otp';
default:
return 'otp';
}
}
@OnEvent(EventType.NOTIFICATION_CREATED)
handleNotificationCreatedEvent(notification: Notification, data?: any) {
this.logger.log(
`Handling ${EventType.NOTIFICATION_CREATED} event for notification ${notification.id} and type ${notification.channel}`,
);
switch (notification.channel) {
case NotificationChannel.SMS:
return this.sendSMS(notification.recipient!, notification.message);
case NotificationChannel.PUSH:
return this.sendPushNotification(notification.userId, notification.title, notification.message);
case NotificationChannel.EMAIL:
return this.sendEmail({
to: notification.recipient!,
subject: notification.title,
data,
template: this.getTemplateFromNotification(notification),
});
}
}
}

View File

@ -0,0 +1,22 @@
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) {
this.logger.log(`Sending SMS to ${to}`);
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

@ -0,0 +1,21 @@
<body>
<div class="otp">
<h1 class="title">Your OTP Code</h1>
<p class="message">To verify your account, please use the following One-Time Password (OTP):</p>
<div class="otp-code">{{otp}}</div>
</div>
<style>
.otp {
text-align: center;
font-family: sans-serif;
font-size: 16px;
line-height: 1.5;
}
.otp-code {
font-size: 24px;
font-weight: bold;
margin-top: 20px;
}
</style>
</body>

View File

@ -1 +1,2 @@
export * from './otp-contant.constants';
export * from './otp-default.constant';

View File

@ -0,0 +1,2 @@
export const OTP_TITLE = 'ZOD - OTP';
export const OTP_BODY = 'Your verification code for ZOD is {otp}';

View File

@ -1,5 +1,5 @@
import { Column, CreateDateColumn, Entity, Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { User } from '~/auth/entities';
import { User } from '~/user/entities';
import { OtpScope, OtpType } from '../enums';
@Entity('otp')

View File

@ -1,13 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { NotificationModule } from '../notification/notification.module';
import { Otp } from './entities';
import { OtpRepository } from './repositories';
import { OtpService } from './services/otp.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([Otp])],
providers: [OtpService, OtpRepository],
exports: [OtpService],
imports: [TypeOrmModule.forFeature([Otp]), NotificationModule],
})
export class OtpModule {}

View File

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NotificationsService } from '../../notification/services/notifications.service';
import { DEFAULT_OTP_DIGIT, DEFAULT_OTP_LENGTH } from '../constants';
import { OtpType } from '../enums';
import { ISendOtp, IVerifyOtp } from '../interfaces';
@ -8,28 +9,41 @@ import { generateRandomOtp } from '../utils';
@Injectable()
export class OtpService {
constructor(private readonly configService: ConfigService, private readonly otpRepository: OtpRepository) {}
private readonly logger = new Logger(OtpService.name);
constructor(
private readonly configService: ConfigService,
private readonly otpRepository: OtpRepository,
private readonly notificationService: NotificationsService,
) {}
private useMock = this.configService.get<boolean>('USE_MOCK', false);
async generateAndSendOtp(sendotpRequest: ISendOtp): Promise<string> {
async generateAndSendOtp(sendOtpRequest: ISendOtp): Promise<string> {
this.logger.log(`Generating OTP for ${sendOtpRequest.recipient}`);
const otp = this.useMock ? DEFAULT_OTP_DIGIT.repeat(DEFAULT_OTP_LENGTH) : generateRandomOtp(DEFAULT_OTP_LENGTH);
await this.otpRepository.createOtp({ ...sendotpRequest, value: otp });
await this.otpRepository.createOtp({ ...sendOtpRequest, value: otp });
this.sendOtp(sendotpRequest, otp);
return sendotpRequest.otpType == OtpType.EMAIL
? sendotpRequest.recipient
: sendotpRequest.recipient?.replace(/.(?=.{4})/g, '*');
await this.sendOtp(sendOtpRequest, otp);
this.logger.log(`OTP generated and sent successfully to ${sendOtpRequest.recipient}`);
return sendOtpRequest.otpType == OtpType.EMAIL
? sendOtpRequest.recipient
: sendOtpRequest.recipient?.replace(/.(?=.{4})/g, '*');
}
async verifyOtp(verifyOtpRequest: IVerifyOtp) {
this.logger.log(`Verifying OTP for ${verifyOtpRequest.userId}`);
const otp = await this.otpRepository.findOtp(verifyOtpRequest);
if (!otp) {
this.logger.error(
`OTP value ${verifyOtpRequest.value} not found for ${verifyOtpRequest.userId} and ${verifyOtpRequest.otpType}`,
);
return false;
}
return !!otp;
}
private sendOtp(sendotpRequest: ISendOtp, otp: string) {
// TODO: send OTP to the user
return;
private sendOtp(sendOtpRequest: ISendOtp, otp: string) {
return this.notificationService.sendOtpNotification(sendOtpRequest, otp);
}
}

View File

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

View File

@ -1,3 +1,6 @@
export * from '././keyv-options';
export * from './config-options';
export * from './typeorm-options';
export * from './logger-options';
export * from './mailer-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,24 @@
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { ConfigService } from '@nestjs/config';
import path from 'path';
export function buildMailerOptions(config: ConfigService) {
return {
transport: {
from: config.getOrThrow<string>('MAIL_FROM'),
host: config.getOrThrow<string>('MAIL_HOST'),
port: config.getOrThrow<number>('MAIL_PORT'),
auth: {
user: config.getOrThrow<string>('MAIL_USER'),
pass: config.getOrThrow<string>('MAIL_PASSWORD'),
},
},
template: {
dir: path.join(__dirname, '../../common/modules/notification/templates'),
adapter: new HandlebarsAdapter(),
options: {
strict: true,
},
},
};
}

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];

11
src/cron/cron.module.ts Normal file
View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AllowanceModule } from '~/allowance/allowance.module';
import { MoneyRequestModule } from '~/money-request/money-request.module';
import { BaseCronService } from './services';
import { AllowanceTask, MoneyRequestTask } from './tasks';
@Module({
imports: [AllowanceModule, MoneyRequestModule],
providers: [AllowanceTask, MoneyRequestTask, BaseCronService],
})
export class CronModule {}

View File

@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { CacheService } from '~/common/modules/cache/services';
@Injectable()
export class BaseCronService {
constructor(private readonly cacheService: CacheService) {}
async acquireLock(key: string, ttl: number) {
const lock = await this.cacheService.get(key);
if (lock) {
return false;
}
await this.cacheService.set(key, true, ttl);
return true;
}
async releaseLock(key: string) {
await this.cacheService.delete(key);
}
}

View File

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

View File

@ -0,0 +1,54 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import moment from 'moment';
import { AllowancesService } from '~/allowance/services';
import { CacheService } from '~/common/modules/cache/services';
import { BaseCronService } from '../services/base-cron.service';
const TEN = 10;
const SIXTY = 60;
const THOUSAND = 1000;
const TEN_MINUTES = TEN * SIXTY * THOUSAND;
const CHUNK_SIZE = 50;
@Injectable()
export class AllowanceTask extends BaseCronService {
private readonly logger = new Logger(AllowanceTask.name);
private readonly cronLockKey = `${AllowanceTask.name}-lock`;
private readonly cronLockTtl = TEN_MINUTES;
constructor(cacheService: CacheService, private readonly allowanceService: AllowancesService) {
super(cacheService);
}
@Cron(CronExpression.EVERY_DAY_AT_3AM)
async handleCron() {
try {
const isLockAcquired = await this.acquireLock(this.cronLockKey, this.cronLockTtl);
if (!isLockAcquired) {
this.logger.log('Lock already acquired. Skipping cron job.');
return;
}
this.logger.log('Processing cron job');
await this.processJob();
} catch (error) {
this.logger.error('Error processing cron job', error);
} finally {
this.logger.log('Releasing lock');
await this.releaseLock(this.cronLockKey);
}
}
private async processJob() {
const today = moment().startOf('day');
const allowancesChunks = await this.allowanceService.findAllowancesChunks(CHUNK_SIZE);
for await (const allowances of allowancesChunks) {
for (const allowance of allowances) {
this.logger.log(`Processing allowance ${allowance.id} with next payment date ${allowance.nextPaymentDate}`);
// if today is the same as allowance payment date
if (moment(allowance.nextPaymentDate).startOf('day').isSame(today)) {
this.logger.log(`Today is the payment date for allowance ${allowance.id}`);
//@TODO: Implement payment logic
}
}
}
}
}

2
src/cron/tasks/index.ts Normal file
View File

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

View File

@ -0,0 +1,54 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import moment from 'moment';
import { CacheService } from '~/common/modules/cache/services';
import { MoneyRequestsService } from '~/money-request/services';
import { BaseCronService } from '../services';
const TEN = 10;
const SIXTY = 60;
const THOUSAND = 1000;
const TEN_MINUTES = TEN * SIXTY * THOUSAND;
const CHUNK_SIZE = 50;
@Injectable()
export class MoneyRequestTask extends BaseCronService {
private readonly cronLockKey = `${MoneyRequestTask.name}-lock`;
private readonly cronLockTtl = TEN_MINUTES;
private readonly logger = new Logger(MoneyRequestTask.name);
constructor(cacheService: CacheService, private readonly moneyRequestService: MoneyRequestsService) {
super(cacheService);
}
@Cron(CronExpression.EVERY_DAY_AT_3AM)
async handleCron() {
try {
const isLockAcquired = await this.acquireLock(this.cronLockKey, this.cronLockTtl);
if (!isLockAcquired) {
this.logger.log('Lock already acquired. Skipping cron job for MoneyRequestTask.');
return;
}
this.logger.log('Processing cron job for MoneyRequestTask');
await this.processJob();
} catch (error) {
this.logger.error('Error processing MoneyRequestTask cron job', error);
} finally {
this.logger.log('Releasing lock for MoneyRequestTask');
await this.releaseLock(this.cronLockKey);
}
}
private async processJob() {
const today = moment().startOf('day');
const moneyRequestsChunks = await this.moneyRequestService.findMoneyRequestsChunks(CHUNK_SIZE);
for await (const moneyRequests of moneyRequestsChunks) {
for (const moneyRequest of moneyRequests) {
this.logger.log(
`Processing money request ${moneyRequest.id} with next payment date ${moneyRequest.nextPaymentDate}`,
);
// if today is the same as money request payment date
if (moment(moneyRequest.nextPaymentDate).startOf('day').isSame(today)) {
this.logger.log(`Today is the payment date for money request ${moneyRequest.id}`);
}
}
}
}
}

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 { IJwtPayload } from '~/auth/interfaces';
import { DEVICE_ID_HEADER } from '~/common/constants';
import { AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards';
import { ApiDataResponse } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils';
import { UpdateCustomerRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request';
import { CustomerResponseDto, NotificationSettingsResponseDto } from '../dtos/response';
@ -11,11 +13,21 @@ import { CustomerService } from '../services';
@Controller('customers')
@ApiTags('Customers')
@ApiBearerAuth()
@UseGuards(AccessTokenGuard)
export class CustomerController {
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('')
@UseGuards(AccessTokenGuard)
@ApiDataResponse(CustomerResponseDto)
async updateCustomer(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: UpdateCustomerRequestDto) {
const customer = await this.customerService.updateCustomer(sub, body);
@ -23,11 +35,14 @@ export class CustomerController {
}
@Patch('settings/notifications')
@UseGuards(AccessTokenGuard)
@ApiDataResponse(NotificationSettingsResponseDto)
async updateNotificationSettings(
@AuthenticatedUser() { sub }: IJwtPayload,
@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));
}

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