Compare commits

..

17 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
85 changed files with 999 additions and 136 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"
]

53
package-lock.json generated
View File

@ -22,6 +22,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",
@ -2198,6 +2199,33 @@
"@nestjs/core": "^10.0.0"
}
},
"node_modules/@nestjs/schedule": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.2.tgz",
"integrity": "sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==",
"license": "MIT",
"dependencies": {
"cron": "3.2.1",
"uuid": "11.0.3"
},
"peerDependencies": {
"@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0",
"@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/@nestjs/schedule/node_modules/uuid": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz",
"integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/@nestjs/schematics": {
"version": "10.2.3",
"dev": true,
@ -2842,6 +2870,12 @@
"license": "MIT",
"optional": true
},
"node_modules/@types/luxon": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
"integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==",
"license": "MIT"
},
"node_modules/@types/methods": {
"version": "1.1.4",
"dev": true,
@ -5005,6 +5039,16 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/cron": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/cron/-/cron-3.2.1.tgz",
"integrity": "sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==",
"license": "MIT",
"dependencies": {
"@types/luxon": "~3.4.0",
"luxon": "~3.5.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.5",
"license": "MIT",
@ -9238,6 +9282,15 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/luxon": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz",
"integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.8",
"dev": true,

View File

@ -39,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",

View File

@ -15,6 +15,6 @@ import { AllowanceChangeRequestsService, AllowancesService } from './services';
AllowanceChangeRequestsService,
AllowanceChangeRequestsRepository,
],
exports: [],
exports: [AllowancesService],
})
export class AllowanceModule {}

View File

@ -1,3 +1,4 @@
import moment from 'moment';
import {
Column,
CreateDateColumn,
@ -13,7 +14,6 @@ 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')
@ -71,4 +71,37 @@ export class Allowance {
@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

@ -44,4 +44,21 @@ export class AllowancesRepository {
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

@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { PageOptionsRequestDto } from '~/core/dtos';
import { OciService } from '~/document/services';
@ -10,6 +10,7 @@ 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,
@ -17,9 +18,11 @@ export class AllowanceChangeRequestsService {
) {}
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');
}
@ -30,6 +33,7 @@ export class AllowanceChangeRequestsService {
});
if (requestWithTheSameAmount) {
this.logger.error(`There is a pending request with the same amount`);
throw new BadRequestException('ALLOWANCE_CHANGE_REQUEST.SAME_AMOUNT_PENDING');
}
@ -37,16 +41,20 @@ export class AllowanceChangeRequestsService {
}
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(
@ -56,12 +64,15 @@ export class AllowanceChangeRequestsService {
}
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(
@ -74,6 +85,7 @@ export class AllowanceChangeRequestsService {
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,
@ -81,10 +93,12 @@ export class AllowanceChangeRequestsService {
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,
@ -94,15 +108,18 @@ export class AllowanceChangeRequestsService {
);
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;

View File

@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import moment from 'moment';
import { PageOptionsRequestDto } from '~/core/dtos';
import { OciService } from '~/document/services';
@ -9,6 +9,7 @@ import { AllowancesRepository } from '../repositories';
@Injectable()
export class AllowancesService {
private readonly logger = new Logger(AllowancesService.name);
constructor(
private readonly allowancesRepository: AllowancesRepository,
private readonly juniorService: JuniorService,
@ -16,63 +17,87 @@ export class AllowancesService {
) {}
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;

View File

@ -1,6 +1,8 @@
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';
@ -16,6 +18,7 @@ import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/
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';
@ -26,6 +29,7 @@ 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: [],
@ -52,9 +56,9 @@ import { TaskModule } from './task/task.module';
inject: [ConfigService],
}),
I18nModule.forRoot(buildI18nOptions()),
CacheModule,
EventEmitterModule.forRoot(),
ScheduleModule.forRoot(),
// App modules
AuthModule,
CustomerModule,
@ -66,14 +70,16 @@ import { TaskModule } from './task/task.module';
AllowanceModule,
MoneyRequestModule,
GiftModule,
NotificationModule,
OtpModule,
DocumentModule,
LookupModule,
HealthModule,
NotificationModule,
UserModule,
CronModule,
],
providers: [
// Global Pipes

View File

@ -1,24 +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 } 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, Device]),
JwtModule.register({}),
forwardRef(() => CustomerModule),
forwardRef(() => JuniorModule),
],
providers: [AuthService, UserRepository, UserService, DeviceService, DeviceRepository, AccessTokenStrategy],
imports: [JwtModule.register({}), JuniorModule, UserModule],
providers: [AuthService, AccessTokenStrategy],
controllers: [AuthController],
exports: [UserService, DeviceService],
exports: [],
})
export class AuthModule {}

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,4 @@
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';
@ -7,7 +7,9 @@ import { CacheService } from '~/common/modules/cache/services';
import { OtpScope, OtpType } from '~/common/modules/otp/enums';
import { OtpService } from '~/common/modules/otp/services';
import { JuniorTokenService } from '~/junior/services';
import { PASSCODE_REGEX, PASSWORD_REGEX } from '../constants';
import { DeviceService, UserService } from '~/user/services';
import { User } from '../../user/entities';
import { PASSCODE_REGEX } from '../constants';
import {
CreateUnverifiedUserRequestDto,
DisableBiometricRequestDto,
@ -17,19 +19,17 @@ import {
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 { 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,
@ -40,20 +40,25 @@ export class AuthService {
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');
}
@ -65,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');
}
@ -92,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,
@ -115,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');
}
@ -125,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');
}
@ -136,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');
}
@ -151,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({
@ -163,6 +188,7 @@ export class AuthService {
});
if (!isOtpValid) {
this.logger.error(`Invalid OTP for user with email ${email}`);
throw new BadRequestException('USERS.INVALID_OTP');
}
@ -171,81 +197,102 @@ 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, {
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');
}
@ -258,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 },
@ -284,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,3 +1 @@
export * from './auth.service';
export * from './device.service';
export * from './user.service';

View File

@ -1,19 +1,23 @@
import { Inject, Injectable } from '@nestjs/common';
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

@ -1,23 +1,27 @@
import { Injectable } from '@nestjs/common';
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(
@ -26,6 +30,7 @@ export class LookupService {
}),
);
this.logger.log(`Default tasks logos returned successfully`);
return documents;
}
}

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

@ -7,7 +7,7 @@ import {
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from '~/auth/entities';
import { User } from '~/user/entities';
import { NotificationChannel, NotificationScope, NotificationStatus } from '../enums';
@Entity('notifications')

View File

@ -0,0 +1,3 @@
export enum EventType {
NOTIFICATION_CREATED = 'NOTIFICATION_CREATED',
}

View File

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

View File

@ -2,4 +2,5 @@ export enum NotificationScope {
USER_REGISTERED = 'USER_REGISTERED',
TASK_COMPLETED = 'TASK_COMPLETED',
GIFT_RECEIVED = 'GIFT_RECEIVED',
OTP = 'OTP',
}

View File

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

View File

@ -1,23 +1,27 @@
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 { AuthModule } from '~/auth/auth.module';
import { buildTwilioOptions } from '~/core/module-options';
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 } from './services/firebase.service';
import { NotificationsService } from './services/notifications.service';
import { TwilioService } from './services/twilio.service';
import { FirebaseService, NotificationsService, TwilioService } from './services';
@Module({
imports: [
TypeOrmModule.forFeature([Notification]),
AuthModule,
TwilioModule.forRootAsync({
useFactory: buildTwilioOptions,
inject: [ConfigService],
}),
MailerModule.forRootAsync({
useFactory: buildMailerOptions,
inject: [ConfigService],
}),
UserModule,
],
providers: [NotificationsService, FirebaseService, NotificationsRepository, TwilioService],
exports: [NotificationsService],

View File

@ -1,8 +1,9 @@
import { Injectable } from '@nestjs/common';
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({
@ -14,6 +15,7 @@ export class FirebaseService {
}
sendNotification(tokens: string | string[], title: string, body: string) {
this.logger.log(`Sending push notification to ${tokens}`);
const message = {
notification: {
title,

View File

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

View File

@ -1,26 +1,38 @@
import { Injectable } from '@nestjs/common';
import { DeviceService } from '~/auth/services';
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 deviceService: DeviceService,
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
@ -28,23 +40,84 @@ export class NotificationsService {
}
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

@ -8,6 +8,7 @@ export class TwilioService {
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;

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

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

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,
},
},
};
}

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,6 +1,6 @@
import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from '~/auth/auth.module';
import { UserModule } from '~/user/user.module';
import { CustomerController } from './controllers';
import { Customer } from './entities';
import { CustomerNotificationSettings } from './entities/customer-notification-settings.entity';
@ -8,7 +8,7 @@ import { CustomerRepository } from './repositories/customer.repository';
import { CustomerService } from './services';
@Module({
imports: [TypeOrmModule.forFeature([Customer, CustomerNotificationSettings]), forwardRef(() => AuthModule)],
imports: [TypeOrmModule.forFeature([Customer, CustomerNotificationSettings]), forwardRef(() => UserModule)],
controllers: [CustomerController],
providers: [CustomerService, CustomerRepository],
exports: [CustomerService],

View File

@ -29,5 +29,6 @@ export class UpdateCustomerRequestDto {
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' })
@IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'customer.profilePictureId' }) })
@IsOptional()
profilePictureId!: string;
}

View File

@ -26,7 +26,7 @@ export class CustomerResponseDto {
nationalId!: string;
@ApiProperty()
nationaIdExpiry!: Date;
nationalIdExpiry!: Date;
@ApiProperty()
countryOfResidence!: string;
@ -66,7 +66,7 @@ export class CustomerResponseDto {
this.lastName = customer.lastName;
this.dateOfBirth = customer.dateOfBirth;
this.nationalId = customer.nationalId;
this.nationaIdExpiry = customer.nationaIdExpiry;
this.nationalIdExpiry = customer.nationalIdExpiry;
this.countryOfResidence = customer.countryOfResidence;
this.sourceOfIncome = customer.sourceOfIncome;
this.profession = customer.profession;

View File

@ -8,10 +8,10 @@ import {
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { User } from '~/auth/entities';
import { Document } from '~/document/entities';
import { Guardian } from '~/guardian/entities/guradian.entity';
import { Junior } from '~/junior/entities';
import { User } from '~/user/entities';
import { CustomerNotificationSettings } from './customer-notification-settings.entity';
@Entity('customers')
@ -38,7 +38,7 @@ export class Customer extends BaseEntity {
nationalId!: string;
@Column('date', { nullable: true, name: 'national_id_expiry' })
nationaIdExpiry!: Date;
nationalIdExpiry!: Date;
@Column('varchar', { length: 255, nullable: true, name: 'country_of_residence' })
countryOfResidence!: string;

View File

@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, Repository } from 'typeorm';
import { User } from '~/auth/entities';
import { Roles } from '~/auth/enums';
import { User } from '~/user/entities';
import { UpdateNotificationsSettingsRequestDto } from '../dtos/request';
import { Customer } from '../entities';
import { CustomerNotificationSettings } from '../entities/customer-notification-settings.entity';

View File

@ -1,53 +1,63 @@
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
import { User } from '~/auth/entities';
import { DeviceService } from '~/auth/services';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { OciService } from '~/document/services';
import { User } from '~/user/entities';
import { DeviceService } from '~/user/services';
import { UpdateCustomerRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request';
import { Customer } from '../entities';
import { CustomerRepository } from '../repositories/customer.repository';
@Injectable()
export class CustomerService {
private readonly logger = new Logger(CustomerService.name);
constructor(
private readonly customerRepository: CustomerRepository,
private readonly ociService: OciService,
@Inject(forwardRef(() => DeviceService)) private readonly deviceService: DeviceService,
private readonly deviceService: DeviceService,
) {}
async updateNotificationSettings(userId: string, data: UpdateNotificationsSettingsRequestDto, deviceId: string) {
this.logger.log(`Updating notification settings for user ${userId}`);
const customer = await this.findCustomerById(userId);
const notificationSettings = (await this.customerRepository.updateNotificationSettings(customer, data))
.notificationSettings;
if (data.isPushEnabled && deviceId) {
this.logger.log(`Updating device ${deviceId} with fcmToken`);
await this.deviceService.updateDevice(deviceId, {
fcmToken: data.fcmToken,
userId: userId,
});
}
this.logger.log(`Notification settings updated for user ${userId}`);
return notificationSettings;
}
async updateCustomer(userId: string, data: UpdateCustomerRequestDto): Promise<Customer> {
this.logger.log(`Updating customer ${userId}`);
await this.customerRepository.updateCustomer(userId, data);
this.logger.log(`Customer ${userId} updated successfully`);
return this.findCustomerById(userId);
}
createCustomer(customerData: Partial<Customer>, user: User) {
this.logger.log(`Creating customer for user ${user.id}`);
return this.customerRepository.createCustomer(customerData, user);
}
async findCustomerById(id: string) {
this.logger.log(`Finding customer ${id}`);
const customer = await this.customerRepository.findOne({ id });
if (!customer) {
this.logger.error(`Customer ${id} not found`);
throw new BadRequestException('CUSTOMER.NOT_FOUND');
}
if (customer.profilePicture) {
this.logger.log(`Generating pre-signed url for profile picture of customer ${id}`);
customer.profilePicture.url = await this.ociService.generatePreSignedUrl(customer.profilePicture);
}
this.logger.log(`Customer ${id} found successfully`);
return customer;
}
}

View File

@ -12,6 +12,7 @@ export class CreateMoneyRequestEntity1734503895302 implements MigrationInterface
"frequency" character varying NOT NULL DEFAULT 'ONE_TIME',
"status" character varying NOT NULL DEFAULT 'PENDING',
"reviewed_at" TIMESTAMP WITH TIME ZONE,
"start_date" TIMESTAMP WITH TIME ZONE,
"end_date" TIMESTAMP WITH TIME ZONE,
"requester_id" uuid NOT NULL,
"reviewer_id" uuid NOT NULL,

View File

@ -1,11 +1,11 @@
import { Column, Entity, OneToMany, OneToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
import { User } from '~/auth/entities';
import { Customer } from '~/customer/entities';
import { Gift } from '~/gift/entities';
import { Junior, Theme } from '~/junior/entities';
import { SavingGoal } from '~/saving-goals/entities';
import { Task } from '~/task/entities';
import { TaskSubmission } from '~/task/entities/task-submissions.entity';
import { User } from '~/user/entities';
import { DocumentType } from '../enums';
@Entity('documents')

View File

@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { UploadDocumentRequestDto } from '../dtos/request';
import { Document } from '../entities';
@ -7,13 +7,16 @@ import { OciService } from './oci.service';
@Injectable()
export class DocumentService {
private readonly logger = new Logger(DocumentService.name);
constructor(private readonly ociService: OciService, private readonly documentRepository: DocumentRepository) {}
async createDocument(file: Express.Multer.File, uploadedDocumentRequest: UploadDocumentRequestDto) {
this.logger.log(`creating document for with type ${uploadedDocumentRequest.documentType}`);
const uploadedFile = await this.ociService.uploadFile(file, uploadedDocumentRequest);
return this.documentRepository.createDocument(uploadedFile);
}
findDocuments(where: FindOptionsWhere<Document>) {
this.logger.log(`finding documents with where clause ${JSON.stringify(where)}`);
return this.documentRepository.findDocuments(where);
}
}

View File

@ -38,6 +38,7 @@ export class OciService {
}
async uploadFile(file: Express.Multer.File, { documentType }: UploadDocumentRequestDto): Promise<UploadResponseDto> {
this.logger.log(`Uploading file with type ${documentType}`);
const bucketName = BUCKETS[documentType];
const objectName = generateNewFileName(file.originalname);
@ -66,12 +67,16 @@ export class OciService {
}
async generatePreSignedUrl(document?: Document): Promise<string | any> {
this.logger.log(`Generating pre-signed url for document ${document?.id}`);
if (!document) {
this.logger.error('Document not found, skipping pre-signed url generation');
return null;
}
const cachedUrl = await this.cacheService.get<string>(document.id);
if (cachedUrl) {
this.logger.debug(`Returning cached pre-signed url for document ${document.id}`);
return cachedUrl;
}
@ -92,8 +97,9 @@ export class OciService {
},
retryConfiguration: { terminationStrategy: { shouldTerminate: () => true } },
});
this.cacheService.set(document.id, res.preauthenticatedRequest.fullPath + objectName, '1h');
this.logger.log(`Pre-signed url generated successfully for document ${document.id}`);
await this.cacheService.set(document.id, res.preauthenticatedRequest.fullPath + objectName, '1h');
this.logger.log(`Pre-signed url cached for document ${document.id}`);
return res.preauthenticatedRequest.fullPath + objectName;
} catch (error) {
this.logger.error(`Error generating pre-signed url: ${error}`);

View File

@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces';
import { OciService } from '~/document/services';
@ -10,6 +10,7 @@ import { GiftsRepository } from '../repositories';
@Injectable()
export class GiftsService {
private readonly logger = new Logger(GiftsService.name);
constructor(
private readonly juniorService: JuniorService,
private readonly giftsRepository: GiftsRepository,
@ -17,42 +18,51 @@ export class GiftsService {
) {}
async createGift(guardianId: string, body: CreateGiftRequestDto) {
this.logger.log(`Creating gift for junior ${body.recipientId} by guardian ${guardianId}`);
const doesJuniorBelongToGuardian = await this.juniorService.doesJuniorBelongToGuardian(
guardianId,
body.recipientId,
);
if (!doesJuniorBelongToGuardian) {
this.logger.error(`Junior ${body.recipientId} does not belong to guardian ${guardianId}`);
throw new BadRequestException('JUNIOR.DOES_NOT_BELONG_TO_GUARDIAN');
}
const gift = await this.giftsRepository.create(guardianId, body);
this.logger.log(`Gift ${gift.id} created successfully`);
return this.findUserGiftById({ sub: guardianId, roles: [Roles.GUARDIAN] }, gift.id);
}
async findUserGiftById(user: IJwtPayload, giftId: string) {
this.logger.log(`Finding gift ${giftId} for user ${user.sub} with roles ${user.roles}`);
const gift = user.roles.includes(Roles.GUARDIAN)
? await this.giftsRepository.findGuardianGiftById(user.sub, giftId)
: await this.giftsRepository.findJuniorGiftById(user.sub, giftId);
if (!gift) {
this.logger.error(`Gift ${giftId} not found`);
throw new BadRequestException('GIFT.NOT_FOUND');
}
await this.prepareGiftImages([gift]);
this.logger.log(`Gift ${giftId} found successfully`);
return gift;
}
async redeemGift(juniorId: string, giftId: string) {
this.logger.log(`Redeeming gift ${giftId} for junior ${juniorId}`);
const gift = await this.giftsRepository.findJuniorGiftById(juniorId, giftId);
if (!gift) {
this.logger.error(`Gift ${giftId} not found`);
throw new BadRequestException('GIFT.NOT_FOUND');
}
if (gift.status === GiftStatus.REDEEMED) {
this.logger.error(`Gift ${giftId} already redeemed`);
throw new BadRequestException('GIFT.ALREADY_REDEEMED');
}
@ -60,13 +70,16 @@ export class GiftsService {
}
async UndoGiftRedemption(juniorId: string, giftId: string) {
this.logger.log(`Undoing gift redemption for junior ${juniorId} and gift ${giftId}`);
const gift = await this.giftsRepository.findJuniorGiftById(juniorId, giftId);
if (!gift) {
this.logger.error(`Gift ${giftId} not found`);
throw new BadRequestException('GIFT.NOT_FOUND');
}
if (gift.status === GiftStatus.AVAILABLE) {
this.logger.error(`Gift ${giftId} is not redeemed yet`);
throw new BadRequestException('GIFT.NOT_REDEEMED');
}
@ -74,13 +87,16 @@ export class GiftsService {
}
async replyToGift(juniorId: string, giftId: string, body: GiftReplyRequestDto) {
this.logger.log(`Replying to gift ${giftId} for junior ${juniorId}`);
const gift = await this.giftsRepository.findJuniorGiftById(juniorId, giftId);
if (!gift) {
this.logger.error(`Gift ${giftId} not found`);
throw new BadRequestException('GIFT.NOT_FOUND');
}
if (gift.reply) {
this.logger.error(`Gift ${giftId} already replied`);
throw new BadRequestException('GIFT.ALREADY_REPLIED');
}
@ -88,10 +104,12 @@ export class GiftsService {
}
findGifts(user: IJwtPayload, filters: GiftFiltersRequestDto) {
this.logger.log(`Finding gifts for user ${user.sub} with roles ${user.roles}`);
return this.giftsRepository.findGifts(user, filters);
}
private async prepareGiftImages(gifts: Gift[]) {
this.logger.log('Preparing gifts image');
await Promise.all(
gifts.map(async (gift) => {
gift.image.url = await this.ociService.generatePreSignedUrl(gift.image);

View File

@ -1,20 +1,24 @@
import { forwardRef, Module } from '@nestjs/common';
import { HttpModule } from '@nestjs/axios';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from '~/auth/auth.module';
import { CustomerModule } from '~/customer/customer.module';
import { UserModule } from '~/user/user.module';
import { JuniorController } from './controllers';
import { Junior, JuniorRegistrationToken, Theme } from './entities';
import { JuniorRepository, JuniorTokenRepository } from './repositories';
import { JuniorService, JuniorTokenService, QrcodeService } from './services';
import { BranchIoService, JuniorService, JuniorTokenService, QrcodeService } from './services';
@Module({
controllers: [JuniorController],
providers: [JuniorService, JuniorRepository, JuniorTokenService, JuniorTokenRepository, QrcodeService],
imports: [
TypeOrmModule.forFeature([Junior, Theme, JuniorRegistrationToken]),
forwardRef(() => AuthModule),
CustomerModule,
providers: [
JuniorService,
JuniorRepository,
JuniorTokenService,
JuniorTokenRepository,
QrcodeService,
BranchIoService,
],
imports: [TypeOrmModule.forFeature([Junior, Theme, JuniorRegistrationToken]), UserModule, CustomerModule, HttpModule],
exports: [JuniorService, JuniorTokenService],
})
export class JuniorModule {}

View File

@ -0,0 +1,31 @@
import { HttpService } from '@nestjs/axios';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class BranchIoService {
private readonly logger = new Logger(BranchIoService.name);
private readonly branchIoKey = this.configService.getOrThrow<string>('BRANCH_IO_KEY');
private readonly branchIoUrl = this.configService.getOrThrow<string>('BRANCH_IO_URL');
constructor(private readonly configService: ConfigService, private readonly httpService: HttpService) {}
async createBranchLink(token: string) {
this.logger.log(`Creating branch link`);
const payload = {
branch_key: this.branchIoKey,
channel: 'junior',
feature: 'invite',
alias: token,
};
const response = await this.httpService.axiosRef({
url: this.branchIoUrl,
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
data: payload,
});
return response.data.url;
}
}

View File

@ -1,3 +1,4 @@
export * from './branch-io.service';
export * from './junior-token.service';
export * from './junior.service';
export * from './qrcode.service';

View File

@ -1,33 +1,41 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { JuniorTokenRepository } from '../repositories';
import { QrcodeService } from './qrcode.service';
@Injectable()
export class JuniorTokenService {
private readonly logger = new Logger(JuniorTokenService.name);
constructor(
private readonly juniorTokenRepository: JuniorTokenRepository,
private readonly qrCodeService: QrcodeService,
) {}
async generateToken(juniorId: string): Promise<string> {
this.logger.log(`Generating token for junior ${juniorId}`);
const tokenEntity = await this.juniorTokenRepository.generateToken(juniorId);
this.logger.log(`Token generated successfully for junior ${juniorId}`);
return this.qrCodeService.generateQrCode(tokenEntity.token);
}
async validateToken(token: string) {
this.logger.log(`Validating token ${token}`);
const tokenEntity = await this.juniorTokenRepository.findByToken(token);
if (!tokenEntity) {
this.logger.error(`Token ${token} not found`);
throw new BadRequestException('TOKEN.INVALID');
}
if (tokenEntity.expiryDate < new Date()) {
this.logger.error(`Token ${token} expired`);
throw new BadRequestException('TOKEN.EXPIRED');
}
this.logger.log(`Token validated successfully`);
return tokenEntity.juniorId;
}
invalidateToken(token: string) {
this.logger.log(`Invalidating token ${token}`);
return this.juniorTokenRepository.invalidateToken(token);
}
}

View File

@ -1,10 +1,10 @@
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { Transactional } from 'typeorm-transactional';
import { Roles } from '~/auth/enums';
import { UserService } from '~/auth/services';
import { PageOptionsRequestDto } from '~/core/dtos';
import { CustomerNotificationSettings } from '~/customer/entities/customer-notification-settings.entity';
import { CustomerService } from '~/customer/services';
import { UserService } from '~/user/services';
import { CreateJuniorRequestDto, SetThemeRequestDto } from '../dtos/request';
import { Junior } from '../entities';
import { JuniorRepository } from '../repositories';
@ -12,18 +12,21 @@ import { JuniorTokenService } from './junior-token.service';
@Injectable()
export class JuniorService {
private readonly logger = new Logger(JuniorService.name);
constructor(
private readonly juniorRepository: JuniorRepository,
private readonly juniorTokenService: JuniorTokenService,
@Inject(forwardRef(() => UserService)) private readonly userService: UserService,
@Inject(forwardRef(() => CustomerService)) private readonly customerService: CustomerService,
private readonly userService: UserService,
private readonly customerService: CustomerService,
) {}
@Transactional()
async createJuniors(body: CreateJuniorRequestDto, guardianId: string) {
this.logger.log(`Creating junior for guardian ${guardianId}`);
const existingUser = await this.userService.findUser([{ email: body.email }, { phoneNumber: body.phoneNumber }]);
if (existingUser) {
this.logger.error(`User with email ${body.email} or phone number ${body.phoneNumber} already exists`);
throw new BadRequestException('USER.ALREADY_EXISTS');
}
@ -51,43 +54,56 @@ export class JuniorService {
user,
);
this.logger.log(`Junior ${user.id} created successfully`);
return this.juniorTokenService.generateToken(user.id);
}
async findJuniorById(juniorId: string, withGuardianRelation = false, guardianId?: string) {
this.logger.log(`Finding junior ${juniorId}`);
const junior = await this.juniorRepository.findJuniorById(juniorId, withGuardianRelation, guardianId);
if (!junior) {
this.logger.error(`Junior ${juniorId} not found`);
throw new BadRequestException('JUNIOR.NOT_FOUND');
}
this.logger.log(`Junior ${juniorId} found successfully`);
return junior;
}
@Transactional()
async setTheme(body: SetThemeRequestDto, juniorId: string) {
this.logger.log(`Setting theme for junior ${juniorId}`);
const junior = await this.findJuniorById(juniorId);
if (junior.theme) {
this.logger.log(`Removing existing theme for junior ${juniorId}`);
await this.juniorRepository.removeTheme(junior.theme);
}
await this.juniorRepository.setTheme(body, junior);
this.logger.log(`Theme set for junior ${juniorId}`);
return this.juniorRepository.findThemeForJunior(juniorId);
}
findJuniorsByGuardianId(guardianId: string, pageOptions: PageOptionsRequestDto) {
this.logger.log(`Finding juniors for guardian ${guardianId}`);
return this.juniorRepository.findJuniorsByGuardianId(guardianId, pageOptions);
}
async validateToken(token: string) {
this.logger.log(`Validating token ${token}`);
const juniorId = await this.juniorTokenService.validateToken(token);
return this.findJuniorById(juniorId, true);
}
generateToken(juniorId: string) {
this.logger.log(`Generating token for junior ${juniorId}`);
return this.juniorTokenService.generateToken(juniorId);
}
async doesJuniorBelongToGuardian(guardianId: string, juniorId: string) {
this.logger.log(`Checking if junior ${juniorId} belongs to guardian ${guardianId}`);
const junior = await this.findJuniorById(juniorId, false, guardianId);
return !!junior;

View File

@ -1,9 +1,16 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import * as qrcode from 'qrcode';
import { BranchIoService } from './branch-io.service';
@Injectable()
export class QrcodeService {
generateQrCode(token: string): Promise<string> {
return qrcode.toDataURL(token);
constructor(private readonly branchIoService: BranchIoService) {}
private readonly logger = new Logger(QrcodeService.name);
async generateQrCode(token: string): Promise<string> {
this.logger.log(`Generating QR code for token ${token}`);
const link = await this.branchIoService.createBranchLink(token);
return qrcode.toDataURL(link);
}
}

View File

@ -1,4 +1,4 @@
import { INestApplication } from '@nestjs/common';
import { INestApplication, RequestMethod } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
@ -17,6 +17,12 @@ async function bootstrap() {
preflightContinue: false,
optionsSuccessStatus: 204,
});
app.setGlobalPrefix('api', {
exclude: [
{ path: 'health', method: RequestMethod.GET },
{ path: 'health/details', method: RequestMethod.GET },
],
});
const config = app.get(ConfigService);
const swaggerDocument = await createSwagger(app);

View File

@ -1,5 +1,15 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import {
IsDateString,
IsEnum,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
Max,
Min,
ValidateIf,
} from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { MoneyRequestFrequency } from '~/money-request/enums';
const MIN_REQUESTED_AMOUNT = 0.01;
@ -30,6 +40,14 @@ export class CreateMoneyRequestRequestDto {
@IsOptional()
frequency: MoneyRequestFrequency = MoneyRequestFrequency.ONE_TIME;
@ApiPropertyOptional({ example: '2021-01-01' })
@IsDateString(
{},
{ message: i18n('validation.IsDateString', { path: 'general', property: 'moneyRequest.startDate' }) },
)
@ValidateIf((o) => o.frequency !== MoneyRequestFrequency.ONE_TIME)
startDate?: string;
@ApiPropertyOptional({ example: '2021-01-01' })
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'moneyRequest.endDate' }) })
@IsOptional()

View File

@ -22,6 +22,9 @@ export class MoneyRequestResponseDto {
@ApiProperty({ example: MoneyRequestFrequency.ONE_TIME })
frequency!: MoneyRequestFrequency;
@ApiProperty({ example: '2021-01-01' })
startDate!: Date | null;
@ApiProperty({ example: '2021-01-01' })
endDate!: Date | null;
@ -41,6 +44,7 @@ export class MoneyRequestResponseDto {
this.requestedAmount = moneyRequest.requestedAmount;
this.message = moneyRequest.message;
this.frequency = moneyRequest.frequency;
this.startDate = moneyRequest.startDate || null;
this.endDate = moneyRequest.endDate || null;
this.status = moneyRequest.status;
this.reviewedAt = moneyRequest.reviewedAt || null;

View File

@ -1,3 +1,4 @@
import moment from 'moment';
import {
Column,
CreateDateColumn,
@ -31,6 +32,9 @@ export class MoneyRequest {
@Column({ type: 'timestamp with time zone', name: 'reviewed_at', nullable: true })
reviewedAt!: Date;
@Column({ type: 'timestamp with time zone', name: 'start_date', nullable: true })
startDate!: Date | null;
@Column({ type: 'timestamp with time zone', name: 'end_date', nullable: true })
endDate!: Date | null;
@ -53,4 +57,41 @@ export class MoneyRequest {
@UpdateDateColumn({ type: 'timestamp with time zone', name: 'updated_at' })
updatedAt!: Date;
get nextPaymentDate(): Date | null {
if (this.frequency === MoneyRequestFrequency.ONE_TIME) {
return 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 MoneyRequestFrequency.DAILY:
return calculateNextDate('days');
case MoneyRequestFrequency.WEEKLY:
return calculateNextDate('weeks');
case MoneyRequestFrequency.MONTHLY:
return calculateNextDate('months');
default:
return null;
}
}
}

View File

@ -9,7 +9,7 @@ import { MoneyRequestsService } from './services';
@Module({
controllers: [MoneyRequestsController],
providers: [MoneyRequestsService, MoneyRequestsRepository],
exports: [],
exports: [MoneyRequestsService],
imports: [TypeOrmModule.forFeature([MoneyRequest]), JuniorModule],
})
export class MoneyRequestModule {}

View File

@ -1,9 +1,9 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { IsNull, MoreThan, Not, Repository } from 'typeorm';
import { CreateMoneyRequestRequestDto, MoneyRequestsFiltersRequestDto } from '../dtos/request';
import { MoneyRequest } from '../entities';
import { MoneyRequestStatus } from '../enums';
import { MoneyRequestFrequency, MoneyRequestStatus } from '../enums';
const ONE = 1;
@Injectable()
export class MoneyRequestsRepository {
@ -16,6 +16,7 @@ export class MoneyRequestsRepository {
reviewerId,
requestedAmount: body.requestedAmount,
message: body.message,
startDate: body.startDate,
endDate: body.endDate,
frequency: body.frequency,
}),
@ -46,4 +47,33 @@ export class MoneyRequestsRepository {
updateMoneyRequestStatus(moneyRequestId: string, reviewerId: string, status: MoneyRequestStatus) {
return this.moneyRequestRepository.update({ id: moneyRequestId, reviewerId }, { status, reviewedAt: new Date() });
}
async *findMoneyRequestsChunks(chunkSize: number) {
let offset = 0;
while (true) {
const moneyRequests = await this.moneyRequestRepository.find({
take: chunkSize,
skip: offset,
where: [
{
status: MoneyRequestStatus.APPROVED,
frequency: Not(MoneyRequestFrequency.ONE_TIME),
endDate: MoreThan(new Date()),
},
{
status: MoneyRequestStatus.APPROVED,
frequency: Not(MoneyRequestFrequency.ONE_TIME),
endDate: IsNull(),
},
],
});
if (!moneyRequests.length) {
break;
}
yield moneyRequests;
offset += chunkSize;
}
}
}

View File

@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { OciService } from '~/document/services';
import { JuniorService } from '~/junior/services';
import { CreateMoneyRequestRequestDto, MoneyRequestsFiltersRequestDto } from '../dtos/request';
@ -8,6 +8,7 @@ import { MoneyRequestsRepository } from '../repositories';
@Injectable()
export class MoneyRequestsService {
private readonly logger = new Logger(MoneyRequestsService.name);
constructor(
private readonly moneyRequestsRepository: MoneyRequestsRepository,
private readonly juniorService: JuniorService,
@ -15,30 +16,47 @@ export class MoneyRequestsService {
) {}
async createMoneyRequest(userId: string, body: CreateMoneyRequestRequestDto) {
this.logger.log(`Creating money request for junior ${userId}`);
if (body.frequency === MoneyRequestFrequency.ONE_TIME) {
this.logger.log(`Setting end date to null for one time money request`);
delete body.endDate;
delete body.startDate;
}
if (body.startDate && new Date(body.startDate) < new Date()) {
this.logger.error(`Start date ${body.startDate} is in the past`);
throw new BadRequestException('MONEY_REQUEST.START_DATE_IN_THE_PAST');
}
if (body.endDate && new Date(body.endDate) < new Date()) {
this.logger.error(`End date ${body.endDate} is in the past`);
throw new BadRequestException('MONEY_REQUEST.END_DATE_IN_THE_PAST');
}
if (body.endDate && body.startDate && new Date(body.endDate) < new Date(body.startDate)) {
this.logger.error(`End date ${body.endDate} is before start date ${body.startDate}`);
throw new BadRequestException('MONEY_REQUEST.END_DATE_BEFORE_START_DATE');
}
const junior = await this.juniorService.findJuniorById(userId, true);
const moneyRequest = await this.moneyRequestsRepository.createMoneyRequest(junior.id, junior.guardianId, body);
this.logger.log(`Money request ${moneyRequest.id} created successfully`);
return this.findMoneyRequestById(moneyRequest.id);
}
async findMoneyRequestById(moneyRequestId: string, reviewerId?: string) {
this.logger.log(`Finding money request ${moneyRequestId} ${reviewerId ? `by reviewer ${reviewerId}` : ''}`);
const moneyRequest = await this.moneyRequestsRepository.findMoneyRequestById(moneyRequestId, reviewerId);
if (!moneyRequest) {
this.logger.error(`Money request ${moneyRequestId} not found ${reviewerId ? `for reviewer ${reviewerId}` : ''}`);
throw new BadRequestException('MONEY_REQUEST.NOT_FOUND');
}
await this.prepareMoneyRequestDocument([moneyRequest]);
this.logger.log(`Money request ${moneyRequestId} found successfully`);
return moneyRequest;
}
@ -46,12 +64,16 @@ export class MoneyRequestsService {
ReviewerId: string,
filters: MoneyRequestsFiltersRequestDto,
): Promise<[MoneyRequest[], number]> {
this.logger.log(`Finding money requests for reviewer ${ReviewerId}`);
const [moneyRequests, itemCount] = await this.moneyRequestsRepository.findMoneyRequests(ReviewerId, filters);
await this.prepareMoneyRequestDocument(moneyRequests);
this.logger.log(`Returning money requests for reviewer ${ReviewerId}`);
return [moneyRequests, itemCount];
}
async approveMoneyRequest(moneyRequestId: string, reviewerId: string) {
this.logger.log(`Approving money request ${moneyRequestId} by reviewer ${reviewerId}`);
await this.validateMoneyRequestForReview(moneyRequestId, reviewerId);
await this.moneyRequestsRepository.updateMoneyRequestStatus(
moneyRequestId,
@ -63,6 +85,7 @@ export class MoneyRequestsService {
}
async rejectMoneyRequest(moneyRequestId: string, reviewerId: string) {
this.logger.log(`Rejecting money request ${moneyRequestId} by reviewer ${reviewerId}`);
await this.validateMoneyRequestForReview(moneyRequestId, reviewerId);
await this.moneyRequestsRepository.updateMoneyRequestStatus(
@ -74,7 +97,13 @@ export class MoneyRequestsService {
//@TODO send notification
}
findMoneyRequestsChunks(chunkSize: number) {
this.logger.log(`Finding money requests chunks`);
return this.moneyRequestsRepository.findMoneyRequestsChunks(chunkSize);
}
private async prepareMoneyRequestDocument(moneyRequests: MoneyRequest[]) {
this.logger.log(`Preparing document for money requests`);
await Promise.all(
moneyRequests.map(async (moneyRequest) => {
const profilePicture = moneyRequest.requester.customer.profilePicture;
@ -87,12 +116,21 @@ export class MoneyRequestsService {
}
private async validateMoneyRequestForReview(moneyRequestId: string, reviewerId: string) {
this.logger.log(`Validating money request ${moneyRequestId} for reviewer ${reviewerId}`);
const moneyRequest = await this.moneyRequestsRepository.findMoneyRequestById(moneyRequestId, reviewerId);
if (!moneyRequest) {
this.logger.error(`Money request ${moneyRequestId} not found`);
throw new BadRequestException('MONEY_REQUEST.NOT_FOUND');
}
if (moneyRequest.endDate && new Date(moneyRequest.endDate) < new Date()) {
this.logger.error(`Money request ${moneyRequestId} has already ended`);
throw new BadRequestException('MONEY_REQUEST.ENDED');
}
if (moneyRequest.status !== MoneyRequestStatus.PENDING) {
this.logger.error(`Money request ${moneyRequestId} already reviewed`);
throw new BadRequestException('MONEY_REQUEST.ALREADY_REVIEWED');
}
}

View File

@ -24,7 +24,7 @@ export class SavingGoal extends BaseEntity {
@Column({ type: 'varchar', length: 255, name: 'description', nullable: true })
description!: string;
@Column({ type: 'date', name: 'due_date' })
@Column({ type: 'timestamp with time zone', name: 'due_date' })
dueDate!: Date;
@Column({

View File

@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { createCategoryRequestDto } from '../dtos/request';
import { Category } from '../entities';
import { CategoryType } from '../enums';
@ -6,27 +6,35 @@ import { CategoryRepository } from '../repositories';
@Injectable()
export class CategoryService {
private readonly logger = new Logger(CategoryService.name);
constructor(private readonly categoryRepository: CategoryRepository) {}
createCustomCategory(juniorId: string, body: createCategoryRequestDto) {
this.logger.log(`Creating custom category for junior ${juniorId}`);
return this.categoryRepository.createCustomCategory(juniorId, body);
}
async findCategoryByIds(categoryIds: string[], juniorId: string) {
this.logger.log(`Finding categories by ids for junior ${juniorId} with ids ${categoryIds}`);
const categories = await this.categoryRepository.findCategoryById(categoryIds, juniorId);
if (categories.length !== categoryIds.length) {
throw new BadRequestException('CATEGORY.NOT_FOUND');
this.logger.error(`Invalid category ids ${categoryIds}`);
throw new BadRequestException('CATEGORY.INVALID_CATEGORY_ID');
}
this.logger.log(`Categories found successfully for junior ${juniorId}`);
return categories;
}
async findCategories(juniorId: string): Promise<{ globalCategories: Category[]; customCategories: Category[] }> {
this.logger.log(`Finding categories for junior ${juniorId}`);
const categories = await this.categoryRepository.findCategories(juniorId);
const globalCategories = categories.filter((category) => category.type === CategoryType.GLOBAL);
const customCategories = categories.filter((category) => category.type === CategoryType.CUSTOM);
this.logger.log(`Returning categories for junior ${juniorId}`);
return {
globalCategories,
customCategories,

View File

@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import moment from 'moment';
import { PageOptionsRequestDto } from '~/core/dtos';
import { OciService } from '~/document/services';
@ -9,6 +9,7 @@ import { SavingGoalsRepository } from '../repositories';
import { CategoryService } from './category.service';
@Injectable()
export class SavingGoalsService {
private readonly logger = new Logger(SavingGoalsService.name);
constructor(
private readonly savingGoalsRepository: SavingGoalsRepository,
private readonly categoryService: CategoryService,
@ -16,7 +17,9 @@ export class SavingGoalsService {
) {}
async createGoal(juniorId: string, body: CreateGoalRequestDto) {
this.logger.log(`Creating goal for junior ${juniorId}`);
if (moment(body.dueDate).isBefore(moment())) {
this.logger.error(`Due date must be in the future`);
throw new BadRequestException('GOAL.DUE_DATE_MUST_BE_IN_THE_FUTURE');
}
@ -24,43 +27,57 @@ export class SavingGoalsService {
const createdGoal = await this.savingGoalsRepository.createGoal(juniorId, body, categories);
this.logger.log(`Goal ${createdGoal.id} created successfully`);
return this.findGoalById(juniorId, createdGoal.id);
}
async findGoals(juniorId: string, pageOptions: PageOptionsRequestDto): Promise<[SavingGoal[], number]> {
this.logger.log(`Finding goals for junior ${juniorId}`);
const [goals, itemCount] = await this.savingGoalsRepository.findGoals(juniorId, pageOptions);
await this.prepareGoalsImages(goals);
this.logger.log(`Returning goals for junior ${juniorId}`);
return [goals, itemCount];
}
async findGoalById(juniorId: string, goalId: string) {
this.logger.log(`Finding goal ${goalId} for junior ${juniorId}`);
const goal = await this.savingGoalsRepository.findGoalById(juniorId, goalId);
if (!goal) {
this.logger.error(`Goal ${goalId} not found`);
throw new BadRequestException('GOAL.NOT_FOUND');
}
await this.prepareGoalsImages([goal]);
this.logger.log(`Goal ${goalId} found successfully`);
return goal;
}
async fundGoal(juniorId: string, goalId: string, body: FundGoalRequestDto) {
this.logger.log(`Funding goal ${goalId} for junior ${juniorId}`);
const goal = await this.savingGoalsRepository.findGoalById(juniorId, goalId);
if (!goal) {
this.logger.error(`Goal ${goalId} not found`);
throw new BadRequestException('GOAL.NOT_FOUND');
}
if (goal.currentAmount + body.fundAmount > goal.targetAmount) {
this.logger.error(
`Funding amount exceeds total amount , currentAmount: ${goal.currentAmount}, fundAmount: ${body.fundAmount}, targetAmount: ${goal.targetAmount}`,
);
throw new BadRequestException('GOAL.FUND_EXCEEDS_TOTAL_AMOUNT');
}
await this.savingGoalsRepository.fundGoal(juniorId, goalId, body.fundAmount);
this.logger.log(`Goal ${goalId} funded successfully with amount ${body.fundAmount}`);
}
async getStats(juniorId: string): Promise<IGoalStats> {
this.logger.log(`Getting stats for junior ${juniorId}`);
const result = await this.savingGoalsRepository.getStats(juniorId);
return {
totalTarget: result?.totalTarget,
@ -69,6 +86,7 @@ export class SavingGoalsService {
}
private async prepareGoalsImages(goals: SavingGoal[]) {
this.logger.log(`Preparing images for goals`);
await Promise.all(
goals.map(async (goal) => {
if (goal.imageId) {

View File

@ -36,10 +36,10 @@ export class Task extends BaseEntity {
@Column({ type: 'varchar', name: 'task_frequency' })
taskFrequency!: TaskFrequency;
@Column({ type: 'date', name: 'start_date' })
@Column({ type: 'timestamp with time zone', name: 'start_date' })
startDate!: Date;
@Column({ type: 'date', name: 'due_date' })
@Column({ type: 'timestamp with time zone', name: 'due_date' })
dueDate!: Date;
@Column({ type: 'boolean', name: 'is_proof_required' })

View File

@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, Repository } from 'typeorm';
import { Brackets, FindOptionsWhere, Repository } from 'typeorm';
import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces';
import { CreateTaskRequestDto, TasksFilterOptions, TaskSubmissionRequestDto } from '../dtos/request';
@ -77,8 +77,13 @@ export class TaskRepository {
}
if (query.status === TaskStatus.COMPLETED) {
queryBuilder.andWhere('task.dueDate < :today', { today: new Date() });
queryBuilder.orWhere('submission.status = :status', { status: SubmissionStatus.APPROVED });
queryBuilder.andWhere(
new Brackets((qb) => {
qb.where('task.dueDate < :today', { today: new Date() }).orWhere('submission.status = :status', {
status: SubmissionStatus.APPROVED,
});
}),
);
}
queryBuilder.orderBy('task.createdAt', 'DESC');

View File

@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import moment from 'moment';
import { FindOptionsWhere } from 'typeorm';
import { IJwtPayload } from '~/auth/interfaces';
@ -10,80 +10,104 @@ import { TaskRepository } from '../repositories';
@Injectable()
export class TaskService {
private readonly logger = new Logger(TaskService.name);
constructor(private readonly taskRepository: TaskRepository, private readonly ociService: OciService) {}
async createTask(userId: string, body: CreateTaskRequestDto) {
this.logger.log(`Creating task for user ${userId}`);
if (moment(body.dueDate).isBefore(moment(body.startDate))) {
this.logger.error(`Due date must be after start date`);
throw new BadRequestException('TASK.DUE_DATE_BEFORE_START_DATE');
}
if (moment(body.dueDate).isBefore(moment())) {
this.logger.error(`Due date must be in the future`);
throw new BadRequestException('TASK.DUE_DATE_IN_PAST');
}
const task = await this.taskRepository.createTask(userId, body);
this.logger.log(`Task ${task.id} created successfully`);
return this.findTask({ id: task.id });
}
async findTask(where: FindOptionsWhere<Task>) {
this.logger.log(`Finding task with where ${JSON.stringify(where)}`);
const task = await this.taskRepository.findTask(where);
if (!task) {
this.logger.error(`Task not found`);
throw new BadRequestException('TASK.NOT_FOUND');
}
await this.prepareTasksPictures([task]);
this.logger.log(`Task found successfully`);
return task;
}
async findTasks(user: IJwtPayload, query: TasksFilterOptions): Promise<[Task[], number]> {
this.logger.log(`Finding tasks for user ${user.sub} and roles ${user.roles} and filters ${JSON.stringify(query)}`);
const [tasks, count] = await this.taskRepository.findTasks(user, query);
await this.prepareTasksPictures(tasks);
this.logger.log(`Returning tasks for user ${user.sub}`);
return [tasks, count];
}
async submitTask(userId: string, taskId: string, body: TaskSubmissionRequestDto) {
this.logger.log(`Submitting task ${taskId} for user ${userId}`);
const task = await this.findTask({ id: taskId, assignedToId: userId });
if (task.status == TaskStatus.COMPLETED) {
this.logger.error(`Task ${taskId} already completed`);
throw new BadRequestException('TASK.ALREADY_COMPLETED');
}
if (task.isProofRequired && !body.imageId) {
this.logger.error(`Proof of completion is required for task ${taskId}`);
throw new BadRequestException('TASK.PROOF_REQUIRED');
}
await this.taskRepository.createSubmission(task, body);
this.logger.log(`Task ${taskId} submitted successfully`);
}
async approveTaskSubmission(userId: string, taskId: string) {
this.logger.log(`Approving task submission ${taskId} by user ${userId}`);
const task = await this.findTask({ id: taskId, assignedById: userId });
if (!task.submission) {
this.logger.error(`No submission found for task ${taskId}`);
throw new BadRequestException('TASK.NO_SUBMISSION');
}
if (task.submission.status == SubmissionStatus.APPROVED) {
this.logger.error(`Submission already approved for task ${taskId}`);
throw new BadRequestException('TASK.SUBMISSION_ALREADY_REVIEWED');
}
await this.taskRepository.approveSubmission(task.submission);
this.logger.log(`Task submission ${taskId} approved successfully`);
}
async rejectTaskSubmission(userId: string, taskId: string) {
this.logger.log(`Rejecting task submission ${taskId} by user ${userId}`);
const task = await this.findTask({ id: taskId, assignedById: userId });
if (!task.submission) {
this.logger.error(`No submission found for task ${taskId}`);
throw new BadRequestException('TASK.NO_SUBMISSION');
}
if (task.submission.status == SubmissionStatus.REJECTED) {
this.logger.error(`Submission already rejected for task ${taskId}`);
throw new BadRequestException('TASK.SUBMISSION_ALREADY_REVIEWED');
}
await this.taskRepository.rejectSubmission(task.submission);
this.logger.log(`Task submission ${taskId} rejected successfully`);
}
async prepareTasksPictures(tasks: Task[]) {
this.logger.log(`Preparing tasks pictures`);
await Promise.all(
tasks.map(async (task) => {
const [imageUrl, submissionUrl, profilePictureUrl] = await Promise.all([

View File

@ -11,7 +11,7 @@ import {
import { Notification } from '~/common/modules/notification/entities';
import { Otp } from '~/common/modules/otp/entities';
import { Customer } from '~/customer/entities/customer.entity';
import { Roles } from '../enums';
import { Roles } from '../../auth/enums';
import { Device } from './device.entity';
@Entity('users')

View File

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Not, Repository } from 'typeorm';
import { Device } from '../entities';
import { Device } from '../../user/entities';
@Injectable()
export class DeviceRepository {

View File

@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, Repository } from 'typeorm';
import { User } from '../entities';
import { User } from '../../user/entities';
@Injectable()
export class UserRepository {

View File

@ -1,22 +1,27 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { Device } from '../entities';
import { DeviceRepository } from '../repositories';
@Injectable()
export class DeviceService {
private readonly logger = new Logger(DeviceService.name);
constructor(private readonly deviceRepository: DeviceRepository) {}
findUserDeviceById(deviceId: string, userId: string) {
this.logger.log(`Finding device with id ${deviceId} for user ${userId}`);
return this.deviceRepository.findUserDeviceById(deviceId, userId);
}
createDevice(data: Partial<Device>) {
this.logger.log(`Creating device with data ${JSON.stringify(data)}`);
return this.deviceRepository.createDevice(data);
}
updateDevice(deviceId: string, data: Partial<Device>) {
this.logger.log(`Updating device with id ${deviceId} with data ${JSON.stringify(data)}`);
return this.deviceRepository.updateDevice(deviceId, data);
}
async getTokens(userId: string): Promise<string[]> {
this.logger.log(`Getting tokens for user ${userId}`);
const devices = await this.deviceRepository.getTokens(userId);
return devices.map((device) => device.fcmToken!);

View File

@ -0,0 +1,2 @@
export * from './device.service';
export * from './user.service';

View File

@ -1,67 +1,82 @@
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
import { BadRequestException, forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm';
import { CustomerNotificationSettings } from '~/customer/entities/customer-notification-settings.entity';
import { CustomerService } from '~/customer/services';
import { Guardian } from '~/guardian/entities/guradian.entity';
import { CreateUnverifiedUserRequestDto } from '../dtos/request';
import { CreateUnverifiedUserRequestDto } from '../../auth/dtos/request';
import { Roles } from '../../auth/enums';
import { User } from '../entities';
import { Roles } from '../enums';
import { UserRepository } from '../repositories';
@Injectable()
export class UserService {
private readonly logger = new Logger(UserService.name);
constructor(
private readonly userRepository: UserRepository,
@Inject(forwardRef(() => CustomerService)) private readonly customerService: CustomerService,
) {}
findUser(where: FindOptionsWhere<User> | FindOptionsWhere<User>[]) {
this.logger.log(`finding user with where clause ${JSON.stringify(where)}`);
return this.userRepository.findOne(where);
}
async findUserOrThrow(where: FindOptionsWhere<User>) {
this.logger.log(`Finding user with where clause ${JSON.stringify(where)}`);
const user = await this.findUser(where);
if (!user) {
this.logger.error(`User with not found with where clause ${JSON.stringify(where)}`);
throw new BadRequestException('USERS.NOT_FOUND');
}
this.logger.log(`User with where clause ${JSON.stringify(where)} found successfully`);
return user;
}
async findOrCreateUser({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) {
this.logger.log(`Finding or creating user with phone number ${phoneNumber} and country code ${countryCode}`);
const user = await this.userRepository.findOne({ phoneNumber });
if (!user) {
this.logger.log(`User with phone number ${phoneNumber} not found, creating new user`);
return this.userRepository.createUnverifiedUser({ phoneNumber, countryCode, roles: [Roles.GUARDIAN] });
}
if (user && user.roles.includes(Roles.GUARDIAN) && user.isPasswordSet) {
this.logger.error(`User with phone number ${phoneNumber} already exists`);
throw new BadRequestException('USERS.PHONE_NUMBER_ALREADY_EXISTS');
}
if (user && user.roles.includes(Roles.JUNIOR)) {
this.logger.error(`User with phone number ${phoneNumber} is an already registered junior`);
throw new BadRequestException('USERS.JUNIOR_UPGRADE_NOT_SUPPORTED_YET');
//TODO add role Guardian to the existing user and send OTP
}
this.logger.log(`User with phone number ${phoneNumber} and country code ${countryCode} found successfully`);
return user;
}
async createUser(data: Partial<User>) {
this.logger.log(`Creating user with data ${JSON.stringify(data)}`);
const user = await this.userRepository.createUser(data);
this.logger.log(`User with data ${JSON.stringify(data)} created successfully`);
return user;
}
setEmail(userId: string, email: string) {
this.logger.log(`Setting email ${email} for user ${userId}`);
return this.userRepository.update(userId, { email });
}
setPasscode(userId: string, passcode: string, salt: string) {
this.logger.log(`Setting passcode for user ${userId}`);
return this.userRepository.update(userId, { password: passcode, salt, isProfileCompleted: true });
}
async verifyUserAndCreateCustomer(user: User) {
this.logger.log(`Verifying user ${user.id} and creating customer`);
await this.customerService.createCustomer(
{
guardian: Guardian.create({ id: user.id }),
@ -70,6 +85,7 @@ export class UserService {
user,
);
this.logger.log(`User ${user.id} verified and customer created successfully`);
return this.findUserOrThrow({ id: user.id });
}
}

13
src/user/user.module.ts Normal file
View File

@ -0,0 +1,13 @@
import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CustomerModule } from '~/customer/customer.module';
import { Device, User } from './entities';
import { DeviceRepository, UserRepository } from './repositories';
import { DeviceService, UserService } from './services';
@Module({
imports: [TypeOrmModule.forFeature([User, Device]), forwardRef(() => CustomerModule)],
providers: [UserService, DeviceService, UserRepository, DeviceRepository],
exports: [UserService, DeviceService],
})
export class UserModule {}