diff --git a/scripts/backfill-business-owners.ts b/scripts/backfill-business-owners.ts new file mode 100644 index 0000000..c48095f --- /dev/null +++ b/scripts/backfill-business-owners.ts @@ -0,0 +1,143 @@ +/** + * One-off: attach missing business owners from contactPhone. + * Usage: node --import ts-node/register/esm OR npx ts-node -r dotenv/config scripts/backfill-business-owners.ts + */ +import 'dotenv/config'; +import { DataSource } from 'typeorm'; +import { join } from 'path'; + +const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹'; +const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩'; +const INVISIBLE_CHARS = /[\u200c\u200d\u202a-\u202e\ufeff]/g; +const IRAN_MOBILE_REGEX = /^09\d{9}$/; + +function toAsciiDigits(value: string): string { + return value.replace(/[۰-۹٠-٩]/g, (ch) => { + const persian = PERSIAN_DIGITS.indexOf(ch); + if (persian >= 0) return String(persian); + const arabic = ARABIC_DIGITS.indexOf(ch); + if (arabic >= 0) return String(arabic); + return ch; + }); +} + +function normalizeIranMobile(raw: string): string { + let mobile = toAsciiDigits(String(raw ?? '').trim()); + mobile = mobile.replace(INVISIBLE_CHARS, ''); + let digits = mobile.replace(/\D/g, ''); + if (!digits) return ''; + if (digits.startsWith('0098')) digits = digits.slice(2); + if (digits.startsWith('98') && digits.length >= 12) { + digits = `0${digits.slice(2)}`; + } else if (digits.startsWith('9') && digits.length === 10) { + digits = `0${digits}`; + } + return digits; +} + +function isValidIranMobile(mobile: string): boolean { + return IRAN_MOBILE_REGEX.test(mobile); +} + +async function main() { + const ds = new DataSource({ + type: 'postgres', + host: process.env.DB_HOST, + port: parseInt(process.env.POSTGRES_PORT || '5432', 10), + username: process.env.POSTGRES_USER, + password: process.env.POSTGRES_PASSWORD, + database: process.env.POSTGRES_DB, + entities: [], + synchronize: false, + }); + + await ds.initialize(); + console.log('Connected to', process.env.POSTGRES_DB); + + const businesses: Array<{ + id: string; + title: string; + contactPhone: string | null; + ownerId: string | null; + }> = await ds.query(` + SELECT b.id, b.title, b."contactPhone", b."ownerId" + FROM businesses b + LEFT JOIN users u ON u.id = b."ownerId" + WHERE b."contactPhone" IS NOT NULL + AND b."contactPhone" <> '' + AND (b."ownerId" IS NULL OR u.id IS NULL) + ORDER BY b."createdAt" ASC + `); + + console.log(`Found ${businesses.length} business(es) needing owner backfill`); + + let created = 0; + let linked = 0; + let skipped = 0; + + for (const biz of businesses) { + const mobile = normalizeIranMobile(biz.contactPhone ?? ''); + if (!isValidIranMobile(mobile)) { + console.log(`SKIP ${biz.title}: invalid mobile "${biz.contactPhone}"`); + skipped += 1; + continue; + } + + const defaultName = `مدیر ${biz.title}`; + + let users: Array<{ id: string; fullName: string | null; role: string }> = + await ds.query(`SELECT id, "fullName", role FROM users WHERE mobile = $1 LIMIT 1`, [ + mobile, + ]); + + let userId: string; + if (!users.length) { + const inserted: Array<{ id: string }> = await ds.query( + ` + INSERT INTO users (id, mobile, "fullName", role, "isActive", "createdAt", "updatedAt") + VALUES (gen_random_uuid(), $1, $2, 'business_owner', true, NOW(), NOW()) + RETURNING id + `, + [mobile, defaultName], + ); + userId = inserted[0].id; + created += 1; + console.log(`CREATE user ${mobile} (${defaultName}) → ${biz.title}`); + } else { + userId = users[0].id; + const updates: string[] = []; + const params: unknown[] = []; + + if (users[0].role !== 'admin') { + params.push('business_owner'); + updates.push(`role = $${params.length}`); + } + if (!users[0].fullName?.trim()) { + params.push(defaultName); + updates.push(`"fullName" = $${params.length}`); + } + params.push(true); + updates.push(`"isActive" = $${params.length}`); + params.push(userId); + await ds.query( + `UPDATE users SET ${updates.join(', ')}, "updatedAt" = NOW() WHERE id = $${params.length}`, + params, + ); + linked += 1; + console.log(`LINK existing user ${mobile} → ${biz.title}`); + } + + await ds.query( + `UPDATE businesses SET "ownerId" = $1, "contactPhone" = $2, "updatedAt" = NOW() WHERE id = $3`, + [userId, mobile, biz.id], + ); + } + + console.log(`Done. created=${created} linked=${linked} skipped=${skipped}`); + await ds.destroy(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/modules/businesses/businesses.controller.ts b/src/modules/businesses/businesses.controller.ts index 4f588b2..b55bf95 100644 --- a/src/modules/businesses/businesses.controller.ts +++ b/src/modules/businesses/businesses.controller.ts @@ -147,6 +147,13 @@ export class BusinessesController { return { success: true }; } + @Post('backfill-owners') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + backfillOwners(): Promise<{ filled: number; skipped: number }> { + return this.service.backfillMissingOwners(); + } + @Post(':id/assign-owner') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) diff --git a/src/modules/businesses/businesses.service.ts b/src/modules/businesses/businesses.service.ts index 7a79fb4..56c69fc 100644 --- a/src/modules/businesses/businesses.service.ts +++ b/src/modules/businesses/businesses.service.ts @@ -9,6 +9,10 @@ import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { deleteBusinessCascade } from '../../common/utils/business-cascade.util'; import { parseHmToMinute } from '../../common/utils/booking-schedule.util'; +import { + isValidIranMobile, + normalizeIranMobile, +} from '../../common/utils/iran-mobile.util'; import { resolveUniqueSlug, normalizeSlugInput } from '../../common/utils/slug.util'; import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity'; import { BusinessCategoryEntity } from '../../entities/business-category.entity'; @@ -114,13 +118,15 @@ export class BusinessesService { await this.ensureCategoryExists(payload.categoryId); + const contactPhone = this.normalizeOptionalContactPhone(payload.contactPhone); + const entity = this.repo.create({ title: payload.title, slug, subtitle: payload.subtitle ?? null, categoryId: payload.categoryId, description: payload.description ?? null, - contactPhone: payload.contactPhone ?? null, + contactPhone, address: payload.address ?? null, latitude: payload.latitude ?? null, longitude: payload.longitude ?? null, @@ -128,7 +134,8 @@ export class BusinessesService { isActive: payload.isActive ?? true, }); - return this.repo.save(entity); + const saved = await this.repo.save(entity); + return this.ensureOwnerFromContactPhone(saved); } async update( @@ -150,8 +157,13 @@ export class BusinessesService { await this.ensureCategoryExists(payload.categoryId); } + if (payload.contactPhone !== undefined) { + payload.contactPhone = this.normalizeOptionalContactPhone(payload.contactPhone); + } + Object.assign(business, payload); - return this.repo.save(business); + const saved = await this.repo.save(business); + return this.ensureOwnerFromContactPhone(saved); } async remove(id: string): Promise { @@ -217,13 +229,22 @@ export class BusinessesService { dto: AssignBusinessOwnerDto, ): Promise { const business = await this.findOne(id); - const mobile = this.normalizeMobile(dto.mobile); + const mobile = normalizeIranMobile(dto.mobile); + if (!isValidIranMobile(mobile)) { + throw new BadRequestException('Invalid mobile number'); + } + + const defaultName = `مدیر ${business.title}`; + const resolvedName = + dto.fullName === undefined + ? undefined + : dto.fullName?.trim() || defaultName; let user = await this.userRepo.findOne({ where: { mobile } }); if (!user) { user = this.userRepo.create({ mobile, - fullName: dto.fullName ?? null, + fullName: resolvedName ?? defaultName, role: UserRole.BUSINESS_OWNER, isActive: true, }); @@ -231,17 +252,80 @@ export class BusinessesService { if (user.role !== UserRole.ADMIN) { user.role = UserRole.BUSINESS_OWNER; } - if (dto.fullName !== undefined) { - user.fullName = dto.fullName; + if (resolvedName !== undefined) { + user.fullName = resolvedName; + } else if (!user.fullName?.trim()) { + user.fullName = defaultName; } user.isActive = true; } const savedUser = await this.userRepo.save(user); business.ownerId = savedUser.id; + if (!business.contactPhone) { + business.contactPhone = mobile; + } return this.repo.save(business); } + async backfillMissingOwners(): Promise<{ filled: number; skipped: number }> { + const withoutOwner = await this.repo + .createQueryBuilder('business') + .where('business.ownerId IS NULL') + .andWhere('business.contactPhone IS NOT NULL') + .andWhere("business.contactPhone <> ''") + .getMany(); + + let filled = 0; + let skipped = 0; + + for (const business of withoutOwner) { + const mobile = normalizeIranMobile(business.contactPhone ?? ''); + if (!isValidIranMobile(mobile)) { + skipped += 1; + continue; + } + const existing = await this.userRepo.findOne({ where: { mobile } }); + await this.assignOwner(business.id, { + mobile, + ...(existing ? {} : { fullName: `مدیر ${business.title}` }), + }); + filled += 1; + } + + return { filled, skipped }; + } + + /** Create/attach owner user from contact phone when the business has none yet. */ + private async ensureOwnerFromContactPhone( + business: BusinessEntity, + ): Promise { + if (business.ownerId || !business.contactPhone) { + return business; + } + + const mobile = normalizeIranMobile(business.contactPhone); + if (!isValidIranMobile(mobile)) { + return business; + } + + const existing = await this.userRepo.findOne({ where: { mobile } }); + return this.assignOwner(business.id, { + mobile, + ...(existing ? {} : { fullName: `مدیر ${business.title}` }), + }); + } + + private normalizeOptionalContactPhone( + value: string | null | undefined, + ): string | null { + if (value == null) return null; + const trimmed = String(value).trim(); + if (!trimmed) return null; + const mobile = normalizeIranMobile(trimmed); + return isValidIranMobile(mobile) ? mobile : trimmed; + } + private async attachCoverImages(businesses: BusinessEntity[]): Promise { if (!businesses.length) { return; @@ -395,14 +479,6 @@ export class BusinessesService { } } - private normalizeMobile(mobile: string): string { - const normalized = String(mobile ?? '').trim(); - if (!/^(\+?\d{10,15}|09\d{9})$/.test(normalized)) { - throw new BadRequestException('Invalid mobile number'); - } - return normalized; - } - private async attachReviewStats(businesses: BusinessEntity[]): Promise { if (!businesses.length) return;