diff --git a/src/entities/wallet/wallet.entity.ts b/src/entities/wallet/wallet.entity.ts new file mode 100644 index 0000000..5146eef --- /dev/null +++ b/src/entities/wallet/wallet.entity.ts @@ -0,0 +1,79 @@ +type WalletProps = { + id?: string; + userId: string; + name: string; + initialBalance: number; + isDefault: boolean; + + createdAt: Date; + updatedAt: Date; + deletedAt?: Date; +}; + +export class Wallet { + constructor(private readonly props: WalletProps) {} + + get id(): string | undefined { + return this.props.id; + } + + set id(id: string | undefined) { + this.props.id = id; + } + + get userId(): string { + return this.props.userId; + } + + set userId(userId: string) { + this.props.userId = userId; + } + + get name(): string { + return this.props.name; + } + + set name(name: string) { + this.props.name = name; + } + + get initialBalance(): number { + return this.props.initialBalance; + } + + set initialBalance(balance: number) { + this.props.initialBalance = balance; + } + + get isDefault(): boolean { + return this.props.isDefault; + } + + set isDefault(isDefault: boolean) { + this.props.isDefault = isDefault; + } + + get createdAt(): Date { + return this.props.createdAt; + } + + set createdAt(createdAt: Date) { + this.props.createdAt = createdAt; + } + + get updatedAt(): Date { + return this.props.updatedAt; + } + + set updatedAt(updatedAt: Date) { + this.props.updatedAt = updatedAt; + } + + get deletedAt(): Date | undefined { + return this.props.deletedAt; + } + + set deletedAt(deletedAt: Date | undefined) { + this.props.deletedAt = deletedAt; + } +} diff --git a/src/infra/database/migrations/1784669759347_create-wallets-table.js b/src/infra/database/migrations/1784669759347_create-wallets-table.js new file mode 100644 index 0000000..8a31fd8 --- /dev/null +++ b/src/infra/database/migrations/1784669759347_create-wallets-table.js @@ -0,0 +1,29 @@ +exports.up = (pgm) => { + pgm.sql(` + CREATE TABLE wallets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE, + name VARCHAR(100) NOT NULL, + initial_balance DECIMAL(15, 2) NOT NULL DEFAULT 0.00, + is_default BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP + ); + `); + + pgm.sql(` + CREATE INDEX idx_wallets_user_id + ON wallets (user_id); + `); + + pgm.sql(` + CREATE UNIQUE INDEX idx_wallets_default_per_user + ON wallets (user_id) + WHERE is_default = true AND deleted_at IS NULL; + `); +}; + +exports.down = (pgm) => { + pgm.sql('DROP TABLE wallets'); +}; diff --git a/src/models/users/use-cases/create-user/create-user.service.spec.ts b/src/models/users/use-cases/create-user/create-user.service.spec.ts index 2aac1a5..30b060c 100644 --- a/src/models/users/use-cases/create-user/create-user.service.spec.ts +++ b/src/models/users/use-cases/create-user/create-user.service.spec.ts @@ -2,8 +2,10 @@ import { HttpStatus } from '@nestjs/common'; import { Queue } from 'bullmq'; import { UsersRepositoryInterface } from '@/repositories/users/users.repository.interface'; +import { WalletsRepositoryInterface } from '@/repositories/wallets/wallets.repository.interface'; import { CreateUserService } from './create-user.service'; import { UsersInMemoryRepository } from '@/repositories/users/users-in-memory.repository'; +import { WalletsInMemoryRepository } from '@/repositories/wallets/wallets-in-memory.repository'; import { UserFactory } from '@/entities/user/user.factory'; import { QueuesService } from '@/infra/queues/queues.service'; @@ -11,17 +13,23 @@ describe('CreateUserService', () => { let createUserService: CreateUserService; let queuesService: QueuesService; let usersRepository: UsersRepositoryInterface; + let walletsRepository: WalletsRepositoryInterface; let confirmationEmailQueue: Queue; beforeEach(() => { usersRepository = new UsersInMemoryRepository(); + walletsRepository = new WalletsInMemoryRepository(); confirmationEmailQueue = { add: jest.fn(), } as unknown as Queue; queuesService = new QueuesService(confirmationEmailQueue, {} as Queue); - createUserService = new CreateUserService(usersRepository, queuesService); + createUserService = new CreateUserService( + usersRepository, + walletsRepository, + queuesService, + ); }); it('should not be able to create a new user with the an email already used', async () => { @@ -46,6 +54,7 @@ describe('CreateUserService', () => { }); const newUser = await usersRepository.findByEmail('johndoe@email.com'); + const newWallets = await walletsRepository.findByUserId(newUser.id); expect(newUser.id).toBeDefined(); expect(newUser.name).toBe('John Doe'); @@ -55,6 +64,15 @@ describe('CreateUserService', () => { expect(newUser.updatedAt).not.toBeNull(); expect(newUser.deletedAt).toBeNull(); + expect(newWallets.length).toBe(1); + expect(newWallets[0].userId).toBe(newUser.id); + expect(newWallets[0].name).toBe('Carteira'); + expect(newWallets[0].initialBalance).toBe(0); + expect(newWallets[0].isDefault).toBe(true); + expect(newWallets[0].createdAt).not.toBeNull(); + expect(newWallets[0].updatedAt).not.toBeNull(); + expect(newWallets[0].deletedAt).toBeNull(); + expect(confirmationEmailQueue.add).toHaveBeenCalledTimes(1); expect(result).toMatchObject({ diff --git a/src/models/users/use-cases/create-user/create-user.service.ts b/src/models/users/use-cases/create-user/create-user.service.ts index 83b286d..8e72999 100644 --- a/src/models/users/use-cases/create-user/create-user.service.ts +++ b/src/models/users/use-cases/create-user/create-user.service.ts @@ -8,7 +8,9 @@ import { import { CreateUserRequestDto } from './create-user.request.dto'; import { CreateUserResponseDto } from './create-user.response.dto'; import { User } from '@/entities/user/user.entity'; +import { Wallet } from '@/entities/wallet/wallet.entity'; import { UsersRepositoryInterface } from '@/repositories/users/users.repository.interface'; +import { WalletsRepositoryInterface } from '@/repositories/wallets/wallets.repository.interface'; import { Providers } from '@/repositories/providers.enum'; import { QueuesService } from '@/infra/queues/queues.service'; import { QueueType } from '@/infra/queues/queues.enum'; @@ -18,6 +20,8 @@ export class CreateUserService { constructor( @Inject(Providers.USERS_REPOSITORY) private readonly usersRepository: UsersRepositoryInterface, + @Inject(Providers.WALLETS_REPOSITORY) + private readonly walletsRepository: WalletsRepositoryInterface, private readonly queuesService: QueuesService, ) {} @@ -38,6 +42,17 @@ export class CreateUserService { }); await this.usersRepository.save(user); + const wallet = new Wallet({ + userId: user.id, + name: 'Carteira', + initialBalance: 0, + isDefault: true, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }); + await this.walletsRepository.save(wallet); + await this.queuesService.execute({ userId: user.id, queueType: QueueType.CONFIRMATION_EMAIL, diff --git a/src/models/users/users.module.ts b/src/models/users/users.module.ts index 3099915..815b390 100644 --- a/src/models/users/users.module.ts +++ b/src/models/users/users.module.ts @@ -22,6 +22,7 @@ import { ResetPasswordService } from './use-cases/reset-password/reset-password. import { UsersPostgresRepository } from '@/repositories/users/users-postgres.repository'; import { TokensPostgresRepository } from '@/repositories/tokens/tokens-postgres.repository'; +import { WalletsPostgresRepository } from '@/repositories/wallets/wallets-postgres.repository'; @Module({ controllers: [ @@ -51,6 +52,10 @@ import { TokensPostgresRepository } from '@/repositories/tokens/tokens-postgres. provide: Providers.TOKENS_REPOSITORY, useClass: TokensPostgresRepository, }, + { + provide: Providers.WALLETS_REPOSITORY, + useClass: WalletsPostgresRepository, + }, ], }) export class UsersModule {} diff --git a/src/repositories/providers.enum.ts b/src/repositories/providers.enum.ts index c913482..5dff2b0 100644 --- a/src/repositories/providers.enum.ts +++ b/src/repositories/providers.enum.ts @@ -1,4 +1,5 @@ export enum Providers { USERS_REPOSITORY = 'UsersRepository', TOKENS_REPOSITORY = 'TokensRepository', + WALLETS_REPOSITORY = 'WalletsRepository', } diff --git a/src/repositories/wallets/wallets-in-memory.repository.ts b/src/repositories/wallets/wallets-in-memory.repository.ts new file mode 100644 index 0000000..5b300c4 --- /dev/null +++ b/src/repositories/wallets/wallets-in-memory.repository.ts @@ -0,0 +1,26 @@ +import { v7 as uuid } from 'uuid'; + +import { WalletsRepositoryInterface } from './wallets.repository.interface'; +import { Wallet } from '@/entities/wallet/wallet.entity'; + +export class WalletsInMemoryRepository implements WalletsRepositoryInterface { + private wallets: Wallet[] = []; + + async save(wallet: Wallet): Promise { + if (wallet.id) { + const index = this.wallets.findIndex((w) => w.id === wallet.id); + this.wallets[index] = wallet; + } else { + wallet.id = uuid(); + this.wallets.push(wallet); + } + + return wallet; + } + + async findByUserId(userId: string): Promise { + return this.wallets.filter( + (wallet) => wallet.userId === userId && !wallet.deletedAt, + ); + } +} diff --git a/src/repositories/wallets/wallets-postgres.repository.ts b/src/repositories/wallets/wallets-postgres.repository.ts new file mode 100644 index 0000000..defa405 --- /dev/null +++ b/src/repositories/wallets/wallets-postgres.repository.ts @@ -0,0 +1,76 @@ +import { Inject } from '@nestjs/common'; + +import { WalletsRepositoryInterface } from './wallets.repository.interface'; +import { DatabaseService } from '@/infra/database/database.service'; +import { Wallet } from '@/entities/wallet/wallet.entity'; +import { mapSnakeToCamel } from '@/utils/map-snake-to-camel'; + +export class WalletsPostgresRepository implements WalletsRepositoryInterface { + constructor( + @Inject(DatabaseService) + private readonly database: DatabaseService, + ) {} + + async save(wallet: Wallet): Promise { + const findWallet = (await this.findByUserId(wallet.userId)).find( + (w) => w.id === wallet.id, + ); + + let query = ''; + let values = []; + + if (findWallet) { + query = ` + UPDATE wallets + SET + user_id = $1, + name = $2, + initial_balance = $3, + is_default = $4, + updated_at = $5 + WHERE id = $6 + RETURNING *; + `; + + values = [ + wallet.userId, + wallet.name, + wallet.initialBalance, + wallet.isDefault, + wallet.updatedAt, + findWallet.id, + ]; + } else { + query = ` + INSERT INTO wallets (user_id, name, initial_balance, is_default, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *; + `; + + values = [ + wallet.userId, + wallet.name, + wallet.initialBalance, + wallet.isDefault, + wallet.createdAt, + wallet.updatedAt, + ]; + } + + const rows = await this.database.query(query, values); + return new Wallet(mapSnakeToCamel(rows[0])); + } + + async findByUserId(userId: string): Promise { + const rows = await this.database.query( + ` + SELECT * FROM wallets + WHERE user_id = $1 + AND deleted_at IS NULL; + `, + [userId], + ); + + return rows.map((row) => new Wallet(mapSnakeToCamel(row))); + } +} diff --git a/src/repositories/wallets/wallets.repository.interface.ts b/src/repositories/wallets/wallets.repository.interface.ts new file mode 100644 index 0000000..f6b3c0c --- /dev/null +++ b/src/repositories/wallets/wallets.repository.interface.ts @@ -0,0 +1,6 @@ +import { Wallet } from '@/entities/wallet/wallet.entity'; + +export interface WalletsRepositoryInterface { + save(wallet: Wallet): Promise; + findByUserId(userId: string): Promise; +}