mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2025-11-26 00:24:54 +00:00
Compare commits
25 Commits
feat/paren
...
9d9408dedd
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d9408dedd | |||
| bbeece9e03 | |||
| 596562f6dc | |||
| 10de8f69c9 | |||
| 8a6b1cc900 | |||
| d16ae66252 | |||
| e966f95463 | |||
| 2714255dd1 | |||
| 39a0b131b8 | |||
| 4f778f7904 | |||
| 7e9bc397a9 | |||
| 7bfc14f0d9 | |||
| d2e084d3e4 | |||
| f81714a525 | |||
| f3282a680b | |||
| 7b57277a7f | |||
| fdd2e23669 | |||
| d70ab09960 | |||
| 297a2fe5ad | |||
| 33b4f13ec8 | |||
| 310233c519 | |||
| 15621124ad | |||
| 7fc1918de0 | |||
| dd6886ff2b | |||
| 649191f3f4 |
16
src/card/dtos/responses/child-transfer-item.response.dto.ts
Normal file
16
src/card/dtos/responses/child-transfer-item.response.dto.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ChildTransferItemDto {
|
||||
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
|
||||
date!: Date;
|
||||
|
||||
@ApiProperty({ example: 50.0 })
|
||||
amount!: number;
|
||||
|
||||
@ApiProperty({ example: 'SAR' })
|
||||
currency!: string;
|
||||
|
||||
@ApiProperty({ example: 'You received {{amount}} {{currency}} from your parent.' })
|
||||
message!: string;
|
||||
}
|
||||
|
||||
@ -4,3 +4,12 @@ export * from './child-card.response.dto';
|
||||
export * from './transaction-item.response.dto';
|
||||
export * from './guardian-home.response.dto';
|
||||
export * from './paged-transactions.response.dto';
|
||||
export * from './parent-transfer-item.response.dto';
|
||||
export * from './parent-home.response.dto';
|
||||
export * from './paged-parent-transfers.response.dto';
|
||||
export * from './child-transfer-item.response.dto';
|
||||
export * from './junior-home.response.dto';
|
||||
export * from './paged-child-transfers.response.dto';
|
||||
export * from './spending-history-item.response.dto';
|
||||
export * from './spending-history.response.dto';
|
||||
export * from './transaction-detail.response.dto';
|
||||
|
||||
16
src/card/dtos/responses/junior-home.response.dto.ts
Normal file
16
src/card/dtos/responses/junior-home.response.dto.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ChildTransferItemDto } from './child-transfer-item.response.dto';
|
||||
|
||||
export class JuniorHomeResponseDto {
|
||||
@ApiProperty({ example: 500.0 })
|
||||
availableBalance!: number;
|
||||
|
||||
@ApiProperty({ type: [ChildTransferItemDto] })
|
||||
recentTransfers!: ChildTransferItemDto[];
|
||||
|
||||
constructor(availableBalance: number, recentTransfers: ChildTransferItemDto[]) {
|
||||
this.availableBalance = availableBalance;
|
||||
this.recentTransfers = recentTransfers;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ChildTransferItemDto } from './child-transfer-item.response.dto';
|
||||
|
||||
export class PagedChildTransfersResponseDto {
|
||||
@ApiProperty({ type: [ChildTransferItemDto] })
|
||||
items!: ChildTransferItemDto[];
|
||||
|
||||
@ApiProperty({ example: 1 })
|
||||
page!: number;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
size!: number;
|
||||
|
||||
@ApiProperty({ example: 20 })
|
||||
total!: number;
|
||||
|
||||
@ApiProperty({ example: true })
|
||||
hasMore!: boolean;
|
||||
|
||||
constructor(
|
||||
items: ChildTransferItemDto[],
|
||||
page: number,
|
||||
size: number,
|
||||
total: number,
|
||||
) {
|
||||
this.items = items;
|
||||
this.page = page;
|
||||
this.size = size;
|
||||
this.total = total;
|
||||
this.hasMore = page * size < total;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ParentTransferItemDto } from './parent-transfer-item.response.dto';
|
||||
|
||||
export class PagedParentTransfersResponseDto {
|
||||
@ApiProperty({ type: [ParentTransferItemDto] })
|
||||
items!: ParentTransferItemDto[];
|
||||
|
||||
@ApiProperty({ example: 1 })
|
||||
page!: number;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
size!: number;
|
||||
|
||||
@ApiProperty({ example: 45 })
|
||||
total!: number;
|
||||
|
||||
@ApiProperty({ example: true })
|
||||
hasMore!: boolean;
|
||||
|
||||
constructor(
|
||||
items: ParentTransferItemDto[],
|
||||
page: number,
|
||||
size: number,
|
||||
total: number,
|
||||
) {
|
||||
this.items = items;
|
||||
this.page = page;
|
||||
this.size = size;
|
||||
this.total = total;
|
||||
this.hasMore = page * size < total;
|
||||
}
|
||||
}
|
||||
|
||||
16
src/card/dtos/responses/parent-home.response.dto.ts
Normal file
16
src/card/dtos/responses/parent-home.response.dto.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ParentTransferItemDto } from './parent-transfer-item.response.dto';
|
||||
|
||||
export class ParentHomeResponseDto {
|
||||
@ApiProperty({ example: 2000.0 })
|
||||
availableBalance!: number;
|
||||
|
||||
@ApiProperty({ type: [ParentTransferItemDto] })
|
||||
recentTransfers!: ParentTransferItemDto[];
|
||||
|
||||
constructor(availableBalance: number, recentTransfers: ParentTransferItemDto[]) {
|
||||
this.availableBalance = availableBalance;
|
||||
this.recentTransfers = recentTransfers;
|
||||
}
|
||||
}
|
||||
|
||||
16
src/card/dtos/responses/parent-transfer-item.response.dto.ts
Normal file
16
src/card/dtos/responses/parent-transfer-item.response.dto.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ParentTransferItemDto {
|
||||
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
|
||||
date!: Date;
|
||||
|
||||
@ApiProperty({ example: 50.0 })
|
||||
amount!: number;
|
||||
|
||||
@ApiProperty({ example: 'SAR' })
|
||||
currency!: string;
|
||||
|
||||
@ApiProperty({ example: 'Ahmed Ali' })
|
||||
childName!: string;
|
||||
}
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transaction } from '~/card/entities/transaction.entity';
|
||||
|
||||
export class SpendingHistoryItemDto {
|
||||
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
|
||||
date!: Date;
|
||||
|
||||
@ApiProperty({ example: 50.5 })
|
||||
amount!: number;
|
||||
|
||||
@ApiProperty({ example: 'SAR' })
|
||||
currency!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Shopping' })
|
||||
category!: string | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Target Store' })
|
||||
merchantName!: string | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Riyadh' })
|
||||
merchantCity!: string | null;
|
||||
|
||||
@ApiProperty({ example: '277012*****3456' })
|
||||
cardMasked!: string;
|
||||
|
||||
@ApiProperty()
|
||||
transactionId!: string;
|
||||
|
||||
constructor(transaction: Transaction) {
|
||||
this.date = transaction.transactionDate;
|
||||
this.amount = transaction.transactionAmount;
|
||||
this.currency = transaction.transactionCurrency === '682' ? 'SAR' : transaction.transactionCurrency;
|
||||
this.category = this.mapMccToCategory(transaction.merchantCategoryCode);
|
||||
this.merchantName = transaction.merchantName;
|
||||
this.merchantCity = transaction.merchantCity;
|
||||
this.cardMasked = transaction.cardMaskedNumber;
|
||||
this.transactionId = transaction.id;
|
||||
}
|
||||
|
||||
private mapMccToCategory(mcc: string | null): string {
|
||||
if (!mcc) return 'Other';
|
||||
|
||||
const mccCode = mcc;
|
||||
|
||||
// Map MCC codes to categories
|
||||
if (mccCode >= '5200' && mccCode <= '5599') return 'Shopping';
|
||||
if (mccCode >= '5800' && mccCode <= '5899') return 'Food & Dining';
|
||||
if (mccCode >= '3000' && mccCode <= '3999') return 'Travel';
|
||||
if (mccCode >= '4000' && mccCode <= '4799') return 'Transportation';
|
||||
if (mccCode >= '7200' && mccCode <= '7999') return 'Entertainment';
|
||||
if (mccCode >= '5900' && mccCode <= '5999') return 'Services';
|
||||
if (mccCode >= '4800' && mccCode <= '4899') return 'Utilities';
|
||||
if (mccCode >= '8000' && mccCode <= '8999') return 'Health & Wellness';
|
||||
|
||||
return 'Other';
|
||||
}
|
||||
}
|
||||
|
||||
24
src/card/dtos/responses/spending-history.response.dto.ts
Normal file
24
src/card/dtos/responses/spending-history.response.dto.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { SpendingHistoryItemDto } from './spending-history-item.response.dto';
|
||||
|
||||
export class SpendingHistoryResponseDto {
|
||||
@ApiProperty({ type: [SpendingHistoryItemDto] })
|
||||
transactions!: SpendingHistoryItemDto[];
|
||||
|
||||
@ApiProperty({ example: 150.75 })
|
||||
totalSpent!: number;
|
||||
|
||||
@ApiProperty({ example: 'SAR' })
|
||||
currency!: string;
|
||||
|
||||
@ApiProperty({ example: 10 })
|
||||
count!: number;
|
||||
|
||||
constructor(transactions: SpendingHistoryItemDto[], currency: string = 'SAR') {
|
||||
this.transactions = transactions;
|
||||
this.totalSpent = transactions.reduce((sum, tx) => sum + tx.amount, 0);
|
||||
this.currency = currency;
|
||||
this.count = transactions.length;
|
||||
}
|
||||
}
|
||||
|
||||
74
src/card/dtos/responses/transaction-detail.response.dto.ts
Normal file
74
src/card/dtos/responses/transaction-detail.response.dto.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transaction } from '~/card/entities/transaction.entity';
|
||||
|
||||
export class TransactionDetailResponseDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ example: '2025-10-14T09:53:40.000Z' })
|
||||
date!: Date;
|
||||
|
||||
@ApiProperty({ example: 50.5 })
|
||||
amount!: number;
|
||||
|
||||
@ApiProperty({ example: 'SAR' })
|
||||
currency!: string;
|
||||
|
||||
@ApiProperty({ example: 2.5 })
|
||||
fees!: number;
|
||||
|
||||
@ApiProperty({ example: 0.5 })
|
||||
vatOnFees!: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Target Store' })
|
||||
merchantName!: string | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Shopping' })
|
||||
category!: string | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Riyadh' })
|
||||
merchantCity!: string | null;
|
||||
|
||||
@ApiProperty({ example: '277012*****3456' })
|
||||
cardMasked!: string;
|
||||
|
||||
@ApiProperty()
|
||||
rrn!: string;
|
||||
|
||||
@ApiProperty()
|
||||
transactionId!: string;
|
||||
|
||||
constructor(transaction: Transaction) {
|
||||
this.id = transaction.id;
|
||||
this.date = transaction.transactionDate;
|
||||
this.amount = transaction.transactionAmount;
|
||||
this.currency = transaction.transactionCurrency === '682' ? 'SAR' : transaction.transactionCurrency;
|
||||
this.fees = transaction.fees;
|
||||
this.vatOnFees = transaction.vatOnFees;
|
||||
this.merchantName = transaction.merchantName;
|
||||
this.category = this.mapMccToCategory(transaction.merchantCategoryCode);
|
||||
this.merchantCity = transaction.merchantCity;
|
||||
this.cardMasked = transaction.cardMaskedNumber;
|
||||
this.rrn = transaction.rrn;
|
||||
this.transactionId = transaction.transactionId;
|
||||
}
|
||||
|
||||
private mapMccToCategory(mcc: string | null): string {
|
||||
if (!mcc) return 'Other';
|
||||
|
||||
const mccCode = mcc;
|
||||
|
||||
// Map MCC codes to categories
|
||||
if (mccCode >= '5200' && mccCode <= '5599') return 'Shopping';
|
||||
if (mccCode >= '5800' && mccCode <= '5899') return 'Food & Dining';
|
||||
if (mccCode >= '3000' && mccCode <= '3999') return 'Travel';
|
||||
if (mccCode >= '4000' && mccCode <= '4799') return 'Transportation';
|
||||
if (mccCode >= '7200' && mccCode <= '7999') return 'Entertainment';
|
||||
if (mccCode >= '5900' && mccCode <= '5999') return 'Services';
|
||||
if (mccCode >= '4800' && mccCode <= '4899') return 'Utilities';
|
||||
if (mccCode >= '8000' && mccCode <= '8999') return 'Health & Wellness';
|
||||
|
||||
return 'Other';
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,6 +59,15 @@ export class Transaction {
|
||||
@Column({ type: 'decimal', name: 'vat_on_fees', precision: 12, scale: 2, default: 0.0 })
|
||||
vatOnFees!: number;
|
||||
|
||||
@Column({ name: 'merchant_name', type: 'varchar', nullable: true })
|
||||
merchantName!: string | null;
|
||||
|
||||
@Column({ name: 'merchant_category_code', type: 'varchar', nullable: true })
|
||||
merchantCategoryCode!: string | null;
|
||||
|
||||
@Column({ name: 'merchant_city', type: 'varchar', nullable: true })
|
||||
merchantCity!: string | null;
|
||||
|
||||
@Column({ name: 'card_id', type: 'uuid', nullable: true })
|
||||
cardId!: string;
|
||||
|
||||
|
||||
@ -34,6 +34,9 @@ export class TransactionRepository {
|
||||
accountReference: card.account!.accountReference,
|
||||
transactionScope: TransactionScope.CARD,
|
||||
vatOnFees: transactionData.vatOnFees,
|
||||
merchantName: transactionData.cardAcceptorLocation?.merchantName || null,
|
||||
merchantCategoryCode: transactionData.cardAcceptorLocation?.mcc || null,
|
||||
merchantCity: transactionData.cardAcceptorLocation?.merchantCity || null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@ -144,4 +147,37 @@ export class TransactionRepository {
|
||||
.andWhere('parentCards.customerId = :guardianCustomerId', { guardianCustomerId })
|
||||
.getCount();
|
||||
}
|
||||
|
||||
findTransfersToJunior(juniorId: string, skip: number, take: number): Promise<Transaction[]> {
|
||||
return this.transactionRepository
|
||||
.createQueryBuilder('tx')
|
||||
.innerJoinAndSelect('tx.card', 'card')
|
||||
.innerJoinAndSelect('card.account', 'account')
|
||||
.where('card.customerId = :juniorId', { juniorId })
|
||||
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
|
||||
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
|
||||
.orderBy('tx.transactionDate', 'DESC')
|
||||
.skip(skip)
|
||||
.take(take)
|
||||
.getMany();
|
||||
}
|
||||
|
||||
countTransfersToJunior(juniorId: string): Promise<number> {
|
||||
return this.transactionRepository
|
||||
.createQueryBuilder('tx')
|
||||
.innerJoin('tx.card', 'card')
|
||||
.where('card.customerId = :juniorId', { juniorId })
|
||||
.andWhere('tx.transactionScope = :scope', { scope: TransactionScope.CARD })
|
||||
.andWhere('tx.transactionType = :type', { type: TransactionType.INTERNAL })
|
||||
.getCount();
|
||||
}
|
||||
|
||||
findTransactionById(transactionId: string, juniorId: string): Promise<Transaction | null> {
|
||||
return this.transactionRepository
|
||||
.createQueryBuilder('tx')
|
||||
.innerJoinAndSelect('tx.card', 'card')
|
||||
.where('tx.id = :transactionId', { transactionId })
|
||||
.andWhere('card.customerId = :juniorId', { juniorId })
|
||||
.getOne();
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,14 @@ import { CustomerType, TransactionType } from '../enums';
|
||||
import { TransactionRepository } from '../repositories/transaction.repository';
|
||||
import { AccountService } from './account.service';
|
||||
import { CardService } from './card.service';
|
||||
import { TransactionItemResponseDto, PagedTransactionsResponseDto } from '../dtos/responses';
|
||||
import {
|
||||
TransactionItemResponseDto,
|
||||
PagedTransactionsResponseDto,
|
||||
ParentTransferItemDto,
|
||||
PagedParentTransfersResponseDto,
|
||||
ChildTransferItemDto,
|
||||
PagedChildTransfersResponseDto,
|
||||
} from '../dtos/responses';
|
||||
import { ParentTransactionType } from '../enums';
|
||||
|
||||
@Injectable()
|
||||
@ -78,8 +85,8 @@ export class TransactionService {
|
||||
}
|
||||
|
||||
async getWeeklySummary(juniorId: string) {
|
||||
const startOfWeek = moment().startOf('week').toDate();
|
||||
const endOfWeek = moment().endOf('week').toDate();
|
||||
const endOfWeek = moment().toDate();
|
||||
const startOfWeek = moment().subtract(1, 'week').toDate();
|
||||
|
||||
const transactions = await this.transactionRepository.getTransactionsForCardWithinDateRange(
|
||||
juniorId,
|
||||
@ -182,6 +189,91 @@ export class TransactionService {
|
||||
return new PagedTransactionsResponseDto(mapped, page, size, total);
|
||||
}
|
||||
|
||||
async getParentTransfersOnly(guardianCustomerId: string, page: number, size: number): Promise<ParentTransferItemDto[]> {
|
||||
const skip = (page - 1) * size;
|
||||
const transfers = await this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size);
|
||||
return transfers.map((t) => this.mapToParentTransferItem(t));
|
||||
}
|
||||
|
||||
async getParentTransfersPaginated(
|
||||
guardianCustomerId: string,
|
||||
page: number,
|
||||
size: number,
|
||||
): Promise<PagedParentTransfersResponseDto> {
|
||||
const skip = (page - 1) * size;
|
||||
const [transfers, total] = await Promise.all([
|
||||
this.transactionRepository.findParentTransfers(guardianCustomerId, skip, size),
|
||||
this.transactionRepository.countParentTransfers(guardianCustomerId),
|
||||
]);
|
||||
const items = transfers.map((t) => this.mapToParentTransferItem(t));
|
||||
return new PagedParentTransfersResponseDto(items, page, size, total);
|
||||
}
|
||||
|
||||
async getChildTransfers(juniorId: string, page: number, size: number): Promise<ChildTransferItemDto[]> {
|
||||
const skip = (page - 1) * size;
|
||||
const transfers = await this.transactionRepository.findTransfersToJunior(juniorId, skip, size);
|
||||
return transfers.map((t) => this.mapToChildTransferItem(t));
|
||||
}
|
||||
|
||||
async getChildTransfersPaginated(
|
||||
juniorId: string,
|
||||
page: number,
|
||||
size: number,
|
||||
): Promise<PagedChildTransfersResponseDto> {
|
||||
const skip = (page - 1) * size;
|
||||
const [transfers, total] = await Promise.all([
|
||||
this.transactionRepository.findTransfersToJunior(juniorId, skip, size),
|
||||
this.transactionRepository.countTransfersToJunior(juniorId),
|
||||
]);
|
||||
const items = transfers.map((t) => this.mapToChildTransferItem(t));
|
||||
return new PagedChildTransfersResponseDto(items, page, size, total);
|
||||
}
|
||||
|
||||
private mapToParentTransferItem(t: Transaction): ParentTransferItemDto {
|
||||
const child = t.card?.customer;
|
||||
const currency = t.transactionCurrency === '682' ? 'SAR' : t.transactionCurrency;
|
||||
return {
|
||||
date: t.transactionDate,
|
||||
amount: Math.abs(t.transactionAmount),
|
||||
currency,
|
||||
childName: child ? `${child.firstName} ${child.lastName}` : 'Child',
|
||||
};
|
||||
}
|
||||
|
||||
private mapToChildTransferItem(t: Transaction): ChildTransferItemDto {
|
||||
const amount = Math.abs(t.transactionAmount);
|
||||
const currency = t.transactionCurrency === '682' ? 'SAR' : t.transactionCurrency;
|
||||
return {
|
||||
date: t.transactionDate,
|
||||
amount,
|
||||
currency,
|
||||
message: `You received {{amount}} {{currency}} from your parent.`,
|
||||
};
|
||||
}
|
||||
|
||||
async getChildSpendingHistory(juniorId: string, startUtc: Date, endUtc: Date) {
|
||||
const transactions = await this.transactionRepository.getTransactionsForCardWithinDateRange(
|
||||
juniorId,
|
||||
startUtc,
|
||||
endUtc,
|
||||
);
|
||||
|
||||
const { SpendingHistoryItemDto, SpendingHistoryResponseDto } = await import('../dtos/responses');
|
||||
const items = transactions.map((t) => new SpendingHistoryItemDto(t));
|
||||
return new SpendingHistoryResponseDto(items);
|
||||
}
|
||||
|
||||
async getTransactionDetail(transactionId: string, juniorId: string) {
|
||||
const transaction = await this.transactionRepository.findTransactionById(transactionId, juniorId);
|
||||
|
||||
if (!transaction) {
|
||||
throw new UnprocessableEntityException('TRANSACTION.NOT_FOUND');
|
||||
}
|
||||
|
||||
const { TransactionDetailResponseDto } = await import('../dtos/responses');
|
||||
return new TransactionDetailResponseDto(transaction);
|
||||
}
|
||||
|
||||
private mapParentItem(t: Transaction): TransactionItemResponseDto {
|
||||
const dto = new TransactionItemResponseDto();
|
||||
dto.date = t.transactionDate;
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddMerchantInfoToTransactions1760869651296 implements MigrationInterface {
|
||||
name = 'AddMerchantInfoToTransactions1760869651296'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "transactions" ADD COLUMN IF NOT EXISTS "merchant_name" character varying`);
|
||||
await queryRunner.query(`ALTER TABLE "transactions" ADD COLUMN IF NOT EXISTS "merchant_category_code" character varying`);
|
||||
await queryRunner.query(`ALTER TABLE "transactions" ADD COLUMN IF NOT EXISTS "merchant_city" character varying`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "merchant_city"`);
|
||||
await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "merchant_category_code"`);
|
||||
await queryRunner.query(`ALTER TABLE "transactions" DROP COLUMN "merchant_name"`);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddUniqueConstraintToUserEmail1761032305682 implements MigrationInterface {
|
||||
name = 'AddUniqueConstraintToUserEmail1761032305682'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
await queryRunner.query(`ALTER TABLE "users" ADD CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE ("email")`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3"`);
|
||||
}
|
||||
|
||||
}
|
||||
@ -4,3 +4,5 @@ export * from './1754915164810-seed-default-avatar';
|
||||
export * from './1757349525708-create-money-requests-table';
|
||||
export * from './1757433339849-add-reservation-amount-to-account-entity';
|
||||
export * from './1757915357218-add-deleted-at-column-to-junior';
|
||||
export * from './1760869651296-AddMerchantInfoToTransactions';
|
||||
export * from './1761032305682-AddUniqueConstraintToUserEmail';
|
||||
@ -6,8 +6,7 @@ import { AllowedRoles, AuthenticatedUser } from '~/common/decorators';
|
||||
import { AccessTokenGuard, RolesGuard } from '~/common/guards';
|
||||
import { ApiDataResponse, ApiLangRequestHeader } from '~/core/decorators';
|
||||
import { ResponseFactory } from '~/core/utils';
|
||||
import { GuardianHomeResponseDto, PagedTransactionsResponseDto } from '~/card/dtos/responses';
|
||||
import { ParentTransactionType } from '~/card/enums';
|
||||
import { ParentHomeResponseDto, PagedParentTransfersResponseDto } from '~/card/dtos/responses';
|
||||
import { GuardianTransactionsService } from '../services';
|
||||
|
||||
|
||||
@ -22,7 +21,7 @@ export class GuardianTransactionsController {
|
||||
|
||||
@Get('home')
|
||||
@ApiQuery({ name: 'size', required: false, type: Number, example: 5 })
|
||||
@ApiDataResponse(GuardianHomeResponseDto)
|
||||
@ApiDataResponse(ParentHomeResponseDto)
|
||||
async getHome(
|
||||
@AuthenticatedUser() user: IJwtPayload,
|
||||
@Query('size') size?: number,
|
||||
@ -32,20 +31,18 @@ export class GuardianTransactionsController {
|
||||
return ResponseFactory.data(res);
|
||||
}
|
||||
|
||||
@Get('transactions')
|
||||
@Get('transfers')
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||||
@ApiQuery({ name: 'size', required: false, type: Number, example: 10 })
|
||||
@ApiQuery({ name: 'type', required: false, enum: ParentTransactionType })
|
||||
@ApiDataResponse(PagedTransactionsResponseDto)
|
||||
async getTransactions(
|
||||
@ApiDataResponse(PagedParentTransfersResponseDto)
|
||||
async getTransfers(
|
||||
@AuthenticatedUser() user: IJwtPayload,
|
||||
@Query('page') page?: number,
|
||||
@Query('size') size?: number,
|
||||
@Query('type') type?: ParentTransactionType,
|
||||
) {
|
||||
const pageNum = Math.max(1, Number(page) || 1);
|
||||
const pageSize = Math.max(1, Math.min(Number(size) || 10, 50));
|
||||
const res = await this.guardianTxService.getTransactions(user.sub, pageNum, pageSize, type);
|
||||
const res = await this.guardianTxService.getTransfers(user.sub, pageNum, pageSize);
|
||||
return ResponseFactory.data(res);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CustomerService } from '~/customer/services';
|
||||
import { GuardianHomeResponseDto, PagedTransactionsResponseDto } from '~/card/dtos/responses';
|
||||
import { TransactionItemResponseDto } from '~/card/dtos/responses';
|
||||
import { ParentTransactionType } from '~/card/enums';
|
||||
import { ParentHomeResponseDto, PagedParentTransfersResponseDto } from '~/card/dtos/responses';
|
||||
import { TransactionService } from '~/card/services/transaction.service';
|
||||
|
||||
@Injectable()
|
||||
@ -12,7 +10,7 @@ export class GuardianTransactionsService {
|
||||
private readonly transactionService: TransactionService,
|
||||
) {}
|
||||
|
||||
async getHome(guardianId: string, size: number): Promise<GuardianHomeResponseDto> {
|
||||
async getHome(guardianId: string, size: number): Promise<ParentHomeResponseDto> {
|
||||
const parent = await this.customerService.findCustomerById(guardianId);
|
||||
const primaryCard = parent.cards?.[0];
|
||||
|
||||
@ -27,18 +25,17 @@ export class GuardianTransactionsService {
|
||||
}
|
||||
}
|
||||
|
||||
const items: TransactionItemResponseDto[] = await this.transactionService.getParentConsolidated(guardianId, 1, size);
|
||||
const recentTransfers = await this.transactionService.getParentTransfersOnly(guardianId, 1, size);
|
||||
|
||||
return new GuardianHomeResponseDto(availableBalance, items);
|
||||
return new ParentHomeResponseDto(availableBalance, recentTransfers);
|
||||
}
|
||||
|
||||
async getTransactions(
|
||||
async getTransfers(
|
||||
guardianId: string,
|
||||
page: number,
|
||||
size: number,
|
||||
type?: ParentTransactionType,
|
||||
): Promise<PagedTransactionsResponseDto> {
|
||||
return this.transactionService.getParentTransactionsPaginated(guardianId, page, size, type);
|
||||
): Promise<PagedParentTransfersResponseDto> {
|
||||
return this.transactionService.getParentTransfersPaginated(guardianId, page, size);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiBearerAuth, ApiTags, ApiQuery } from '@nestjs/swagger';
|
||||
import { Roles } from '~/auth/enums';
|
||||
import { IJwtPayload } from '~/auth/interfaces';
|
||||
import { AllowedRoles, AuthenticatedUser, Public } from '~/common/decorators';
|
||||
@ -33,6 +33,7 @@ import {
|
||||
TransferToJuniorResponseDto,
|
||||
} from '../dtos/response';
|
||||
import { WeeklySummaryResponseDto } from '../dtos/response/weekly-summary.response.dto';
|
||||
import { JuniorHomeResponseDto, PagedChildTransfersResponseDto } from '~/card/dtos/responses';
|
||||
import { JuniorService } from '../services';
|
||||
|
||||
@Controller('juniors')
|
||||
@ -157,4 +158,64 @@ export class JuniorController {
|
||||
const summary = await this.juniorService.getWeeklySummary(juniorId, user.sub);
|
||||
return ResponseFactory.data(summary);
|
||||
}
|
||||
|
||||
@Get(':juniorId/home')
|
||||
@UseGuards(RolesGuard)
|
||||
@AllowedRoles(Roles.JUNIOR, Roles.GUARDIAN)
|
||||
@ApiQuery({ name: 'size', required: false, type: Number, example: 5 })
|
||||
@ApiDataResponse(JuniorHomeResponseDto)
|
||||
async getJuniorHome(
|
||||
@Param('juniorId', CustomParseUUIDPipe) juniorId: string,
|
||||
@AuthenticatedUser() user: IJwtPayload,
|
||||
@Query('size') size?: number,
|
||||
) {
|
||||
const limit = Math.max(1, Math.min(Number(size) || 5, 20));
|
||||
const res = await this.juniorService.getJuniorHome(juniorId, user.sub, limit);
|
||||
return ResponseFactory.data(res);
|
||||
}
|
||||
|
||||
@Get(':juniorId/transfers')
|
||||
@UseGuards(RolesGuard)
|
||||
@AllowedRoles(Roles.JUNIOR, Roles.GUARDIAN)
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
|
||||
@ApiQuery({ name: 'size', required: false, type: Number, example: 10 })
|
||||
@ApiDataResponse(PagedChildTransfersResponseDto)
|
||||
async getJuniorTransfers(
|
||||
@Param('juniorId', CustomParseUUIDPipe) juniorId: string,
|
||||
@AuthenticatedUser() user: IJwtPayload,
|
||||
@Query('page') page?: number,
|
||||
@Query('size') size?: number,
|
||||
) {
|
||||
const pageNum = Math.max(1, Number(page) || 1);
|
||||
const pageSize = Math.max(1, Math.min(Number(size) || 10, 50));
|
||||
const res = await this.juniorService.getJuniorTransfers(juniorId, user.sub, pageNum, pageSize);
|
||||
return ResponseFactory.data(res);
|
||||
}
|
||||
|
||||
@Get(':juniorId/spending-history')
|
||||
@UseGuards(RolesGuard)
|
||||
@AllowedRoles(Roles.JUNIOR, Roles.GUARDIAN)
|
||||
@ApiQuery({ name: 'startUtc', required: true, type: String, example: '2025-01-01T00:00:00.000Z' })
|
||||
@ApiQuery({ name: 'endUtc', required: true, type: String, example: '2025-01-31T23:59:59.999Z' })
|
||||
async getSpendingHistory(
|
||||
@Param('juniorId', CustomParseUUIDPipe) juniorId: string,
|
||||
@AuthenticatedUser() user: IJwtPayload,
|
||||
@Query('startUtc') startUtc: string,
|
||||
@Query('endUtc') endUtc: string,
|
||||
) {
|
||||
const res = await this.juniorService.getSpendingHistory(juniorId, user.sub, new Date(startUtc), new Date(endUtc));
|
||||
return ResponseFactory.data(res);
|
||||
}
|
||||
|
||||
@Get(':juniorId/transactions/:transactionId')
|
||||
@UseGuards(RolesGuard)
|
||||
@AllowedRoles(Roles.JUNIOR, Roles.GUARDIAN)
|
||||
async getTransactionDetail(
|
||||
@Param('juniorId', CustomParseUUIDPipe) juniorId: string,
|
||||
@Param('transactionId', CustomParseUUIDPipe) transactionId: string,
|
||||
@AuthenticatedUser() user: IJwtPayload,
|
||||
) {
|
||||
const res = await this.juniorService.getTransactionDetail(juniorId, user.sub, transactionId);
|
||||
return ResponseFactory.data(res);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Gender } from '~/customer/enums';
|
||||
import { Guardian } from '~/guardian/entities/guradian.entity';
|
||||
import { Junior } from '~/junior/entities';
|
||||
import { GuardianRelationship } from '~/junior/enums';
|
||||
import { ChildRelationshipLabel, GuardianRelationship, Relationship } from '~/junior/enums';
|
||||
|
||||
export class QrCodeValidationDetailsResponse {
|
||||
@ApiProperty()
|
||||
@ -26,6 +27,17 @@ export class QrCodeValidationDetailsResponse {
|
||||
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];
|
||||
|
||||
if (guardian) {
|
||||
this.relationship = junior.relationship;
|
||||
} else {
|
||||
if (junior.relationship === Relationship.PARENT) {
|
||||
this.relationship = junior.customer.gender === Gender.MALE
|
||||
? ChildRelationshipLabel.SON
|
||||
: ChildRelationshipLabel.DAUGHTER;
|
||||
} else {
|
||||
this.relationship = GuardianRelationship[junior.relationship];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5
src/junior/enums/child-relationship-label.enum.ts
Normal file
5
src/junior/enums/child-relationship-label.enum.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export enum ChildRelationshipLabel {
|
||||
SON = 'SON',
|
||||
DAUGHTER = 'DAUGHTER',
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from './child-relationship-label.enum';
|
||||
export * from './guardian-relationship.enum';
|
||||
export * from './relationship.enum';
|
||||
export * from './theme-color.enum';
|
||||
|
||||
@ -2,7 +2,7 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
import { Transactional } from 'typeorm-transactional';
|
||||
import { Roles } from '~/auth/enums';
|
||||
import { CardService } from '~/card/services';
|
||||
import { CardService, TransactionService } from '~/card/services';
|
||||
import { NeoLeapService } from '~/common/modules/neoleap/services';
|
||||
import { PageOptionsRequestDto } from '~/core/dtos';
|
||||
import { setIf } from '~/core/utils';
|
||||
@ -20,6 +20,7 @@ import {
|
||||
import { Junior } from '../entities';
|
||||
import { JuniorRepository } from '../repositories';
|
||||
import { QrcodeService } from './qrcode.service';
|
||||
import { JuniorHomeResponseDto, PagedChildTransfersResponseDto } from '~/card/dtos/responses';
|
||||
|
||||
@Injectable()
|
||||
export class JuniorService {
|
||||
@ -34,6 +35,7 @@ export class JuniorService {
|
||||
private readonly qrCodeService: QrcodeService,
|
||||
private readonly neoleapService: NeoLeapService,
|
||||
private readonly cardService: CardService,
|
||||
private readonly transactionService: TransactionService,
|
||||
) {}
|
||||
|
||||
@Transactional()
|
||||
@ -118,6 +120,7 @@ export class JuniorService {
|
||||
setIf(customer, 'firstName', body.firstName);
|
||||
setIf(customer, 'lastName', body.lastName);
|
||||
setIf(customer, 'dateOfBirth', body.dateOfBirth as unknown as Date);
|
||||
setIf(customer, 'gender', body.gender);
|
||||
|
||||
setIf(junior, 'relationship', body.relationship);
|
||||
await Promise.all([junior.save(), customer.save(), user.save()]);
|
||||
@ -209,8 +212,8 @@ export class JuniorService {
|
||||
this.logger.log(`Junior ${juniorId} deleted successfully`);
|
||||
}
|
||||
|
||||
getWeeklySummary(juniorId: string, guardianId: string) {
|
||||
const doesBelong = this.doesJuniorBelongToGuardian(guardianId, juniorId);
|
||||
async getWeeklySummary(juniorId: string, guardianId: string) {
|
||||
const doesBelong = await this.doesJuniorBelongToGuardian(guardianId, juniorId);
|
||||
|
||||
if (!doesBelong) {
|
||||
this.logger.error(`Junior ${juniorId} does not belong to guardian ${guardianId}`);
|
||||
@ -221,6 +224,92 @@ export class JuniorService {
|
||||
return this.cardService.getWeeklySummary(juniorId);
|
||||
}
|
||||
|
||||
async getJuniorHome(juniorId: string, userId: string, size: number): Promise<JuniorHomeResponseDto> {
|
||||
this.logger.log(`Getting home for junior ${juniorId}`);
|
||||
|
||||
// Check if user is the junior themselves or their guardian
|
||||
let junior: Junior | null;
|
||||
if (juniorId === userId) {
|
||||
// User is the junior accessing their own home
|
||||
junior = await this.findJuniorById(juniorId, false);
|
||||
} else {
|
||||
// User might be the guardian accessing junior's home
|
||||
junior = await this.findJuniorById(juniorId, false, userId);
|
||||
}
|
||||
|
||||
if (!junior) {
|
||||
throw new BadRequestException('JUNIOR.NOT_FOUND');
|
||||
}
|
||||
|
||||
const card = junior.customer?.cards?.[0];
|
||||
const availableBalance = card ? Math.min(card.limit, card.account.balance) : 0;
|
||||
|
||||
const recentTransfers = await this.transactionService.getChildTransfers(juniorId, 1, size);
|
||||
|
||||
return new JuniorHomeResponseDto(availableBalance, recentTransfers);
|
||||
}
|
||||
|
||||
async getJuniorTransfers(
|
||||
juniorId: string,
|
||||
userId: string,
|
||||
page: number,
|
||||
size: number,
|
||||
): Promise<PagedChildTransfersResponseDto> {
|
||||
this.logger.log(`Getting transfers for junior ${juniorId}`);
|
||||
|
||||
// Check if user is the junior themselves or their guardian
|
||||
let junior: Junior | null;
|
||||
if (juniorId === userId) {
|
||||
// User is the junior accessing their own transfers
|
||||
junior = await this.findJuniorById(juniorId, false);
|
||||
} else {
|
||||
// User might be the guardian accessing junior's transfers
|
||||
junior = await this.findJuniorById(juniorId, false, userId);
|
||||
}
|
||||
|
||||
if (!junior) {
|
||||
throw new BadRequestException('JUNIOR.NOT_FOUND');
|
||||
}
|
||||
|
||||
return this.transactionService.getChildTransfersPaginated(juniorId, page, size);
|
||||
}
|
||||
|
||||
async getSpendingHistory(juniorId: string, userId: string, startUtc: Date, endUtc: Date) {
|
||||
this.logger.log(`Getting spending history for junior ${juniorId}`);
|
||||
|
||||
// Check if user is the junior themselves or their guardian
|
||||
let junior: Junior | null;
|
||||
if (juniorId === userId) {
|
||||
junior = await this.findJuniorById(juniorId, false);
|
||||
} else {
|
||||
junior = await this.findJuniorById(juniorId, false, userId);
|
||||
}
|
||||
|
||||
if (!junior) {
|
||||
throw new BadRequestException('JUNIOR.NOT_FOUND');
|
||||
}
|
||||
|
||||
return this.transactionService.getChildSpendingHistory(juniorId, startUtc, endUtc);
|
||||
}
|
||||
|
||||
async getTransactionDetail(juniorId: string, userId: string, transactionId: string) {
|
||||
this.logger.log(`Getting transaction detail ${transactionId} for junior ${juniorId}`);
|
||||
|
||||
// Check if user is the junior themselves or their guardian
|
||||
let junior: Junior | null;
|
||||
if (juniorId === userId) {
|
||||
junior = await this.findJuniorById(juniorId, false);
|
||||
} else {
|
||||
junior = await this.findJuniorById(juniorId, false, userId);
|
||||
}
|
||||
|
||||
if (!junior) {
|
||||
throw new BadRequestException('JUNIOR.NOT_FOUND');
|
||||
}
|
||||
|
||||
return this.transactionService.getTransactionDetail(transactionId, juniorId);
|
||||
}
|
||||
|
||||
private async prepareJuniorImages(juniors: Junior[]) {
|
||||
this.logger.log(`Preparing junior images`);
|
||||
await Promise.all(
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { i18nValidationMessage as i18n } from 'nestjs-i18n';
|
||||
import { Gender } from '~/customer/enums';
|
||||
export class UpdateUserRequestDto {
|
||||
@ApiProperty({ example: 'John' })
|
||||
@IsString({ message: i18n('validation.IsString', { path: 'general', property: 'user.firstName' }) })
|
||||
@ -14,8 +15,23 @@ export class UpdateUserRequestDto {
|
||||
@IsOptional()
|
||||
lastName!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'child@example.com' })
|
||||
@IsEmail({}, { message: i18n('validation.IsEmail', { path: 'general', property: 'user.email' }) })
|
||||
@IsOptional()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: '123e4567-e89b-12d3-a456-426614174000' })
|
||||
@IsUUID('4', { message: i18n('validation.IsUUID', { path: 'general', property: 'user.profilePictureId' }) })
|
||||
@IsOptional()
|
||||
profilePictureId!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: Gender })
|
||||
@IsEnum(Gender, { message: i18n('validation.IsEnum', { path: 'general', property: 'customer.gender' }) })
|
||||
@IsOptional()
|
||||
gender!: Gender;
|
||||
|
||||
@ApiPropertyOptional({ example: '2020-01-01' })
|
||||
@IsDateString({}, { message: i18n('validation.IsDateString', { path: 'general', property: 'customer.dateOfBirth' }) })
|
||||
@IsOptional()
|
||||
dateOfBirth!: Date;
|
||||
}
|
||||
|
||||
@ -28,7 +28,7 @@ export class User extends BaseEntity {
|
||||
@Column('varchar', { length: 255, name: 'last_name', nullable: false })
|
||||
lastName!: string;
|
||||
|
||||
@Column('varchar', { length: 255, name: 'email', nullable: true })
|
||||
@Column('varchar', { length: 255, name: 'email', nullable: true, unique: true })
|
||||
email!: string;
|
||||
|
||||
@Column('varchar', { length: 255, name: 'phone_number', nullable: true })
|
||||
|
||||
@ -191,16 +191,38 @@ export class UserService {
|
||||
async updateUser(userId: string, data: UpdateUserRequestDto) {
|
||||
await this.validateProfilePictureId(data.profilePictureId, userId);
|
||||
|
||||
if (data.email) {
|
||||
const userWithEmail = await this.findUser({ email: data.email });
|
||||
if (userWithEmail && userWithEmail.id !== userId) {
|
||||
this.logger.error(`Email ${data.email} is already taken by another user`);
|
||||
throw new BadRequestException('USER.EMAIL_ALREADY_TAKEN');
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Updating user ${userId} with data ${JSON.stringify(data)}`);
|
||||
const { affected } = await this.userRepository.update(userId, data);
|
||||
|
||||
const { gender, dateOfBirth, ...userData } = data;
|
||||
|
||||
const { affected } = await this.userRepository.update(userId, userData);
|
||||
if (affected === 0) {
|
||||
this.logger.error(`User with id ${userId} not found`);
|
||||
throw new BadRequestException('USER.NOT_FOUND');
|
||||
}
|
||||
|
||||
if (gender !== undefined || dateOfBirth !== undefined) {
|
||||
const customerData: Partial<{ gender: typeof gender; dateOfBirth: Date }> = {};
|
||||
if (gender !== undefined) {
|
||||
customerData.gender = gender;
|
||||
}
|
||||
if (dateOfBirth !== undefined) {
|
||||
customerData.dateOfBirth = dateOfBirth;
|
||||
}
|
||||
await this.customerService.updateCustomer(userId, customerData);
|
||||
}
|
||||
}
|
||||
|
||||
async updateUserEmail(userId: string, email: string) {
|
||||
const userWithEmail = await this.findUser({ email, isEmailVerified: true });
|
||||
const userWithEmail = await this.findUser({ email });
|
||||
|
||||
if (userWithEmail) {
|
||||
if (userWithEmail.id === userId) {
|
||||
|
||||
Reference in New Issue
Block a user