add business_owner
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
@@ -147,6 +147,13 @@ export class BusinessesController {
|
|||||||
return { success: true };
|
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')
|
@Post(':id/assign-owner')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles(UserRole.ADMIN)
|
@Roles(UserRole.ADMIN)
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { DataSource, Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
import { deleteBusinessCascade } from '../../common/utils/business-cascade.util';
|
import { deleteBusinessCascade } from '../../common/utils/business-cascade.util';
|
||||||
import { parseHmToMinute } from '../../common/utils/booking-schedule.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 { resolveUniqueSlug, normalizeSlugInput } from '../../common/utils/slug.util';
|
||||||
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
|
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
|
||||||
import { BusinessCategoryEntity } from '../../entities/business-category.entity';
|
import { BusinessCategoryEntity } from '../../entities/business-category.entity';
|
||||||
@@ -114,13 +118,15 @@ export class BusinessesService {
|
|||||||
|
|
||||||
await this.ensureCategoryExists(payload.categoryId);
|
await this.ensureCategoryExists(payload.categoryId);
|
||||||
|
|
||||||
|
const contactPhone = this.normalizeOptionalContactPhone(payload.contactPhone);
|
||||||
|
|
||||||
const entity = this.repo.create({
|
const entity = this.repo.create({
|
||||||
title: payload.title,
|
title: payload.title,
|
||||||
slug,
|
slug,
|
||||||
subtitle: payload.subtitle ?? null,
|
subtitle: payload.subtitle ?? null,
|
||||||
categoryId: payload.categoryId,
|
categoryId: payload.categoryId,
|
||||||
description: payload.description ?? null,
|
description: payload.description ?? null,
|
||||||
contactPhone: payload.contactPhone ?? null,
|
contactPhone,
|
||||||
address: payload.address ?? null,
|
address: payload.address ?? null,
|
||||||
latitude: payload.latitude ?? null,
|
latitude: payload.latitude ?? null,
|
||||||
longitude: payload.longitude ?? null,
|
longitude: payload.longitude ?? null,
|
||||||
@@ -128,7 +134,8 @@ export class BusinessesService {
|
|||||||
isActive: payload.isActive ?? true,
|
isActive: payload.isActive ?? true,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.repo.save(entity);
|
const saved = await this.repo.save(entity);
|
||||||
|
return this.ensureOwnerFromContactPhone(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
@@ -150,8 +157,13 @@ export class BusinessesService {
|
|||||||
await this.ensureCategoryExists(payload.categoryId);
|
await this.ensureCategoryExists(payload.categoryId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.contactPhone !== undefined) {
|
||||||
|
payload.contactPhone = this.normalizeOptionalContactPhone(payload.contactPhone);
|
||||||
|
}
|
||||||
|
|
||||||
Object.assign(business, payload);
|
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<void> {
|
async remove(id: string): Promise<void> {
|
||||||
@@ -217,13 +229,22 @@ export class BusinessesService {
|
|||||||
dto: AssignBusinessOwnerDto,
|
dto: AssignBusinessOwnerDto,
|
||||||
): Promise<BusinessEntity> {
|
): Promise<BusinessEntity> {
|
||||||
const business = await this.findOne(id);
|
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 } });
|
let user = await this.userRepo.findOne({ where: { mobile } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
user = this.userRepo.create({
|
user = this.userRepo.create({
|
||||||
mobile,
|
mobile,
|
||||||
fullName: dto.fullName ?? null,
|
fullName: resolvedName ?? defaultName,
|
||||||
role: UserRole.BUSINESS_OWNER,
|
role: UserRole.BUSINESS_OWNER,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
});
|
});
|
||||||
@@ -231,17 +252,80 @@ export class BusinessesService {
|
|||||||
if (user.role !== UserRole.ADMIN) {
|
if (user.role !== UserRole.ADMIN) {
|
||||||
user.role = UserRole.BUSINESS_OWNER;
|
user.role = UserRole.BUSINESS_OWNER;
|
||||||
}
|
}
|
||||||
if (dto.fullName !== undefined) {
|
if (resolvedName !== undefined) {
|
||||||
user.fullName = dto.fullName;
|
user.fullName = resolvedName;
|
||||||
|
} else if (!user.fullName?.trim()) {
|
||||||
|
user.fullName = defaultName;
|
||||||
}
|
}
|
||||||
user.isActive = true;
|
user.isActive = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedUser = await this.userRepo.save(user);
|
const savedUser = await this.userRepo.save(user);
|
||||||
business.ownerId = savedUser.id;
|
business.ownerId = savedUser.id;
|
||||||
|
if (!business.contactPhone) {
|
||||||
|
business.contactPhone = mobile;
|
||||||
|
}
|
||||||
return this.repo.save(business);
|
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<BusinessEntity> {
|
||||||
|
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<void> {
|
private async attachCoverImages(businesses: BusinessEntity[]): Promise<void> {
|
||||||
if (!businesses.length) {
|
if (!businesses.length) {
|
||||||
return;
|
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<void> {
|
private async attachReviewStats(businesses: BusinessEntity[]): Promise<void> {
|
||||||
if (!businesses.length) return;
|
if (!businesses.length) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user