add clients by excel
This commit is contained in:
Generated
+105
-1
@@ -54,7 +54,8 @@
|
|||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"ulid": "^3.0.1",
|
"ulid": "^3.0.1",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
@@ -8146,6 +8147,15 @@
|
|||||||
"node": ">=0.4.0"
|
"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": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
@@ -8908,6 +8918,19 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chalk": {
|
||||||
"version": "4.1.2",
|
"version": "4.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||||
@@ -9149,6 +9172,15 @@
|
|||||||
"node": ">= 0.12.0"
|
"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": {
|
"node_modules/collect-v8-coverage": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz",
|
"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": {
|
"node_modules/create-require": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||||
@@ -11027,6 +11071,15 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/fresh": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||||
@@ -15923,6 +15976,18 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/stack-utils": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
|
||||||
@@ -17601,6 +17666,24 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"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": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
+2
-1
@@ -79,7 +79,8 @@
|
|||||||
"slugify": "^1.6.6",
|
"slugify": "^1.6.6",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"ulid": "^3.0.1",
|
"ulid": "^3.0.1",
|
||||||
"uuid": "^13.0.0"
|
"uuid": "^13.0.0",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
|||||||
@@ -775,6 +775,13 @@ export const enum PagerMessage {
|
|||||||
export const enum UserSuccessMessage {
|
export const enum UserSuccessMessage {
|
||||||
ADDRESS_DELETED_SUCCESS = 'آدرس با موفقیت حذف شد',
|
ADDRESS_DELETED_SUCCESS = 'آدرس با موفقیت حذف شد',
|
||||||
SCORE_CONVERTED_SUCCESS = 'امتیاز با موفقیت به کیف پول تبدیل شد',
|
SCORE_CONVERTED_SUCCESS = 'امتیاز با موفقیت به کیف پول تبدیل شد',
|
||||||
|
USERS_IMPORTED_SUCCESS = 'کاربران با موفقیت وارد شدند',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const enum UserImportMessage {
|
||||||
|
FILE_REQUIRED = 'فایل اکسل الزامی است',
|
||||||
|
PHONE_REQUIRED = 'شماره تلفن الزامی است',
|
||||||
|
INVALID_PHONE = 'فرمت شماره تلفن صحیح نیست',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const enum ContactMessage {
|
export const enum ContactMessage {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Controller, Get, UseGuards, Patch, Body, ValidationPipe, Post, Query, Delete, Param } from '@nestjs/common';
|
import { Controller, Get, UseGuards, Patch, Body, ValidationPipe, Post, Query, Delete, Param, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiBody, ApiOkResponse, ApiHeader } from '@nestjs/swagger';
|
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 { AuthGuard } from 'src/modules/auth/guards/auth.guard';
|
||||||
import { UserService } from '../providers/user.service';
|
import { UserService } from '../providers/user.service';
|
||||||
import { UpdateUserDto } from '../dto/update-user.dto';
|
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 { Permission } from 'src/common/enums/permission.enum';
|
||||||
import { WalletService } from '../providers/wallet.service';
|
import { WalletService } from '../providers/wallet.service';
|
||||||
import { FindUserByPhoneDto } from '../dto/find-user-by-phone.dto';
|
import { FindUserByPhoneDto } from '../dto/find-user-by-phone.dto';
|
||||||
|
import { ImportUsersFromExcelDto } from '../dto/import-users-from-excel.dto';
|
||||||
|
|
||||||
@ApiTags('User')
|
@ApiTags('User')
|
||||||
@Controller()
|
@Controller()
|
||||||
@@ -193,4 +195,21 @@ export class UsersController {
|
|||||||
) {
|
) {
|
||||||
return this.userService.findByPhone(query.phone);
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { FilterQuery, RequiredEntityData } from '@mikro-orm/core';
|
import { FilterQuery, RequiredEntityData } from '@mikro-orm/core';
|
||||||
|
import type { File } from '@nest-lab/fastify-multer';
|
||||||
import { User } from '../entities/user.entity';
|
import { User } from '../entities/user.entity';
|
||||||
import { UserAddress } from '../entities/user-address.entity';
|
import { UserAddress } from '../entities/user-address.entity';
|
||||||
import { Restaurant } from 'src/modules/restaurants/entities/restaurant.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 { WalletTransaction } from '../entities/wallet-transaction.entity';
|
||||||
import { UserRestaurantRepository } from '../repositories/user-restuarant.repository';
|
import { UserRestaurantRepository } from '../repositories/user-restuarant.repository';
|
||||||
import { UserRestaurant } from '../entities/user-restuarant.entity';
|
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()
|
@Injectable()
|
||||||
export class UserService {
|
export class UserService {
|
||||||
@@ -420,4 +424,97 @@ export class UserService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async importUsersFromExcel(restId: string, file: File): Promise<ImportUsersResult> {
|
||||||
|
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>;
|
||||||
|
|
||||||
|
user = this.userRepository.create(createData);
|
||||||
|
await this.em.persistAndFlush(user);
|
||||||
|
result.usersCreated++;
|
||||||
|
} else {
|
||||||
|
const updates: Partial<User> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, unknown>, name: string): unknown {
|
||||||
|
const key = Object.keys(row).find(k => normalizeHeader(k).toLowerCase() === name.toLowerCase());
|
||||||
|
return key ? row[key] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRowEmpty(row: Record<string, unknown>): 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<Record<string, unknown>>(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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user