Compare commits

..

17 Commits

Author SHA1 Message Date
ed57ce6e91 feat: working on money requests jounrey 2024-12-18 12:57:23 +03:00
33453b193f Merge pull request #13 from HamzaSha1/feat/register-junior-via-qrcode
feat: onboard junior by qrcode
2024-12-15 17:00:42 +03:00
b0972f1a0a feat: onbard junior by qrcode 2024-12-15 16:46:49 +03:00
7437403756 Merge pull request #12 from HamzaSha1/feat/saving-goals
feat: working on saving goals journey for juniors
2024-12-15 12:53:27 +03:00
4d2f6f57f4 feat: working on saving goals jounrey for juniors 2024-12-15 12:44:59 +03:00
24d990592d fix: fix tasks submission journey 2024-12-12 15:22:04 +03:00
5b7b7ff689 fix: fix oci bucket name in generating signed url 2024-12-12 13:30:11 +03:00
6fccacd085 Merge pull request #11 from HamzaSha1/feat/customer-settings
feat: update customer profile picture and notifications settings
2024-12-12 13:23:28 +03:00
51fa61dbc6 feat: update customer profile picture and notifications settings 2024-12-12 13:15:47 +03:00
4867a5f858 Merge pull request #10 from HamzaSha1/feat/tasks-default-logo
feat: seed default task logos
2024-12-12 11:22:20 +03:00
687b6a5c6d feat: seed default task logos 2024-12-12 11:14:38 +03:00
e6ed1772f7 Merge pull request #9 from HamzaSha1/feat/signed-urls
Feat/signed urls
2024-12-12 10:01:52 +03:00
1f0a14fee4 fix: fix magic number lint issue 2024-12-12 09:47:49 +03:00
eb70828ae0 Merge branch 'mvp-1' of github.com:HamzaSha1/zod-backend into feat/signed-urls 2024-12-12 09:46:48 +03:00
220a03cc46 feat: working on signed url for private files 2024-12-12 09:46:38 +03:00
39b1e76bb5 fix: import tasks migration 2024-12-11 17:49:15 +03:00
83fc634d25 Merge pull request #8 from HamzaSha1/feat/tasks
feat: tasks journey
2024-12-11 11:14:21 +03:00
116 changed files with 5082 additions and 2977 deletions

5928
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -28,6 +28,7 @@
"dependencies": { "dependencies": {
"@abdalhamid/hello": "^2.0.0", "@abdalhamid/hello": "^2.0.0",
"@hamid/hello": "file:../libraries/test-package", "@hamid/hello": "file:../libraries/test-package",
"@keyv/redis": "^4.0.2",
"@nestjs-modules/mailer": "^2.0.2", "@nestjs-modules/mailer": "^2.0.2",
"@nestjs/axios": "^3.1.2", "@nestjs/axios": "^3.1.2",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
@ -45,6 +46,7 @@
"amqp-connection-manager": "^4.1.14", "amqp-connection-manager": "^4.1.14",
"amqplib": "^0.10.4", "amqplib": "^0.10.4",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cacheable": "^1.8.5",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.1", "class-validator": "^0.14.1",
"google-libphonenumber": "^3.2.39", "google-libphonenumber": "^3.2.39",
@ -62,6 +64,7 @@
"pg": "^8.13.1", "pg": "^8.13.1",
"pino-http": "^10.3.0", "pino-http": "^10.3.0",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"typeorm": "^0.3.20", "typeorm": "^0.3.20",
@ -81,6 +84,7 @@
"@types/node": "^20.3.1", "@types/node": "^20.3.1",
"@types/nodemailer": "^6.4.16", "@types/nodemailer": "^6.4.16",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/qrcode": "^1.5.5",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.0",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^5.59.2", "@typescript-eslint/eslint-plugin": "^5.59.2",
@ -89,8 +93,10 @@
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-security": "^1.7.1", "eslint-plugin-security": "^1.7.1",
"i": "^0.3.7",
"jest": "^29.5.0", "jest": "^29.5.0",
"lint-staged": "^13.2.2", "lint-staged": "^13.2.2",
"npm": "^10.9.2",
"prettier": "^2.8.8", "prettier": "^2.8.8",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",

View File

@ -7,6 +7,7 @@ import { LoggerModule } from 'nestjs-pino';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { addTransactionalDataSource } from 'typeorm-transactional'; import { addTransactionalDataSource } from 'typeorm-transactional';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { CacheModule } from './common/modules/cache/cache.module';
import { LookupModule } from './common/modules/lookup/lookup.module'; import { LookupModule } from './common/modules/lookup/lookup.module';
import { OtpModule } from './common/modules/otp/otp.module'; import { OtpModule } from './common/modules/otp/otp.module';
import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters'; import { AllExceptionsFilter, buildI18nValidationExceptionFilter } from './core/filters';
@ -19,6 +20,8 @@ import { DocumentModule } from './document/document.module';
import { GuardianModule } from './guardian/guardian.module'; import { GuardianModule } from './guardian/guardian.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { JuniorModule } from './junior/junior.module'; import { 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 { TaskModule } from './task/task.module';
@Module({ @Module({
controllers: [], controllers: [],
@ -45,12 +48,17 @@ import { TaskModule } from './task/task.module';
inject: [ConfigService], inject: [ConfigService],
}), }),
I18nModule.forRoot(buildI18nOptions()), I18nModule.forRoot(buildI18nOptions()),
CacheModule,
// App modules // App modules
AuthModule, AuthModule,
CustomerModule, CustomerModule,
JuniorModule, JuniorModule,
TaskModule, TaskModule,
GuardianModule, GuardianModule,
SavingGoalsModule,
MoneyRequestModule,
OtpModule, OtpModule,
DocumentModule, DocumentModule,
LookupModule, LookupModule,

View File

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

View File

@ -1,7 +1,7 @@
import { Body, Controller, Headers, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Headers, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { DEVICE_ID_HEADER } from '~/common/constants'; import { DEVICE_ID_HEADER } from '~/common/constants';
import { AuthenticatedUser } from '~/common/decorators'; import { AuthenticatedUser, Public } from '~/common/decorators';
import { AccessTokenGuard } from '~/common/guards'; import { AccessTokenGuard } from '~/common/guards';
import { ResponseFactory } from '~/core/utils'; import { ResponseFactory } from '~/core/utils';
import { import {
@ -12,6 +12,7 @@ import {
LoginRequestDto, LoginRequestDto,
SendForgetPasswordOtpRequestDto, SendForgetPasswordOtpRequestDto,
SetEmailRequestDto, SetEmailRequestDto,
setJuniorPasswordRequestDto,
SetPasscodeRequestDto, SetPasscodeRequestDto,
VerifyUserRequestDto, VerifyUserRequestDto,
} from '../dtos/request'; } from '../dtos/request';
@ -77,6 +78,13 @@ export class AuthController {
return this.authService.verifyForgetPasswordOtp(forgetPasswordDto); return this.authService.verifyForgetPasswordOtp(forgetPasswordDto);
} }
@Post('junior/set-passcode')
@HttpCode(HttpStatus.NO_CONTENT)
@Public()
setJuniorPasscode(@Body() setPasscodeDto: setJuniorPasswordRequestDto) {
return this.authService.setJuniorPasscode(setPasscodeDto);
}
@Post('login') @Post('login')
async login(@Body() loginDto: LoginRequestDto, @Headers(DEVICE_ID_HEADER) deviceId: string) { async login(@Body() loginDto: LoginRequestDto, @Headers(DEVICE_ID_HEADER) deviceId: string) {
const [res, user] = await this.authService.login(loginDto, deviceId); const [res, user] = await this.authService.login(loginDto, deviceId);

View File

@ -5,5 +5,6 @@ export * from './forget-password.request.dto';
export * from './login.request.dto'; export * from './login.request.dto';
export * from './send-forget-password-otp.request.dto'; export * from './send-forget-password-otp.request.dto';
export * from './set-email.request.dto'; export * from './set-email.request.dto';
export * from './set-junior-password.request.dto';
export * from './set-passcode.request.dto'; export * from './set-passcode.request.dto';
export * from './verify-user.request.dto'; export * from './verify-user.request.dto';

View File

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

View File

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

View File

@ -3,7 +3,6 @@ import {
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
JoinColumn,
OneToMany, OneToMany,
OneToOne, OneToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
@ -11,10 +10,8 @@ import {
} from 'typeorm'; } from 'typeorm';
import { Otp } from '~/common/modules/otp/entities'; import { Otp } from '~/common/modules/otp/entities';
import { Customer } from '~/customer/entities/customer.entity'; import { Customer } from '~/customer/entities/customer.entity';
import { Document } from '~/document/entities';
import { Roles } from '../enums'; import { Roles } from '../enums';
import { Device } from './device.entity'; import { Device } from './device.entity';
import { UserNotificationSettings } from './user-notification-settings.entity';
@Entity('users') @Entity('users')
export class User extends BaseEntity { export class User extends BaseEntity {
@ -48,22 +45,9 @@ export class User extends BaseEntity {
@Column('text', { nullable: true, array: true, name: 'roles' }) @Column('text', { nullable: true, array: true, name: 'roles' })
roles!: Roles[]; roles!: Roles[];
@Column('varchar', { name: 'profile_picture_id', nullable: true })
profilePictureId!: string;
@OneToOne(() => Document, (document) => document.user, { cascade: true, nullable: true })
@JoinColumn({ name: 'profile_picture_id' })
profilePicture!: Document;
@OneToMany(() => Otp, (otp) => otp.user) @OneToMany(() => Otp, (otp) => otp.user)
otp!: Otp[]; otp!: Otp[];
@OneToOne(() => UserNotificationSettings, (notificationSettings) => notificationSettings.user, {
cascade: true,
eager: true,
})
notificationSettings!: UserNotificationSettings;
@OneToOne(() => Customer, (customer) => customer.user, { cascade: true, eager: true }) @OneToOne(() => Customer, (customer) => customer.user, { cascade: true, eager: true })
customer!: Customer; customer!: Customer;

View File

@ -1,8 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, Repository } from 'typeorm'; import { FindOptionsWhere, Repository } from 'typeorm';
import { UpdateNotificationsSettingsRequestDto } from '~/customer/dtos/request'; import { User } from '../entities';
import { User, UserNotificationSettings } from '../entities';
@Injectable() @Injectable()
export class UserRepository { export class UserRepository {
@ -14,7 +13,6 @@ export class UserRepository {
phoneNumber: data.phoneNumber, phoneNumber: data.phoneNumber,
countryCode: data.countryCode, countryCode: data.countryCode,
roles: data.roles, roles: data.roles,
notificationSettings: UserNotificationSettings.create(),
}), }),
); );
} }
@ -23,11 +21,6 @@ export class UserRepository {
return this.userRepository.findOne({ where }); return this.userRepository.findOne({ where });
} }
updateNotificationSettings(user: User, body: UpdateNotificationsSettingsRequestDto) {
user.notificationSettings = UserNotificationSettings.create({ ...user.notificationSettings, ...body });
return this.userRepository.save(user);
}
update(userId: string, data: Partial<User>) { update(userId: string, data: Partial<User>) {
return this.userRepository.update(userId, data); return this.userRepository.update(userId, data);
} }
@ -35,7 +28,6 @@ export class UserRepository {
createUser(data: Partial<User>) { createUser(data: Partial<User>) {
const user = this.userRepository.create({ const user = this.userRepository.create({
...data, ...data,
notificationSettings: UserNotificationSettings.create(),
}); });
return this.userRepository.save(user); return this.userRepository.save(user);

View File

@ -4,6 +4,7 @@ import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { OtpScope, OtpType } from '~/common/modules/otp/enums'; import { OtpScope, OtpType } from '~/common/modules/otp/enums';
import { OtpService } from '~/common/modules/otp/services'; import { OtpService } from '~/common/modules/otp/services';
import { JuniorTokenService } from '~/junior/services';
import { PASSCODE_REGEX, PASSWORD_REGEX } from '../constants'; import { PASSCODE_REGEX, PASSWORD_REGEX } from '../constants';
import { import {
CreateUnverifiedUserRequestDto, CreateUnverifiedUserRequestDto,
@ -13,6 +14,7 @@ import {
LoginRequestDto, LoginRequestDto,
SendForgetPasswordOtpRequestDto, SendForgetPasswordOtpRequestDto,
SetEmailRequestDto, SetEmailRequestDto,
setJuniorPasswordRequestDto,
} from '../dtos/request'; } from '../dtos/request';
import { VerifyUserRequestDto } from '../dtos/request/verify-user.request.dto'; import { VerifyUserRequestDto } from '../dtos/request/verify-user.request.dto';
import { User } from '../entities'; import { User } from '../entities';
@ -32,6 +34,7 @@ export class AuthService {
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly userService: UserService, private readonly userService: UserService,
private readonly deviceService: DeviceService, private readonly deviceService: DeviceService,
private readonly juniorTokenService: JuniorTokenService,
) {} ) {}
async sendRegisterOtp({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) { async sendRegisterOtp({ phoneNumber, countryCode }: CreateUnverifiedUserRequestDto) {
const user = await this.userService.findOrCreateUser({ phoneNumber, countryCode }); const user = await this.userService.findOrCreateUser({ phoneNumber, countryCode });
@ -186,6 +189,14 @@ export class AuthService {
return [tokens, user]; return [tokens, user];
} }
async setJuniorPasscode(body: setJuniorPasswordRequestDto) {
const juniorId = await this.juniorTokenService.validateToken(body.qrToken);
const salt = bcrypt.genSaltSync(SALT_ROUNDS);
const hashedPasscode = bcrypt.hashSync(body.passcode, salt);
await this.userService.setPasscode(juniorId, hashedPasscode, salt);
await this.juniorTokenService.invalidateToken(body.qrToken);
}
private async loginWithPassword(loginDto: LoginRequestDto, user: User): Promise<ILoginResponse> { private async loginWithPassword(loginDto: LoginRequestDto, user: User): Promise<ILoginResponse> {
const isPasswordValid = bcrypt.compareSync(loginDto.password, user.password); const isPasswordValid = bcrypt.compareSync(loginDto.password, user.password);

View File

@ -1,6 +1,6 @@
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common'; import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
import { FindOptionsWhere } from 'typeorm'; import { FindOptionsWhere } from 'typeorm';
import { UpdateNotificationsSettingsRequestDto } from '~/customer/dtos/request'; import { CustomerNotificationSettings } from '~/customer/entities/customer-notification-settings.entity';
import { CustomerService } from '~/customer/services'; import { CustomerService } from '~/customer/services';
import { Guardian } from '~/guardian/entities/guradian.entity'; import { Guardian } from '~/guardian/entities/guradian.entity';
import { CreateUnverifiedUserRequestDto } from '../dtos/request'; import { CreateUnverifiedUserRequestDto } from '../dtos/request';
@ -15,15 +15,6 @@ export class UserService {
@Inject(forwardRef(() => CustomerService)) private readonly customerService: CustomerService, @Inject(forwardRef(() => CustomerService)) private readonly customerService: CustomerService,
) {} ) {}
async updateNotificationSettings(userId: string, body: UpdateNotificationsSettingsRequestDto) {
const user = await this.findUserOrThrow({ id: userId });
const notificationSettings = (await this.userRepository.updateNotificationSettings(user, body))
.notificationSettings;
return notificationSettings;
}
findUser(where: FindOptionsWhere<User> | FindOptionsWhere<User>[]) { findUser(where: FindOptionsWhere<User> | FindOptionsWhere<User>[]) {
return this.userRepository.findOne(where); return this.userRepository.findOne(where);
} }
@ -74,6 +65,7 @@ export class UserService {
await this.customerService.createCustomer( await this.customerService.createCustomer(
{ {
guardian: Guardian.create({ id: user.id }), guardian: Guardian.create({ id: user.id }),
notificationSettings: new CustomerNotificationSettings(),
}, },
user, user,
); );

View File

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

View File

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

View File

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

View File

@ -20,4 +20,13 @@ export class LookupController {
return ResponseFactory.dataArray(avatars.map((avatar) => new DocumentMetaResponseDto(avatar))); return ResponseFactory.dataArray(avatars.map((avatar) => new DocumentMetaResponseDto(avatar)));
} }
@UseGuards(AccessTokenGuard)
@Get('default-task-logos')
@ApiDataArrayResponse(DocumentMetaResponseDto)
async findDefaultTaskLogos() {
const avatars = await this.lookupService.findDefaultTasksLogo();
return ResponseFactory.dataArray(avatars.map((avatar) => new DocumentMetaResponseDto(avatar)));
}
} }

View File

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

View File

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

View File

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

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

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

View File

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

View File

@ -1,9 +1,9 @@
import { Body, Controller, Patch, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces'; import { IJwtPayload } from '~/auth/interfaces';
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators'; import { AuthenticatedUser } from '~/common/decorators';
import { AccessTokenGuard, RolesGuard } from '~/common/guards'; import { AccessTokenGuard } from '~/common/guards';
import { ApiDataResponse } from '~/core/decorators';
import { ResponseFactory } from '~/core/utils'; import { ResponseFactory } from '~/core/utils';
import { UpdateCustomerRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request'; import { UpdateCustomerRequestDto, UpdateNotificationsSettingsRequestDto } from '../dtos/request';
import { CustomerResponseDto, NotificationSettingsResponseDto } from '../dtos/response'; import { CustomerResponseDto, NotificationSettingsResponseDto } from '../dtos/response';
@ -15,9 +15,18 @@ import { CustomerService } from '../services';
export class CustomerController { export class CustomerController {
constructor(private readonly customerService: CustomerService) {} constructor(private readonly customerService: CustomerService) {}
@Get('/profile')
@UseGuards(AccessTokenGuard)
@ApiDataResponse(CustomerResponseDto)
async getCustomerProfile(@AuthenticatedUser() { sub }: IJwtPayload) {
const customer = await this.customerService.findCustomerById(sub);
return ResponseFactory.data(new CustomerResponseDto(customer));
}
@Patch('') @Patch('')
@UseGuards(RolesGuard) @UseGuards(AccessTokenGuard)
@AllowedRoles(Roles.GUARDIAN) @ApiDataResponse(CustomerResponseDto)
async updateCustomer(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: UpdateCustomerRequestDto) { async updateCustomer(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: UpdateCustomerRequestDto) {
const customer = await this.customerService.updateCustomer(sub, body); const customer = await this.customerService.updateCustomer(sub, body);
@ -26,6 +35,7 @@ export class CustomerController {
@Patch('settings/notifications') @Patch('settings/notifications')
@UseGuards(AccessTokenGuard) @UseGuards(AccessTokenGuard)
@ApiDataResponse(NotificationSettingsResponseDto)
async updateNotificationSettings( async updateNotificationSettings(
@AuthenticatedUser() { sub }: IJwtPayload, @AuthenticatedUser() { sub }: IJwtPayload,
@Body() body: UpdateNotificationsSettingsRequestDto, @Body() body: UpdateNotificationsSettingsRequestDto,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,7 +24,7 @@ export class CreateJuniorEntity1733731507261 implements MigrationInterface {
`ALTER TABLE "juniors" ADD CONSTRAINT "FK_4662c4433223c01fe69fc1382f5" FOREIGN KEY ("civil_id_back_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, `ALTER TABLE "juniors" ADD CONSTRAINT "FK_4662c4433223c01fe69fc1382f5" FOREIGN KEY ("civil_id_back_id") REFERENCES "documents"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
); );
await queryRunner.query( await queryRunner.query(
`ALTER TABLE "juniors" ADD CONSTRAINT "FK_dfbf64ede1ff823a489902448a2" FOREIGN KEY ("customer_id") REFERENCES "customer"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, `ALTER TABLE "juniors" ADD CONSTRAINT "FK_dfbf64ede1ff823a489902448a2" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
); );
} }

View File

@ -17,7 +17,7 @@ export class CreateGuardianEntity1733732021622 implements MigrationInterface {
`ALTER TABLE "juniors" ADD CONSTRAINT "FK_0b11aa56264184690e2220da4a0" FOREIGN KEY ("guardian_id") REFERENCES "guardians"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, `ALTER TABLE "juniors" ADD CONSTRAINT "FK_0b11aa56264184690e2220da4a0" FOREIGN KEY ("guardian_id") REFERENCES "guardians"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
); );
await queryRunner.query( await queryRunner.query(
`ALTER TABLE "guardians" ADD CONSTRAINT "FK_6c46a1b6af00e6457cb1b70f7e7" FOREIGN KEY ("customer_id") REFERENCES "customer"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, `ALTER TABLE "guardians" ADD CONSTRAINT "FK_6c46a1b6af00e6457cb1b70f7e7" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
); );
} }

View File

@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { v4 as uuid } from 'uuid';
import { Document } from '~/document/entities';
import { DocumentType } from '~/document/enums';
const DEFAULT_TASK_LOGOS = [
{
id: uuid(),
name: 'bed-furniture',
extension: '.jpg',
documentType: DocumentType.DEFAULT_TASKS_LOGO,
},
{
id: uuid(),
name: 'dog',
extension: '.jpg',
documentType: DocumentType.DEFAULT_TASKS_LOGO,
},
{
id: uuid(),
name: 'dish-washing',
extension: '.jpg',
documentType: DocumentType.DEFAULT_TASKS_LOGO,
},
{
id: uuid(),
name: 'walking-the-dog',
extension: '.jpg',
documentType: DocumentType.DEFAULT_TASKS_LOGO,
},
];
export class SeedsDefaultTasksLogo1733990253208 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.manager.getRepository(Document).save(DEFAULT_TASK_LOGOS);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await DEFAULT_TASK_LOGOS.forEach(async (logo) => {
await queryRunner.manager.getRepository(Document).delete({ name: logo.name, documentType: logo.documentType });
});
}
}

View File

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

View File

@ -0,0 +1,70 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateSavingGoalsEntities1734246386471 implements MigrationInterface {
name = 'CreateSavingGoalsEntities1734246386471';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "saving_goals"
("id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"name" character varying(255) NOT NULL,
"description" character varying(255),
"due_date" date NOT NULL,
"target_amount" numeric(12,3) NOT NULL,
"current_amount" numeric(12,3) NOT NULL DEFAULT '0',
"image_id" uuid, "junior_id" uuid NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_5193f14c1c3a38e6657a159795e" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "categories"
("id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"name" character varying(255) NOT NULL,
"type" character varying(255) NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
"junior_id" uuid, CONSTRAINT "PK_24dbc6126a28ff948da33e97d3b" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "saving_goals_categories"
("saving_goal_id" uuid NOT NULL,
"category_id" uuid NOT NULL,
CONSTRAINT "PK_a49d4f57d06d0a36a8385b6c28f" PRIMARY KEY ("saving_goal_id", "category_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_d421de423f21c01672ea7c2e98" ON "saving_goals_categories" ("saving_goal_id") `,
);
await queryRunner.query(
`CREATE INDEX "IDX_b0a721a8f7f5b6fe93f3603ebc" ON "saving_goals_categories" ("category_id") `,
);
await queryRunner.query(
`ALTER TABLE "saving_goals" ADD CONSTRAINT "FK_dad35932272342c1a247a2cee1c" FOREIGN KEY ("image_id") REFERENCES "documents"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "saving_goals" ADD CONSTRAINT "FK_f494ba0a361b2f9cbe5f6e56e38" FOREIGN KEY ("junior_id") REFERENCES "juniors"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "categories" ADD CONSTRAINT "FK_4f98e0b010d5e90cae0a2007748" FOREIGN KEY ("junior_id") REFERENCES "juniors"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "saving_goals_categories" ADD CONSTRAINT "FK_d421de423f21c01672ea7c2e98f" FOREIGN KEY ("saving_goal_id") REFERENCES "saving_goals"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "saving_goals_categories" ADD CONSTRAINT "FK_b0a721a8f7f5b6fe93f3603ebc8" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "saving_goals_categories" DROP CONSTRAINT "FK_b0a721a8f7f5b6fe93f3603ebc8"`);
await queryRunner.query(`ALTER TABLE "saving_goals_categories" DROP CONSTRAINT "FK_d421de423f21c01672ea7c2e98f"`);
await queryRunner.query(`ALTER TABLE "categories" DROP CONSTRAINT "FK_4f98e0b010d5e90cae0a2007748"`);
await queryRunner.query(`ALTER TABLE "saving_goals" DROP CONSTRAINT "FK_f494ba0a361b2f9cbe5f6e56e38"`);
await queryRunner.query(`ALTER TABLE "saving_goals" DROP CONSTRAINT "FK_dad35932272342c1a247a2cee1c"`);
await queryRunner.query(`DROP INDEX "public"."IDX_b0a721a8f7f5b6fe93f3603ebc"`);
await queryRunner.query(`DROP INDEX "public"."IDX_d421de423f21c01672ea7c2e98"`);
await queryRunner.query(`DROP TABLE "saving_goals_categories"`);
await queryRunner.query(`DROP TABLE "categories"`);
await queryRunner.query(`DROP TABLE "saving_goals"`);
}
}

View File

@ -0,0 +1,57 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import { CategoryType } from '~/saving-goals/enums';
const DEFAULT_CATEGORIES = [
{
name: 'School',
type: CategoryType.GLOBAL,
},
{
name: 'Toys',
type: CategoryType.GLOBAL,
},
{
name: 'Games',
type: CategoryType.GLOBAL,
},
{
name: 'Clothes',
type: CategoryType.GLOBAL,
},
{
name: 'Hobbies',
type: CategoryType.GLOBAL,
},
{
name: 'Party',
type: CategoryType.GLOBAL,
},
{
name: 'Sport',
type: CategoryType.GLOBAL,
},
{
name: 'University',
type: CategoryType.GLOBAL,
},
{
name: 'Travel',
type: CategoryType.GLOBAL,
},
];
export class SeedsGoalsCategories1734247702310 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`INSERT INTO categories (name, type) VALUES ${DEFAULT_CATEGORIES.map(
(category) => `('${category.name}', '${category.type}')`,
)}`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM categories WHERE name IN (${DEFAULT_CATEGORIES.map(
(category) => `'${category.name}'`,
)}) AND type = '${CategoryType.GLOBAL}'`,
);
}
}

View File

@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateJuniorRegistrationTokenTable1734262619426 implements MigrationInterface {
name = 'CreateJuniorRegistrationTokenTable1734262619426';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "junior_registration_tokens"
("id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"token" character varying(255) NOT NULL,
"is_used" boolean NOT NULL DEFAULT false,
"expiry_date" TIMESTAMP NOT NULL,
"junior_id" uuid NOT NULL,
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "UQ_e6a3e23d5a63be76812dc5d7728" UNIQUE ("token"),
CONSTRAINT "PK_610992ebec8f664113ae48b946f" PRIMARY KEY ("id"))`,
);
await queryRunner.query(`CREATE INDEX "IDX_e6a3e23d5a63be76812dc5d772" ON "junior_registration_tokens" ("token") `);
await queryRunner.query(
`ALTER TABLE "junior_registration_tokens" ADD CONSTRAINT "FK_19c52317b5b7aeecc0a11789b53" FOREIGN KEY ("junior_id") REFERENCES "juniors"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "junior_registration_tokens" DROP CONSTRAINT "FK_19c52317b5b7aeecc0a11789b53"`,
);
await queryRunner.query(`DROP INDEX "public"."IDX_e6a3e23d5a63be76812dc5d772"`);
await queryRunner.query(`DROP TABLE "junior_registration_tokens"`);
}
}

View File

@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateMoneyRequestEntity1734503895302 implements MigrationInterface {
name = 'CreateMoneyRequestEntity1734503895302';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "money_requests"
("id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"requested_amount" numeric(10,3) NOT NULL,
"message" character varying NOT NULL,
"frequency" character varying NOT NULL DEFAULT 'ONE_TIME',
"status" character varying NOT NULL DEFAULT 'PENDING',
"reviewed_at" TIMESTAMP WITH TIME ZONE,
"end_date" TIMESTAMP WITH TIME ZONE,
"requester_id" uuid NOT NULL,
"reviewer_id" uuid NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_28cff23e9fb06cd5dbf73cd53e7" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "money_requests" ADD CONSTRAINT "FK_5cce02836c6033b6e2412995e34" FOREIGN KEY ("requester_id") REFERENCES "juniors"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "money_requests" ADD CONSTRAINT "FK_75ba0766db9a7bf03126facf31c" FOREIGN KEY ("reviewer_id") REFERENCES "guardians"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "money_requests" DROP CONSTRAINT "FK_75ba0766db9a7bf03126facf31c"`);
await queryRunner.query(`ALTER TABLE "money_requests" DROP CONSTRAINT "FK_5cce02836c6033b6e2412995e34"`);
await queryRunner.query(`DROP TABLE "money_requests"`);
}
}

View File

@ -1,10 +1,16 @@
export * from './1732434281561-create-document-entity'; export * from './1732434281561-create-document-entity';
export * from './1733206728721-create-user-entity'; export * from './1733206728721-create-user-entity';
export * from './1733209041336-create-otp-entity'; export * from './1733209041336-create-otp-entity';
export * from './1733231692252-create-notification-settings-table';
export * from './1733298524771-create-customer-entity'; export * from './1733298524771-create-customer-entity';
export * from './1733314952318-create-device-entity'; export * from './1733314952318-create-device-entity';
export * from './1733731507261-create-junior-entity'; export * from './1733731507261-create-junior-entity';
export * from './1733732021622-create-guardian-entity'; export * from './1733732021622-create-guardian-entity';
export * from './1733748083604-create-theme-entity'; export * from './1733748083604-create-theme-entity';
export * from './1733750228289-seed-default-avatar'; export * from './1733750228289-seed-default-avatar';
export * from './1733904556416-create-task-entities';
export * from './1733990253208-seeds-default-tasks-logo';
export * from './1733993920226-create-customer-notifications-settings-table';
export * from './1734246386471-create-saving-goals-entities';
export * from './1734247702310-seeds-goals-categories';
export * from './1734262619426-create-junior-registration-token-table';
export * from './1734503895302-create-money-request-entity';

View File

@ -4,4 +4,8 @@ export const BUCKETS: Record<DocumentType, string> = {
[DocumentType.PROFILE_PICTURE]: 'profile-pictures', [DocumentType.PROFILE_PICTURE]: 'profile-pictures',
[DocumentType.PASSPORT]: 'passports', [DocumentType.PASSPORT]: 'passports',
[DocumentType.DEFAULT_AVATAR]: 'avatars', [DocumentType.DEFAULT_AVATAR]: 'avatars',
[DocumentType.DEFAULT_TASKS_LOGO]: 'tasks-logo',
[DocumentType.CUSTOM_AVATAR]: 'avatars',
[DocumentType.CUSTOM_TASKS_LOGO]: 'tasks-logo',
[DocumentType.GOALS]: 'goals',
}; };

View File

@ -24,7 +24,9 @@ export class DocumentController {
}, },
documentType: { documentType: {
type: 'string', type: 'string',
enum: Object.values(DocumentType), enum: Object.values(DocumentType).filter(
(value) => ![DocumentType.DEFAULT_AVATAR, DocumentType.DEFAULT_TASKS_LOGO].includes(value),
),
}, },
}, },
required: ['document', 'documentType'], required: ['document', 'documentType'],

View File

@ -1,14 +1,16 @@
import { Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { CacheModule } from '~/common/modules/cache/cache.module';
import { DocumentController } from './controllers'; import { DocumentController } from './controllers';
import { Document } from './entities'; import { Document } from './entities';
import { DocumentRepository } from './repositories'; import { DocumentRepository } from './repositories';
import { DocumentService, OciService } from './services'; import { DocumentService, OciService } from './services';
@Global()
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Document])], imports: [TypeOrmModule.forFeature([Document]), CacheModule],
controllers: [DocumentController], controllers: [DocumentController],
providers: [DocumentService, OciService, DocumentRepository], providers: [DocumentService, OciService, DocumentRepository],
exports: [DocumentService], exports: [DocumentService, OciService],
}) })
export class DocumentModule {} export class DocumentModule {}

View File

@ -15,18 +15,14 @@ export class DocumentMetaResponseDto {
@ApiProperty() @ApiProperty()
documentType!: DocumentType; documentType!: DocumentType;
@ApiProperty() @ApiProperty({ type: String })
createdAt!: Date; url!: string | null;
@ApiProperty()
updatedAt!: Date;
constructor(document: Document) { constructor(document: Document) {
this.id = document.id; this.id = document.id;
this.name = document.name; this.name = document.name;
this.extension = document.extension; this.extension = document.extension;
this.documentType = document.documentType; this.documentType = document.documentType;
this.createdAt = document.createdAt; this.url = document.url || null;
this.updatedAt = document.updatedAt;
} }
} }

View File

@ -1,6 +1,8 @@
import { Column, Entity, OneToMany, OneToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; import { Column, Entity, OneToMany, OneToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
import { User } from '~/auth/entities'; import { User } from '~/auth/entities';
import { Customer } from '~/customer/entities';
import { Junior, Theme } from '~/junior/entities'; import { Junior, Theme } from '~/junior/entities';
import { SavingGoal } from '~/saving-goals/entities';
import { Task } from '~/task/entities'; import { Task } from '~/task/entities';
import { TaskSubmission } from '~/task/entities/task-submissions.entity'; import { TaskSubmission } from '~/task/entities/task-submissions.entity';
import { DocumentType } from '../enums'; import { DocumentType } from '../enums';
@ -16,16 +18,16 @@ export class Document {
@Column({ type: 'varchar', length: 255 }) @Column({ type: 'varchar', length: 255 })
extension!: string; extension!: string;
@Column({ type: 'varchar', length: 255 }) @Column({ type: 'varchar', length: 255, name: 'document_type' })
documentType!: DocumentType; documentType!: DocumentType;
@OneToOne(() => User, (user) => user.profilePicture, { onDelete: 'CASCADE' }) @OneToOne(() => Customer, (customer) => customer.profilePicture, { onDelete: 'SET NULL' })
user?: User; customerPicture?: Customer;
@OneToOne(() => Junior, (junior) => junior.civilIdFront, { onDelete: 'CASCADE' }) @OneToOne(() => Junior, (junior) => junior.civilIdFront, { onDelete: 'SET NULL' })
juniorCivilIdFront?: User; juniorCivilIdFront?: User;
@OneToOne(() => Junior, (junior) => junior.civilIdBack, { onDelete: 'CASCADE' }) @OneToOne(() => Junior, (junior) => junior.civilIdBack, { onDelete: 'SET NULL' })
juniorCivilIdBack?: User; juniorCivilIdBack?: User;
@OneToMany(() => Theme, (theme) => theme.avatar) @OneToMany(() => Theme, (theme) => theme.avatar)
@ -37,9 +39,15 @@ export class Document {
@OneToMany(() => TaskSubmission, (taskSubmission) => taskSubmission.proofOfCompletion) @OneToMany(() => TaskSubmission, (taskSubmission) => taskSubmission.proofOfCompletion)
submissions?: TaskSubmission[]; submissions?: TaskSubmission[];
@OneToMany(() => SavingGoal, (savingGoal) => savingGoal.image)
goals?: SavingGoal[];
@UpdateDateColumn({ name: 'updated_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) @UpdateDateColumn({ name: 'updated_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date; updatedAt!: Date;
@Column({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) @Column({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date; createdAt!: Date;
// virtual field
url?: string;
} }

View File

@ -2,4 +2,8 @@ export enum DocumentType {
PROFILE_PICTURE = 'PROFILE_PICTURE', PROFILE_PICTURE = 'PROFILE_PICTURE',
PASSPORT = 'PASSPORT', PASSPORT = 'PASSPORT',
DEFAULT_AVATAR = 'DEFAULT_AVATAR', DEFAULT_AVATAR = 'DEFAULT_AVATAR',
DEFAULT_TASKS_LOGO = 'DEFAULT_TASKS_LOGO',
CUSTOM_AVATAR = 'CUSTOM_AVATAR',
CUSTOM_TASKS_LOGO = 'CUSTOM_TASKS_LOGO',
GOALS = 'GOALS',
} }

View File

@ -1,14 +1,18 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import moment from 'moment';
import { Region, SimpleAuthenticationDetailsProvider } from 'oci-common'; import { Region, SimpleAuthenticationDetailsProvider } from 'oci-common';
import { ObjectStorageClient } from 'oci-objectstorage'; import { ObjectStorageClient } from 'oci-objectstorage';
import { CreatePreauthenticatedRequestDetails } from 'oci-objectstorage/lib/model';
import path from 'path'; import path from 'path';
import { CacheService } from '~/common/modules/cache/services';
import { BUCKETS } from '../constants'; import { BUCKETS } from '../constants';
import { UploadDocumentRequestDto } from '../dtos/request'; import { UploadDocumentRequestDto } from '../dtos/request';
import { UploadResponseDto } from '../dtos/response'; import { UploadResponseDto } from '../dtos/response';
import { Document } from '../entities';
import { generateNewFileName } from '../utils'; import { generateNewFileName } from '../utils';
const TWO = 2;
@Injectable() @Injectable()
export class OciService { export class OciService {
private readonly ociClient: ObjectStorageClient; private readonly ociClient: ObjectStorageClient;
@ -20,7 +24,7 @@ export class OciService {
private readonly namespace: string = this.configService.getOrThrow('OCI_NAMESPACE'); private readonly namespace: string = this.configService.getOrThrow('OCI_NAMESPACE');
private readonly region: string = this.configService.getOrThrow('OCI_REGION'); private readonly region: string = this.configService.getOrThrow('OCI_REGION');
private readonly logger = new Logger(OciService.name); private readonly logger = new Logger(OciService.name);
constructor(private configService: ConfigService) { constructor(private configService: ConfigService, private readonly cacheService: CacheService) {
this.ociClient = new ObjectStorageClient({ this.ociClient = new ObjectStorageClient({
authenticationDetailsProvider: new SimpleAuthenticationDetailsProvider( authenticationDetailsProvider: new SimpleAuthenticationDetailsProvider(
this.tenancyId, this.tenancyId,
@ -60,4 +64,40 @@ export class OciService {
documentType, documentType,
}); });
} }
async generatePreSignedUrl(document?: Document): Promise<string | any> {
if (!document) {
return null;
}
const cachedUrl = await this.cacheService.get<string>(document.id);
if (cachedUrl) {
return cachedUrl;
}
const bucketName = BUCKETS[document.documentType];
const objectName = document.name;
const expiration = moment().add(TWO, 'hours').toDate();
try {
this.logger.debug(`Generating pre-signed url for object ${objectName} in bucket ${bucketName}`);
const res = await this.ociClient.createPreauthenticatedRequest({
namespaceName: this.namespace,
bucketName,
createPreauthenticatedRequestDetails: {
name: objectName,
accessType: CreatePreauthenticatedRequestDetails.AccessType.AnyObjectRead,
timeExpires: expiration,
objectName,
},
retryConfiguration: { terminationStrategy: { shouldTerminate: () => true } },
});
this.cacheService.set(document.id, res.preauthenticatedRequest.fullPath + objectName, '1h');
return res.preauthenticatedRequest.fullPath + objectName;
} catch (error) {
this.logger.error(`Error generating pre-signed url: ${error}`);
return document.name;
}
}
} }

View File

@ -11,6 +11,7 @@ import {
} from 'typeorm'; } from 'typeorm';
import { Customer } from '~/customer/entities'; import { Customer } from '~/customer/entities';
import { Junior } from '~/junior/entities'; import { Junior } from '~/junior/entities';
import { MoneyRequest } from '~/money-request/entities';
import { Task } from '~/task/entities'; import { Task } from '~/task/entities';
@Entity('guardians') @Entity('guardians')
@ -31,6 +32,9 @@ export class Guardian extends BaseEntity {
@OneToMany(() => Task, (task) => task.assignedBy) @OneToMany(() => Task, (task) => task.assignedBy)
tasks?: Task[]; tasks?: Task[];
@OneToMany(() => MoneyRequest, (moneyRequest) => moneyRequest.reviewer)
moneyRequests?: MoneyRequest[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) @CreateDateColumn({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date; createdAt!: Date;

View File

@ -2,14 +2,14 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/co
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Roles } from '~/auth/enums'; import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces'; import { IJwtPayload } from '~/auth/interfaces';
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators'; import { AllowedRoles, AuthenticatedUser, Public } from '~/common/decorators';
import { RolesGuard } from '~/common/guards'; import { RolesGuard } from '~/common/guards';
import { ApiDataPageResponse, ApiDataResponse } from '~/core/decorators'; import { ApiDataPageResponse, ApiDataResponse } from '~/core/decorators';
import { PageOptionsRequestDto } from '~/core/dtos'; import { PageOptionsRequestDto } from '~/core/dtos';
import { CustomParseUUIDPipe } from '~/core/pipes'; import { CustomParseUUIDPipe } from '~/core/pipes';
import { ResponseFactory } from '~/core/utils'; import { ResponseFactory } from '~/core/utils';
import { CreateJuniorRequestDto, SetThemeRequestDto } from '../dtos/request'; import { CreateJuniorRequestDto, SetThemeRequestDto } from '../dtos/request';
import { JuniorResponseDto, ThemeResponseDto } from '../dtos/response'; import { JuniorResponseDto, QrCodeValidationResponseDto, ThemeResponseDto } from '../dtos/response';
import { JuniorService } from '../services'; import { JuniorService } from '../services';
@Controller('juniors') @Controller('juniors')
@ -23,9 +23,9 @@ export class JuniorController {
@AllowedRoles(Roles.GUARDIAN) @AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse(JuniorResponseDto) @ApiDataResponse(JuniorResponseDto)
async createJunior(@Body() body: CreateJuniorRequestDto, @AuthenticatedUser() user: IJwtPayload) { async createJunior(@Body() body: CreateJuniorRequestDto, @AuthenticatedUser() user: IJwtPayload) {
const junior = await this.juniorService.createJuniors(body, user.sub); const token = await this.juniorService.createJuniors(body, user.sub);
return ResponseFactory.data(new JuniorResponseDto(junior)); return ResponseFactory.data(token);
} }
@Get() @Get()
@ -53,7 +53,7 @@ export class JuniorController {
@AuthenticatedUser() user: IJwtPayload, @AuthenticatedUser() user: IJwtPayload,
@Param('juniorId', CustomParseUUIDPipe) juniorId: string, @Param('juniorId', CustomParseUUIDPipe) juniorId: string,
) { ) {
const junior = await this.juniorService.findJuniorById(juniorId, user.sub); const junior = await this.juniorService.findJuniorById(juniorId, false, user.sub);
return ResponseFactory.data(new JuniorResponseDto(junior)); return ResponseFactory.data(new JuniorResponseDto(junior));
} }
@ -66,4 +66,23 @@ export class JuniorController {
const theme = await this.juniorService.setTheme(body, user.sub); const theme = await this.juniorService.setTheme(body, user.sub);
return ResponseFactory.data(new ThemeResponseDto(theme)); return ResponseFactory.data(new ThemeResponseDto(theme));
} }
@Get(':juniorId/qr-code')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@ApiDataResponse('string')
async generateQrCode(@Param('juniorId', CustomParseUUIDPipe) juniorId: string) {
const qrCode = await this.juniorService.generateToken(juniorId);
return ResponseFactory.data(qrCode);
}
@Get('qr-code/:token/validate')
@Public()
@ApiDataResponse(QrCodeValidationResponseDto)
async validateToken(@Param('token') token: string) {
const junior = await this.juniorService.validateToken(token);
return ResponseFactory.data(new QrCodeValidationResponseDto(junior));
}
} }

View File

@ -1,2 +1,4 @@
export * from './junior.response.dto'; export * from './junior.response.dto';
export * from './qr-code-validation-details.response.dto';
export * from './qr-code-validation.response.dto';
export * from './theme.response.dto'; export * from './theme.response.dto';

View File

@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { DocumentMetaResponseDto } from '~/document/dtos/response';
import { Junior } from '~/junior/entities'; import { Junior } from '~/junior/entities';
import { Relationship } from '~/junior/enums'; import { Relationship } from '~/junior/enums';
@ -12,13 +13,15 @@ export class JuniorResponseDto {
@ApiProperty({ example: 'relationship' }) @ApiProperty({ example: 'relationship' })
relationship!: Relationship; relationship!: Relationship;
@ApiProperty({ example: 'profilePictureId' }) @ApiProperty({ type: DocumentMetaResponseDto })
profilePictureId: string | null; profilePicture!: DocumentMetaResponseDto | null;
constructor(junior: Junior) { constructor(junior: Junior) {
this.id = junior.id; this.id = junior.id;
this.fullName = `${junior.customer.firstName} ${junior.customer.lastName}`; this.fullName = `${junior.customer.firstName} ${junior.customer.lastName}`;
this.relationship = junior.relationship; this.relationship = junior.relationship;
this.profilePictureId = junior.customer.user.profilePictureId; this.profilePicture = junior.customer.profilePicture
? new DocumentMetaResponseDto(junior.customer.profilePicture)
: null;
} }
} }

View File

@ -0,0 +1,31 @@
import { ApiProperty } from '@nestjs/swagger';
import { Guardian } from '~/guardian/entities/guradian.entity';
import { Junior } from '~/junior/entities';
import { GuardianRelationship } from '~/junior/enums';
export class QrCodeValidationDetailsResponse {
@ApiProperty()
fullname!: string;
@ApiProperty()
phoneNumber!: string;
@ApiProperty()
email!: string;
@ApiProperty()
relationship?: string;
@ApiProperty()
dateOfBirth!: Date;
constructor(junior: Junior, guardian?: Guardian) {
const person = guardian ? guardian : junior;
this.fullname = `${person.customer.firstName} ${person.customer.lastName}`;
this.phoneNumber = person.customer.user.phoneNumber;
this.email = person.customer.user.email;
this.dateOfBirth = person.customer.dateOfBirth;
this.relationship = guardian ? junior.relationship : GuardianRelationship[junior.relationship];
}
}

View File

@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { Junior } from '~/junior/entities';
import { QrCodeValidationDetailsResponse } from './qr-code-validation-details.response.dto';
export class QrCodeValidationResponseDto {
@ApiProperty({ type: QrCodeValidationDetailsResponse })
parentDetails: any;
@ApiProperty({ type: QrCodeValidationDetailsResponse })
childDetails: any;
constructor(junior: Junior) {
this.parentDetails = new QrCodeValidationDetailsResponse(junior, junior.guardian);
this.childDetails = new QrCodeValidationDetailsResponse(junior);
}
}

View File

@ -1,2 +1,3 @@
export * from './junior-registration-token.entity';
export * from './junior.entity'; export * from './junior.entity';
export * from './theme.entity'; export * from './theme.entity';

View File

@ -0,0 +1,41 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Junior } from './junior.entity';
@Entity('junior_registration_tokens')
export class JuniorRegistrationToken extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 255, name: 'token', unique: true })
@Index()
token!: string;
@Column({ type: 'boolean', default: false, name: 'is_used' })
isUsed!: boolean;
@Column({ type: 'timestamp', name: 'expiry_date' })
expiryDate!: Date;
@Column({ type: 'uuid', name: 'junior_id' })
juniorId!: string;
@ManyToOne(() => Junior, (junior) => junior.registrationTokens)
@JoinColumn({ name: 'junior_id' })
junior!: Junior;
@UpdateDateColumn({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', name: 'updated_at' })
updatedAt!: Date;
@CreateDateColumn({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP', name: 'created_at' })
createdAt!: Date;
}

View File

@ -13,8 +13,11 @@ import {
import { Customer } from '~/customer/entities'; import { Customer } from '~/customer/entities';
import { Document } from '~/document/entities'; import { Document } from '~/document/entities';
import { Guardian } from '~/guardian/entities/guradian.entity'; import { Guardian } from '~/guardian/entities/guradian.entity';
import { MoneyRequest } from '~/money-request/entities';
import { Category, SavingGoal } from '~/saving-goals/entities';
import { Task } from '~/task/entities'; import { Task } from '~/task/entities';
import { Relationship } from '../enums'; import { Relationship } from '../enums';
import { JuniorRegistrationToken } from './junior-registration-token.entity';
import { Theme } from './theme.entity'; import { Theme } from './theme.entity';
@Entity('juniors') @Entity('juniors')
@ -57,7 +60,19 @@ export class Junior extends BaseEntity {
guardian!: Guardian; guardian!: Guardian;
@OneToMany(() => Task, (task) => task.assignedTo) @OneToMany(() => Task, (task) => task.assignedTo)
tasks?: Task[]; tasks!: Task[];
@OneToMany(() => SavingGoal, (savingGoal) => savingGoal.junior)
goals!: SavingGoal[];
@OneToMany(() => Category, (category) => category.junior)
categories!: Category[];
@OneToMany(() => JuniorRegistrationToken, (token) => token.junior)
registrationTokens!: JuniorRegistrationToken[];
@OneToMany(() => MoneyRequest, (moneyRequest) => moneyRequest.requester)
moneyRequests!: MoneyRequest[];
@CreateDateColumn({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' }) @CreateDateColumn({ name: 'created_at', type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date; createdAt!: Date;

View File

@ -0,0 +1,9 @@
import { ObjectValues } from '~/core/types';
import { Relationship } from './relationship.enum';
export const GuardianRelationship = {
[Relationship.PARENT]: 'CHILD',
[Relationship.GUARDIAN]: 'WARD',
} as const;
export type GuardianRelationship = ObjectValues<typeof GuardianRelationship>;

View File

@ -1,2 +1,3 @@
export * from './guardian-relationship.enum';
export * from './relationship.enum'; export * from './relationship.enum';
export * from './theme-color.enum'; export * from './theme-color.enum';

View File

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

View File

@ -1 +1,2 @@
export * from './junior-token.repository';
export * from './junior.repository'; export * from './junior.repository';

View File

@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import * as crypto from 'crypto';
import moment from 'moment';
import { Repository } from 'typeorm';
import { JuniorRegistrationToken } from '../entities';
const TOKEN_LENGTH = 16;
@Injectable()
export class JuniorTokenRepository {
constructor(
@InjectRepository(JuniorRegistrationToken)
private readonly juniorTokenRepository: Repository<JuniorRegistrationToken>,
) {}
generateToken(juniorId: string) {
return this.juniorTokenRepository.save(
this.juniorTokenRepository.create({
juniorId,
expiryDate: moment().add('15', 'minutes').toDate(),
token: `${moment().unix()}-${crypto.randomBytes(TOKEN_LENGTH).toString('hex')}`,
}),
);
}
findByToken(token: string) {
return this.juniorTokenRepository.findOne({ where: { token, isUsed: false } });
}
invalidateToken(token: string) {
return this.juniorTokenRepository.update({ token }, { isUsed: true });
}
}

View File

@ -19,10 +19,14 @@ export class JuniorRepository {
}); });
} }
findJuniorById(juniorId: string, guardianId?: string) { findJuniorById(juniorId: string, withGuardianRelation = false, guardianId?: string) {
const relations = ['customer', 'customer.user', 'theme', 'theme.avatar'];
if (withGuardianRelation) {
relations.push('guardian', 'guardian.customer', 'guardian.customer.user');
}
return this.juniorRepository.findOne({ return this.juniorRepository.findOne({
where: { id: juniorId, guardianId }, where: { id: juniorId, guardianId },
relations: ['customer', 'customer.user', 'theme'], relations,
}); });
} }

View File

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

View File

@ -0,0 +1,33 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { JuniorTokenRepository } from '../repositories';
import { QrcodeService } from './qrcode.service';
@Injectable()
export class JuniorTokenService {
constructor(
private readonly juniorTokenRepository: JuniorTokenRepository,
private readonly qrCodeService: QrcodeService,
) {}
async generateToken(juniorId: string): Promise<string> {
const tokenEntity = await this.juniorTokenRepository.generateToken(juniorId);
return this.qrCodeService.generateQrCode(tokenEntity.token);
}
async validateToken(token: string) {
const tokenEntity = await this.juniorTokenRepository.findByToken(token);
if (!tokenEntity) {
throw new BadRequestException('TOKEN.INVALID');
}
if (tokenEntity.expiryDate < new Date()) {
throw new BadRequestException('TOKEN.EXPIRED');
}
return tokenEntity.juniorId;
}
invalidateToken(token: string) {
return this.juniorTokenRepository.invalidateToken(token);
}
}

View File

@ -3,10 +3,12 @@ import { Transactional } from 'typeorm-transactional';
import { Roles } from '~/auth/enums'; import { Roles } from '~/auth/enums';
import { UserService } from '~/auth/services'; import { UserService } from '~/auth/services';
import { PageOptionsRequestDto } from '~/core/dtos'; import { PageOptionsRequestDto } from '~/core/dtos';
import { CustomerNotificationSettings } from '~/customer/entities/customer-notification-settings.entity';
import { CustomerService } from '~/customer/services'; import { CustomerService } from '~/customer/services';
import { CreateJuniorRequestDto, SetThemeRequestDto } from '../dtos/request'; import { CreateJuniorRequestDto, SetThemeRequestDto } from '../dtos/request';
import { Junior } from '../entities'; import { Junior } from '../entities';
import { JuniorRepository } from '../repositories'; import { JuniorRepository } from '../repositories';
import { JuniorTokenService } from './junior-token.service';
@Injectable() @Injectable()
export class JuniorService { export class JuniorService {
@ -14,6 +16,7 @@ export class JuniorService {
private readonly juniorRepository: JuniorRepository, private readonly juniorRepository: JuniorRepository,
private readonly userService: UserService, private readonly userService: UserService,
private readonly customerService: CustomerService, private readonly customerService: CustomerService,
private readonly juniorTokenService: JuniorTokenService,
) {} ) {}
@Transactional() @Transactional()
@ -43,15 +46,16 @@ export class JuniorService {
civilIdFrontId: body.civilIdFrontId, civilIdFrontId: body.civilIdFrontId,
civilIdBackId: body.civilIdBackId, civilIdBackId: body.civilIdBackId,
}), }),
notificationSettings: new CustomerNotificationSettings(),
}, },
user, user,
); );
return this.findJuniorById(user.id, guardianId); return this.juniorTokenService.generateToken(user.id);
} }
async findJuniorById(juniorId: string, guardianId?: string) { async findJuniorById(juniorId: string, withGuardianRelation = false, guardianId?: string) {
const junior = await this.juniorRepository.findJuniorById(juniorId, guardianId); const junior = await this.juniorRepository.findJuniorById(juniorId, withGuardianRelation, guardianId);
if (!junior) { if (!junior) {
throw new BadRequestException('JUNIOR.NOT_FOUND'); throw new BadRequestException('JUNIOR.NOT_FOUND');
@ -72,4 +76,14 @@ export class JuniorService {
findJuniorsByGuardianId(guardianId: string, pageOptions: PageOptionsRequestDto) { findJuniorsByGuardianId(guardianId: string, pageOptions: PageOptionsRequestDto) {
return this.juniorRepository.findJuniorsByGuardianId(guardianId, pageOptions); return this.juniorRepository.findJuniorsByGuardianId(guardianId, pageOptions);
} }
async validateToken(token: string) {
const juniorId = await this.juniorTokenService.validateToken(token);
return this.findJuniorById(juniorId, true);
}
generateToken(juniorId: string) {
return this.juniorTokenService.generateToken(juniorId);
}
} }

View File

@ -0,0 +1,9 @@
import { Injectable } from '@nestjs/common';
import * as qrcode from 'qrcode';
@Injectable()
export class QrcodeService {
generateQrCode(token: string): Promise<string> {
return qrcode.toDataURL(token);
}
}

View File

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

View File

@ -0,0 +1,81 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces';
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
import { RolesGuard } from '~/common/guards';
import { ApiDataPageResponse, ApiDataResponse } from '~/core/decorators';
import { CustomParseUUIDPipe } from '~/core/pipes';
import { ResponseFactory } from '~/core/utils';
import { CreateMoneyRequestRequestDto, MoneyRequestsFiltersRequestDto } from '../dtos/request';
import { MoneyRequestResponseDto } from '../dtos/response/money-request.response.dto';
import { MoneyRequestsService } from '../services';
@Controller('money-requests')
@ApiTags('Money Requests')
@ApiBearerAuth()
export class MoneyRequestsController {
constructor(private readonly moneyRequestsService: MoneyRequestsService) {}
@Post()
@UseGuards(RolesGuard)
@AllowedRoles(Roles.JUNIOR)
@ApiDataResponse(MoneyRequestResponseDto)
async createMoneyRequest(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateMoneyRequestRequestDto) {
const moneyRequest = await this.moneyRequestsService.createMoneyRequest(sub, body);
return ResponseFactory.data(new MoneyRequestResponseDto(moneyRequest));
}
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@Get()
@ApiDataPageResponse(MoneyRequestResponseDto)
async findMoneyRequests(@AuthenticatedUser() { sub }: IJwtPayload, @Query() filters: MoneyRequestsFiltersRequestDto) {
const [moneyRequests, itemCount] = await this.moneyRequestsService.findMoneyRequests(sub, filters);
return ResponseFactory.dataPage(
moneyRequests.map((moneyRequest) => new MoneyRequestResponseDto(moneyRequest)),
{
itemCount,
page: filters.page,
size: filters.size,
},
);
}
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@Get(':moneyRequestId')
@ApiDataResponse(MoneyRequestResponseDto)
async findMoneyRequestById(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('moneyRequestId', CustomParseUUIDPipe) moneyRequestId: string,
) {
const moneyRequest = await this.moneyRequestsService.findMoneyRequestById(moneyRequestId, sub);
return ResponseFactory.data(new MoneyRequestResponseDto(moneyRequest));
}
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@Patch(':moneyRequestId/approve')
@HttpCode(HttpStatus.NO_CONTENT)
async approveMoneyRequest(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('moneyRequestId', CustomParseUUIDPipe) moneyRequestId: string,
) {
await this.moneyRequestsService.approveMoneyRequest(moneyRequestId, sub);
}
@UseGuards(RolesGuard)
@AllowedRoles(Roles.GUARDIAN)
@Patch(':moneyRequestId/reject')
@HttpCode(HttpStatus.NO_CONTENT)
async rejectMoneyRequest(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('moneyRequestId', CustomParseUUIDPipe) moneyRequestId: string,
) {
await this.moneyRequestsService.rejectMoneyRequest(moneyRequestId, sub);
}
}

View File

@ -0,0 +1,37 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { MoneyRequestFrequency } from '~/money-request/enums';
const MIN_REQUESTED_AMOUNT = 0.01;
const MAX_REQUESTED_AMOUNT = 1000000;
export class CreateMoneyRequestRequestDto {
@Min(MIN_REQUESTED_AMOUNT, {
message: i18n('validation.Min', { path: 'general', property: 'moneyRequest.requestedAmount' }),
})
@Max(MAX_REQUESTED_AMOUNT, {
message: i18n('validation.Max', { path: 'general', property: 'moneyRequest.requestedAmount' }),
})
@IsNumber(
{ allowNaN: false },
{ message: i18n('validation.IsNumber', { path: 'general', property: 'moneyRequest.requestedAmount' }) },
)
@ApiProperty()
requestedAmount!: number;
@ApiProperty()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'moneyRequest.message' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'moneyRequest.message' }) })
message!: string;
@ApiPropertyOptional({ example: MoneyRequestFrequency.ONE_TIME })
@IsEnum(MoneyRequestFrequency, {
message: i18n('validation.IsEnum', { path: 'general', property: 'moneyRequest.frequency' }),
})
@IsOptional()
frequency: MoneyRequestFrequency = MoneyRequestFrequency.ONE_TIME;
@ApiPropertyOptional({ example: '2021-01-01' })
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'moneyRequest.endDate' }) })
@IsOptional()
endDate?: string;
}

View File

@ -0,0 +1,2 @@
export * from './create-money-request.request.dto';
export * from './money-requests-filters.request.dto';

View File

@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
import { PageOptionsRequestDto } from '~/core/dtos';
import { MoneyRequestStatus } from '~/money-request/enums';
export class MoneyRequestsFiltersRequestDto extends PageOptionsRequestDto {
@ApiProperty({ example: MoneyRequestStatus.PENDING, enum: MoneyRequestStatus })
@IsEnum(MoneyRequestStatus, {
message: i18n('validation.IsEnum', { path: 'general', property: 'moneyRequest.status' }),
})
status: MoneyRequestStatus = MoneyRequestStatus.PENDING;
}

View File

@ -0,0 +1,49 @@
import { ApiProperty } from '@nestjs/swagger';
import { JuniorResponseDto } from '~/junior/dtos/response';
import { MoneyRequest } from '~/money-request/entities';
import { MoneyRequestFrequency, MoneyRequestStatus } from '~/money-request/enums';
export class MoneyRequestResponseDto {
@ApiProperty({ example: 'f5c7e193-bc5e-4aa5-837b-c1edc6449880' })
id!: string;
@ApiProperty({ type: JuniorResponseDto })
requester!: JuniorResponseDto;
@ApiProperty({ example: 'f5c7e193-bc5e-4aa5-837b-c1edc6449880' })
reviewerId!: string;
@ApiProperty({ example: 100.0 })
requestedAmount!: number;
@ApiProperty({ example: 'Please give me money' })
message!: string;
@ApiProperty({ example: MoneyRequestFrequency.ONE_TIME })
frequency!: MoneyRequestFrequency;
@ApiProperty({ example: '2021-01-01' })
endDate!: Date | null;
@ApiProperty({ example: MoneyRequestStatus.PENDING })
status!: MoneyRequestStatus;
@ApiProperty()
reviewedAt!: Date | null;
@ApiProperty()
createdAt!: Date;
constructor(moneyRequest: MoneyRequest) {
this.id = moneyRequest.id;
this.requester = new JuniorResponseDto(moneyRequest.requester);
this.reviewerId = moneyRequest.reviewerId;
this.requestedAmount = moneyRequest.requestedAmount;
this.message = moneyRequest.message;
this.frequency = moneyRequest.frequency;
this.endDate = moneyRequest.endDate || null;
this.status = moneyRequest.status;
this.reviewedAt = moneyRequest.reviewedAt || null;
this.createdAt = moneyRequest.createdAt;
}
}

View File

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

View File

@ -0,0 +1,56 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Guardian } from '~/guardian/entities/guradian.entity';
import { Junior } from '~/junior/entities';
import { MoneyRequestFrequency, MoneyRequestStatus } from '../enums';
@Entity('money_requests')
export class MoneyRequest {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'decimal', precision: 10, scale: 3, name: 'requested_amount' })
requestedAmount!: number;
@Column({ type: 'varchar', name: 'message' })
message!: string;
@Column({ type: 'varchar', name: 'frequency', default: MoneyRequestFrequency.ONE_TIME })
frequency!: MoneyRequestFrequency;
@Column({ type: 'varchar', name: 'status', default: MoneyRequestStatus.PENDING })
status!: MoneyRequestStatus;
@Column({ type: 'timestamp with time zone', name: 'reviewed_at', nullable: true })
reviewedAt!: Date;
@Column({ type: 'timestamp with time zone', name: 'end_date', nullable: true })
endDate!: Date | null;
@Column({ type: 'uuid', name: 'requester_id' })
requesterId!: string;
@Column({ type: 'uuid', name: 'reviewer_id' })
reviewerId!: string;
@ManyToOne(() => Junior, (junior) => junior.moneyRequests)
@JoinColumn({ name: 'requester_id' })
requester!: Junior;
@ManyToOne(() => Guardian, (guardian) => guardian.moneyRequests)
@JoinColumn({ name: 'reviewer_id' })
reviewer!: Guardian;
@CreateDateColumn({ type: 'timestamp with time zone', name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamp with time zone', name: 'updated_at' })
updatedAt!: Date;
}

View File

@ -0,0 +1,2 @@
export * from './money-request-frequency.enum';
export * from './money-request-status.enum';

View File

@ -0,0 +1,6 @@
export enum MoneyRequestFrequency {
ONE_TIME = 'ONE_TIME',
DAILY = 'DAILY',
WEEKLY = 'WEEKLY',
MONTHLY = 'MONTHLY',
}

View File

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

View File

@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JuniorModule } from '~/junior/junior.module';
import { MoneyRequestsController } from './controllers';
import { MoneyRequest } from './entities';
import { MoneyRequestsRepository } from './repositories';
import { MoneyRequestsService } from './services';
@Module({
controllers: [MoneyRequestsController],
providers: [MoneyRequestsService, MoneyRequestsRepository],
exports: [],
imports: [TypeOrmModule.forFeature([MoneyRequest]), JuniorModule],
})
export class MoneyRequestModule {}

View File

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

View File

@ -0,0 +1,49 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateMoneyRequestRequestDto, MoneyRequestsFiltersRequestDto } from '../dtos/request';
import { MoneyRequest } from '../entities';
import { MoneyRequestStatus } from '../enums';
const ONE = 1;
@Injectable()
export class MoneyRequestsRepository {
constructor(@InjectRepository(MoneyRequest) private readonly moneyRequestRepository: Repository<MoneyRequest>) {}
createMoneyRequest(requesterId: string, reviewerId: string, body: CreateMoneyRequestRequestDto) {
return this.moneyRequestRepository.save(
this.moneyRequestRepository.create({
requesterId,
reviewerId,
requestedAmount: body.requestedAmount,
message: body.message,
endDate: body.endDate,
frequency: body.frequency,
}),
);
}
findMoneyRequestById(moneyRequestId: string, reviewerId?: string) {
return this.moneyRequestRepository.findOne({
where: { id: moneyRequestId, reviewerId },
relations: ['requester', 'requester.customer', 'requester.customer.profilePicture'],
});
}
findMoneyRequests(reviewerId: string, filters: MoneyRequestsFiltersRequestDto) {
const query = this.moneyRequestRepository.createQueryBuilder('moneyRequest');
query.leftJoinAndSelect('moneyRequest.requester', 'requester');
query.leftJoinAndSelect('requester.customer', 'customer');
query.leftJoinAndSelect('customer.profilePicture', 'profilePicture');
query.orderBy('moneyRequest.createdAt', 'DESC');
query.where('moneyRequest.reviewerId = :reviewerId', { reviewerId });
query.andWhere('moneyRequest.status = :status', { status: filters.status });
query.skip((filters.page - ONE) * filters.size);
query.take(filters.size);
return query.getManyAndCount();
}
updateMoneyRequestStatus(moneyRequestId: string, reviewerId: string, status: MoneyRequestStatus) {
return this.moneyRequestRepository.update({ id: moneyRequestId, reviewerId }, { status, reviewedAt: new Date() });
}
}

View File

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

View File

@ -0,0 +1,99 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { OciService } from '~/document/services';
import { JuniorService } from '~/junior/services';
import { CreateMoneyRequestRequestDto, MoneyRequestsFiltersRequestDto } from '../dtos/request';
import { MoneyRequest } from '../entities';
import { MoneyRequestFrequency, MoneyRequestStatus } from '../enums';
import { MoneyRequestsRepository } from '../repositories';
@Injectable()
export class MoneyRequestsService {
constructor(
private readonly moneyRequestsRepository: MoneyRequestsRepository,
private readonly juniorService: JuniorService,
private readonly ociService: OciService,
) {}
async createMoneyRequest(userId: string, body: CreateMoneyRequestRequestDto) {
if (body.frequency === MoneyRequestFrequency.ONE_TIME) {
delete body.endDate;
}
if (body.endDate && new Date(body.endDate) < new Date()) {
throw new BadRequestException('MONEY_REQUEST.END_DATE_IN_THE_PAST');
}
const junior = await this.juniorService.findJuniorById(userId, true);
const moneyRequest = await this.moneyRequestsRepository.createMoneyRequest(junior.id, junior.guardianId, body);
return this.findMoneyRequestById(moneyRequest.id);
}
async findMoneyRequestById(moneyRequestId: string, reviewerId?: string) {
const moneyRequest = await this.moneyRequestsRepository.findMoneyRequestById(moneyRequestId, reviewerId);
if (!moneyRequest) {
throw new BadRequestException('MONEY_REQUEST.NOT_FOUND');
}
await this.prepareMoneyRequestDocument([moneyRequest]);
return moneyRequest;
}
async findMoneyRequests(
ReviewerId: string,
filters: MoneyRequestsFiltersRequestDto,
): Promise<[MoneyRequest[], number]> {
const [moneyRequests, itemCount] = await this.moneyRequestsRepository.findMoneyRequests(ReviewerId, filters);
await this.prepareMoneyRequestDocument(moneyRequests);
return [moneyRequests, itemCount];
}
async approveMoneyRequest(moneyRequestId: string, reviewerId: string) {
await this.validateMoneyRequestForReview(moneyRequestId, reviewerId);
await this.moneyRequestsRepository.updateMoneyRequestStatus(
moneyRequestId,
reviewerId,
MoneyRequestStatus.APPROVED,
);
//@TODO send notification and update junior balance
}
async rejectMoneyRequest(moneyRequestId: string, reviewerId: string) {
await this.validateMoneyRequestForReview(moneyRequestId, reviewerId);
await this.moneyRequestsRepository.updateMoneyRequestStatus(
moneyRequestId,
reviewerId,
MoneyRequestStatus.REJECTED,
);
//@TODO send notification
}
private async prepareMoneyRequestDocument(moneyRequests: MoneyRequest[]) {
await Promise.all(
moneyRequests.map(async (moneyRequest) => {
const profilePicture = moneyRequest.requester.customer.profilePicture;
if (profilePicture) {
profilePicture.url = await this.ociService.generatePreSignedUrl(profilePicture);
}
}),
);
}
private async validateMoneyRequestForReview(moneyRequestId: string, reviewerId: string) {
const moneyRequest = await this.moneyRequestsRepository.findMoneyRequestById(moneyRequestId, reviewerId);
if (!moneyRequest) {
throw new BadRequestException('MONEY_REQUEST.NOT_FOUND');
}
if (moneyRequest.status !== MoneyRequestStatus.PENDING) {
throw new BadRequestException('MONEY_REQUEST.ALREADY_REVIEWED');
}
}
}

View File

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

View File

@ -0,0 +1,95 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Roles } from '~/auth/enums';
import { IJwtPayload } from '~/auth/interfaces';
import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
import { RolesGuard } from '~/common/guards';
import { ApiDataPageResponse, ApiDataResponse } from '~/core/decorators';
import { PageOptionsRequestDto } from '~/core/dtos';
import { CustomParseUUIDPipe } from '~/core/pipes';
import { ResponseFactory } from '~/core/utils';
import { createCategoryRequestDto, CreateGoalRequestDto, FundGoalRequestDto } from '../dtos/request';
import {
CategoriesListResponseDto,
CategoryResponseDto,
GoalsStatsResponseDto,
SavingGoalDetailsResponseDto,
} from '../dtos/response';
import { SavingGoalsService } from '../services';
import { CategoryService } from '../services/category.service';
@Controller('saving-goals')
@ApiTags('Saving Goals')
@UseGuards(RolesGuard)
@AllowedRoles(Roles.JUNIOR)
@ApiBearerAuth()
export class SavingGoalsController {
constructor(
private readonly savingGoalsService: SavingGoalsService,
private readonly categoryService: CategoryService,
) {}
@Post()
@ApiDataResponse(SavingGoalDetailsResponseDto)
async createGoal(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: CreateGoalRequestDto) {
const goal = await this.savingGoalsService.createGoal(sub, body);
return ResponseFactory.data(new SavingGoalDetailsResponseDto(goal));
}
@Get()
@ApiDataPageResponse(SavingGoalDetailsResponseDto)
async getGoals(@AuthenticatedUser() { sub }: IJwtPayload, @Query() pageOptions: PageOptionsRequestDto) {
const [goals, itemCount] = await this.savingGoalsService.findGoals(sub, pageOptions);
return ResponseFactory.dataPage(
goals.map((goal) => new SavingGoalDetailsResponseDto(goal)),
{
page: pageOptions.page,
size: pageOptions.size,
itemCount,
},
);
}
@Get('stats')
@ApiDataResponse(GoalsStatsResponseDto)
async getStats(@AuthenticatedUser() { sub }: IJwtPayload) {
const stats = await this.savingGoalsService.getStats(sub);
return ResponseFactory.data(new GoalsStatsResponseDto(stats));
}
@Get('categories')
@ApiDataResponse(CategoriesListResponseDto)
async getCategories(@AuthenticatedUser() { sub }: IJwtPayload) {
const categories = await this.categoryService.findCategories(sub);
return ResponseFactory.data(new CategoriesListResponseDto(categories));
}
@Get(':goalId')
@ApiDataResponse(SavingGoalDetailsResponseDto)
async getGoal(@AuthenticatedUser() { sub }: IJwtPayload, @Param('goalId', CustomParseUUIDPipe) goalId: string) {
const goal = await this.savingGoalsService.findGoalById(sub, goalId);
return ResponseFactory.data(new SavingGoalDetailsResponseDto(goal));
}
@Post('categories')
@ApiDataResponse(CategoryResponseDto)
async createCategory(@AuthenticatedUser() { sub }: IJwtPayload, @Body() body: createCategoryRequestDto) {
const category = await this.categoryService.createCustomCategory(sub, body);
return ResponseFactory.data(new CategoryResponseDto(category));
}
@Post(':goalId/fund')
@HttpCode(HttpStatus.NO_CONTENT)
async fundGoal(
@AuthenticatedUser() { sub }: IJwtPayload,
@Param('goalId', CustomParseUUIDPipe) goalId: string,
@Body() body: FundGoalRequestDto,
) {
await this.savingGoalsService.fundGoal(sub, goalId, body);
}
}

View File

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

View File

@ -0,0 +1,35 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsArray, IsDateString, IsNotEmpty, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
export class CreateGoalRequestDto {
@ApiProperty()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'goal.name' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'goal.name' }) })
name!: string;
@ApiPropertyOptional()
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'goal.description' }) })
@IsOptional()
description?: string;
@ApiProperty({ example: '2021-12-31' })
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'goal.dueDate' }) })
dueDate!: string;
@ApiProperty()
@IsNumber({}, { message: i18n('validation.IsNumber', { path: 'general', property: 'goal.targetAmount' }) })
@IsNotEmpty({ message: i18n('validation.IsNotEmpty', { path: 'general', property: 'goal.targetAmount' }) })
targetAmount!: number;
@ApiProperty()
@IsArray({ message: i18n('validation.IsArray', { path: 'general', property: 'goal.categoryIds' }) })
@IsUUID('4', { each: true, message: i18n('validation.IsUUID', { path: 'general', property: 'goal.categoryIds' }) })
@Transform(({ value }) => (typeof value === 'string' ? [value] : value))
categoryIds!: string[];
@ApiPropertyOptional()
@IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'goal.imageId' }) })
@IsOptional()
imageId?: string;
}

View File

@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber, Min } from 'class-validator';
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
const MIN_FUND = 1;
export class FundGoalRequestDto {
@ApiProperty({ example: '200' })
@IsNumber({}, { message: i18n('validation.IsNumber', { path: 'general', property: 'goal.fundAmount' }) })
@Min(MIN_FUND)
fundAmount!: number;
}

View File

@ -0,0 +1,3 @@
export * from './create-category.request.dto';
export * from './create-goal.request.dto';
export * from './fund-goal.request.dto';

View File

@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { Category } from '~/saving-goals/entities';
import { CategoryResponseDto } from './category-response.dto';
export class CategoriesListResponseDto {
@ApiProperty({ type: CategoryResponseDto, isArray: true })
globalCategories!: CategoryResponseDto[];
@ApiProperty({ type: CategoryResponseDto, isArray: true })
customCategories!: CategoryResponseDto[];
constructor(data: { globalCategories: Category[]; customCategories: Category[] }) {
this.globalCategories = data.globalCategories.map((category) => new CategoryResponseDto(category));
this.customCategories = data.customCategories.map((category) => new CategoryResponseDto(category));
}
}

View File

@ -0,0 +1,19 @@
import { ApiProperty } from '@nestjs/swagger';
import { CategoryType } from '~/saving-goals/enums';
export class CategoryResponseDto {
@ApiProperty()
id!: string;
@ApiProperty()
name!: string;
@ApiProperty({ example: CategoryType.CUSTOM })
type!: CategoryType;
constructor(data: CategoryResponseDto) {
this.id = data.id;
this.name = data.name;
this.type = data.type;
}
}

View File

@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IGoalStats } from '~/saving-goals/interfaces';
const ZERO = 0;
export class GoalsStatsResponseDto {
@ApiProperty()
totalTarget: number;
@ApiProperty()
totalSaved: number;
constructor(stats: IGoalStats) {
this.totalTarget = stats.totalTarget || ZERO;
this.totalSaved = stats.totalSaved || ZERO;
}
}

View File

@ -0,0 +1,4 @@
export * from './categories-list.response.dto';
export * from './category-response.dto';
export * from './goals-stats.response.dto';
export * from './saving-goal-details.response.dto';

View File

@ -0,0 +1,49 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { DocumentMetaResponseDto } from '~/document/dtos/response';
import { SavingGoal } from '~/saving-goals/entities';
import { CategoryResponseDto } from './category-response.dto';
export class SavingGoalDetailsResponseDto {
@ApiProperty()
id!: string;
@ApiProperty()
name!: string;
@ApiPropertyOptional()
description?: string;
@ApiProperty()
dueDate!: Date;
@ApiProperty()
targetAmount!: number;
@ApiProperty()
currentAmount!: number;
@ApiPropertyOptional({ type: CategoryResponseDto, isArray: true })
categories?: CategoryResponseDto[];
@ApiPropertyOptional({ type: DocumentMetaResponseDto, isArray: true })
image: DocumentMetaResponseDto | null;
@ApiProperty()
createdAt!: Date;
@ApiProperty()
updatedAt!: Date;
constructor(data: SavingGoal) {
this.id = data.id;
this.name = data.name;
this.description = data.description;
this.dueDate = data.dueDate;
this.targetAmount = data.targetAmount;
this.currentAmount = data.currentAmount;
this.categories = data.categories ? data.categories.map((category) => new CategoryResponseDto(category)) : [];
this.image = data.image ? new DocumentMetaResponseDto(data.image) : null;
this.createdAt = data.createdAt;
this.updatedAt = data.updatedAt;
}
}

View File

@ -0,0 +1,41 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToMany,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Junior } from '~/junior/entities';
import { CategoryType } from '../enums';
import { SavingGoal } from './saving-goal.entity';
@Entity('categories')
export class Category {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 255, name: 'name' })
name!: string;
@Column({ type: 'varchar', length: 255, name: 'type' })
type!: CategoryType;
@Column({ type: 'uuid', name: 'junior_id', nullable: true })
juniorId!: string;
@ManyToOne(() => Junior, (junior) => junior.categories, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'junior_id' })
junior!: Junior;
@ManyToMany(() => SavingGoal, (savingGoal) => savingGoal.categories)
goals!: SavingGoal[];
@CreateDateColumn({ type: 'timestamp', name: 'created_at', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamp', name: 'updated_at', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date;
}

View File

@ -0,0 +1,2 @@
export * from './category.entity';
export * from './saving-goal.entity';

View File

@ -0,0 +1,76 @@
import {
BaseEntity,
Column,
CreateDateColumn,
Entity,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Document } from '~/document/entities';
import { Junior } from '~/junior/entities';
import { Category } from './category.entity';
const ZERO = 0;
@Entity('saving_goals')
export class SavingGoal extends BaseEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', length: 255, name: 'name' })
name!: string;
@Column({ type: 'varchar', length: 255, name: 'description', nullable: true })
description!: string;
@Column({ type: 'date', name: 'due_date' })
dueDate!: Date;
@Column({
type: 'decimal',
name: 'target_amount',
precision: 12,
scale: 3,
transformer: { to: (value: number) => value, from: (value: string) => parseFloat(value) },
})
targetAmount!: number;
@Column({
type: 'decimal',
name: 'current_amount',
precision: 12,
scale: 3,
default: ZERO,
transformer: { to: (value: number) => value, from: (value: string) => parseFloat(value) },
})
currentAmount!: number;
@Column({ type: 'uuid', name: 'image_id', nullable: true })
imageId!: string;
@Column({ type: 'uuid', name: 'junior_id' })
juniorId!: string;
@ManyToOne(() => Document, (document) => document.goals, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'image_id' })
image!: Document;
@ManyToOne(() => Junior, (junior) => junior.goals, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'junior_id' })
junior!: Junior;
@ManyToMany(() => Category, (category) => category.goals)
@JoinTable({
name: 'saving_goals_categories',
joinColumn: { name: 'saving_goal_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'category_id', referencedColumnName: 'id' },
})
categories!: Category[];
@CreateDateColumn({ type: 'timestamp', name: 'created_at', default: () => 'CURRENT_TIMESTAMP' })
createdAt!: Date;
@CreateDateColumn({ type: 'timestamp', name: 'updated_at', default: () => 'CURRENT_TIMESTAMP' })
updatedAt!: Date;
}

View File

@ -0,0 +1,4 @@
export class CategoryType {
static readonly GLOBAL = 'GLOBAL';
static readonly CUSTOM = 'CUSTOM';
}

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