From 2ce395d1f88d0a8163e7af8a639164796e332307 Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Thu, 18 Jun 2026 22:48:25 +0330 Subject: [PATCH] add clients by excel --- package-lock.json | 106 ++++++++++++++++- package.json | 3 +- src/common/enums/message.enum.ts | 7 ++ .../users/controllers/users.controller.ts | 23 +++- .../users/dto/import-users-from-excel.dto.ts | 7 ++ .../interfaces/import-users.interface.ts | 21 ++++ src/modules/users/providers/user.service.ts | 97 +++++++++++++++ .../users/utils/import-users-excel.util.ts | 110 ++++++++++++++++++ 8 files changed, 370 insertions(+), 4 deletions(-) create mode 100644 src/modules/users/dto/import-users-from-excel.dto.ts create mode 100644 src/modules/users/interfaces/import-users.interface.ts create mode 100644 src/modules/users/utils/import-users-excel.util.ts diff --git a/package-lock.json b/package-lock.json index 25312c2..5b80815 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,7 +54,8 @@ "slugify": "^1.6.6", "socket.io": "^4.8.1", "ulid": "^3.0.1", - "uuid": "^13.0.0" + "uuid": "^13.0.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -8146,6 +8147,15 @@ "node": ">=0.4.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -8908,6 +8918,19 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -9149,6 +9172,15 @@ "node": ">= 0.12.0" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", @@ -9357,6 +9389,18 @@ } } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -11027,6 +11071,15 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fresh": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", @@ -15923,6 +15976,18 @@ "node": ">= 0.6" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -17601,6 +17666,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -17736,6 +17819,27 @@ } } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index b97f426..28afb6d 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,8 @@ "slugify": "^1.6.6", "socket.io": "^4.8.1", "ulid": "^3.0.1", - "uuid": "^13.0.0" + "uuid": "^13.0.0", + "xlsx": "^0.18.5" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 086fb79..bcb60ff 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -775,6 +775,13 @@ export const enum PagerMessage { export const enum UserSuccessMessage { ADDRESS_DELETED_SUCCESS = 'آدرس با موفقیت حذف شد', SCORE_CONVERTED_SUCCESS = 'امتیاز با موفقیت به کیف پول تبدیل شد', + USERS_IMPORTED_SUCCESS = 'کاربران با موفقیت وارد شدند', +} + +export const enum UserImportMessage { + FILE_REQUIRED = 'فایل اکسل الزامی است', + PHONE_REQUIRED = 'شماره تلفن الزامی است', + INVALID_PHONE = 'فرمت شماره تلفن صحیح نیست', } export const enum ContactMessage { diff --git a/src/modules/users/controllers/users.controller.ts b/src/modules/users/controllers/users.controller.ts index 13940cb..1338efe 100644 --- a/src/modules/users/controllers/users.controller.ts +++ b/src/modules/users/controllers/users.controller.ts @@ -1,5 +1,6 @@ -import { Controller, Get, UseGuards, Patch, Body, ValidationPipe, Post, Query, Delete, Param } from '@nestjs/common'; -import { ApiTags, ApiBearerAuth, ApiOperation, ApiBody, ApiOkResponse, ApiHeader } from '@nestjs/swagger'; +import { Controller, Get, UseGuards, Patch, Body, ValidationPipe, Post, Query, Delete, Param, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation, ApiBody, ApiOkResponse, ApiHeader, ApiConsumes } from '@nestjs/swagger'; +import { FileInterceptor, type File } from '@nest-lab/fastify-multer'; import { AuthGuard } from 'src/modules/auth/guards/auth.guard'; import { UserService } from '../providers/user.service'; import { UpdateUserDto } from '../dto/update-user.dto'; @@ -16,6 +17,7 @@ import { Permissions } from 'src/common/decorators/permissions.decorator'; import { Permission } from 'src/common/enums/permission.enum'; import { WalletService } from '../providers/wallet.service'; import { FindUserByPhoneDto } from '../dto/find-user-by-phone.dto'; +import { ImportUsersFromExcelDto } from '../dto/import-users-from-excel.dto'; @ApiTags('User') @Controller() @@ -193,4 +195,21 @@ export class UsersController { ) { return this.userService.findByPhone(query.phone); } + + @UseGuards(AdminAuthGuard) + @ApiBearerAuth() + @Permissions(Permission.MANAGE_USERS) + @ApiHeader(API_HEADER_SLUG) + @ApiOperation({ summary: 'Import users from Excel file into restaurant customers' }) + @ApiConsumes('multipart/form-data') + @UseInterceptors(FileInterceptor('file')) + @ApiBody({ type: ImportUsersFromExcelDto }) + @Post('admin/users/import') + async importUsersFromExcel(@RestId() restId: string, @UploadedFile() file: File) { + const result = await this.userService.importUsersFromExcel(restId, file); + return { + message: UserSuccessMessage.USERS_IMPORTED_SUCCESS, + ...result, + }; + } } diff --git a/src/modules/users/dto/import-users-from-excel.dto.ts b/src/modules/users/dto/import-users-from-excel.dto.ts new file mode 100644 index 0000000..7c09327 --- /dev/null +++ b/src/modules/users/dto/import-users-from-excel.dto.ts @@ -0,0 +1,7 @@ +import type { File } from '@nest-lab/fastify-multer'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ImportUsersFromExcelDto { + @ApiProperty({ type: 'string', format: 'binary', nullable: false }) + file!: File; +} diff --git a/src/modules/users/interfaces/import-users.interface.ts b/src/modules/users/interfaces/import-users.interface.ts new file mode 100644 index 0000000..cb63520 --- /dev/null +++ b/src/modules/users/interfaces/import-users.interface.ts @@ -0,0 +1,21 @@ +export interface ImportUserRow { + rowNumber: number; + phone: string; + firstName?: string; + lastName?: string; + gender?: boolean; +} + +export interface ImportUserRowError { + row: number; + phone?: string; + message: string; +} + +export interface ImportUsersResult { + total: number; + usersCreated: number; + usersLinked: number; + usersAlreadyLinked: number; + errors: ImportUserRowError[]; +} diff --git a/src/modules/users/providers/user.service.ts b/src/modules/users/providers/user.service.ts index 8ccf78c..84a4c4a 100644 --- a/src/modules/users/providers/user.service.ts +++ b/src/modules/users/providers/user.service.ts @@ -1,5 +1,6 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { FilterQuery, RequiredEntityData } from '@mikro-orm/core'; +import type { File } from '@nest-lab/fastify-multer'; import { User } from '../entities/user.entity'; import { UserAddress } from '../entities/user-address.entity'; import { Restaurant } from 'src/modules/restaurants/entities/restaurant.entity'; @@ -20,6 +21,9 @@ import { WalletTransactionReason, WalletTransactionType } from '../interface/wal import { WalletTransaction } from '../entities/wallet-transaction.entity'; import { UserRestaurantRepository } from '../repositories/user-restuarant.repository'; import { UserRestaurant } from '../entities/user-restuarant.entity'; +import { ImportUsersResult } from '../interfaces/import-users.interface'; +import { assertExcelMimeType, parseUsersExcel } from '../utils/import-users-excel.util'; +import { UserImportMessage } from 'src/common/enums/message.enum'; @Injectable() export class UserService { @@ -420,4 +424,97 @@ export class UserService { }); } + async importUsersFromExcel(restId: string, file: File): Promise { + if (!file?.buffer?.length) { + throw new BadRequestException(UserImportMessage.FILE_REQUIRED); + } + + assertExcelMimeType(file.mimetype); + const rows = parseUsersExcel(file.buffer); + + const restaurant = await this.restaurantRepository.findOne({ id: restId }); + if (!restaurant) { + throw new NotFoundException(`Restaurant with ID ${restId} not found.`); + } + + const result: ImportUsersResult = { + total: rows.length, + usersCreated: 0, + usersLinked: 0, + usersAlreadyLinked: 0, + errors: [], + }; + + for (const row of rows) { + try { + if (!row.phone) { + result.errors.push({ + row: row.rowNumber, + message: UserImportMessage.PHONE_REQUIRED, + }); + continue; + } + + const normalizedPhone = normalizePhone(row.phone); + if (!normalizedPhone) { + result.errors.push({ + row: row.rowNumber, + phone: row.phone, + message: UserImportMessage.INVALID_PHONE, + }); + continue; + } + + let user = await this.userRepository.findOne({ phone: normalizedPhone }); + + if (!user) { + const createData = { + phone: normalizedPhone, + firstName: row.firstName?.trim() || '[نام]', + lastName: row.lastName?.trim() || undefined, + gender: row.gender, + birthDate: undefined, + marriageDate: undefined, + } as unknown as RequiredEntityData; + + user = this.userRepository.create(createData); + await this.em.persistAndFlush(user); + result.usersCreated++; + } else { + const updates: Partial = {}; + if (row.firstName?.trim()) { + updates.firstName = row.firstName.trim(); + } + if (row.lastName?.trim()) { + updates.lastName = row.lastName.trim(); + } + if (row.gender !== undefined) { + updates.gender = row.gender; + } + if (Object.keys(updates).length > 0) { + this.em.assign(user, updates); + await this.em.flush(); + } + } + + const existingLink = await this.userRestaurantRepository.findOne({ user, restaurant }); + if (existingLink) { + result.usersAlreadyLinked++; + continue; + } + + await this.addUserToRestaurant(user, restaurant); + result.usersLinked++; + } catch (error) { + result.errors.push({ + row: row.rowNumber, + phone: row.phone, + message: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + return result; + } + } diff --git a/src/modules/users/utils/import-users-excel.util.ts b/src/modules/users/utils/import-users-excel.util.ts new file mode 100644 index 0000000..db25904 --- /dev/null +++ b/src/modules/users/utils/import-users-excel.util.ts @@ -0,0 +1,110 @@ +import { BadRequestException } from '@nestjs/common'; +import * as XLSX from 'xlsx'; +import { normalizePhone } from 'src/modules/utils/phone.util'; +import { ImportUserRow } from '../interfaces/import-users.interface'; + +export const MAX_IMPORT_ROWS = 500; + +const ALLOWED_MIME_TYPES = new Set([ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + 'text/csv', + 'application/csv', +]); + +function normalizeHeader(header: string): string { + return String(header).trim(); +} + +function cellToString(value: unknown): string { + if (value === null || value === undefined) { + return ''; + } + return String(value).trim(); +} + +export function parseGender(value: unknown): boolean | undefined { + if (value === null || value === undefined || value === '') { + return undefined; + } + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'number') { + return value === 1; + } + const str = String(value).trim().toLowerCase(); + if (['true', '1', 'male', 'm', 'man', 'مرد'].includes(str)) { + return true; + } + if (['false', '0', 'female', 'f', 'woman', 'زن'].includes(str)) { + return false; + } + return undefined; +} + +function getField(row: Record, name: string): unknown { + const key = Object.keys(row).find(k => normalizeHeader(k).toLowerCase() === name.toLowerCase()); + return key ? row[key] : undefined; +} + +function isRowEmpty(row: Record): boolean { + const phone = cellToString(getField(row, 'phone')); + const firstName = cellToString(getField(row, 'firstName')); + const lastName = cellToString(getField(row, 'lastName')); + const gender = getField(row, 'gender'); + return !phone && !firstName && !lastName && (gender === undefined || gender === null || gender === ''); +} + +export function assertExcelMimeType(mimetype: string | undefined): void { + if (!mimetype || !ALLOWED_MIME_TYPES.has(mimetype)) { + throw new BadRequestException('File must be an Excel (.xlsx, .xls) or CSV file'); + } +} + +export function parseUsersExcel(buffer: Buffer): ImportUserRow[] { + const workbook = XLSX.read(buffer, { type: 'buffer' }); + if (!workbook.SheetNames.length) { + throw new BadRequestException('Excel file has no sheets'); + } + + const sheet = workbook.Sheets[workbook.SheetNames[0]]; + const rawRows = XLSX.utils.sheet_to_json>(sheet, { defval: '' }); + + if (rawRows.length > MAX_IMPORT_ROWS) { + throw new BadRequestException(`Excel file contains more than ${MAX_IMPORT_ROWS} rows`); + } + + if (rawRows.length === 0) { + return []; + } + + const hasPhoneColumn = Object.keys(rawRows[0]).some( + key => normalizeHeader(key).toLowerCase() === 'phone', + ); + if (!hasPhoneColumn) { + throw new BadRequestException('Excel file must have a "phone" column'); + } + + const rows: ImportUserRow[] = []; + + rawRows.forEach((row, index) => { + if (isRowEmpty(row)) { + return; + } + + rows.push({ + rowNumber: index + 2, + phone: normalizePhone(cellToString(getField(row, 'phone'))), + firstName: cellToString(getField(row, 'firstName')) || undefined, + lastName: cellToString(getField(row, 'lastName')) || undefined, + gender: parseGender(getField(row, 'gender')), + }); + }); + + if (rows.length > MAX_IMPORT_ROWS) { + throw new BadRequestException(`Excel file contains more than ${MAX_IMPORT_ROWS} data rows`); + } + + return rows; +}