Compare commits
39
Commits
97abb808b8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67506e2d98 | ||
|
|
61c8653af6 | ||
|
|
893035cef2 | ||
|
|
74815cb05c | ||
|
|
aa32dae736 | ||
|
|
3844594506 | ||
|
|
2ba959b3ae | ||
|
|
2eb6718bf4 | ||
|
|
bb5f2ef2b3 | ||
|
|
9ec1a476a0 | ||
|
|
1d685b3338 | ||
|
|
4e33d3f8e1 | ||
|
|
d9b18ba0ea | ||
|
|
d3d61f5e5a | ||
|
|
ea0860d176 | ||
|
|
49bf3118c9 | ||
|
|
0d90703827 | ||
|
|
150b189be5 | ||
|
|
7affab06f1 | ||
|
|
9afd54b522 | ||
|
|
a4e9761825 | ||
|
|
390df394e9 | ||
|
|
a87478d5b9 | ||
|
|
9eddcbd2a7 | ||
|
|
0673729a4d | ||
|
|
f213ad2453 | ||
|
|
c502fd86d7 | ||
|
|
f9bce4d872 | ||
|
|
a8c255a3a4 | ||
|
|
8263198d1e | ||
|
|
26c3e02d6f | ||
|
|
27bd903e9d | ||
|
|
1da070969e | ||
|
|
58dde62f04 | ||
|
|
1522ff62f5 | ||
|
|
aa990c3204 | ||
|
|
97a9ee022f | ||
|
|
715ca585a8 | ||
|
|
2d6bae2ba1 |
+35
-8
@@ -1,7 +1,9 @@
|
||||
DB_HOST=127.0.0.1
|
||||
# Local Docker (docker compose up -d): 5432
|
||||
# Remote DB via SSH tunnel (-L 5433:127.0.0.1:5432): 5433
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=montime
|
||||
POSTGRES_PASSWORD=change_me
|
||||
POSTGRES_PASSWORD=montime
|
||||
POSTGRES_DB=montime
|
||||
# Dev only: auto-sync schema from entities (do not use true in production)
|
||||
DB_SYNC=true
|
||||
@@ -16,15 +18,40 @@ OTP_REQUEST_MAX_PER_HOUR=5
|
||||
# Dev/test only: returns OTP code in API response instead of sending SMS
|
||||
AUTH_RETURN_OTP=true
|
||||
|
||||
# Comma-separated frontend origins (required in production for browser clients)
|
||||
# CORS_ORIGINS=https://app.example.com,http://localhost:5173
|
||||
# Local folder for business images (served at /uploads/...)
|
||||
UPLOAD_DIR=uploads
|
||||
|
||||
# 0098sms.com — used when AUTH_RETURN_OTP is not true
|
||||
# From panel: account username, password (or web service password), approved sender line (FROM)
|
||||
# Comma-separated frontend origins (required in production for browser clients)
|
||||
# Include both dev ports if calling API directly (without Vite /api proxy)
|
||||
CORS_ORIGINS=https://montime.ir,https://adm.montime.ir,http://localhost:5173,http://localhost:5174
|
||||
|
||||
# SMS — used when AUTH_RETURN_OTP is not true
|
||||
# Provider: trez (default, smspanel.Trez.ir) | 0098 (legacy)
|
||||
SMS_PROVIDER=trez
|
||||
SMS_USERNAME=
|
||||
SMS_PASSWORD=
|
||||
# Alternative name supported for password (same as some older projects)
|
||||
# SMS_API_KEY=
|
||||
# SMS_API_KEY= # alias for SMS_PASSWORD
|
||||
# Trez/Raygan sender line (PhoneNumber), e.g. 90002847
|
||||
SMS_SENDER=
|
||||
# Optional; default is 0098
|
||||
# Trez panel base URL and send path (defaults shown)
|
||||
# SMS_BASE_URL=http://smspanel.Trez.ir
|
||||
# SMS_SEND_PATH=/SendMessageWithCode.ashx
|
||||
# SMS_OTP_TEMPLATE=کد ورود منتایم: {code}
|
||||
# SMS_TIMEOUT_MS=20000
|
||||
# --- legacy 0098 only (SMS_PROVIDER=0098) ---
|
||||
# SMS_TRANSPORT=soap
|
||||
# SMS_DOMAIN=0098
|
||||
|
||||
# Optional: log Server-Timing to PM2 stdout (total;dur=… per request)
|
||||
# LOG_SERVER_TIMING=true
|
||||
|
||||
# Public URLs (payment links in SMS + Zarinpal callback)
|
||||
WEB_APP_URL=https://montime.ir
|
||||
API_PUBLIC_URL=https://api.montime.ir
|
||||
|
||||
# BOOKING_REVIEW_DELAY_MINUTES=5
|
||||
|
||||
# Online payment (Zarinpal). بدون ZARINPAL_MERCHANT_ID پرداخت سوری فعال است.
|
||||
# ZARINPAL_MERCHANT_ID=
|
||||
# ZARINPAL_SANDBOX=true
|
||||
# PAYMENT_MOCK_MODE=false # فقط وقتی مرچنت دارید و میخواهید درگاه واقعی برود
|
||||
|
||||
@@ -21,3 +21,6 @@ npm-debug.log*
|
||||
# Test / coverage
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# User uploads
|
||||
uploads/
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# PostgreSQL for local development (no SSH tunnel).
|
||||
# Start: docker compose up -d
|
||||
# Stop: docker compose down
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '5432:5432'
|
||||
environment:
|
||||
POSTGRES_USER: montime
|
||||
POSTGRES_PASSWORD: montime
|
||||
POSTGRES_DB: montime
|
||||
volumes:
|
||||
- montime_pg_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U montime -d montime']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
montime_pg_data:
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import 'dotenv/config';
|
||||
|
||||
const username = process.env.SMS_USERNAME;
|
||||
const password = process.env.SMS_PASSWORD ?? process.env.SMS_API_KEY;
|
||||
const sender = process.env.SMS_SENDER;
|
||||
const provider = (process.env.SMS_PROVIDER ?? 'trez').toLowerCase();
|
||||
|
||||
if (!username || !password || !sender) {
|
||||
console.error('Set SMS_USERNAME, SMS_PASSWORD (or SMS_API_KEY), and SMS_SENDER in .env');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const targets = process.argv.slice(2).length
|
||||
? process.argv.slice(2)
|
||||
: ['09128081392'];
|
||||
|
||||
const text = 'کد ورود منتایم: 123456';
|
||||
const timeoutMs = Number.parseInt(process.env.SMS_TIMEOUT_MS ?? '20000', 10);
|
||||
|
||||
function parseTrezResponse(body) {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return { ok: true, reason: 'empty' };
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower.includes('error') || lower.includes('fail') || lower.includes('خطا')) {
|
||||
return { ok: false, reason: trimmed };
|
||||
}
|
||||
if (/^-?\d+$/.test(trimmed)) {
|
||||
const n = Number(trimmed);
|
||||
if (n === 1 || n > 1) return { ok: true, reason: trimmed };
|
||||
return { ok: false, reason: `code ${trimmed}` };
|
||||
}
|
||||
return { ok: true, reason: trimmed };
|
||||
}
|
||||
|
||||
async function sendViaTrez(mobile) {
|
||||
const baseUrl = (process.env.SMS_BASE_URL ?? 'http://smspanel.Trez.ir').replace(/\/$/, '');
|
||||
const path = process.env.SMS_SEND_PATH ?? '/SendMessageWithCode.ashx';
|
||||
const url = `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
PhoneNumber: sender,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Mobile: mobile,
|
||||
Message: text,
|
||||
}).toString(),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
const body = await res.text();
|
||||
const parsed = parseTrezResponse(body);
|
||||
console.log(
|
||||
`mobile=${mobile} provider=trez http=${res.status} body=${JSON.stringify(body.trim())} ${parsed.ok ? 'OK' : 'FAILED'} (${parsed.reason})`,
|
||||
);
|
||||
}
|
||||
|
||||
async function sendVia0098(mobile) {
|
||||
const escapeXml = (value) =>
|
||||
String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const soapBody = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soap:Body>
|
||||
<SendSMS xmlns="http://tempuri.org/">
|
||||
<username>${escapeXml(username)}</username>
|
||||
<password>${escapeXml(password)}</password>
|
||||
<text>${escapeXml(text)}</text>
|
||||
<mobileno>${escapeXml(mobile)}</mobileno>
|
||||
<pnlno>${escapeXml(sender)}</pnlno>
|
||||
</SendSMS>
|
||||
</soap:Body>
|
||||
</soap:Envelope>`;
|
||||
|
||||
const res = await fetch('https://webservice.0098sms.com/service.asmx', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/xml; charset=utf-8',
|
||||
SOAPAction: 'http://tempuri.org/SendSMS',
|
||||
},
|
||||
body: soapBody,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
const body = await res.text();
|
||||
const match = body.match(/<SendSMSResult[^>]*>([^<]*)<\/SendSMSResult>/i);
|
||||
const code = match?.[1]?.trim() ?? '?';
|
||||
const n = Number(code);
|
||||
const ok = Number.isFinite(n) ? n >= 0 : false;
|
||||
console.log(
|
||||
`mobile=${mobile} provider=0098 http=${res.status} RESULT=${code} ${ok ? 'OK' : 'FAILED'}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`SMS_PROVIDER=${provider}`);
|
||||
for (const mobile of targets) {
|
||||
if (provider === '0098') {
|
||||
await sendVia0098(mobile);
|
||||
} else {
|
||||
await sendViaTrez(mobile);
|
||||
}
|
||||
}
|
||||
+14
-6
@@ -4,14 +4,18 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { dataSourceOptions } from './config/typeorm.config';
|
||||
import { BusinessCategoriesModule } from './modules/business-categories/business-categories.module';
|
||||
import { BusinessesModule } from './modules/businesses/businesses.module';
|
||||
import { ServiceTemplatesModule } from './modules/service-templates/service-templates.module';
|
||||
import { BusinessServicesModule } from './modules/business-services/business-services.module';
|
||||
import { AttributeDefinitionsModule } from './modules/attribute-definitions/attribute-definitions.module';
|
||||
import { BusinessAttributeValuesModule } from './modules/business-attribute-values/business-attribute-values.module';
|
||||
import { BookingsModule } from './modules/bookings/bookings.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { BookingsModule } from './modules/bookings/bookings.module';
|
||||
import { BusinessAttributeValuesModule } from './modules/business-attribute-values/business-attribute-values.module';
|
||||
import { BusinessCategoriesModule } from './modules/business-categories/business-categories.module';
|
||||
import { BusinessRegistrationRequestsModule } from './modules/business-registration-requests/business-registration-requests.module';
|
||||
import { BusinessServiceClassesModule } from './modules/business-service-classes/business-service-classes.module';
|
||||
import { BusinessServiceRequestsModule } from './modules/business-service-requests/business-service-requests.module';
|
||||
import { BusinessServicesModule } from './modules/business-services/business-services.module';
|
||||
import { BusinessesModule } from './modules/businesses/businesses.module';
|
||||
import { PlatformSettingsModule } from './modules/platform-settings/platform-settings.module';
|
||||
import { ServiceTemplatesModule } from './modules/service-templates/service-templates.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
|
||||
@Module({
|
||||
@@ -25,8 +29,12 @@ import { UsersModule } from './modules/users/users.module';
|
||||
AttributeDefinitionsModule,
|
||||
BusinessAttributeValuesModule,
|
||||
BookingsModule,
|
||||
BusinessRegistrationRequestsModule,
|
||||
BusinessServiceRequestsModule,
|
||||
BusinessServiceClassesModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
PlatformSettingsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Shared catalog of landing hero highlights (platform-wide, not per-category). */
|
||||
|
||||
export const BUSINESS_HIGHLIGHT_KEYS = [
|
||||
'customer_satisfaction',
|
||||
'professional_staff',
|
||||
'online_booking',
|
||||
'fair_pricing',
|
||||
'clean_space',
|
||||
'modern_equipment',
|
||||
'quality_materials',
|
||||
'fast_service',
|
||||
'experienced_team',
|
||||
'easy_access',
|
||||
'secure_payment',
|
||||
'friendly_support',
|
||||
] as const;
|
||||
|
||||
export type BusinessHighlightKey = (typeof BUSINESS_HIGHLIGHT_KEYS)[number];
|
||||
|
||||
export const DEFAULT_BUSINESS_HIGHLIGHT_KEYS: BusinessHighlightKey[] = [
|
||||
'customer_satisfaction',
|
||||
'professional_staff',
|
||||
'online_booking',
|
||||
];
|
||||
|
||||
const KEY_SET = new Set<string>(BUSINESS_HIGHLIGHT_KEYS);
|
||||
|
||||
export function isBusinessHighlightKey(value: string): value is BusinessHighlightKey {
|
||||
return KEY_SET.has(value);
|
||||
}
|
||||
|
||||
/** Keep at most 3 unique valid keys; empty/invalid input → empty array (caller may fall back to defaults). */
|
||||
export function sanitizeBusinessHighlightKeys(
|
||||
keys: string[] | null | undefined,
|
||||
): BusinessHighlightKey[] {
|
||||
if (!keys?.length) return [];
|
||||
const seen = new Set<string>();
|
||||
const result: BusinessHighlightKey[] = [];
|
||||
for (const raw of keys) {
|
||||
const key = String(raw).trim();
|
||||
if (!isBusinessHighlightKey(key) || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(key);
|
||||
if (result.length >= 3) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
registerDecorator,
|
||||
ValidationOptions,
|
||||
ValidationArguments,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
isValidIranMobile,
|
||||
normalizeIranMobile,
|
||||
} from '../utils/iran-mobile.util';
|
||||
|
||||
export function IsIranMobile(validationOptions?: ValidationOptions) {
|
||||
return function (object: object, propertyName: string) {
|
||||
registerDecorator({
|
||||
name: 'isIranMobile',
|
||||
target: object.constructor,
|
||||
propertyName,
|
||||
options: {
|
||||
message: 'mobile must be a valid mobile number',
|
||||
...validationOptions,
|
||||
},
|
||||
validator: {
|
||||
validate(value: unknown, args: ValidationArguments) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeIranMobile(String(value));
|
||||
(args.object as Record<string, unknown>)[propertyName] = normalized;
|
||||
return isValidIranMobile(normalized);
|
||||
},
|
||||
defaultMessage() {
|
||||
return 'mobile must be a valid mobile number';
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ConflictException,
|
||||
ExceptionFilter,
|
||||
HttpStatus,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
|
||||
@Catch(QueryFailedError)
|
||||
export class TypeOrmExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(TypeOrmExceptionFilter.name);
|
||||
|
||||
catch(exception: QueryFailedError, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse();
|
||||
const driver = exception.driverError as { code?: string; detail?: string };
|
||||
|
||||
if (driver?.code === '23503') {
|
||||
response.status(HttpStatus.CONFLICT).json({
|
||||
statusCode: HttpStatus.CONFLICT,
|
||||
message:
|
||||
'Cannot delete: related records still exist. Remove bookings and linked data first.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (driver?.code === '23505') {
|
||||
response.status(HttpStatus.CONFLICT).json({
|
||||
statusCode: HttpStatus.CONFLICT,
|
||||
message: 'A record with the same unique value already exists.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.error(exception.message, exception.stack);
|
||||
const internal = new InternalServerErrorException();
|
||||
response.status(internal.getStatus()).json(internal.getResponse());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
Logger,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
@Injectable()
|
||||
export class ServerTimingInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(ServerTimingInterceptor.name);
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const http = context.switchToHttp();
|
||||
const response = http.getResponse<{
|
||||
setHeader(name: string, value: string): void;
|
||||
getHeader(name: string): string | number | string[] | undefined;
|
||||
}>();
|
||||
const request = http.getRequest<{ method?: string; url?: string }>();
|
||||
const start = performance.now();
|
||||
|
||||
return next.handle().pipe(
|
||||
finalize(() => {
|
||||
const dur = Math.max(0, Math.round(performance.now() - start));
|
||||
const existing = response.getHeader('Server-Timing');
|
||||
const total = `total;dur=${dur}`;
|
||||
response.setHeader(
|
||||
'Server-Timing',
|
||||
existing ? `${String(existing)}, ${total}` : total,
|
||||
);
|
||||
if (process.env.LOG_SERVER_TIMING === 'true') {
|
||||
const route = request.url ?? '';
|
||||
this.logger.log(
|
||||
`${request.method ?? '?'} ${route} total;dur=${dur}`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
/** Iranian week: شنبه=0 … جمعه=6 */
|
||||
const TEHRAN_WEEKDAY: Record<string, number> = {
|
||||
Sat: 0,
|
||||
Sun: 1,
|
||||
Mon: 2,
|
||||
Tue: 3,
|
||||
Wed: 4,
|
||||
Thu: 5,
|
||||
Fri: 6,
|
||||
};
|
||||
|
||||
const TIME_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
||||
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
|
||||
|
||||
function toAsciiDigits(value: string): string {
|
||||
return value.replace(/./g, (ch) => {
|
||||
const pi = PERSIAN_DIGITS.indexOf(ch);
|
||||
if (pi >= 0) return String(pi);
|
||||
const ai = ARABIC_DIGITS.indexOf(ch);
|
||||
if (ai >= 0) return String(ai);
|
||||
return ch;
|
||||
});
|
||||
}
|
||||
|
||||
/** Accepts browser variants (9:00, 09:00:00, Persian digits) → HH:mm */
|
||||
export function normalizeHmTimeInput(value: unknown): string | null {
|
||||
if (value == null || value === '') return null;
|
||||
let raw = toAsciiDigits(String(value).trim()).normalize('NFKC');
|
||||
raw = raw.replace(/[\u200e\u200f\u202a\u202c]/g, '');
|
||||
|
||||
const ampm = /^(\d{1,2}):(\d{1,2})(?::\d{1,2})?\s*([ap])(?:\.)?m?\.?$/i.exec(
|
||||
raw,
|
||||
);
|
||||
if (ampm) {
|
||||
let h = Number.parseInt(ampm[1], 10);
|
||||
const m = Number.parseInt(ampm[2], 10);
|
||||
const isPm = ampm[3].toLowerCase() === 'p';
|
||||
if (h === 12) h = isPm ? 12 : 0;
|
||||
else if (isPm) h += 12;
|
||||
if (h < 0 || h > 23 || m < 0 || m > 59) return null;
|
||||
return minuteToHm(h * 60 + m);
|
||||
}
|
||||
|
||||
const match = /^(\d{1,2}):(\d{1,2})(?::\d{1,2})?$/.exec(raw);
|
||||
if (!match) return null;
|
||||
let h = Number.parseInt(match[1], 10);
|
||||
const m = Number.parseInt(match[2], 10);
|
||||
if (h === 24 && m === 0) {
|
||||
h = 23;
|
||||
return '23:59';
|
||||
}
|
||||
if (h < 0 || h > 23 || m < 0 || m > 59) return null;
|
||||
return minuteToHm(h * 60 + m);
|
||||
}
|
||||
|
||||
export type AvailabilityWindow = {
|
||||
dayOfWeek: number;
|
||||
startMinute: number;
|
||||
endMinute: number;
|
||||
};
|
||||
|
||||
export type BusyInterval = {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
};
|
||||
|
||||
export function parseHmToMinute(value: string): number {
|
||||
const normalized = normalizeHmTimeInput(value);
|
||||
const match = TIME_RE.exec(normalized ?? '');
|
||||
if (!match) {
|
||||
throw new BadRequestException('Time must be HH:mm (24h)');
|
||||
}
|
||||
return Number.parseInt(match[1], 10) * 60 + Number.parseInt(match[2], 10);
|
||||
}
|
||||
|
||||
export function minuteToHm(minute: number): string {
|
||||
const h = Math.floor(minute / 60);
|
||||
const m = minute % 60;
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function parseGregorianYmd(ymd: string): void {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) {
|
||||
throw new BadRequestException('date must be YYYY-MM-DD');
|
||||
}
|
||||
}
|
||||
|
||||
/** Tehran local datetime as UTC Date. */
|
||||
export function tehranDateTime(ymd: string, hour: number, minute: number): Date {
|
||||
parseGregorianYmd(ymd);
|
||||
const hh = String(hour).padStart(2, '0');
|
||||
const mm = String(minute).padStart(2, '0');
|
||||
return new Date(`${ymd}T${hh}:${mm}:00+03:30`);
|
||||
}
|
||||
|
||||
export function tehranYmd(date: Date): string {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).formatToParts(date);
|
||||
const y = parts.find((p) => p.type === 'year')?.value;
|
||||
const m = parts.find((p) => p.type === 'month')?.value;
|
||||
const d = parts.find((p) => p.type === 'day')?.value;
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export function tehranDayOfWeek(date: Date): number {
|
||||
const wd = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
weekday: 'short',
|
||||
}).format(date);
|
||||
const mapped = TEHRAN_WEEKDAY[wd];
|
||||
if (mapped === undefined) {
|
||||
throw new BadRequestException('Could not resolve weekday');
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
export function isSameTehranDay(a: Date, b: Date): boolean {
|
||||
return tehranYmd(a) === tehranYmd(b);
|
||||
}
|
||||
|
||||
export function intervalsOverlap(
|
||||
aStart: number,
|
||||
aEnd: number,
|
||||
bStart: number,
|
||||
bEnd: number,
|
||||
): boolean {
|
||||
return aStart < bEnd && bStart < aEnd;
|
||||
}
|
||||
|
||||
export function resolveServiceDurationMinutes(
|
||||
durationMinutes: number | null | undefined,
|
||||
templateDefault?: number | null,
|
||||
): number {
|
||||
if (durationMinutes != null && durationMinutes > 0) {
|
||||
return durationMinutes;
|
||||
}
|
||||
if (templateDefault != null && templateDefault > 0) {
|
||||
return templateDefault;
|
||||
}
|
||||
return 30;
|
||||
}
|
||||
|
||||
export function generateAvailableSlotStarts(params: {
|
||||
dateYmd: string;
|
||||
durationMinutes: number;
|
||||
leadMinutes: number;
|
||||
slotStepMinutes: number;
|
||||
windows: AvailabilityWindow[];
|
||||
busy: BusyInterval[];
|
||||
concurrentCapacity?: number;
|
||||
now?: Date;
|
||||
}): string[] {
|
||||
const {
|
||||
dateYmd,
|
||||
durationMinutes,
|
||||
leadMinutes,
|
||||
slotStepMinutes,
|
||||
windows,
|
||||
busy,
|
||||
concurrentCapacity = 1,
|
||||
now = new Date(),
|
||||
} = params;
|
||||
|
||||
const capacity = Math.max(1, concurrentCapacity);
|
||||
|
||||
const dayAnchor = tehranDateTime(dateYmd, 12, 0);
|
||||
const dow = tehranDayOfWeek(dayAnchor);
|
||||
const dayWindows = windows.filter((w) => w.dayOfWeek === dow);
|
||||
if (!dayWindows.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const earliestMs = isSameTehranDay(dayAnchor, now)
|
||||
? now.getTime() + leadMinutes * 60 * 1000
|
||||
: tehranDateTime(dateYmd, 0, 0).getTime();
|
||||
|
||||
const slots: string[] = [];
|
||||
const step = Math.max(5, slotStepMinutes);
|
||||
|
||||
for (const window of dayWindows) {
|
||||
for (
|
||||
let startMin = window.startMinute;
|
||||
startMin + durationMinutes <= window.endMinute;
|
||||
startMin += step
|
||||
) {
|
||||
const hour = Math.floor(startMin / 60);
|
||||
const minute = startMin % 60;
|
||||
const startDate = tehranDateTime(dateYmd, hour, minute);
|
||||
const startMs = startDate.getTime();
|
||||
const endMs = startMs + durationMinutes * 60 * 1000;
|
||||
|
||||
if (startMs < earliestMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const windowEndMs = tehranDateTime(
|
||||
dateYmd,
|
||||
Math.floor(window.endMinute / 60),
|
||||
window.endMinute % 60,
|
||||
).getTime();
|
||||
if (endMs > windowEndMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const overlapCount = busy.filter((b) =>
|
||||
intervalsOverlap(startMs, endMs, b.startMs, b.endMs),
|
||||
).length;
|
||||
if (overlapCount >= capacity) {
|
||||
continue;
|
||||
}
|
||||
|
||||
slots.push(startDate.toISOString());
|
||||
}
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { EntityManager } from 'typeorm';
|
||||
import { AttributeDefinitionEntity } from '../../entities/attribute-definition.entity';
|
||||
import { BookingEntity } from '../../entities/booking.entity';
|
||||
import { BusinessAttributeValueEntity } from '../../entities/business-attribute-value.entity';
|
||||
import { BusinessCategoryEntity } from '../../entities/business-category.entity';
|
||||
import { BusinessServiceRequestEntity } from '../../entities/business-service-request.entity';
|
||||
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
||||
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
|
||||
import { BusinessImageEntity } from '../../entities/business-image.entity';
|
||||
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
|
||||
|
||||
/** Remove a business and dependent rows that block FK deletes. */
|
||||
export async function deleteBusinessCascade(
|
||||
manager: EntityManager,
|
||||
businessId: string,
|
||||
): Promise<void> {
|
||||
await manager.delete(BookingEntity, { businessId });
|
||||
await manager.delete(BusinessServiceRequestEntity, { businessId });
|
||||
await manager.delete(BusinessAvailabilitySlotEntity, { businessId });
|
||||
await manager.delete(BusinessServiceEntity, { businessId });
|
||||
await manager.delete(BusinessServiceClassEntity, { businessId });
|
||||
await manager.delete(BusinessAttributeValueEntity, { businessId });
|
||||
await manager.delete(BusinessImageEntity, { businessId });
|
||||
|
||||
const result = await manager.delete(BusinessEntity, businessId);
|
||||
if (!result.affected) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a category after all its businesses and catalog rows are cleared. */
|
||||
export async function deleteCategoryCascade(
|
||||
manager: EntityManager,
|
||||
categoryId: string,
|
||||
businessIds: string[],
|
||||
): Promise<void> {
|
||||
for (const businessId of businessIds) {
|
||||
await deleteBusinessCascade(manager, businessId);
|
||||
}
|
||||
|
||||
await manager.delete(ServiceTemplateEntity, { categoryId });
|
||||
await manager.delete(AttributeDefinitionEntity, { categoryId });
|
||||
|
||||
const result = await manager.delete(BusinessCategoryEntity, categoryId);
|
||||
if (!result.affected) {
|
||||
throw new NotFoundException('Business category not found');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
|
||||
const INVISIBLE_CHARS = /[\u200c\u200d\u202a-\u202e\ufeff]/g;
|
||||
|
||||
export 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;
|
||||
});
|
||||
}
|
||||
|
||||
/** Normalize Iranian mobile to 09xxxxxxxxx (11 digits). */
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function isValidIranMobile(mobile: string): boolean {
|
||||
return IRAN_MOBILE_REGEX.test(mobile);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { randomBytes, scrypt, timingSafeEqual } from 'crypto';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const scryptAsync = promisify(scrypt);
|
||||
const KEY_LENGTH = 64;
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = randomBytes(16).toString('hex');
|
||||
const derived = (await scryptAsync(password, salt, KEY_LENGTH)) as Buffer;
|
||||
return `scrypt:${salt}:${derived.toString('hex')}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
stored: string,
|
||||
): Promise<boolean> {
|
||||
const parts = stored.split(':');
|
||||
if (parts.length !== 3 || parts[0] !== 'scrypt') {
|
||||
return false;
|
||||
}
|
||||
const [, salt, hashHex] = parts;
|
||||
if (!salt || !hashHex) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const derived = (await scryptAsync(password, salt, KEY_LENGTH)) as Buffer;
|
||||
const expected = Buffer.from(hashHex, 'hex');
|
||||
if (derived.length !== expected.length) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(derived, expected);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
|
||||
export function slugifyTitle(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
export function normalizeSlugInput(value?: string | null): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export async function uniqueSlugFromTitle(
|
||||
title: string,
|
||||
exists: (slug: string) => Promise<boolean>,
|
||||
fallbackBase = 'item',
|
||||
): Promise<string> {
|
||||
let base = slugifyTitle(title);
|
||||
if (!base) {
|
||||
base = fallbackBase;
|
||||
}
|
||||
let slug = base;
|
||||
let suffix = 0;
|
||||
while (await exists(slug)) {
|
||||
suffix += 1;
|
||||
slug = `${base}-${suffix}`;
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
export async function resolveUniqueSlug(
|
||||
title: string,
|
||||
exists: (slug: string) => Promise<boolean>,
|
||||
provided?: string | null,
|
||||
fallbackBase = 'item',
|
||||
): Promise<string> {
|
||||
const normalized = normalizeSlugInput(provided);
|
||||
if (normalized) {
|
||||
if (!SLUG_PATTERN.test(normalized)) {
|
||||
throw new Error('Invalid slug format');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
return uniqueSlugFromTitle(title, exists, fallbackBase);
|
||||
}
|
||||
|
||||
/** @deprecated Use uniqueSlugFromTitle */
|
||||
export async function uniqueBusinessSlug(
|
||||
title: string,
|
||||
exists: (slug: string) => Promise<boolean>,
|
||||
): Promise<string> {
|
||||
return uniqueSlugFromTitle(title, exists, 'business');
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { UserEntity } from '../../entities/user.entity';
|
||||
|
||||
export type PublicUser = {
|
||||
id: string;
|
||||
mobile: string;
|
||||
fullName: string | null;
|
||||
role: string;
|
||||
isActive: boolean;
|
||||
hasPassword: boolean;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
};
|
||||
|
||||
export function serializeUser(user: UserEntity): PublicUser {
|
||||
return {
|
||||
id: user.id,
|
||||
mobile: user.mobile,
|
||||
fullName: user.fullName,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
hasPassword: Boolean(user.passwordHash),
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateBusinessImages1789040000000 implements MigrationInterface {
|
||||
name = 'CreateBusinessImages1789040000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "business_images" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"businessId" uuid NOT NULL,
|
||||
"url" character varying(500) NOT NULL,
|
||||
"originalName" character varying(255),
|
||||
"sortOrder" integer NOT NULL DEFAULT 0,
|
||||
"isCover" boolean NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_business_images_id" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_business_images_business"
|
||||
FOREIGN KEY ("businessId") REFERENCES "businesses"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_business_images_businessId"
|
||||
ON "business_images" ("businessId")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'DROP INDEX IF EXISTS "IDX_business_images_businessId"',
|
||||
);
|
||||
await queryRunner.query('DROP TABLE IF EXISTS "business_images"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddUserPasswordHash1789041000000 implements MigrationInterface {
|
||||
name = 'AddUserPasswordHash1789041000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "passwordHash" character varying(255)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "users"
|
||||
DROP COLUMN IF EXISTS "passwordHash"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateBusinessRegistrationRequests1789042000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CreateBusinessRegistrationRequests1789042000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "business_registration_requests" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"applicantId" uuid NOT NULL,
|
||||
"status" character varying(20) NOT NULL DEFAULT 'pending',
|
||||
"title" character varying(150) NOT NULL,
|
||||
"slug" character varying(120) NOT NULL,
|
||||
"categoryId" uuid NOT NULL,
|
||||
"description" character varying(500),
|
||||
"contactPhone" character varying(20),
|
||||
"address" character varying(500),
|
||||
"latitude" double precision,
|
||||
"longitude" double precision,
|
||||
"adminNote" character varying(500),
|
||||
"reviewedById" uuid,
|
||||
"reviewedAt" TIMESTAMP WITH TIME ZONE,
|
||||
"businessId" uuid,
|
||||
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_business_registration_requests" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_brr_applicant" FOREIGN KEY ("applicantId")
|
||||
REFERENCES "users"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_brr_category" FOREIGN KEY ("categoryId")
|
||||
REFERENCES "business_categories"("id") ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_brr_reviewed_by" FOREIGN KEY ("reviewedById")
|
||||
REFERENCES "users"("id") ON DELETE SET NULL,
|
||||
CONSTRAINT "FK_brr_business" FOREIGN KEY ("businessId")
|
||||
REFERENCES "businesses"("id") ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_brr_applicant_status"
|
||||
ON "business_registration_requests" ("applicantId", "status")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "business_registration_requests"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateBusinessAvailabilitySlots1789043000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CreateBusinessAvailabilitySlots1789043000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "business_availability_slots" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"businessId" uuid NOT NULL,
|
||||
"dayOfWeek" smallint NOT NULL,
|
||||
"startMinute" integer NOT NULL,
|
||||
"endMinute" integer NOT NULL,
|
||||
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_business_availability_slots" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_availability_business" FOREIGN KEY ("businessId")
|
||||
REFERENCES "businesses"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_availability_business_day"
|
||||
ON "business_availability_slots" ("businessId", "dayOfWeek")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "business_availability_slots"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateBusinessServiceRequests1789044000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CreateBusinessServiceRequests1789044000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "business_service_requests" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"businessId" uuid NOT NULL,
|
||||
"requestedById" uuid NOT NULL,
|
||||
"status" varchar(20) NOT NULL DEFAULT 'pending',
|
||||
"title" varchar(150) NOT NULL,
|
||||
"description" varchar(500),
|
||||
"price" numeric(12,2) NOT NULL,
|
||||
"durationMinutes" integer,
|
||||
"adminNote" varchar(500),
|
||||
"reviewedById" uuid,
|
||||
"reviewedAt" TIMESTAMPTZ,
|
||||
"serviceTemplateId" uuid,
|
||||
"businessServiceId" uuid,
|
||||
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_business_service_requests" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "FK_bsr_business" FOREIGN KEY ("businessId")
|
||||
REFERENCES "businesses"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_bsr_requested_by" FOREIGN KEY ("requestedById")
|
||||
REFERENCES "users"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_bsr_reviewed_by" FOREIGN KEY ("reviewedById")
|
||||
REFERENCES "users"("id") ON DELETE SET NULL,
|
||||
CONSTRAINT "FK_bsr_template" FOREIGN KEY ("serviceTemplateId")
|
||||
REFERENCES "service_templates"("id") ON DELETE SET NULL,
|
||||
CONSTRAINT "FK_bsr_business_service" FOREIGN KEY ("businessServiceId")
|
||||
REFERENCES "business_services"("id") ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_bsr_business_status"
|
||||
ON "business_service_requests" ("businessId", "status")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "business_service_requests"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class NullableServiceTemplateDefaultDuration1789045000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'NullableServiceTemplateDefaultDuration1789045000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "service_templates"
|
||||
ALTER COLUMN "defaultDurationMinutes" DROP DEFAULT,
|
||||
ALTER COLUMN "defaultDurationMinutes" DROP NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
UPDATE "service_templates"
|
||||
SET "defaultDurationMinutes" = 30
|
||||
WHERE "defaultDurationMinutes" IS NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "service_templates"
|
||||
ALTER COLUMN "defaultDurationMinutes" SET DEFAULT 30,
|
||||
ALTER COLUMN "defaultDurationMinutes" SET NOT NULL
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreateBusinessServiceClasses1789046000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'CreateBusinessServiceClasses1789046000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "business_service_classes" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"businessId" uuid NOT NULL,
|
||||
"title" varchar(120) NOT NULL,
|
||||
"description" varchar(500),
|
||||
"sortOrder" integer NOT NULL DEFAULT 0,
|
||||
"isActive" boolean NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_business_service_classes" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "UQ_business_service_class_title" UNIQUE ("businessId", "title"),
|
||||
CONSTRAINT "FK_service_class_business" FOREIGN KEY ("businessId")
|
||||
REFERENCES "businesses"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "business_service_class_prices" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"businessServiceId" uuid NOT NULL,
|
||||
"classId" uuid NOT NULL,
|
||||
"price" numeric(12,2) NOT NULL,
|
||||
"durationMinutes" integer,
|
||||
"isActive" boolean NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_business_service_class_prices" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "UQ_business_service_class_price" UNIQUE ("businessServiceId", "classId"),
|
||||
CONSTRAINT "FK_class_price_service" FOREIGN KEY ("businessServiceId")
|
||||
REFERENCES "business_services"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_class_price_class" FOREIGN KEY ("classId")
|
||||
REFERENCES "business_service_classes"("id") ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings"
|
||||
ADD COLUMN IF NOT EXISTS "serviceClassId" uuid
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_booking_service_class'
|
||||
) THEN
|
||||
ALTER TABLE "bookings"
|
||||
ADD CONSTRAINT "FK_booking_service_class"
|
||||
FOREIGN KEY ("serviceClassId")
|
||||
REFERENCES "business_service_classes"("id")
|
||||
ON DELETE RESTRICT;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings" DROP CONSTRAINT IF EXISTS "FK_booking_service_class"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings" DROP COLUMN IF EXISTS "serviceClassId"
|
||||
`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "business_service_class_prices"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "business_service_classes"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBusinessServiceClassesEnabled1789046100000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddBusinessServiceClassesEnabled1789046100000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ADD COLUMN IF NOT EXISTS "serviceClassesEnabled" boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses" DROP COLUMN IF EXISTS "serviceClassesEnabled"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBookingGroupId1789046200000 implements MigrationInterface {
|
||||
name = 'AddBookingGroupId1789046200000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings"
|
||||
ADD COLUMN IF NOT EXISTS "bookingGroupId" uuid
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_booking_group"
|
||||
ON "bookings" ("bookingGroupId")
|
||||
WHERE "bookingGroupId" IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_booking_group"`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings" DROP COLUMN IF EXISTS "bookingGroupId"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBusinessConcurrentCapacity1789046300000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddBusinessConcurrentCapacity1789046300000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ADD COLUMN IF NOT EXISTS "concurrentCapacity" integer NOT NULL DEFAULT 1
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses" DROP COLUMN IF EXISTS "concurrentCapacity"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBusinessSlotStepMinutes1789046400000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddBusinessSlotStepMinutes1789046400000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ADD COLUMN IF NOT EXISTS "slotStepMinutes" integer NOT NULL DEFAULT 30
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses" DROP COLUMN IF EXISTS "slotStepMinutes"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class SetBusinessSlotStepDefault301789046500000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'SetBusinessSlotStepDefault301789046500000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ALTER COLUMN "slotStepMinutes" SET DEFAULT 30
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ALTER COLUMN "slotStepMinutes" SET DEFAULT 15
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBusinessSubtitle1789046600000 implements MigrationInterface {
|
||||
name = 'AddBusinessSubtitle1789046600000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ADD COLUMN IF NOT EXISTS "subtitle" character varying(150)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "business_registration_requests"
|
||||
ADD COLUMN IF NOT EXISTS "subtitle" character varying(150)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "business_registration_requests"
|
||||
DROP COLUMN IF EXISTS "subtitle"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
DROP COLUMN IF EXISTS "subtitle"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class CreatePlatformSettings1789046700000 implements MigrationInterface {
|
||||
name = 'CreatePlatformSettings1789046700000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "platform_settings" (
|
||||
"id" smallint NOT NULL DEFAULT 1,
|
||||
"login_hero_image_url" character varying(500),
|
||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_platform_settings" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "CHK_platform_settings_singleton" CHECK ("id" = 1)
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
INSERT INTO "platform_settings" ("id")
|
||||
VALUES (1)
|
||||
ON CONFLICT ("id") DO NOTHING
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "platform_settings"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBookingCancellationReason1789046800000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddBookingCancellationReason1789046800000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings"
|
||||
ADD COLUMN IF NOT EXISTS "cancellationReason" text
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings"
|
||||
DROP COLUMN IF EXISTS "cancellationReason"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBookingPaymentAndAutoApprove1789046900000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddBookingPaymentAndAutoApprove1789046900000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ADD COLUMN IF NOT EXISTS "autoApproveBookings" boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings"
|
||||
ADD COLUMN IF NOT EXISTS "paymentToken" varchar(64)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings"
|
||||
ADD COLUMN IF NOT EXISTS "paymentAmount" numeric(12,0)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings"
|
||||
ADD COLUMN IF NOT EXISTS "paymentAuthority" varchar(64)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE
|
||||
enum_type regtype;
|
||||
BEGIN
|
||||
SELECT a.atttypid::regtype INTO enum_type
|
||||
FROM pg_attribute a
|
||||
JOIN pg_class c ON a.attrelid = c.oid
|
||||
JOIN pg_namespace n ON c.relnamespace = n.oid
|
||||
WHERE c.relname = 'bookings'
|
||||
AND n.nspname = current_schema()
|
||||
AND a.attname = 'status'
|
||||
AND NOT a.attisdropped;
|
||||
|
||||
IF enum_type IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_enum e
|
||||
WHERE e.enumtypid = enum_type::oid
|
||||
AND e.enumlabel = 'awaiting_payment'
|
||||
) THEN
|
||||
EXECUTE format('ALTER TYPE %s ADD VALUE %L', enum_type, 'awaiting_payment');
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings" DROP COLUMN IF EXISTS "paymentAuthority"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings" DROP COLUMN IF EXISTS "paymentAmount"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings" DROP COLUMN IF EXISTS "paymentToken"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses" DROP COLUMN IF EXISTS "autoApproveBookings"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBookingReviews1789047000000 implements MigrationInterface {
|
||||
name = 'AddBookingReviews1789047000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings"
|
||||
ADD COLUMN IF NOT EXISTS "completedAt" TIMESTAMPTZ
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings"
|
||||
ADD COLUMN IF NOT EXISTS "reviewPromptedAt" TIMESTAMPTZ
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "booking_reviews" (
|
||||
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
|
||||
"bookingId" uuid NOT NULL,
|
||||
"customerId" uuid NOT NULL,
|
||||
"businessId" uuid NOT NULL,
|
||||
"rating" integer NOT NULL,
|
||||
"tipAmount" numeric(12,0),
|
||||
"tipPaid" boolean NOT NULL DEFAULT false,
|
||||
"tipAuthority" varchar(64),
|
||||
"createdAt" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "PK_booking_reviews" PRIMARY KEY ("id"),
|
||||
CONSTRAINT "UQ_booking_reviews_booking" UNIQUE ("bookingId"),
|
||||
CONSTRAINT "FK_booking_reviews_booking" FOREIGN KEY ("bookingId")
|
||||
REFERENCES "bookings"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_booking_reviews_customer" FOREIGN KEY ("customerId")
|
||||
REFERENCES "users"("id") ON DELETE RESTRICT,
|
||||
CONSTRAINT "FK_booking_reviews_business" FOREIGN KEY ("businessId")
|
||||
REFERENCES "businesses"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "CHK_booking_reviews_rating" CHECK ("rating" >= 1 AND "rating" <= 5)
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_booking_reviews_business"
|
||||
ON "booking_reviews" ("businessId")
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "booking_reviews"`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings" DROP COLUMN IF EXISTS "reviewPromptedAt"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings" DROP COLUMN IF EXISTS "completedAt"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBusinessTipsEnabled1789047100000 implements MigrationInterface {
|
||||
name = 'AddBusinessTipsEnabled1789047100000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ADD COLUMN IF NOT EXISTS "tipsEnabled" boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses" DROP COLUMN IF EXISTS "tipsEnabled"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddServiceLoyalty1789047200000 implements MigrationInterface {
|
||||
name = 'AddServiceLoyalty1789047200000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "business_services"
|
||||
ADD COLUMN IF NOT EXISTS "loyaltyEnabled" boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "business_services"
|
||||
ADD COLUMN IF NOT EXISTS "loyaltyEveryN" integer
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings"
|
||||
ADD COLUMN IF NOT EXISTS "loyaltyRedeemed" boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "customer_service_loyalty" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"customerId" uuid NOT NULL,
|
||||
"businessServiceId" uuid NOT NULL,
|
||||
"paidCount" integer NOT NULL DEFAULT 0,
|
||||
"reservedBookingId" uuid,
|
||||
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
"updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "UQ_customer_service_loyalty" UNIQUE ("customerId", "businessServiceId"),
|
||||
CONSTRAINT "FK_loyalty_customer"
|
||||
FOREIGN KEY ("customerId") REFERENCES "users"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_loyalty_business_service"
|
||||
FOREIGN KEY ("businessServiceId") REFERENCES "business_services"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "FK_loyalty_reserved_booking"
|
||||
FOREIGN KEY ("reservedBookingId") REFERENCES "bookings"("id") ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "customer_service_loyalty"`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings" DROP COLUMN IF EXISTS "loyaltyRedeemed"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "business_services" DROP COLUMN IF EXISTS "loyaltyEveryN"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "business_services" DROP COLUMN IF EXISTS "loyaltyEnabled"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/** Promo discount on business (custom title + first_booking | always). */
|
||||
export class AddFirstBookingDiscount1789047300000 implements MigrationInterface {
|
||||
name = 'AddFirstBookingDiscount1789047300000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ADD COLUMN IF NOT EXISTS "promoDiscountEnabled" boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ADD COLUMN IF NOT EXISTS "promoDiscountTitle" character varying(150)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ADD COLUMN IF NOT EXISTS "promoDiscountPercent" integer
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ADD COLUMN IF NOT EXISTS "promoDiscountCode" character varying(40)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ADD COLUMN IF NOT EXISTS "promoDiscountMode" character varying(20) NOT NULL DEFAULT 'first_booking'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings"
|
||||
ADD COLUMN IF NOT EXISTS "promoDiscountApplied" boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings"
|
||||
ADD COLUMN IF NOT EXISTS "promoDiscountPercent" integer
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings"
|
||||
ADD COLUMN IF NOT EXISTS "promoDiscountTitle" character varying(150)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "bookings" DROP COLUMN IF EXISTS "promoDiscountTitle"`);
|
||||
await queryRunner.query(`ALTER TABLE "bookings" DROP COLUMN IF EXISTS "promoDiscountPercent"`);
|
||||
await queryRunner.query(`ALTER TABLE "bookings" DROP COLUMN IF EXISTS "promoDiscountApplied"`);
|
||||
await queryRunner.query(`ALTER TABLE "businesses" DROP COLUMN IF EXISTS "promoDiscountMode"`);
|
||||
await queryRunner.query(`ALTER TABLE "businesses" DROP COLUMN IF EXISTS "promoDiscountCode"`);
|
||||
await queryRunner.query(`ALTER TABLE "businesses" DROP COLUMN IF EXISTS "promoDiscountPercent"`);
|
||||
await queryRunner.query(`ALTER TABLE "businesses" DROP COLUMN IF EXISTS "promoDiscountTitle"`);
|
||||
await queryRunner.query(`ALTER TABLE "businesses" DROP COLUMN IF EXISTS "promoDiscountEnabled"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* If an older version of 1789047300000 created firstBooking* columns, move them to promo*.
|
||||
* Safe no-op when promo* already exists and firstBooking* does not.
|
||||
*/
|
||||
export class MigrateFirstBookingDiscountToPromo1789047400000 implements MigrationInterface {
|
||||
name = 'MigrateFirstBookingDiscountToPromo1789047400000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ADD COLUMN IF NOT EXISTS "promoDiscountEnabled" boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ADD COLUMN IF NOT EXISTS "promoDiscountTitle" character varying(150)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ADD COLUMN IF NOT EXISTS "promoDiscountPercent" integer
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ADD COLUMN IF NOT EXISTS "promoDiscountCode" character varying(40)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ADD COLUMN IF NOT EXISTS "promoDiscountMode" character varying(20) NOT NULL DEFAULT 'first_booking'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'businesses' AND column_name = 'firstBookingDiscountEnabled'
|
||||
) THEN
|
||||
UPDATE businesses SET
|
||||
"promoDiscountEnabled" = "firstBookingDiscountEnabled",
|
||||
"promoDiscountPercent" = "firstBookingDiscountPercent",
|
||||
"promoDiscountCode" = "firstBookingDiscountCode",
|
||||
"promoDiscountTitle" = COALESCE("promoDiscountTitle", 'اولین رزرو شما'),
|
||||
"promoDiscountMode" = 'first_booking'
|
||||
WHERE "firstBookingDiscountEnabled" = true
|
||||
OR "firstBookingDiscountPercent" IS NOT NULL;
|
||||
|
||||
ALTER TABLE businesses DROP COLUMN IF EXISTS "firstBookingDiscountEnabled";
|
||||
ALTER TABLE businesses DROP COLUMN IF EXISTS "firstBookingDiscountPercent";
|
||||
ALTER TABLE businesses DROP COLUMN IF EXISTS "firstBookingDiscountCode";
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings"
|
||||
ADD COLUMN IF NOT EXISTS "promoDiscountApplied" boolean NOT NULL DEFAULT false
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings"
|
||||
ADD COLUMN IF NOT EXISTS "promoDiscountPercent" integer
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "bookings"
|
||||
ADD COLUMN IF NOT EXISTS "promoDiscountTitle" character varying(150)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'bookings' AND column_name = 'firstBookingDiscountApplied'
|
||||
) THEN
|
||||
UPDATE bookings SET
|
||||
"promoDiscountApplied" = "firstBookingDiscountApplied",
|
||||
"promoDiscountPercent" = "firstBookingDiscountPercent",
|
||||
"promoDiscountTitle" = COALESCE("promoDiscountTitle", 'اولین رزرو شما')
|
||||
WHERE "firstBookingDiscountApplied" = true;
|
||||
|
||||
ALTER TABLE bookings DROP COLUMN IF EXISTS "firstBookingDiscountApplied";
|
||||
ALTER TABLE bookings DROP COLUMN IF EXISTS "firstBookingDiscountPercent";
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
// irreversible cleanup helper
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddLogoFields1789047500000 implements MigrationInterface {
|
||||
name = 'AddLogoFields1789047500000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "platform_settings"
|
||||
ADD COLUMN IF NOT EXISTS "logo_light_url" character varying(500),
|
||||
ADD COLUMN IF NOT EXISTS "logo_dark_url" character varying(500)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ADD COLUMN IF NOT EXISTS "logo_light_url" character varying(500),
|
||||
ADD COLUMN IF NOT EXISTS "logo_dark_url" character varying(500)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
DROP COLUMN IF EXISTS "logo_dark_url",
|
||||
DROP COLUMN IF EXISTS "logo_light_url"
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "platform_settings"
|
||||
DROP COLUMN IF EXISTS "logo_dark_url",
|
||||
DROP COLUMN IF EXISTS "logo_light_url"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddBusinessHeroHighlightKeys1789047600000 implements MigrationInterface {
|
||||
name = 'AddBusinessHeroHighlightKeys1789047600000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
ADD COLUMN IF NOT EXISTS "hero_highlight_keys" jsonb
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE "businesses"
|
||||
DROP COLUMN IF EXISTS "hero_highlight_keys"
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { BookingEntity } from './booking.entity';
|
||||
import { BusinessEntity } from './business.entity';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
@Entity('booking_reviews')
|
||||
export class BookingReviewEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@ManyToOne(() => BookingEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'bookingId' })
|
||||
booking!: BookingEntity;
|
||||
|
||||
@Column({ type: 'uuid', unique: true })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => UserEntity, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'customerId' })
|
||||
customer!: UserEntity;
|
||||
|
||||
@Column('uuid')
|
||||
customerId!: string;
|
||||
|
||||
@ManyToOne(() => BusinessEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'businessId' })
|
||||
business!: BusinessEntity;
|
||||
|
||||
@Column('uuid')
|
||||
businessId!: string;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
rating!: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 12, scale: 0, nullable: true })
|
||||
tipAmount!: string | null;
|
||||
|
||||
@Column({ default: false })
|
||||
tipPaid!: boolean;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 64 })
|
||||
tipAuthority!: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -9,10 +9,12 @@ import {
|
||||
} from 'typeorm';
|
||||
import { BusinessEntity } from './business.entity';
|
||||
import { BusinessServiceEntity } from './business-service.entity';
|
||||
import { BusinessServiceClassEntity } from './business-service-class.entity';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
export enum BookingStatus {
|
||||
PENDING = 'pending',
|
||||
AWAITING_PAYMENT = 'awaiting_payment',
|
||||
CONFIRMED = 'confirmed',
|
||||
CANCELLED = 'cancelled',
|
||||
COMPLETED = 'completed',
|
||||
@@ -46,6 +48,16 @@ export class BookingEntity {
|
||||
@Column('uuid')
|
||||
businessServiceId!: string;
|
||||
|
||||
@ManyToOne(() => BusinessServiceClassEntity, { nullable: true, onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'serviceClassId' })
|
||||
serviceClass!: BusinessServiceClassEntity | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
serviceClassId!: string | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
bookingGroupId!: string | null;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
scheduledAt!: Date;
|
||||
|
||||
@@ -55,6 +67,38 @@ export class BookingEntity {
|
||||
@Column({ type: 'text', nullable: true })
|
||||
note!: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
cancellationReason!: string | null;
|
||||
|
||||
/** Shared token for paying a booking group (or single booking). */
|
||||
@Column({ type: 'varchar', nullable: true, length: 64 })
|
||||
paymentToken!: string | null;
|
||||
|
||||
@Column({ type: 'decimal', precision: 12, scale: 0, nullable: true })
|
||||
paymentAmount!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 64 })
|
||||
paymentAuthority!: string | null;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
completedAt!: Date | null;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
reviewPromptedAt!: Date | null;
|
||||
|
||||
/** True when this booking used a loyalty free redeem for its service. */
|
||||
@Column({ default: false })
|
||||
loyaltyRedeemed!: boolean;
|
||||
|
||||
@Column({ default: false })
|
||||
promoDiscountApplied!: boolean;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
promoDiscountPercent!: number | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 150 })
|
||||
promoDiscountTitle!: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { BusinessEntity } from './business.entity';
|
||||
|
||||
/** dayOfWeek: 0=شنبه … 6=جمعه (Iranian week) */
|
||||
@Entity('business_availability_slots')
|
||||
export class BusinessAvailabilitySlotEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@ManyToOne(() => BusinessEntity, (business) => business.availabilitySlots, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'businessId' })
|
||||
business!: BusinessEntity;
|
||||
|
||||
@Column('uuid')
|
||||
businessId!: string;
|
||||
|
||||
@Column({ type: 'smallint' })
|
||||
dayOfWeek!: number;
|
||||
|
||||
/** Minutes from midnight (0–1439), inclusive start */
|
||||
@Column({ type: 'int' })
|
||||
startMinute!: number;
|
||||
|
||||
/** Minutes from midnight, exclusive end */
|
||||
@Column({ type: 'int' })
|
||||
endMinute!: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { BusinessEntity } from './business.entity';
|
||||
|
||||
@Entity('business_images')
|
||||
export class BusinessImageEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@ManyToOne(() => BusinessEntity, (business) => business.images, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'businessId' })
|
||||
business!: BusinessEntity;
|
||||
|
||||
@Column('uuid')
|
||||
businessId!: string;
|
||||
|
||||
/** Relative path under uploads root, e.g. businesses/{id}/{file}.jpg */
|
||||
@Column({ length: 500 })
|
||||
url!: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 255 })
|
||||
originalName!: string | null;
|
||||
|
||||
@Column({ default: 0 })
|
||||
sortOrder!: number;
|
||||
|
||||
@Column({ default: false })
|
||||
isCover!: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { BusinessCategoryEntity } from './business-category.entity';
|
||||
import { BusinessEntity } from './business.entity';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
export enum BusinessRegistrationStatus {
|
||||
PENDING = 'pending',
|
||||
APPROVED = 'approved',
|
||||
REJECTED = 'rejected',
|
||||
}
|
||||
|
||||
@Entity('business_registration_requests')
|
||||
export class BusinessRegistrationRequestEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'applicantId' })
|
||||
applicant!: UserEntity;
|
||||
|
||||
@Column('uuid')
|
||||
applicantId!: string;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
default: BusinessRegistrationStatus.PENDING,
|
||||
})
|
||||
status!: BusinessRegistrationStatus;
|
||||
|
||||
@Column({ length: 150 })
|
||||
title!: string;
|
||||
|
||||
@Column({ length: 120 })
|
||||
slug!: string;
|
||||
|
||||
@ManyToOne(() => BusinessCategoryEntity, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'categoryId' })
|
||||
category!: BusinessCategoryEntity;
|
||||
|
||||
@Column('uuid')
|
||||
categoryId!: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 150 })
|
||||
subtitle!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 500 })
|
||||
description!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 20 })
|
||||
contactPhone!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 500 })
|
||||
address!: string | null;
|
||||
|
||||
@Column({ type: 'double precision', nullable: true })
|
||||
latitude!: number | null;
|
||||
|
||||
@Column({ type: 'double precision', nullable: true })
|
||||
longitude!: number | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 500 })
|
||||
adminNote!: string | null;
|
||||
|
||||
@ManyToOne(() => UserEntity, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'reviewedById' })
|
||||
reviewedBy!: UserEntity | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
reviewedById!: string | null;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
reviewedAt!: Date | null;
|
||||
|
||||
@ManyToOne(() => BusinessEntity, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'businessId' })
|
||||
business!: BusinessEntity | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
businessId!: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { BusinessServiceClassEntity } from './business-service-class.entity';
|
||||
import { BusinessServiceEntity } from './business-service.entity';
|
||||
|
||||
@Entity('business_service_class_prices')
|
||||
@Unique('UQ_business_service_class_price', ['businessServiceId', 'classId'])
|
||||
export class BusinessServiceClassPriceEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@ManyToOne(() => BusinessServiceEntity, (service) => service.classPrices, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'businessServiceId' })
|
||||
businessService!: BusinessServiceEntity;
|
||||
|
||||
@Column('uuid')
|
||||
businessServiceId!: string;
|
||||
|
||||
@ManyToOne(() => BusinessServiceClassEntity, (serviceClass) => serviceClass.classPrices, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'classId' })
|
||||
serviceClass!: BusinessServiceClassEntity;
|
||||
|
||||
@Column('uuid')
|
||||
classId!: string;
|
||||
|
||||
@Column({ type: 'numeric', precision: 12, scale: 2 })
|
||||
price!: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
durationMinutes!: number | null;
|
||||
|
||||
@Column({ default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { BusinessEntity } from './business.entity';
|
||||
import { BusinessServiceClassPriceEntity } from './business-service-class-price.entity';
|
||||
|
||||
@Entity('business_service_classes')
|
||||
@Unique('UQ_business_service_class_title', ['businessId', 'title'])
|
||||
export class BusinessServiceClassEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@ManyToOne(() => BusinessEntity, (business) => business.serviceClasses, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'businessId' })
|
||||
business!: BusinessEntity;
|
||||
|
||||
@Column('uuid')
|
||||
businessId!: string;
|
||||
|
||||
@Column({ length: 120 })
|
||||
title!: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 500 })
|
||||
description!: string | null;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
sortOrder!: number;
|
||||
|
||||
@Column({ default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@OneToMany(() => BusinessServiceClassPriceEntity, (price) => price.serviceClass)
|
||||
classPrices!: BusinessServiceClassPriceEntity[];
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { BusinessEntity } from './business.entity';
|
||||
import { BusinessServiceEntity } from './business-service.entity';
|
||||
import { ServiceTemplateEntity } from './service-template.entity';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
export enum BusinessServiceRequestStatus {
|
||||
PENDING = 'pending',
|
||||
APPROVED = 'approved',
|
||||
REJECTED = 'rejected',
|
||||
}
|
||||
|
||||
@Entity('business_service_requests')
|
||||
export class BusinessServiceRequestEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@ManyToOne(() => BusinessEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'businessId' })
|
||||
business!: BusinessEntity;
|
||||
|
||||
@Column('uuid')
|
||||
businessId!: string;
|
||||
|
||||
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'requestedById' })
|
||||
requestedBy!: UserEntity;
|
||||
|
||||
@Column('uuid')
|
||||
requestedById!: string;
|
||||
|
||||
@Column({
|
||||
type: 'varchar',
|
||||
length: 20,
|
||||
default: BusinessServiceRequestStatus.PENDING,
|
||||
})
|
||||
status!: BusinessServiceRequestStatus;
|
||||
|
||||
@Column({ length: 150 })
|
||||
title!: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 500 })
|
||||
description!: string | null;
|
||||
|
||||
@Column({ type: 'numeric', precision: 12, scale: 2 })
|
||||
price!: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
durationMinutes!: number | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 500 })
|
||||
adminNote!: string | null;
|
||||
|
||||
@ManyToOne(() => UserEntity, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'reviewedById' })
|
||||
reviewedBy!: UserEntity | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
reviewedById!: string | null;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
reviewedAt!: Date | null;
|
||||
|
||||
@ManyToOne(() => ServiceTemplateEntity, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'serviceTemplateId' })
|
||||
serviceTemplate!: ServiceTemplateEntity | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
serviceTemplateId!: string | null;
|
||||
|
||||
@ManyToOne(() => BusinessServiceEntity, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'businessServiceId' })
|
||||
businessService!: BusinessServiceEntity | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
businessServiceId!: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { BookingEntity } from './booking.entity';
|
||||
import { BusinessServiceClassPriceEntity } from './business-service-class-price.entity';
|
||||
import { BusinessEntity } from './business.entity';
|
||||
import { ServiceTemplateEntity } from './service-template.entity';
|
||||
|
||||
@@ -46,9 +47,20 @@ export class BusinessServiceEntity {
|
||||
@Column({ default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
/** When true, customers earn a free visit of this service after N paid completions. */
|
||||
@Column({ default: false })
|
||||
loyaltyEnabled!: boolean;
|
||||
|
||||
/** Number of paid completions required before the next visit is free (e.g. 3 or 5). */
|
||||
@Column({ type: 'int', nullable: true })
|
||||
loyaltyEveryN!: number | null;
|
||||
|
||||
@OneToMany(() => BookingEntity, (booking) => booking.businessService)
|
||||
bookings!: BookingEntity[];
|
||||
|
||||
@OneToMany(() => BusinessServiceClassPriceEntity, (classPrice) => classPrice.businessService)
|
||||
classPrices!: BusinessServiceClassPriceEntity[];
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
} from 'typeorm';
|
||||
import { BookingEntity } from './booking.entity';
|
||||
import { BusinessAttributeValueEntity } from './business-attribute-value.entity';
|
||||
import { BusinessAvailabilitySlotEntity } from './business-availability-slot.entity';
|
||||
import { BusinessCategoryEntity } from './business-category.entity';
|
||||
import { BusinessImageEntity } from './business-image.entity';
|
||||
import { BusinessServiceClassEntity } from './business-service-class.entity';
|
||||
import { BusinessServiceEntity } from './business-service.entity';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
@@ -25,6 +28,9 @@ export class BusinessEntity {
|
||||
@Column({ unique: true, length: 120 })
|
||||
slug!: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 150 })
|
||||
subtitle!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 500 })
|
||||
description!: string | null;
|
||||
|
||||
@@ -43,6 +49,52 @@ export class BusinessEntity {
|
||||
@Column({ default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@Column({ default: false })
|
||||
serviceClassesEnabled!: boolean;
|
||||
|
||||
@Column({ type: 'int', default: 1 })
|
||||
concurrentCapacity!: number;
|
||||
|
||||
@Column({ type: 'int', default: 30 })
|
||||
slotStepMinutes!: number;
|
||||
|
||||
/** When true, new bookings skip manual owner approval and go straight to payment. */
|
||||
@Column({ default: false })
|
||||
autoApproveBookings!: boolean;
|
||||
|
||||
/** When true, customers can leave an optional gratuity after rating a completed booking. */
|
||||
@Column({ default: false })
|
||||
tipsEnabled!: boolean;
|
||||
|
||||
/** Owner-defined promotional percentage discount. */
|
||||
@Column({ default: false })
|
||||
promoDiscountEnabled!: boolean;
|
||||
|
||||
/** Banner / receipt title, e.g. "اولین رزرو شما" or "تخفیف ویژه تابستان". */
|
||||
@Column({ type: 'varchar', nullable: true, length: 150 })
|
||||
promoDiscountTitle!: string | null;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
promoDiscountPercent!: number | null;
|
||||
|
||||
/** Display code on landing (auto-applied; not a separate redeem step). */
|
||||
@Column({ type: 'varchar', nullable: true, length: 40 })
|
||||
promoDiscountCode!: string | null;
|
||||
|
||||
/** `first_booking` = only first booking of customer at this business; `always` = every booking while enabled. */
|
||||
@Column({ type: 'varchar', length: 20, default: 'first_booking' })
|
||||
promoDiscountMode!: 'first_booking' | 'always';
|
||||
|
||||
/** Up to 3 keys from the platform highlight catalog for the landing hero. */
|
||||
@Column({ name: 'hero_highlight_keys', type: 'jsonb', nullable: true })
|
||||
heroHighlightKeys!: string[] | null;
|
||||
|
||||
@Column({ name: 'logo_light_url', type: 'varchar', length: 500, nullable: true })
|
||||
logoLightUrl!: string | null;
|
||||
|
||||
@Column({ name: 'logo_dark_url', type: 'varchar', length: 500, nullable: true })
|
||||
logoDarkUrl!: string | null;
|
||||
|
||||
@ManyToOne(() => BusinessCategoryEntity, (category) => category.businesses, {
|
||||
onDelete: 'RESTRICT',
|
||||
})
|
||||
@@ -62,15 +114,28 @@ export class BusinessEntity {
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
ownerId!: string | null;
|
||||
|
||||
@OneToMany(() => BusinessImageEntity, (image) => image.business, {
|
||||
cascade: true,
|
||||
})
|
||||
images!: BusinessImageEntity[];
|
||||
|
||||
@OneToMany(() => BusinessServiceEntity, (service) => service.business)
|
||||
services!: BusinessServiceEntity[];
|
||||
|
||||
@OneToMany(() => BusinessServiceClassEntity, (serviceClass) => serviceClass.business)
|
||||
serviceClasses!: BusinessServiceClassEntity[];
|
||||
|
||||
@OneToMany(() => BusinessAttributeValueEntity, (attr) => attr.business)
|
||||
attributes!: BusinessAttributeValueEntity[];
|
||||
|
||||
@OneToMany(() => BookingEntity, (booking) => booking.business)
|
||||
bookings!: BookingEntity[];
|
||||
|
||||
@OneToMany(() => BusinessAvailabilitySlotEntity, (slot) => slot.business, {
|
||||
cascade: true,
|
||||
})
|
||||
availabilitySlots!: BusinessAvailabilitySlotEntity[];
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { BookingEntity } from './booking.entity';
|
||||
import { BusinessServiceEntity } from './business-service.entity';
|
||||
import { UserEntity } from './user.entity';
|
||||
|
||||
@Entity('customer_service_loyalty')
|
||||
@Unique('UQ_customer_service_loyalty', ['customerId', 'businessServiceId'])
|
||||
export class CustomerServiceLoyaltyEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'customerId' })
|
||||
customer!: UserEntity;
|
||||
|
||||
@Column('uuid')
|
||||
customerId!: string;
|
||||
|
||||
@ManyToOne(() => BusinessServiceEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'businessServiceId' })
|
||||
businessService!: BusinessServiceEntity;
|
||||
|
||||
@Column('uuid')
|
||||
businessServiceId!: string;
|
||||
|
||||
/** Number of paid completed visits toward the next free service. */
|
||||
@Column({ type: 'int', default: 0 })
|
||||
paidCount!: number;
|
||||
|
||||
/** Booking currently holding a reserved free redeem (awaiting payment / confirmed). */
|
||||
@ManyToOne(() => BookingEntity, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'reservedBookingId' })
|
||||
reservedBooking!: BookingEntity | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
reservedBookingId!: string | null;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity('platform_settings')
|
||||
export class PlatformSettingsEntity {
|
||||
@PrimaryColumn({ type: 'smallint', default: 1 })
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'login_hero_image_url', type: 'varchar', length: 500, nullable: true })
|
||||
loginHeroImageUrl!: string | null;
|
||||
|
||||
@Column({ name: 'logo_light_url', type: 'varchar', length: 500, nullable: true })
|
||||
logoLightUrl!: string | null;
|
||||
|
||||
@Column({ name: 'logo_dark_url', type: 'varchar', length: 500, nullable: true })
|
||||
logoDarkUrl!: string | null;
|
||||
|
||||
@Column({
|
||||
name: 'updated_at',
|
||||
type: 'timestamptz',
|
||||
default: () => 'CURRENT_TIMESTAMP',
|
||||
})
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -25,8 +25,8 @@ export class ServiceTemplateEntity {
|
||||
@Column({ type: 'varchar', nullable: true, length: 400 })
|
||||
description!: string | null;
|
||||
|
||||
@Column({ type: 'int', default: 30 })
|
||||
defaultDurationMinutes!: number;
|
||||
@Column({ type: 'int', nullable: true })
|
||||
defaultDurationMinutes!: number | null;
|
||||
|
||||
@Column({ default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@@ -32,6 +32,9 @@ export class UserEntity {
|
||||
@Column({ default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, length: 255, select: false })
|
||||
passwordHash!: string | null;
|
||||
|
||||
@OneToMany(() => BusinessEntity, (business) => business.owner)
|
||||
ownedBusinesses!: BusinessEntity[];
|
||||
|
||||
|
||||
+22
-4
@@ -1,23 +1,39 @@
|
||||
import 'reflect-metadata';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { join } from 'path';
|
||||
import { TypeOrmExceptionFilter } from './common/filters/typeorm-exception.filter';
|
||||
import { ServerTimingInterceptor } from './common/interceptors/server-timing.interceptor';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
const CORS_EXPOSE_HEADERS = ['Server-Timing'];
|
||||
|
||||
function configureCors(app: Awaited<ReturnType<typeof NestFactory.create>>) {
|
||||
const raw = process.env.CORS_ORIGINS?.trim();
|
||||
if (raw) {
|
||||
const origins = raw.split(',').map((o) => o.trim()).filter(Boolean);
|
||||
app.enableCors({ origin: origins, credentials: true });
|
||||
app.enableCors({
|
||||
origin: origins,
|
||||
credentials: true,
|
||||
exposedHeaders: CORS_EXPOSE_HEADERS,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
app.enableCors({ origin: true, credentials: true });
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
exposedHeaders: CORS_EXPOSE_HEADERS,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
const uploadDir = process.env.UPLOAD_DIR ?? 'uploads';
|
||||
app.useStaticAssets(join(process.cwd(), uploadDir), { prefix: '/uploads' });
|
||||
configureCors(app);
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
@@ -26,10 +42,12 @@ async function bootstrap() {
|
||||
whitelist: true,
|
||||
}),
|
||||
);
|
||||
app.useGlobalFilters(new TypeOrmExceptionFilter());
|
||||
app.useGlobalInterceptors(new ServerTimingInterceptor());
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('Montime API')
|
||||
.setDescription('Local services super app API')
|
||||
.setDescription('Montime (منتایم) — Local services super app API')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth(
|
||||
{
|
||||
|
||||
@@ -2,7 +2,9 @@ import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthUser } from './auth.types';
|
||||
import { CurrentUser } from './decorators/current-user.decorator';
|
||||
import { LoginPasswordDto } from './dto/login-password.dto';
|
||||
import { RequestOtpDto } from './dto/request-otp.dto';
|
||||
import { SetPasswordDto } from './dto/set-password.dto';
|
||||
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
|
||||
@@ -20,6 +22,20 @@ export class AuthController {
|
||||
return this.auth.verifyOtp(payload);
|
||||
}
|
||||
|
||||
@Post('login-password')
|
||||
loginWithPassword(@Body() payload: LoginPasswordDto) {
|
||||
return this.auth.loginWithPassword(payload);
|
||||
}
|
||||
|
||||
@Post('set-password')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
setPassword(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() payload: SetPasswordDto,
|
||||
) {
|
||||
return this.auth.setPassword(user.id, payload);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentUser() user: AuthUser) {
|
||||
|
||||
@@ -27,6 +27,6 @@ import { RolesGuard } from './guards/roles.guard';
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, SmsService, JwtAuthGuard, RolesGuard],
|
||||
exports: [JwtAuthGuard, RolesGuard, JwtModule],
|
||||
exports: [JwtAuthGuard, RolesGuard, JwtModule, SmsService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -12,10 +12,21 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { createHash, randomInt } from 'crypto';
|
||||
import { IsNull, MoreThan, Repository } from 'typeorm';
|
||||
import {
|
||||
isValidIranMobile,
|
||||
normalizeIranMobile,
|
||||
} from '../../common/utils/iran-mobile.util';
|
||||
import {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
} from '../../common/utils/password.util';
|
||||
import { serializeUser } from '../../common/utils/user-serialize.util';
|
||||
import { AuthOtpEntity } from '../../entities/auth-otp.entity';
|
||||
import { UserEntity, UserRole } from '../../entities/user.entity';
|
||||
import { JwtPayload } from './auth.types';
|
||||
import { LoginPasswordDto } from './dto/login-password.dto';
|
||||
import { RequestOtpDto } from './dto/request-otp.dto';
|
||||
import { SetPasswordDto } from './dto/set-password.dto';
|
||||
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
||||
import { SmsService } from './sms.service';
|
||||
|
||||
@@ -24,9 +35,9 @@ type RequestOtpResponse = {
|
||||
otpCode?: string;
|
||||
};
|
||||
|
||||
type VerifyOtpResponse = {
|
||||
type AuthSessionResponse = {
|
||||
accessToken: string;
|
||||
user: Pick<UserEntity, 'id' | 'mobile' | 'fullName' | 'role' | 'isActive'>;
|
||||
user: ReturnType<typeof serializeUser>;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -87,13 +98,17 @@ export class AuthService {
|
||||
'SMS is not configured. Set SMS_USERNAME, SMS_PASSWORD (or SMS_API_KEY), and SMS_SENDER.',
|
||||
);
|
||||
}
|
||||
const sent = await this.sms.sendOtpSms(mobile, code);
|
||||
if (!sent) {
|
||||
const result = await this.sms.sendSmsDetailed(
|
||||
mobile,
|
||||
`کد ورود منتایم: ${code}`,
|
||||
);
|
||||
if (!result.ok) {
|
||||
const detail = result.reason ? ` (${result.reason})` : '';
|
||||
this.logger.warn(
|
||||
`OTP SMS failed for ${this.maskMobile(mobile)}`,
|
||||
`OTP SMS failed for ${this.maskMobile(mobile)} transport=${result.transport} reason=${result.reason}`,
|
||||
);
|
||||
throw new ServiceUnavailableException(
|
||||
'Could not send SMS. Please try again later.',
|
||||
`Could not send SMS. Please try again later.${detail}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -109,11 +124,11 @@ export class AuthService {
|
||||
return response;
|
||||
}
|
||||
|
||||
async verifyOtp(dto: VerifyOtpDto): Promise<VerifyOtpResponse> {
|
||||
async verifyOtp(dto: VerifyOtpDto): Promise<AuthSessionResponse> {
|
||||
const mobile = this.normalizeMobile(dto.mobile);
|
||||
const code = String(dto.code ?? '').trim();
|
||||
|
||||
if (!/^\d{4,8}$/.test(code)) {
|
||||
if (!/^\d{4}$/.test(code)) {
|
||||
throw new BadRequestException('Invalid OTP code');
|
||||
}
|
||||
|
||||
@@ -144,24 +159,84 @@ export class AuthService {
|
||||
await this.otps.save(otp);
|
||||
|
||||
const user = await this.findOrCreateCustomer(mobile);
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
mobile: user.mobile,
|
||||
role: user.role,
|
||||
};
|
||||
|
||||
return {
|
||||
accessToken: await this.jwt.signAsync(payload),
|
||||
user: this.serializeUser(user),
|
||||
};
|
||||
return this.issueSession(user);
|
||||
}
|
||||
|
||||
async loginWithPassword(dto: LoginPasswordDto): Promise<AuthSessionResponse> {
|
||||
const mobile = this.normalizeMobile(dto.mobile);
|
||||
const user = await this.users
|
||||
.createQueryBuilder('user')
|
||||
.addSelect('user.passwordHash')
|
||||
.where('user.mobile = :mobile', { mobile })
|
||||
.getOne();
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('Invalid mobile or password');
|
||||
}
|
||||
if (!user.passwordHash) {
|
||||
throw new UnauthorizedException(
|
||||
'Password is not set. Sign in with OTP first, then set a password in your profile.',
|
||||
);
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(dto.password, user.passwordHash);
|
||||
if (!valid) {
|
||||
throw new UnauthorizedException('Invalid mobile or password');
|
||||
}
|
||||
|
||||
return this.issueSession(user);
|
||||
}
|
||||
|
||||
async setPassword(userId: string, dto: SetPasswordDto) {
|
||||
if (dto.password !== dto.confirmPassword) {
|
||||
throw new BadRequestException('Password confirmation does not match');
|
||||
}
|
||||
|
||||
const user = await this.users
|
||||
.createQueryBuilder('user')
|
||||
.addSelect('user.passwordHash')
|
||||
.where('user.id = :id', { id: userId })
|
||||
.getOne();
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
user.passwordHash = await hashPassword(dto.password);
|
||||
const saved = await this.users.save(user);
|
||||
return serializeUser(saved);
|
||||
}
|
||||
|
||||
async me(userId: string) {
|
||||
const user = await this.users.findOne({ where: { id: userId } });
|
||||
const user = await this.users
|
||||
.createQueryBuilder('user')
|
||||
.addSelect('user.passwordHash')
|
||||
.where('user.id = :id', { id: userId })
|
||||
.getOne();
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
return this.serializeUser(user);
|
||||
return serializeUser(user);
|
||||
}
|
||||
|
||||
private async issueSession(user: UserEntity): Promise<AuthSessionResponse> {
|
||||
const full = await this.users
|
||||
.createQueryBuilder('user')
|
||||
.addSelect('user.passwordHash')
|
||||
.where('user.id = :id', { id: user.id })
|
||||
.getOne();
|
||||
const profile = full ?? user;
|
||||
|
||||
const payload: JwtPayload = {
|
||||
sub: profile.id,
|
||||
mobile: profile.mobile,
|
||||
role: profile.role,
|
||||
};
|
||||
return {
|
||||
accessToken: await this.jwt.signAsync(payload),
|
||||
user: serializeUser(profile),
|
||||
};
|
||||
}
|
||||
|
||||
private async findOrCreateCustomer(mobile: string): Promise<UserEntity> {
|
||||
@@ -189,26 +264,16 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
private serializeUser(user: UserEntity) {
|
||||
return {
|
||||
id: user.id,
|
||||
mobile: user.mobile,
|
||||
fullName: user.fullName,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeMobile(mobile: string): string {
|
||||
const normalized = String(mobile ?? '').trim();
|
||||
if (!/^(\+?\d{10,15}|09\d{9})$/.test(normalized)) {
|
||||
const normalized = normalizeIranMobile(mobile);
|
||||
if (!isValidIranMobile(normalized)) {
|
||||
throw new BadRequestException('Invalid mobile number');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private generateOtpCode(): string {
|
||||
return String(randomInt(100000, 1000000));
|
||||
return String(randomInt(1000, 10000));
|
||||
}
|
||||
|
||||
private hashOtp(mobile: string, code: string): string {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import { IsIranMobile } from '../../../common/decorators/is-iran-mobile.decorator';
|
||||
|
||||
export class LoginPasswordDto {
|
||||
@ApiProperty({ example: '09122222222' })
|
||||
@IsIranMobile()
|
||||
mobile!: string;
|
||||
|
||||
@ApiProperty({ example: 'mySecurePass123' })
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(72)
|
||||
password!: string;
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Matches } from 'class-validator';
|
||||
import { IsString } from 'class-validator';
|
||||
import { IsIranMobile } from '../../../common/decorators/is-iran-mobile.decorator';
|
||||
|
||||
export class RequestOtpDto {
|
||||
@ApiProperty({ example: '09122222222' })
|
||||
@IsString()
|
||||
@Matches(/^(\+?\d{10,15}|09\d{9})$/, {
|
||||
message: 'mobile must be a valid mobile number',
|
||||
})
|
||||
@IsIranMobile()
|
||||
mobile!: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class SetPasswordDto {
|
||||
@ApiProperty({ example: 'mySecurePass123', minLength: 8 })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(72)
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({ example: 'mySecurePass123' })
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
@MaxLength(72)
|
||||
confirmPassword!: string;
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Matches } from 'class-validator';
|
||||
import { IsIranMobile } from '../../../common/decorators/is-iran-mobile.decorator';
|
||||
|
||||
export class VerifyOtpDto {
|
||||
@ApiProperty({ example: '09122222222' })
|
||||
@IsString()
|
||||
@Matches(/^(\+?\d{10,15}|09\d{9})$/, {
|
||||
message: 'mobile must be a valid mobile number',
|
||||
})
|
||||
@IsIranMobile()
|
||||
mobile!: string;
|
||||
|
||||
@ApiProperty({ example: '123456' })
|
||||
@ApiProperty({ example: '1234' })
|
||||
@IsString()
|
||||
@Matches(/^\d{4,8}$/, {
|
||||
message: 'code must be 4 to 8 digits',
|
||||
@Matches(/^\d{4}$/, {
|
||||
message: 'code must be 4 digits',
|
||||
})
|
||||
code!: string;
|
||||
}
|
||||
|
||||
+309
-33
@@ -1,9 +1,25 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
/** HTTP endpoint documented by 0098sms.com for sending via query string. */
|
||||
const SMS_0098_SEND_URL =
|
||||
'http://www.0098sms.com/sendsmslink.aspx';
|
||||
/** Legacy 0098 GET API — kept when SMS_PROVIDER=0098. */
|
||||
const SMS_0098_LINK_URL = 'http://www.0098sms.com/sendsmslink.aspx';
|
||||
|
||||
/** Legacy 0098 SOAP API — kept when SMS_PROVIDER=0098. */
|
||||
const SMS_0098_SOAP_URL = 'https://webservice.0098sms.com/service.asmx';
|
||||
|
||||
const SMS_0098_SOAP_ERROR_CODES: Record<string, string> = {
|
||||
'-22': 'invalid mobile number format',
|
||||
};
|
||||
|
||||
/** Trez / Raygan panel (default). */
|
||||
const SMS_TREZ_DEFAULT_BASE_URL = 'http://smspanel.Trez.ir';
|
||||
const SMS_TREZ_DEFAULT_SEND_PATH = '/SendMessageWithCode.ashx';
|
||||
|
||||
export type SmsSendResult = {
|
||||
ok: boolean;
|
||||
transport: 'trez' | 'soap' | 'link';
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SmsService {
|
||||
@@ -11,7 +27,6 @@ export class SmsService {
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
/** True when username, password, and sender line are all set. */
|
||||
isConfigured(): boolean {
|
||||
const username = this.config.get<string>('SMS_USERNAME');
|
||||
const password = this.getPassword();
|
||||
@@ -19,9 +34,6 @@ export class SmsService {
|
||||
return Boolean(username && password && sender);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats mobile for 0098 `TO` (Iranian mobiles as 09xxxxxxxxx).
|
||||
*/
|
||||
normalizePhoneForSms(mobile: string): string {
|
||||
const digits = String(mobile ?? '').replace(/\D/g, '');
|
||||
if (digits.startsWith('98') && digits.length === 12) {
|
||||
@@ -37,60 +49,305 @@ export class SmsService {
|
||||
}
|
||||
|
||||
async sendOtpSms(mobile: string, code: string): Promise<boolean> {
|
||||
const text = `کد ورود شما: ${code}`;
|
||||
return this.sendSms(mobile, text);
|
||||
const template =
|
||||
this.config.get<string>('SMS_OTP_TEMPLATE') ??
|
||||
'کد ورود منتایم: {code}';
|
||||
const text = template.replace('{code}', code);
|
||||
const result = await this.sendSmsDetailed(mobile, text);
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
async sendSms(phone: string, text: string): Promise<boolean> {
|
||||
const result = await this.sendSmsDetailed(phone, text);
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
async sendSmsDetailed(phone: string, text: string): Promise<SmsSendResult> {
|
||||
const username = this.config.get<string>('SMS_USERNAME');
|
||||
const password = this.getPassword();
|
||||
const sender = this.config.get<string>('SMS_SENDER');
|
||||
const domain = this.config.get<string>('SMS_DOMAIN') ?? '0098';
|
||||
|
||||
if (!username || !password || !sender) {
|
||||
return false;
|
||||
return { ok: false, transport: 'trez', reason: 'SMS not configured' };
|
||||
}
|
||||
|
||||
const to = this.normalizePhoneForSms(phone);
|
||||
const provider = this.resolveProvider();
|
||||
|
||||
if (provider === 'trez') {
|
||||
return this.sendViaTrez(username, password, sender, to, text);
|
||||
}
|
||||
|
||||
const transport = this.resolve0098Transport();
|
||||
if (transport === 'link') {
|
||||
return this.sendVia0098Link(username, password, sender, to, text);
|
||||
}
|
||||
|
||||
const soapResult = await this.sendVia0098Soap(
|
||||
username,
|
||||
password,
|
||||
sender,
|
||||
to,
|
||||
text,
|
||||
);
|
||||
if (soapResult.ok) {
|
||||
return soapResult;
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`0098 SOAP failed to=${this.maskPhone(to)} reason=${soapResult.reason}; trying link fallback`,
|
||||
);
|
||||
return this.sendVia0098Link(username, password, sender, to, text);
|
||||
}
|
||||
|
||||
private resolveProvider(): 'trez' | '0098' {
|
||||
const raw = (this.config.get<string>('SMS_PROVIDER') ?? 'trez').toLowerCase();
|
||||
return raw === '0098' ? '0098' : 'trez';
|
||||
}
|
||||
|
||||
private resolve0098Transport(): 'soap' | 'link' {
|
||||
const raw = (this.config.get<string>('SMS_TRANSPORT') ?? 'soap').toLowerCase();
|
||||
return raw === 'link' ? 'link' : 'soap';
|
||||
}
|
||||
|
||||
private async sendViaTrez(
|
||||
username: string,
|
||||
password: string,
|
||||
sender: string,
|
||||
to: string,
|
||||
text: string,
|
||||
): Promise<SmsSendResult> {
|
||||
const baseUrl = (
|
||||
this.config.get<string>('SMS_BASE_URL') ?? SMS_TREZ_DEFAULT_BASE_URL
|
||||
).replace(/\/$/, '');
|
||||
const path =
|
||||
this.config.get<string>('SMS_SEND_PATH') ?? SMS_TREZ_DEFAULT_SEND_PATH;
|
||||
const url = `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
const timeoutMs = this.readTimeoutMs();
|
||||
|
||||
const body = new URLSearchParams({
|
||||
PhoneNumber: sender,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Mobile: to,
|
||||
Message: text,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: body.toString(),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
const responseBody = (await res.text()).trim();
|
||||
if (!res.ok) {
|
||||
this.logger.warn(
|
||||
`Trez HTTP ${res.status} to=${this.maskPhone(to)} body=${responseBody.slice(0, 200)}`,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
transport: 'trez',
|
||||
reason: `HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = this.parseTrezResponse(responseBody);
|
||||
if (!parsed.ok) {
|
||||
this.logger.warn(
|
||||
`Trez rejected to=${this.maskPhone(to)} reason=${parsed.reason} body=${responseBody.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
return { ...parsed, transport: 'trez' };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
this.logger.error(
|
||||
`Trez request failed to=${this.maskPhone(to)}: ${message}`,
|
||||
e instanceof Error ? e.stack : undefined,
|
||||
);
|
||||
return { ok: false, transport: 'trez', reason: 'Trez request failed' };
|
||||
}
|
||||
}
|
||||
|
||||
private parseTrezResponse(body: string): { ok: boolean; reason: string } {
|
||||
if (!body) {
|
||||
return { ok: true, reason: 'accepted (empty response)' };
|
||||
}
|
||||
|
||||
const trimmed = body.trim();
|
||||
const lower = trimmed.toLowerCase();
|
||||
|
||||
if (
|
||||
lower.includes('error') ||
|
||||
lower.includes('fail') ||
|
||||
lower.includes('invalid') ||
|
||||
lower.includes('خطا')
|
||||
) {
|
||||
return { ok: false, reason: trimmed.slice(0, 120) };
|
||||
}
|
||||
|
||||
if (/^-?\d+$/.test(trimmed)) {
|
||||
const n = Number(trimmed);
|
||||
if (n === 1 || n > 1) {
|
||||
return { ok: true, reason: 'accepted' };
|
||||
}
|
||||
if (n === 0) {
|
||||
return { ok: false, reason: 'provider code 0' };
|
||||
}
|
||||
if (n < 0) {
|
||||
return { ok: false, reason: `provider code ${trimmed}` };
|
||||
}
|
||||
}
|
||||
|
||||
if (lower === 'true' || lower === 'ok' || lower === 'success') {
|
||||
return { ok: true, reason: 'accepted' };
|
||||
}
|
||||
|
||||
if (lower === 'false') {
|
||||
return { ok: false, reason: 'provider returned false' };
|
||||
}
|
||||
|
||||
// Non-numeric IDs (message ref) are treated as success.
|
||||
return { ok: true, reason: 'accepted' };
|
||||
}
|
||||
|
||||
private async sendVia0098Soap(
|
||||
username: string,
|
||||
password: string,
|
||||
sender: string,
|
||||
to: string,
|
||||
text: string,
|
||||
): Promise<SmsSendResult> {
|
||||
const soapBody = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<soap:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soap:Body>
|
||||
<SendSMS xmlns="http://tempuri.org/">
|
||||
<username>${this.escapeXml(username)}</username>
|
||||
<password>${this.escapeXml(password)}</password>
|
||||
<text>${this.escapeXml(text)}</text>
|
||||
<mobileno>${this.escapeXml(to)}</mobileno>
|
||||
<pnlno>${this.escapeXml(sender)}</pnlno>
|
||||
</SendSMS>
|
||||
</soap:Body>
|
||||
</soap:Envelope>`;
|
||||
|
||||
const timeoutMs = this.readTimeoutMs();
|
||||
|
||||
try {
|
||||
const res = await fetch(SMS_0098_SOAP_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/xml; charset=utf-8',
|
||||
SOAPAction: 'http://tempuri.org/SendSMS',
|
||||
},
|
||||
body: soapBody,
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
|
||||
const body = (await res.text()).trim();
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
transport: 'soap',
|
||||
reason: `HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const code = this.parseSoapResultCode(body);
|
||||
if (this.is0098SoapSuccess(code)) {
|
||||
return { ok: true, transport: 'soap' };
|
||||
}
|
||||
|
||||
const reason =
|
||||
SMS_0098_SOAP_ERROR_CODES[code ?? ''] ??
|
||||
(code ? `provider code ${code}` : 'unknown SOAP response');
|
||||
this.logger.warn(
|
||||
`0098 SOAP rejected to=${this.maskPhone(to)} code=${code ?? '?'} body=${body.slice(0, 200)}`,
|
||||
);
|
||||
return { ok: false, transport: 'soap', reason };
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
this.logger.error(
|
||||
`0098 SOAP request failed to=${this.maskPhone(to)}: ${message}`,
|
||||
e instanceof Error ? e.stack : undefined,
|
||||
);
|
||||
return { ok: false, transport: 'soap', reason: 'SOAP request failed' };
|
||||
}
|
||||
}
|
||||
|
||||
private async sendVia0098Link(
|
||||
username: string,
|
||||
password: string,
|
||||
sender: string,
|
||||
to: string,
|
||||
text: string,
|
||||
): Promise<SmsSendResult> {
|
||||
const domain = this.config.get<string>('SMS_DOMAIN') ?? '0098';
|
||||
const params = new URLSearchParams({
|
||||
FROM: sender,
|
||||
TO: this.normalizePhoneForSms(phone),
|
||||
TO: to,
|
||||
TEXT: text,
|
||||
USERNAME: username,
|
||||
PASSWORD: password,
|
||||
DOMAIN: domain,
|
||||
});
|
||||
|
||||
const url = `${SMS_0098_SEND_URL}?${params.toString()}`;
|
||||
|
||||
const to = this.normalizePhoneForSms(phone);
|
||||
const url = `${SMS_0098_LINK_URL}?${params.toString()}`;
|
||||
const timeoutMs = this.readTimeoutMs();
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
const body = (await res.text()).trim();
|
||||
if (!res.ok) {
|
||||
this.logger.warn(
|
||||
`SMS HTTP ${res.status} to=${this.maskPhone(to)} body=${body.slice(0, 300)}`,
|
||||
);
|
||||
return false;
|
||||
return {
|
||||
ok: false,
|
||||
transport: 'link',
|
||||
reason: `HTTP ${res.status}`,
|
||||
};
|
||||
}
|
||||
if (!this.isLikelySuccessBody(body)) {
|
||||
this.logger.warn(
|
||||
`SMS provider rejected to=${this.maskPhone(to)} body=${body.slice(0, 300)}`,
|
||||
);
|
||||
return false;
|
||||
if (!this.isLikelySuccessLinkBody(body)) {
|
||||
return {
|
||||
ok: false,
|
||||
transport: 'link',
|
||||
reason: body.slice(0, 120) || 'link API rejected',
|
||||
};
|
||||
}
|
||||
return true;
|
||||
return { ok: true, transport: 'link' };
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`SMS request failed to=${this.maskPhone(to)}`,
|
||||
e instanceof Error ? e.stack : e,
|
||||
);
|
||||
return false;
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
this.logger.error(`0098 link SMS failed to=${this.maskPhone(to)}: ${message}`);
|
||||
return { ok: false, transport: 'link', reason: 'link request failed' };
|
||||
}
|
||||
}
|
||||
|
||||
/** 0098 often returns plain text; treat obvious error tokens as failure. */
|
||||
private isLikelySuccessBody(body: string): boolean {
|
||||
private is0098SoapSuccess(code: string | null): boolean {
|
||||
if (code == null || code === '') {
|
||||
return false;
|
||||
}
|
||||
const n = Number(code);
|
||||
if (Number.isFinite(n)) {
|
||||
return n >= 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private parseSoapResultCode(xml: string): string | null {
|
||||
const match =
|
||||
xml.match(/<SendSMSResult[^>]*>([^<]*)<\/SendSMSResult>/i) ??
|
||||
xml.match(/<SendSMSResult>([^<]*)<\/SendSMSResult>/i);
|
||||
if (match?.[1] != null) {
|
||||
return match[1].trim();
|
||||
}
|
||||
if (xml.includes('>0<')) {
|
||||
return '0';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private isLikelySuccessLinkBody(body: string): boolean {
|
||||
if (!body) {
|
||||
return true;
|
||||
}
|
||||
@@ -102,9 +359,28 @@ export class SmsService {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const code = body.trim();
|
||||
if (/^-?\d+$/.test(code)) {
|
||||
return Number(code) >= 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private escapeXml(value: string): string {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
private readTimeoutMs(): number {
|
||||
const raw = this.config.get<string>('SMS_TIMEOUT_MS');
|
||||
const n = raw ? Number.parseInt(raw, 10) : 20_000;
|
||||
return Number.isFinite(n) && n > 0 ? n : 20_000;
|
||||
}
|
||||
|
||||
private maskPhone(phone: string): string {
|
||||
if (phone.length <= 6) {
|
||||
return '***';
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Redirect,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiQuery } from '@nestjs/swagger';
|
||||
@@ -19,25 +20,113 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { CreateBookingDto } from './dto/create-booking.dto';
|
||||
import { CreateBookingBatchDto } from './dto/create-booking-batch.dto';
|
||||
import { CancelBookingDto } from './dto/cancel-booking.dto';
|
||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||
import { SubmitBookingReviewDto } from './dto/submit-booking-review.dto';
|
||||
import { InitiateBookingTipDto } from './dto/initiate-booking-tip.dto';
|
||||
|
||||
@Controller('bookings')
|
||||
export class BookingsController {
|
||||
constructor(private readonly service: BookingsService) {}
|
||||
|
||||
@Get('available-slots')
|
||||
getAvailableSlots(
|
||||
@Query('businessId') businessId: string,
|
||||
@Query('date') date: string,
|
||||
@Query('businessServiceId') businessServiceId?: string,
|
||||
@Query('businessServiceIds') businessServiceIds?: string,
|
||||
@Query('serviceClassId') serviceClassId?: string,
|
||||
) {
|
||||
const ids = businessServiceIds
|
||||
? businessServiceIds.split(',').map((id) => id.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
return this.service.getAvailableSlots(businessId, date, {
|
||||
businessServiceId,
|
||||
businessServiceIds: ids,
|
||||
serviceClassId,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('payment/callback')
|
||||
@Redirect()
|
||||
async paymentCallback(
|
||||
@Query('Authority') authority: string,
|
||||
@Query('Status') status: string,
|
||||
): Promise<{ url: string; statusCode: number }> {
|
||||
const { redirectUrl } = await this.service.handlePaymentCallback({
|
||||
authority,
|
||||
status,
|
||||
});
|
||||
return { url: redirectUrl, statusCode: 302 };
|
||||
}
|
||||
|
||||
@Get('payment/by-token/:token')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER)
|
||||
findByPaymentToken(
|
||||
@Param('token') token: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.findByPaymentToken(token, user);
|
||||
}
|
||||
|
||||
@Post('payment/by-token/:token/init')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER)
|
||||
initiatePaymentByToken(
|
||||
@Param('token') token: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.initiatePaymentByToken(token, user);
|
||||
}
|
||||
|
||||
@Get('pending-reviews')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CUSTOMER)
|
||||
getPendingReviews(@CurrentUser() user: AuthUser) {
|
||||
return this.service.getPendingReviews(user);
|
||||
}
|
||||
|
||||
@Get('loyalty/:businessServiceId')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CUSTOMER)
|
||||
getLoyaltyStatus(
|
||||
@Param('businessServiceId') businessServiceId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.getLoyaltyStatus(businessServiceId, user);
|
||||
}
|
||||
|
||||
@Get('tip/callback')
|
||||
@Redirect()
|
||||
async tipCallback(
|
||||
@Query('Authority') authority: string,
|
||||
@Query('Status') status: string,
|
||||
): Promise<{ url: string; statusCode: number }> {
|
||||
const { redirectUrl } = await this.service.handleTipCallback({
|
||||
authority,
|
||||
status,
|
||||
});
|
||||
return { url: redirectUrl, statusCode: 302 };
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER)
|
||||
@ApiQuery({ name: 'customerId', required: false })
|
||||
@ApiQuery({ name: 'businessId', required: false })
|
||||
@ApiQuery({ name: 'status', required: false, enum: BookingStatus })
|
||||
@ApiQuery({ name: 'scope', required: false, enum: ['mine'] })
|
||||
findAll(
|
||||
@Query('customerId') customerId?: string,
|
||||
@Query('businessId') businessId?: string,
|
||||
@Query('status') status?: BookingStatus,
|
||||
@Query('scope') scope?: 'mine',
|
||||
@CurrentUser() user?: AuthUser,
|
||||
): Promise<BookingEntity[]> {
|
||||
return this.service.findAll({ customerId, businessId, status }, user);
|
||||
return this.service.findAll({ customerId, businessId, status, scope }, user);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@@ -52,7 +141,7 @@ export class BookingsController {
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CUSTOMER)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER)
|
||||
create(
|
||||
@Body() payload: CreateBookingDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
@@ -60,6 +149,16 @@ export class BookingsController {
|
||||
return this.service.create(payload, user);
|
||||
}
|
||||
|
||||
@Post('batch')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER)
|
||||
createBatch(
|
||||
@Body() payload: CreateBookingBatchDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<BookingEntity[]> {
|
||||
return this.service.createBatch(payload, user);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
||||
@@ -81,6 +180,13 @@ export class BookingsController {
|
||||
return this.service.confirm(id, user);
|
||||
}
|
||||
|
||||
@Post(':id/payment/init')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER)
|
||||
initiatePayment(@Param('id') id: string, @CurrentUser() user: AuthUser) {
|
||||
return this.service.initiatePayment(id, user);
|
||||
}
|
||||
|
||||
@Post(':id/complete')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
||||
@@ -91,14 +197,37 @@ export class BookingsController {
|
||||
return this.service.complete(id, user);
|
||||
}
|
||||
|
||||
@Post(':id/review')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CUSTOMER)
|
||||
submitReview(
|
||||
@Param('id') id: string,
|
||||
@Body() payload: SubmitBookingReviewDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.submitReview(id, payload.rating, user);
|
||||
}
|
||||
|
||||
@Post(':id/tip/init')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.CUSTOMER)
|
||||
initiateTip(
|
||||
@Param('id') id: string,
|
||||
@Body() payload: InitiateBookingTipDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.initiateTip(id, payload.amountToman, user);
|
||||
}
|
||||
|
||||
@Post(':id/cancel')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER)
|
||||
cancel(
|
||||
@Param('id') id: string,
|
||||
@Body() payload: CancelBookingDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<BookingEntity> {
|
||||
return this.service.cancel(id, user);
|
||||
return this.service.cancel(id, user, payload);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BookingEntity } from '../../entities/booking.entity';
|
||||
import { BookingReviewEntity } from '../../entities/booking-review.entity';
|
||||
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
|
||||
import { BusinessServiceClassPriceEntity } from '../../entities/business-service-class-price.entity';
|
||||
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
|
||||
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { CustomerServiceLoyaltyEntity } from '../../entities/customer-service-loyalty.entity';
|
||||
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
|
||||
import { UserEntity } from '../../entities/user.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PaymentsModule } from '../payments/payments.module';
|
||||
import { BookingsController } from './bookings.controller';
|
||||
import { BookingsService } from './bookings.service';
|
||||
|
||||
@@ -12,13 +19,21 @@ import { BookingsService } from './bookings.service';
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
BookingEntity,
|
||||
BookingReviewEntity,
|
||||
UserEntity,
|
||||
BusinessEntity,
|
||||
BusinessServiceEntity,
|
||||
BusinessAvailabilitySlotEntity,
|
||||
BusinessServiceClassEntity,
|
||||
BusinessServiceClassPriceEntity,
|
||||
CustomerServiceLoyaltyEntity,
|
||||
ServiceTemplateEntity,
|
||||
]),
|
||||
AuthModule,
|
||||
PaymentsModule,
|
||||
],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService],
|
||||
exports: [BookingsService],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class CancelBookingDto {
|
||||
@ApiPropertyOptional({ example: 'ظرفیت این بازه تکمیل شده است.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
cancellationReason?: string;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateBookingBatchItemDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
businessServiceId!: string;
|
||||
}
|
||||
|
||||
export class CreateBookingBatchDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
customerId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
businessId!: string;
|
||||
|
||||
@ApiProperty({ type: [CreateBookingBatchItemDto], minItems: 1 })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateBookingBatchItemDto)
|
||||
items!: CreateBookingBatchItemDto[];
|
||||
|
||||
@ApiProperty({ example: '2026-05-13T10:00:00.000Z' })
|
||||
@IsISO8601()
|
||||
scheduledAt!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', nullable: true })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
serviceClassId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string | null;
|
||||
}
|
||||
@@ -14,6 +14,11 @@ export class CreateBookingDto {
|
||||
@IsUUID()
|
||||
businessServiceId!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', nullable: true })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
serviceClassId?: string | null;
|
||||
|
||||
/** ISO 8601 datetime string */
|
||||
@ApiProperty({ example: '2026-05-13T10:00:00.000Z' })
|
||||
@IsISO8601()
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsInt, Min } from 'class-validator';
|
||||
|
||||
export class InitiateBookingTipDto {
|
||||
@ApiProperty({ minimum: 1000, example: 50000 })
|
||||
@IsInt()
|
||||
@Min(1000)
|
||||
amountToman!: number;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsInt, Max, Min } from 'class-validator';
|
||||
|
||||
export class SubmitBookingReviewDto {
|
||||
@ApiProperty({ minimum: 1, maximum: 5, example: 5 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
rating!: number;
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BusinessCategoryEntity } from '../../entities/business-category.entity';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BusinessesModule } from '../businesses/businesses.module';
|
||||
import { BusinessCategoriesController } from './business-categories.controller';
|
||||
import { BusinessCategoriesService } from './business-categories.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([BusinessCategoryEntity]), AuthModule],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([BusinessCategoryEntity, BusinessEntity]),
|
||||
forwardRef(() => BusinessesModule),
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [BusinessCategoriesController],
|
||||
providers: [BusinessCategoriesService],
|
||||
})
|
||||
|
||||
@@ -4,8 +4,12 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { deleteCategoryCascade } from '../../common/utils/business-cascade.util';
|
||||
import { resolveUniqueSlug, normalizeSlugInput } from '../../common/utils/slug.util';
|
||||
import { BusinessImagesService } from '../businesses/business-images.service';
|
||||
import { BusinessCategoryEntity } from '../../entities/business-category.entity';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { CreateBusinessCategoryDto } from './dto/create-business-category.dto';
|
||||
import { UpdateBusinessCategoryDto } from './dto/update-business-category.dto';
|
||||
|
||||
@@ -14,6 +18,10 @@ export class BusinessCategoriesService {
|
||||
constructor(
|
||||
@InjectRepository(BusinessCategoryEntity)
|
||||
private readonly repo: Repository<BusinessCategoryEntity>,
|
||||
@InjectRepository(BusinessEntity)
|
||||
private readonly businessRepo: Repository<BusinessEntity>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly images: BusinessImagesService,
|
||||
) {}
|
||||
|
||||
findAll(): Promise<BusinessCategoryEntity[]> {
|
||||
@@ -31,14 +39,25 @@ export class BusinessCategoriesService {
|
||||
async create(
|
||||
payload: CreateBusinessCategoryDto,
|
||||
): Promise<BusinessCategoryEntity> {
|
||||
const exists = await this.repo.findOne({ where: { slug: payload.slug } });
|
||||
if (exists) {
|
||||
throw new ConflictException('Slug already exists');
|
||||
const explicitSlug = normalizeSlugInput(payload.slug);
|
||||
if (explicitSlug) {
|
||||
const taken = await this.repo.findOne({ where: { slug: explicitSlug } });
|
||||
if (taken) {
|
||||
throw new ConflictException('Slug already exists');
|
||||
}
|
||||
}
|
||||
|
||||
const slug = await resolveUniqueSlug(
|
||||
payload.title,
|
||||
async (candidate) =>
|
||||
Boolean(await this.repo.findOne({ where: { slug: candidate } })),
|
||||
payload.slug,
|
||||
'category',
|
||||
);
|
||||
|
||||
const entity = this.repo.create({
|
||||
title: payload.title,
|
||||
slug: payload.slug,
|
||||
slug,
|
||||
isActive: payload.isActive ?? true,
|
||||
});
|
||||
return this.repo.save(entity);
|
||||
@@ -62,9 +81,24 @@ export class BusinessCategoriesService {
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const result = await this.repo.delete(id);
|
||||
if (!result.affected) {
|
||||
throw new NotFoundException('Business category not found');
|
||||
}
|
||||
await this.findOne(id);
|
||||
|
||||
const businesses = await this.businessRepo.find({
|
||||
where: { categoryId: id },
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
const businessIds = businesses.map((b) => b.id);
|
||||
const imageUrls = (
|
||||
await Promise.all(
|
||||
businessIds.map((businessId) => this.images.listUrlsForBusiness(businessId)),
|
||||
)
|
||||
).flat();
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await deleteCategoryCascade(manager, id, businessIds);
|
||||
});
|
||||
|
||||
this.images.deleteFiles(imageUrls);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@ export class CreateBusinessCategoryDto {
|
||||
@MaxLength(150)
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({ example: 'car-wash' })
|
||||
@ApiPropertyOptional({ example: 'car-wash' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||
message: 'slug must be lowercase words separated by hyphens',
|
||||
})
|
||||
slug!: string;
|
||||
slug?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: true })
|
||||
@IsOptional()
|
||||
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
BusinessRegistrationRequestEntity,
|
||||
BusinessRegistrationStatus,
|
||||
} from '../../entities/business-registration-request.entity';
|
||||
import { UserRole } from '../../entities/user.entity';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { BusinessRegistrationRequestsService } from './business-registration-requests.service';
|
||||
import { CreateBusinessRegistrationDto } from './dto/create-business-registration.dto';
|
||||
import { RejectBusinessRegistrationDto } from './dto/reject-business-registration.dto';
|
||||
|
||||
@Controller('business-registration-requests')
|
||||
export class BusinessRegistrationRequestsController {
|
||||
constructor(private readonly service: BusinessRegistrationRequestsService) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.CUSTOMER)
|
||||
create(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() payload: CreateBusinessRegistrationDto,
|
||||
): Promise<BusinessRegistrationRequestEntity> {
|
||||
return this.service.create(user.id, payload);
|
||||
}
|
||||
|
||||
@Get('mine')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
findMine(
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<BusinessRegistrationRequestEntity[]> {
|
||||
return this.service.findMine(user.id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
findAll(
|
||||
@Query('status') status?: BusinessRegistrationStatus,
|
||||
): Promise<BusinessRegistrationRequestEntity[]> {
|
||||
return this.service.findAll({ status });
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
findOne(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<BusinessRegistrationRequestEntity> {
|
||||
return this.service.findOne(id).then((entity) => {
|
||||
if (user.role !== UserRole.ADMIN && entity.applicantId !== user.id) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
return entity;
|
||||
});
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
approve(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<BusinessRegistrationRequestEntity> {
|
||||
return this.service.approve(id, user);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
reject(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() payload: RejectBusinessRegistrationDto,
|
||||
): Promise<BusinessRegistrationRequestEntity> {
|
||||
return this.service.reject(id, user, payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BusinessRegistrationRequestEntity } from '../../entities/business-registration-request.entity';
|
||||
import { BusinessCategoryEntity } from '../../entities/business-category.entity';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { UserEntity } from '../../entities/user.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BusinessRegistrationRequestsController } from './business-registration-requests.controller';
|
||||
import { BusinessRegistrationRequestsService } from './business-registration-requests.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
BusinessRegistrationRequestEntity,
|
||||
BusinessEntity,
|
||||
BusinessCategoryEntity,
|
||||
UserEntity,
|
||||
]),
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [BusinessRegistrationRequestsController],
|
||||
providers: [BusinessRegistrationRequestsService],
|
||||
})
|
||||
export class BusinessRegistrationRequestsModule {}
|
||||
@@ -0,0 +1,269 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { resolveUniqueSlug, normalizeSlugInput } from '../../common/utils/slug.util';
|
||||
import {
|
||||
BusinessRegistrationRequestEntity,
|
||||
BusinessRegistrationStatus,
|
||||
} from '../../entities/business-registration-request.entity';
|
||||
import { BusinessCategoryEntity } from '../../entities/business-category.entity';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { UserEntity, UserRole } from '../../entities/user.entity';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { SmsService } from '../auth/sms.service';
|
||||
import { CreateBusinessRegistrationDto } from './dto/create-business-registration.dto';
|
||||
import { RejectBusinessRegistrationDto } from './dto/reject-business-registration.dto';
|
||||
|
||||
type ListFilters = {
|
||||
status?: BusinessRegistrationStatus;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BusinessRegistrationRequestsService {
|
||||
constructor(
|
||||
@InjectRepository(BusinessRegistrationRequestEntity)
|
||||
private readonly repo: Repository<BusinessRegistrationRequestEntity>,
|
||||
@InjectRepository(BusinessEntity)
|
||||
private readonly businessRepo: Repository<BusinessEntity>,
|
||||
@InjectRepository(BusinessCategoryEntity)
|
||||
private readonly categoryRepo: Repository<BusinessCategoryEntity>,
|
||||
@InjectRepository(UserEntity)
|
||||
private readonly userRepo: Repository<UserEntity>,
|
||||
private readonly sms: SmsService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
applicantId: string,
|
||||
dto: CreateBusinessRegistrationDto,
|
||||
): Promise<BusinessRegistrationRequestEntity> {
|
||||
const applicant = await this.userRepo.findOne({ where: { id: applicantId } });
|
||||
if (!applicant) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
if (applicant.role === UserRole.ADMIN) {
|
||||
throw new ForbiddenException('Admin cannot submit business registration');
|
||||
}
|
||||
if (applicant.role === UserRole.BUSINESS_OWNER) {
|
||||
throw new BadRequestException('You already manage a business');
|
||||
}
|
||||
|
||||
const owned = await this.businessRepo.findOne({ where: { ownerId: applicantId } });
|
||||
if (owned) {
|
||||
throw new BadRequestException('You already have a registered business');
|
||||
}
|
||||
|
||||
const pending = await this.repo.findOne({
|
||||
where: {
|
||||
applicantId,
|
||||
status: BusinessRegistrationStatus.PENDING,
|
||||
},
|
||||
});
|
||||
if (pending) {
|
||||
throw new ConflictException('You already have a pending registration request');
|
||||
}
|
||||
|
||||
await this.ensureCategoryExists(dto.categoryId);
|
||||
|
||||
const slugExists = async (candidate: string) => {
|
||||
const inBusiness = await this.businessRepo.findOne({ where: { slug: candidate } });
|
||||
if (inBusiness) return true;
|
||||
const inRequest = await this.repo.findOne({ where: { slug: candidate } });
|
||||
return Boolean(inRequest);
|
||||
};
|
||||
|
||||
const explicitSlug = normalizeSlugInput(dto.slug);
|
||||
if (explicitSlug) {
|
||||
if (await slugExists(explicitSlug)) {
|
||||
throw new ConflictException('Slug already exists');
|
||||
}
|
||||
}
|
||||
|
||||
const slug = await resolveUniqueSlug(
|
||||
dto.title,
|
||||
slugExists,
|
||||
dto.slug,
|
||||
'business',
|
||||
);
|
||||
|
||||
const entity = this.repo.create({
|
||||
applicantId,
|
||||
status: BusinessRegistrationStatus.PENDING,
|
||||
title: dto.title.trim(),
|
||||
slug,
|
||||
categoryId: dto.categoryId,
|
||||
subtitle: dto.subtitle?.trim() ?? null,
|
||||
description: dto.description?.trim() ?? null,
|
||||
contactPhone: dto.contactPhone?.trim() ?? null,
|
||||
address: dto.address?.trim() ?? null,
|
||||
latitude: dto.latitude ?? null,
|
||||
longitude: dto.longitude ?? null,
|
||||
});
|
||||
|
||||
const saved = await this.repo.save(entity);
|
||||
const withRelations = await this.findOne(saved.id);
|
||||
|
||||
await this.notifySubmitted(withRelations, applicant);
|
||||
return withRelations;
|
||||
}
|
||||
|
||||
findMine(applicantId: string): Promise<BusinessRegistrationRequestEntity[]> {
|
||||
return this.repo.find({
|
||||
where: { applicantId },
|
||||
relations: { category: true, business: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
findAll(filters: ListFilters): Promise<BusinessRegistrationRequestEntity[]> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('req')
|
||||
.leftJoinAndSelect('req.applicant', 'applicant')
|
||||
.leftJoinAndSelect('req.category', 'category')
|
||||
.leftJoinAndSelect('req.business', 'business')
|
||||
.orderBy('req.createdAt', 'DESC');
|
||||
|
||||
if (filters.status) {
|
||||
qb.andWhere('req.status = :status', { status: filters.status });
|
||||
}
|
||||
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<BusinessRegistrationRequestEntity> {
|
||||
const entity = await this.repo.findOne({
|
||||
where: { id },
|
||||
relations: { applicant: true, category: true, business: true, reviewedBy: true },
|
||||
});
|
||||
if (!entity) {
|
||||
throw new NotFoundException('Registration request not found');
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
async approve(id: string, admin: AuthUser): Promise<BusinessRegistrationRequestEntity> {
|
||||
const request = await this.findOne(id);
|
||||
if (request.status !== BusinessRegistrationStatus.PENDING) {
|
||||
throw new BadRequestException('Only pending requests can be approved');
|
||||
}
|
||||
|
||||
const slugTaken = await this.businessRepo.findOne({ where: { slug: request.slug } });
|
||||
if (slugTaken) {
|
||||
throw new ConflictException('Business slug already exists');
|
||||
}
|
||||
|
||||
const applicant = await this.userRepo.findOne({ where: { id: request.applicantId } });
|
||||
if (!applicant) {
|
||||
throw new NotFoundException('Applicant not found');
|
||||
}
|
||||
|
||||
const business = this.businessRepo.create({
|
||||
title: request.title,
|
||||
slug: request.slug,
|
||||
categoryId: request.categoryId,
|
||||
subtitle: request.subtitle,
|
||||
description: request.description,
|
||||
contactPhone: request.contactPhone,
|
||||
address: request.address,
|
||||
latitude: request.latitude,
|
||||
longitude: request.longitude,
|
||||
ownerId: applicant.id,
|
||||
isActive: true,
|
||||
});
|
||||
const savedBusiness = await this.businessRepo.save(business);
|
||||
|
||||
if (applicant.role !== UserRole.ADMIN) {
|
||||
applicant.role = UserRole.BUSINESS_OWNER;
|
||||
}
|
||||
applicant.isActive = true;
|
||||
await this.userRepo.save(applicant);
|
||||
|
||||
request.status = BusinessRegistrationStatus.APPROVED;
|
||||
request.businessId = savedBusiness.id;
|
||||
request.reviewedById = admin.id;
|
||||
request.reviewedAt = new Date();
|
||||
request.adminNote = null;
|
||||
const savedRequest = await this.repo.save(request);
|
||||
|
||||
const result = await this.findOne(savedRequest.id);
|
||||
await this.notifyApproved(result, applicant);
|
||||
return result;
|
||||
}
|
||||
|
||||
async reject(
|
||||
id: string,
|
||||
admin: AuthUser,
|
||||
dto: RejectBusinessRegistrationDto,
|
||||
): Promise<BusinessRegistrationRequestEntity> {
|
||||
const request = await this.findOne(id);
|
||||
if (request.status !== BusinessRegistrationStatus.PENDING) {
|
||||
throw new BadRequestException('Only pending requests can be rejected');
|
||||
}
|
||||
|
||||
request.status = BusinessRegistrationStatus.REJECTED;
|
||||
request.adminNote = dto.adminNote?.trim() ?? null;
|
||||
request.reviewedById = admin.id;
|
||||
request.reviewedAt = new Date();
|
||||
const saved = await this.repo.save(request);
|
||||
|
||||
const result = await this.findOne(saved.id);
|
||||
const applicant = result.applicant;
|
||||
if (applicant) {
|
||||
await this.notifyRejected(result, applicant);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async ensureCategoryExists(categoryId: string): Promise<void> {
|
||||
const category = await this.categoryRepo.findOne({ where: { id: categoryId } });
|
||||
if (!category) {
|
||||
throw new NotFoundException('Business category not found');
|
||||
}
|
||||
}
|
||||
|
||||
private async notifySubmitted(
|
||||
request: BusinessRegistrationRequestEntity,
|
||||
applicant: UserEntity,
|
||||
): Promise<void> {
|
||||
await this.sms.sendSms(
|
||||
applicant.mobile,
|
||||
`درخواست ثبت «${request.title}» در منتایم ثبت شد و در انتظار تأیید است.`,
|
||||
);
|
||||
|
||||
const adminMobile = this.config.get<string>('SUPER_ADMIN_MOBILE');
|
||||
if (adminMobile) {
|
||||
await this.sms.sendSms(
|
||||
adminMobile,
|
||||
`درخواست کسبوکار جدید: ${request.title} — ${applicant.mobile}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyApproved(
|
||||
request: BusinessRegistrationRequestEntity,
|
||||
applicant: UserEntity,
|
||||
): Promise<void> {
|
||||
await this.sms.sendSms(
|
||||
applicant.mobile,
|
||||
`کسبوکار «${request.title}» در منتایم تأیید شد. پنل مدیریت در اپ فعال است.`,
|
||||
);
|
||||
}
|
||||
|
||||
private async notifyRejected(
|
||||
request: BusinessRegistrationRequestEntity,
|
||||
applicant: UserEntity,
|
||||
): Promise<void> {
|
||||
const note = request.adminNote ? ` ${request.adminNote}` : '';
|
||||
await this.sms.sendSms(
|
||||
applicant.mobile,
|
||||
`درخواست «${request.title}» در منتایم رد شد.${note}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsLatitude,
|
||||
IsLongitude,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateBusinessRegistrationDto {
|
||||
@ApiProperty({ example: 'کارواش محمد' })
|
||||
@IsString()
|
||||
@MaxLength(150)
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
categoryId!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'mohammad-carwash' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||
message: 'slug must be lowercase words separated by hyphens',
|
||||
})
|
||||
slug?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'تمیزی که میدرخشد' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(150)
|
||||
subtitle?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '09121234567' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
contactPhone?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
address?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsLatitude()
|
||||
latitude?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsLongitude()
|
||||
longitude?: number;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class RejectBusinessRegistrationDto {
|
||||
@ApiPropertyOptional({ example: 'اطلاعات ناقص است' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
adminNote?: string;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
|
||||
import { UserRole } from '../../entities/user.entity';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { BusinessServiceClassesService } from './business-service-classes.service';
|
||||
import { CreateBusinessServiceClassDto } from './dto/create-business-service-class.dto';
|
||||
import { UpdateBusinessServiceClassDto } from './dto/update-business-service-class.dto';
|
||||
|
||||
@Controller('business-service-classes')
|
||||
export class BusinessServiceClassesController {
|
||||
constructor(private readonly service: BusinessServiceClassesService) {}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@Query('businessId') businessId?: string,
|
||||
@Query('isActive') isActive?: string,
|
||||
): Promise<BusinessServiceClassEntity[]> {
|
||||
const parsedIsActive =
|
||||
typeof isActive === 'string' ? isActive === 'true' : undefined;
|
||||
|
||||
return this.service.findAll({ businessId, isActive: parsedIsActive });
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string): Promise<BusinessServiceClassEntity> {
|
||||
return this.service.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
||||
create(
|
||||
@Body() payload: CreateBusinessServiceClassDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<BusinessServiceClassEntity> {
|
||||
return this.service.create(payload, user);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() payload: UpdateBusinessServiceClassDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<BusinessServiceClassEntity> {
|
||||
return this.service.update(id, payload, user);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
||||
async remove(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<{ success: true }> {
|
||||
await this.service.remove(id, user);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BusinessServiceClassesController } from './business-service-classes.controller';
|
||||
import { BusinessServiceClassesService } from './business-service-classes.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([BusinessServiceClassEntity, BusinessEntity]),
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [BusinessServiceClassesController],
|
||||
providers: [BusinessServiceClassesService],
|
||||
exports: [BusinessServiceClassesService],
|
||||
})
|
||||
export class BusinessServiceClassesModule {}
|
||||
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
|
||||
import { UserRole } from '../../entities/user.entity';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CreateBusinessServiceClassDto } from './dto/create-business-service-class.dto';
|
||||
import { UpdateBusinessServiceClassDto } from './dto/update-business-service-class.dto';
|
||||
|
||||
type FindAllFilters = {
|
||||
businessId?: string;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BusinessServiceClassesService {
|
||||
constructor(
|
||||
@InjectRepository(BusinessServiceClassEntity)
|
||||
private readonly repo: Repository<BusinessServiceClassEntity>,
|
||||
@InjectRepository(BusinessEntity)
|
||||
private readonly businessRepo: Repository<BusinessEntity>,
|
||||
) {}
|
||||
|
||||
async findAll(filters: FindAllFilters): Promise<BusinessServiceClassEntity[]> {
|
||||
const query = this.repo
|
||||
.createQueryBuilder('c')
|
||||
.orderBy('c.sortOrder', 'ASC')
|
||||
.addOrderBy('c.createdAt', 'ASC');
|
||||
|
||||
if (filters.businessId) {
|
||||
query.andWhere('c.businessId = :businessId', {
|
||||
businessId: filters.businessId,
|
||||
});
|
||||
}
|
||||
if (typeof filters.isActive === 'boolean') {
|
||||
query.andWhere('c.isActive = :isActive', { isActive: filters.isActive });
|
||||
}
|
||||
|
||||
return query.getMany();
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<BusinessServiceClassEntity> {
|
||||
const row = await this.repo.findOne({ where: { id } });
|
||||
if (!row) {
|
||||
throw new NotFoundException('Service class not found');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
async create(
|
||||
dto: CreateBusinessServiceClassDto,
|
||||
actor?: AuthUser,
|
||||
): Promise<BusinessServiceClassEntity> {
|
||||
const business = await this.businessRepo.findOne({
|
||||
where: { id: dto.businessId },
|
||||
});
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
this.assertCanManageBusiness(business, actor);
|
||||
|
||||
const dup = await this.repo.findOne({
|
||||
where: { businessId: dto.businessId, title: dto.title.trim() },
|
||||
});
|
||||
if (dup) {
|
||||
throw new ConflictException('A class with this title already exists');
|
||||
}
|
||||
|
||||
let sortOrder = dto.sortOrder;
|
||||
if (sortOrder === undefined) {
|
||||
const max = await this.repo
|
||||
.createQueryBuilder('c')
|
||||
.select('MAX(c.sortOrder)', 'max')
|
||||
.where('c.businessId = :businessId', { businessId: dto.businessId })
|
||||
.getRawOne<{ max: string | null }>();
|
||||
sortOrder = (max?.max ? Number.parseInt(max.max, 10) : -1) + 1;
|
||||
}
|
||||
|
||||
const entity = this.repo.create({
|
||||
businessId: dto.businessId,
|
||||
title: dto.title.trim(),
|
||||
description: dto.description?.trim() || null,
|
||||
sortOrder,
|
||||
isActive: dto.isActive ?? true,
|
||||
});
|
||||
|
||||
return this.repo.save(entity);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateBusinessServiceClassDto,
|
||||
actor?: AuthUser,
|
||||
): Promise<BusinessServiceClassEntity> {
|
||||
const row = await this.findOne(id);
|
||||
const business = await this.businessRepo.findOne({
|
||||
where: { id: row.businessId },
|
||||
});
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
this.assertCanManageBusiness(business, actor);
|
||||
|
||||
if (dto.title !== undefined) {
|
||||
const title = dto.title.trim();
|
||||
if (!title) {
|
||||
throw new BadRequestException('title cannot be empty');
|
||||
}
|
||||
const dup = await this.repo.findOne({
|
||||
where: { businessId: row.businessId, title },
|
||||
});
|
||||
if (dup && dup.id !== row.id) {
|
||||
throw new ConflictException('A class with this title already exists');
|
||||
}
|
||||
row.title = title;
|
||||
}
|
||||
if (dto.description !== undefined) {
|
||||
row.description = dto.description?.trim() || null;
|
||||
}
|
||||
if (dto.sortOrder !== undefined) {
|
||||
row.sortOrder = dto.sortOrder;
|
||||
}
|
||||
if (dto.isActive !== undefined) {
|
||||
row.isActive = dto.isActive;
|
||||
}
|
||||
|
||||
return this.repo.save(row);
|
||||
}
|
||||
|
||||
async remove(id: string, actor?: AuthUser): Promise<void> {
|
||||
const row = await this.findOne(id);
|
||||
const business = await this.businessRepo.findOne({
|
||||
where: { id: row.businessId },
|
||||
});
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
this.assertCanManageBusiness(business, actor);
|
||||
|
||||
const result = await this.repo.delete(id);
|
||||
if (!result.affected) {
|
||||
throw new NotFoundException('Service class not found');
|
||||
}
|
||||
}
|
||||
|
||||
private assertCanManageBusiness(
|
||||
business: BusinessEntity,
|
||||
actor?: AuthUser,
|
||||
): void {
|
||||
if (!actor || actor.role === UserRole.ADMIN) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
actor.role !== UserRole.BUSINESS_OWNER ||
|
||||
business.ownerId !== actor.id
|
||||
) {
|
||||
throw new ForbiddenException('You can only manage your own business');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreateBusinessServiceClassDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
businessId!: string;
|
||||
|
||||
@ApiProperty({ example: 'کلاس ۱ — مینی و کوچک' })
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
title!: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
description?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class UpdateBusinessServiceClassDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
title?: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
description?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
BusinessServiceRequestEntity,
|
||||
BusinessServiceRequestStatus,
|
||||
} from '../../entities/business-service-request.entity';
|
||||
import { UserRole } from '../../entities/user.entity';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { BusinessServiceRequestsService } from './business-service-requests.service';
|
||||
import { CreateBusinessServiceRequestDto } from './dto/create-business-service-request.dto';
|
||||
import { RejectBusinessServiceRequestDto } from './dto/reject-business-service-request.dto';
|
||||
|
||||
@Controller('business-service-requests')
|
||||
export class BusinessServiceRequestsController {
|
||||
constructor(private readonly service: BusinessServiceRequestsService) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.BUSINESS_OWNER)
|
||||
create(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() payload: CreateBusinessServiceRequestDto,
|
||||
): Promise<BusinessServiceRequestEntity> {
|
||||
return this.service.create(user.id, payload);
|
||||
}
|
||||
|
||||
@Get('mine')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.BUSINESS_OWNER)
|
||||
findMine(
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<BusinessServiceRequestEntity[]> {
|
||||
return this.service.findMine(user.id);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
findAll(
|
||||
@Query('status') status?: BusinessServiceRequestStatus,
|
||||
): Promise<BusinessServiceRequestEntity[]> {
|
||||
return this.service.findAll({ status });
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
findOne(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<BusinessServiceRequestEntity> {
|
||||
return this.service.findOne(id).then((entity) => {
|
||||
this.service.assertCanView(entity, user);
|
||||
return entity;
|
||||
});
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
approve(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<BusinessServiceRequestEntity> {
|
||||
return this.service.approve(id, user);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
reject(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() payload: RejectBusinessServiceRequestDto,
|
||||
): Promise<BusinessServiceRequestEntity> {
|
||||
return this.service.reject(id, user, payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BusinessServiceRequestEntity } from '../../entities/business-service-request.entity';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { UserEntity } from '../../entities/user.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BusinessServicesModule } from '../business-services/business-services.module';
|
||||
import { ServiceTemplatesModule } from '../service-templates/service-templates.module';
|
||||
import { BusinessServiceRequestsController } from './business-service-requests.controller';
|
||||
import { BusinessServiceRequestsService } from './business-service-requests.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
BusinessServiceRequestEntity,
|
||||
BusinessEntity,
|
||||
UserEntity,
|
||||
]),
|
||||
AuthModule,
|
||||
ServiceTemplatesModule,
|
||||
BusinessServicesModule,
|
||||
],
|
||||
controllers: [BusinessServiceRequestsController],
|
||||
providers: [BusinessServiceRequestsService],
|
||||
})
|
||||
export class BusinessServiceRequestsModule {}
|
||||
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
BusinessServiceRequestEntity,
|
||||
BusinessServiceRequestStatus,
|
||||
} from '../../entities/business-service-request.entity';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { UserEntity, UserRole } from '../../entities/user.entity';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { BusinessServicesService } from '../business-services/business-services.service';
|
||||
import { ServiceTemplatesService } from '../service-templates/service-templates.service';
|
||||
import { CreateBusinessServiceRequestDto } from './dto/create-business-service-request.dto';
|
||||
import { RejectBusinessServiceRequestDto } from './dto/reject-business-service-request.dto';
|
||||
|
||||
type ListFilters = {
|
||||
status?: BusinessServiceRequestStatus;
|
||||
};
|
||||
|
||||
function normalizePrice(value: number): string {
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new BadRequestException('price must be a non-negative finite number');
|
||||
}
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BusinessServiceRequestsService {
|
||||
constructor(
|
||||
@InjectRepository(BusinessServiceRequestEntity)
|
||||
private readonly repo: Repository<BusinessServiceRequestEntity>,
|
||||
@InjectRepository(BusinessEntity)
|
||||
private readonly businessRepo: Repository<BusinessEntity>,
|
||||
@InjectRepository(UserEntity)
|
||||
private readonly userRepo: Repository<UserEntity>,
|
||||
private readonly templatesService: ServiceTemplatesService,
|
||||
private readonly businessServicesService: BusinessServicesService,
|
||||
) {}
|
||||
|
||||
async create(
|
||||
ownerId: string,
|
||||
dto: CreateBusinessServiceRequestDto,
|
||||
): Promise<BusinessServiceRequestEntity> {
|
||||
const business = await this.businessRepo.findOne({ where: { ownerId } });
|
||||
if (!business) {
|
||||
throw new BadRequestException('No business linked to your account');
|
||||
}
|
||||
|
||||
const pending = await this.repo.findOne({
|
||||
where: {
|
||||
businessId: business.id,
|
||||
title: dto.title.trim(),
|
||||
status: BusinessServiceRequestStatus.PENDING,
|
||||
},
|
||||
});
|
||||
if (pending) {
|
||||
throw new ConflictException('You already have a pending request with this title');
|
||||
}
|
||||
|
||||
const entity = this.repo.create({
|
||||
businessId: business.id,
|
||||
requestedById: ownerId,
|
||||
status: BusinessServiceRequestStatus.PENDING,
|
||||
title: dto.title.trim(),
|
||||
description: dto.description?.trim() ?? null,
|
||||
price: normalizePrice(dto.price),
|
||||
durationMinutes: dto.durationMinutes ?? null,
|
||||
});
|
||||
|
||||
const saved = await this.repo.save(entity);
|
||||
return this.findOne(saved.id);
|
||||
}
|
||||
|
||||
findMine(ownerId: string): Promise<BusinessServiceRequestEntity[]> {
|
||||
return this.repo.find({
|
||||
where: { requestedById: ownerId },
|
||||
relations: { business: true, serviceTemplate: true, businessService: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
findAll(filters: ListFilters): Promise<BusinessServiceRequestEntity[]> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('req')
|
||||
.leftJoinAndSelect('req.business', 'business')
|
||||
.leftJoinAndSelect('req.requestedBy', 'requestedBy')
|
||||
.leftJoinAndSelect('req.serviceTemplate', 'serviceTemplate')
|
||||
.orderBy('req.createdAt', 'DESC');
|
||||
|
||||
if (filters.status) {
|
||||
qb.andWhere('req.status = :status', { status: filters.status });
|
||||
}
|
||||
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<BusinessServiceRequestEntity> {
|
||||
const entity = await this.repo.findOne({
|
||||
where: { id },
|
||||
relations: {
|
||||
business: true,
|
||||
requestedBy: true,
|
||||
reviewedBy: true,
|
||||
serviceTemplate: true,
|
||||
businessService: true,
|
||||
},
|
||||
});
|
||||
if (!entity) {
|
||||
throw new NotFoundException('Service request not found');
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
async approve(id: string, admin: AuthUser): Promise<BusinessServiceRequestEntity> {
|
||||
const request = await this.findOne(id);
|
||||
if (request.status !== BusinessServiceRequestStatus.PENDING) {
|
||||
throw new BadRequestException('Only pending requests can be approved');
|
||||
}
|
||||
|
||||
const business = await this.businessRepo.findOne({
|
||||
where: { id: request.businessId },
|
||||
});
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
const template = await this.templatesService.create({
|
||||
title: request.title,
|
||||
categoryId: business.categoryId,
|
||||
description: request.description ?? undefined,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const price = Number.parseFloat(request.price);
|
||||
const service = await this.businessServicesService.create(
|
||||
{
|
||||
businessId: request.businessId,
|
||||
serviceTemplateId: template.id,
|
||||
price,
|
||||
durationMinutes: request.durationMinutes ?? undefined,
|
||||
isActive: true,
|
||||
},
|
||||
admin,
|
||||
);
|
||||
|
||||
request.status = BusinessServiceRequestStatus.APPROVED;
|
||||
request.serviceTemplateId = template.id;
|
||||
request.businessServiceId = service.id;
|
||||
request.reviewedById = admin.id;
|
||||
request.reviewedAt = new Date();
|
||||
request.adminNote = null;
|
||||
|
||||
const saved = await this.repo.save(request);
|
||||
return this.findOne(saved.id);
|
||||
}
|
||||
|
||||
async reject(
|
||||
id: string,
|
||||
admin: AuthUser,
|
||||
dto: RejectBusinessServiceRequestDto,
|
||||
): Promise<BusinessServiceRequestEntity> {
|
||||
const request = await this.findOne(id);
|
||||
if (request.status !== BusinessServiceRequestStatus.PENDING) {
|
||||
throw new BadRequestException('Only pending requests can be rejected');
|
||||
}
|
||||
|
||||
request.status = BusinessServiceRequestStatus.REJECTED;
|
||||
request.adminNote = dto.adminNote?.trim() ?? null;
|
||||
request.reviewedById = admin.id;
|
||||
request.reviewedAt = new Date();
|
||||
|
||||
const saved = await this.repo.save(request);
|
||||
return this.findOne(saved.id);
|
||||
}
|
||||
|
||||
assertCanView(request: BusinessServiceRequestEntity, user: AuthUser): void {
|
||||
if (user.role === UserRole.ADMIN) return;
|
||||
if (request.requestedById === user.id) return;
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateBusinessServiceRequestDto {
|
||||
@ApiProperty({ example: 'رنگ موی تخصصی' })
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(150)
|
||||
title!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ example: 250000 })
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
price!: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 45 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(24 * 60)
|
||||
durationMinutes?: number;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class RejectBusinessServiceRequestDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
adminNote?: string;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
@@ -19,6 +20,7 @@ import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { BusinessServicesService } from './business-services.service';
|
||||
import { CreateBusinessServiceDto } from './dto/create-business-service.dto';
|
||||
import { UpdateBusinessServiceDto } from './dto/update-business-service.dto';
|
||||
import { SetBusinessServiceClassPricesDto } from './dto/set-business-service-class-prices.dto';
|
||||
|
||||
@Controller('business-services')
|
||||
export class BusinessServicesController {
|
||||
@@ -66,6 +68,17 @@ export class BusinessServicesController {
|
||||
return this.service.update(id, payload, user);
|
||||
}
|
||||
|
||||
@Put(':id/class-prices')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
||||
setClassPrices(
|
||||
@Param('id') id: string,
|
||||
@Body() payload: SetBusinessServiceClassPricesDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<BusinessServiceEntity> {
|
||||
return this.service.setClassPrices(id, payload, user);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
||||
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
|
||||
import { BusinessServiceClassPriceEntity } from '../../entities/business-service-class-price.entity';
|
||||
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BusinessServicesController } from './business-services.controller';
|
||||
@@ -13,10 +15,13 @@ import { BusinessServicesService } from './business-services.service';
|
||||
BusinessServiceEntity,
|
||||
BusinessEntity,
|
||||
ServiceTemplateEntity,
|
||||
BusinessServiceClassEntity,
|
||||
BusinessServiceClassPriceEntity,
|
||||
]),
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [BusinessServicesController],
|
||||
providers: [BusinessServicesService],
|
||||
exports: [BusinessServicesService],
|
||||
})
|
||||
export class BusinessServicesModule {}
|
||||
|
||||
@@ -6,14 +6,17 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
||||
import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity';
|
||||
import { BusinessServiceClassPriceEntity } from '../../entities/business-service-class-price.entity';
|
||||
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
|
||||
import { UserRole } from '../../entities/user.entity';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CreateBusinessServiceDto } from './dto/create-business-service.dto';
|
||||
import { UpdateBusinessServiceDto } from './dto/update-business-service.dto';
|
||||
import { SetBusinessServiceClassPricesDto } from './dto/set-business-service-class-prices.dto';
|
||||
|
||||
type FindAllFilters = {
|
||||
businessId?: string;
|
||||
@@ -37,6 +40,10 @@ export class BusinessServicesService {
|
||||
private readonly businessRepo: Repository<BusinessEntity>,
|
||||
@InjectRepository(ServiceTemplateEntity)
|
||||
private readonly templateRepo: Repository<ServiceTemplateEntity>,
|
||||
@InjectRepository(BusinessServiceClassEntity)
|
||||
private readonly classRepo: Repository<BusinessServiceClassEntity>,
|
||||
@InjectRepository(BusinessServiceClassPriceEntity)
|
||||
private readonly classPriceRepo: Repository<BusinessServiceClassPriceEntity>,
|
||||
) {}
|
||||
|
||||
async findAll(filters: FindAllFilters): Promise<BusinessServiceEntity[]> {
|
||||
@@ -44,6 +51,8 @@ export class BusinessServicesService {
|
||||
.createQueryBuilder('bs')
|
||||
.leftJoinAndSelect('bs.business', 'business')
|
||||
.leftJoinAndSelect('bs.serviceTemplate', 'template')
|
||||
.leftJoinAndSelect('bs.classPrices', 'classPrices')
|
||||
.leftJoinAndSelect('classPrices.serviceClass', 'serviceClass')
|
||||
.orderBy('bs.createdAt', 'DESC');
|
||||
|
||||
if (filters.businessId) {
|
||||
@@ -68,7 +77,11 @@ export class BusinessServicesService {
|
||||
async findOne(id: string): Promise<BusinessServiceEntity> {
|
||||
const row = await this.repo.findOne({
|
||||
where: { id },
|
||||
relations: { business: true, serviceTemplate: true },
|
||||
relations: {
|
||||
business: true,
|
||||
serviceTemplate: true,
|
||||
classPrices: { serviceClass: true },
|
||||
},
|
||||
});
|
||||
if (!row) {
|
||||
throw new NotFoundException('Business service not found');
|
||||
@@ -189,10 +202,85 @@ export class BusinessServicesService {
|
||||
if (dto.serviceTemplateId !== undefined) {
|
||||
row.serviceTemplateId = dto.serviceTemplateId;
|
||||
}
|
||||
if (dto.loyaltyEnabled !== undefined) {
|
||||
row.loyaltyEnabled = dto.loyaltyEnabled;
|
||||
if (!dto.loyaltyEnabled && dto.loyaltyEveryN === undefined) {
|
||||
row.loyaltyEveryN = null;
|
||||
}
|
||||
}
|
||||
if (dto.loyaltyEveryN !== undefined) {
|
||||
row.loyaltyEveryN = dto.loyaltyEveryN;
|
||||
}
|
||||
if (row.loyaltyEnabled && (!row.loyaltyEveryN || row.loyaltyEveryN < 2)) {
|
||||
throw new BadRequestException(
|
||||
'loyaltyEveryN must be at least 2 when loyalty is enabled',
|
||||
);
|
||||
}
|
||||
|
||||
return this.repo.save(row);
|
||||
}
|
||||
|
||||
async setClassPrices(
|
||||
id: string,
|
||||
dto: SetBusinessServiceClassPricesDto,
|
||||
actor?: AuthUser,
|
||||
): Promise<BusinessServiceEntity> {
|
||||
const row = await this.findOne(id);
|
||||
const business = await this.businessRepo.findOne({
|
||||
where: { id: row.businessId },
|
||||
});
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
this.assertCanManageBusiness(business, actor);
|
||||
|
||||
const classIds = [...new Set(dto.prices.map((p) => p.classId))];
|
||||
const classes = await this.classRepo.find({
|
||||
where: { businessId: row.businessId, id: In(classIds) },
|
||||
});
|
||||
if (classes.length !== classIds.length) {
|
||||
throw new BadRequestException(
|
||||
'One or more service classes do not belong to this business',
|
||||
);
|
||||
}
|
||||
|
||||
for (const item of dto.prices) {
|
||||
const existing = await this.classPriceRepo.findOne({
|
||||
where: { businessServiceId: id, classId: item.classId },
|
||||
});
|
||||
const payload = {
|
||||
price: normalizePrice(item.price),
|
||||
durationMinutes: item.durationMinutes ?? null,
|
||||
isActive: item.isActive ?? true,
|
||||
};
|
||||
if (existing) {
|
||||
Object.assign(existing, payload);
|
||||
await this.classPriceRepo.save(existing);
|
||||
} else {
|
||||
await this.classPriceRepo.save(
|
||||
this.classPriceRepo.create({
|
||||
businessServiceId: id,
|
||||
classId: item.classId,
|
||||
...payload,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const activePrices = await this.classPriceRepo.find({
|
||||
where: { businessServiceId: id, isActive: true },
|
||||
});
|
||||
if (activePrices.length) {
|
||||
const minPrice = activePrices.reduce((min, p) =>
|
||||
Number.parseFloat(p.price) < Number.parseFloat(min.price) ? p : min,
|
||||
);
|
||||
row.price = minPrice.price;
|
||||
await this.repo.save(row);
|
||||
}
|
||||
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
async remove(id: string, actor?: AuthUser): Promise<void> {
|
||||
const row = await this.findOne(id);
|
||||
const business = await this.businessRepo.findOne({
|
||||
|
||||
@@ -16,7 +16,7 @@ export class CreateBusinessServiceDto {
|
||||
@Min(0)
|
||||
price!: number;
|
||||
|
||||
/** اگر خالی باشد از template.defaultDurationMinutes استفاده میشود */
|
||||
/** مدت خدمت به دقیقه — توسط کسبوکار تعیین میشود */
|
||||
@ApiPropertyOptional({ example: 30, nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class BusinessServiceClassPriceItemDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
classId!: string;
|
||||
|
||||
@ApiProperty({ example: 100000 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
price!: number;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
durationMinutes?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class SetBusinessServiceClassPricesDto {
|
||||
@ApiProperty({ type: [BusinessServiceClassPriceItemDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BusinessServiceClassPriceItemDto)
|
||||
prices!: BusinessServiceClassPriceItemDto[];
|
||||
}
|
||||
@@ -24,4 +24,25 @@ export class UpdateBusinessServiceDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: true,
|
||||
description: 'Enable free-service loyalty for this service',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
loyaltyEnabled?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 3,
|
||||
nullable: true,
|
||||
description: 'After this many paid visits, the next visit of this service is free',
|
||||
minimum: 2,
|
||||
maximum: 100,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(2)
|
||||
@Max(100)
|
||||
loyaltyEveryN?: number | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'fs';
|
||||
import { extname, join } from 'path';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BusinessImageEntity } from '../../entities/business-image.entity';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
|
||||
const ALLOWED_MIME = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
]);
|
||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||
const MAX_IMAGES_PER_BUSINESS = 10;
|
||||
|
||||
@Injectable()
|
||||
export class BusinessImagesService {
|
||||
private readonly uploadRoot: string;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(BusinessImageEntity)
|
||||
private readonly imageRepo: Repository<BusinessImageEntity>,
|
||||
@InjectRepository(BusinessEntity)
|
||||
private readonly businessRepo: Repository<BusinessEntity>,
|
||||
) {
|
||||
this.uploadRoot = join(process.cwd(), process.env.UPLOAD_DIR ?? 'uploads');
|
||||
}
|
||||
|
||||
async listForBusiness(businessId: string): Promise<BusinessImageEntity[]> {
|
||||
await this.ensureBusiness(businessId);
|
||||
return this.imageRepo.find({
|
||||
where: { businessId },
|
||||
order: { isCover: 'DESC', sortOrder: 'ASC', createdAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async upload(
|
||||
businessId: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<BusinessImageEntity[]> {
|
||||
await this.ensureBusiness(businessId);
|
||||
|
||||
if (!files?.length) {
|
||||
throw new BadRequestException('At least one image file is required');
|
||||
}
|
||||
|
||||
const existing = await this.imageRepo.count({ where: { businessId } });
|
||||
if (existing + files.length > MAX_IMAGES_PER_BUSINESS) {
|
||||
throw new BadRequestException(
|
||||
`Maximum ${MAX_IMAGES_PER_BUSINESS} images per business`,
|
||||
);
|
||||
}
|
||||
|
||||
const dir = join(this.uploadRoot, 'businesses', businessId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
const hasCover = await this.imageRepo.exists({
|
||||
where: { businessId, isCover: true },
|
||||
});
|
||||
|
||||
const saved: BusinessImageEntity[] = [];
|
||||
let sortOrder = existing;
|
||||
|
||||
for (const file of files) {
|
||||
this.validateFile(file);
|
||||
const ext = extname(file.originalname).toLowerCase() || '.jpg';
|
||||
const filename = `${randomUUID()}${ext}`;
|
||||
const relativeUrl = `businesses/${businessId}/${filename}`;
|
||||
const absolutePath = join(this.uploadRoot, relativeUrl);
|
||||
|
||||
const { writeFileSync } = await import('fs');
|
||||
writeFileSync(absolutePath, file.buffer);
|
||||
|
||||
const entity = this.imageRepo.create({
|
||||
businessId,
|
||||
url: relativeUrl,
|
||||
originalName: file.originalname,
|
||||
sortOrder,
|
||||
isCover: !hasCover && saved.length === 0,
|
||||
});
|
||||
saved.push(await this.imageRepo.save(entity));
|
||||
sortOrder += 1;
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
async setCover(businessId: string, imageId: string): Promise<BusinessImageEntity> {
|
||||
const image = await this.imageRepo.findOne({
|
||||
where: { id: imageId, businessId },
|
||||
});
|
||||
if (!image) {
|
||||
throw new NotFoundException('Image not found');
|
||||
}
|
||||
|
||||
await this.imageRepo.update({ businessId }, { isCover: false });
|
||||
image.isCover = true;
|
||||
return this.imageRepo.save(image);
|
||||
}
|
||||
|
||||
async remove(businessId: string, imageId: string): Promise<void> {
|
||||
const image = await this.imageRepo.findOne({
|
||||
where: { id: imageId, businessId },
|
||||
});
|
||||
if (!image) {
|
||||
throw new NotFoundException('Image not found');
|
||||
}
|
||||
|
||||
this.deleteFile(image.url);
|
||||
const wasCover = image.isCover;
|
||||
await this.imageRepo.remove(image);
|
||||
|
||||
if (wasCover) {
|
||||
const next = await this.imageRepo.findOne({
|
||||
where: { businessId },
|
||||
order: { sortOrder: 'ASC', createdAt: 'ASC' },
|
||||
});
|
||||
if (next) {
|
||||
next.isCover = true;
|
||||
await this.imageRepo.save(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async listUrlsForBusiness(businessId: string): Promise<string[]> {
|
||||
const images = await this.imageRepo.find({
|
||||
where: { businessId },
|
||||
select: ['url'],
|
||||
});
|
||||
return images.map((image) => image.url);
|
||||
}
|
||||
|
||||
deleteFiles(urls: string[]): void {
|
||||
for (const url of urls) {
|
||||
this.deleteFile(url);
|
||||
}
|
||||
}
|
||||
|
||||
async removeAllForBusiness(businessId: string): Promise<void> {
|
||||
const urls = await this.listUrlsForBusiness(businessId);
|
||||
this.deleteFiles(urls);
|
||||
await this.imageRepo.delete({ businessId });
|
||||
}
|
||||
|
||||
private validateFile(file: Express.Multer.File): void {
|
||||
if (!ALLOWED_MIME.has(file.mimetype)) {
|
||||
throw new BadRequestException(
|
||||
'Only JPEG, PNG, WebP and GIF images are allowed',
|
||||
);
|
||||
}
|
||||
if (file.size > MAX_FILE_BYTES) {
|
||||
throw new BadRequestException('Image must be at most 5 MB');
|
||||
}
|
||||
}
|
||||
|
||||
private deleteFile(relativeUrl: string): void {
|
||||
const absolutePath = join(this.uploadRoot, relativeUrl);
|
||||
if (existsSync(absolutePath)) {
|
||||
unlinkSync(absolutePath);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureBusiness(businessId: string): Promise<void> {
|
||||
const exists = await this.businessRepo.exists({ where: { id: businessId } });
|
||||
if (!exists) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'fs';
|
||||
import { extname, join } from 'path';
|
||||
import { Repository } from 'typeorm';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { UserRole } from '../../entities/user.entity';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
|
||||
const ALLOWED_MIME = new Set([
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'image/gif',
|
||||
]);
|
||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
export type LogoVariant = 'light' | 'dark';
|
||||
|
||||
@Injectable()
|
||||
export class BusinessLogosService {
|
||||
private readonly uploadRoot: string;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(BusinessEntity)
|
||||
private readonly businessRepo: Repository<BusinessEntity>,
|
||||
) {
|
||||
this.uploadRoot = join(process.cwd(), process.env.UPLOAD_DIR ?? 'uploads');
|
||||
}
|
||||
|
||||
async upload(
|
||||
businessId: string,
|
||||
variant: LogoVariant,
|
||||
file: Express.Multer.File,
|
||||
actor: AuthUser,
|
||||
): Promise<BusinessEntity> {
|
||||
const business = await this.ensureCanManage(businessId, actor);
|
||||
this.validateFile(file);
|
||||
|
||||
const dir = join(this.uploadRoot, 'businesses', businessId, 'logo');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
const field = variant === 'light' ? 'logoLightUrl' : 'logoDarkUrl';
|
||||
if (business[field]) {
|
||||
this.deleteStoredFile(business[field]!);
|
||||
}
|
||||
|
||||
business[field] = this.saveFile(dir, businessId, variant, file);
|
||||
return this.businessRepo.save(business);
|
||||
}
|
||||
|
||||
async remove(
|
||||
businessId: string,
|
||||
variant: LogoVariant,
|
||||
actor: AuthUser,
|
||||
): Promise<BusinessEntity> {
|
||||
const business = await this.ensureCanManage(businessId, actor);
|
||||
const field = variant === 'light' ? 'logoLightUrl' : 'logoDarkUrl';
|
||||
|
||||
if (business[field]) {
|
||||
this.deleteStoredFile(business[field]!);
|
||||
business[field] = null;
|
||||
return this.businessRepo.save(business);
|
||||
}
|
||||
|
||||
return business;
|
||||
}
|
||||
|
||||
deleteLogoFiles(business: Pick<BusinessEntity, 'logoLightUrl' | 'logoDarkUrl'>): void {
|
||||
for (const url of [business.logoLightUrl, business.logoDarkUrl]) {
|
||||
if (url) {
|
||||
this.deleteStoredFile(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureCanManage(
|
||||
businessId: string,
|
||||
actor: AuthUser,
|
||||
): Promise<BusinessEntity> {
|
||||
const business = await this.businessRepo.findOne({ where: { id: businessId } });
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
if (actor.role === UserRole.ADMIN) {
|
||||
return business;
|
||||
}
|
||||
|
||||
if (actor.role === UserRole.BUSINESS_OWNER && business.ownerId === actor.id) {
|
||||
return business;
|
||||
}
|
||||
|
||||
throw new ForbiddenException('Not allowed to manage this business logo');
|
||||
}
|
||||
|
||||
private validateFile(file: Express.Multer.File | undefined): void {
|
||||
if (!file) {
|
||||
throw new BadRequestException('Image file is required');
|
||||
}
|
||||
if (!ALLOWED_MIME.has(file.mimetype)) {
|
||||
throw new BadRequestException('Only JPEG, PNG, WebP, and GIF images are allowed');
|
||||
}
|
||||
if (file.size > MAX_FILE_BYTES) {
|
||||
throw new BadRequestException('Image must be at most 5 MB');
|
||||
}
|
||||
}
|
||||
|
||||
private saveFile(
|
||||
dir: string,
|
||||
businessId: string,
|
||||
variant: LogoVariant,
|
||||
file: Express.Multer.File,
|
||||
): string {
|
||||
const ext = extname(file.originalname).toLowerCase() || '.jpg';
|
||||
const filename = `logo-${variant}-${randomUUID()}${ext}`;
|
||||
writeFileSync(join(dir, filename), file.buffer);
|
||||
return `businesses/${businessId}/logo/${filename}`;
|
||||
}
|
||||
|
||||
private deleteStoredFile(relativeUrl: string): void {
|
||||
const absolutePath = join(this.uploadRoot, relativeUrl);
|
||||
if (existsSync(absolutePath)) {
|
||||
unlinkSync(absolutePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,16 @@ import {
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { BusinessImageEntity } from '../../entities/business-image.entity';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { UserRole } from '../../entities/user.entity';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
@@ -17,13 +24,18 @@ import { Roles } from '../auth/decorators/roles.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||
import { BusinessesService } from './businesses.service';
|
||||
import { BusinessLogosService } from './business-logos.service';
|
||||
import { AssignBusinessOwnerDto } from './dto/assign-business-owner.dto';
|
||||
import { CreateBusinessDto } from './dto/create-business.dto';
|
||||
import { SetAvailabilityDto } from './dto/set-availability.dto';
|
||||
import { UpdateBusinessDto } from './dto/update-business.dto';
|
||||
|
||||
@Controller('businesses')
|
||||
export class BusinessesController {
|
||||
constructor(private readonly service: BusinessesService) {}
|
||||
constructor(
|
||||
private readonly service: BusinessesService,
|
||||
private readonly logos: BusinessLogosService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@@ -39,6 +51,112 @@ export class BusinessesController {
|
||||
});
|
||||
}
|
||||
|
||||
@Get('mine')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
||||
findMine(@CurrentUser() user: AuthUser): Promise<BusinessEntity[]> {
|
||||
return this.service.findMine(user.id);
|
||||
}
|
||||
|
||||
@Post('mine/images')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.BUSINESS_OWNER)
|
||||
@UseInterceptors(
|
||||
FilesInterceptor('files', 10, {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
uploadMyImages(
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<BusinessImageEntity[]> {
|
||||
return this.service.uploadMyImages(files, user);
|
||||
}
|
||||
|
||||
@Delete('mine/images/:imageId')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.BUSINESS_OWNER)
|
||||
async removeMyImage(
|
||||
@Param('imageId') imageId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<{ success: true }> {
|
||||
await this.service.removeMyImage(imageId, user);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Patch('mine/images/:imageId/cover')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.BUSINESS_OWNER)
|
||||
setMyCoverImage(
|
||||
@Param('imageId') imageId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<BusinessImageEntity> {
|
||||
return this.service.setMyCoverImage(imageId, user);
|
||||
}
|
||||
|
||||
@Post('mine/logo-light')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.BUSINESS_OWNER)
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
uploadMyLogoLight(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<BusinessEntity> {
|
||||
return this.service.uploadMyLogo('light', file, user);
|
||||
}
|
||||
|
||||
@Post('mine/logo-dark')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.BUSINESS_OWNER)
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
uploadMyLogoDark(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<BusinessEntity> {
|
||||
return this.service.uploadMyLogo('dark', file, user);
|
||||
}
|
||||
|
||||
@Delete('mine/logo-light')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.BUSINESS_OWNER)
|
||||
removeMyLogoLight(@CurrentUser() user: AuthUser): Promise<BusinessEntity> {
|
||||
return this.service.removeMyLogo('light', user);
|
||||
}
|
||||
|
||||
@Delete('mine/logo-dark')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.BUSINESS_OWNER)
|
||||
removeMyLogoDark(@CurrentUser() user: AuthUser): Promise<BusinessEntity> {
|
||||
return this.service.removeMyLogo('dark', user);
|
||||
}
|
||||
|
||||
@Get(':id/availability')
|
||||
getAvailability(@Param('id') id: string) {
|
||||
return this.service.getAvailability(id);
|
||||
}
|
||||
|
||||
@Put(':id/availability')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
||||
setAvailability(
|
||||
@Param('id') id: string,
|
||||
@Body() payload: SetAvailabilityDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.setAvailability(id, payload, user);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string): Promise<BusinessEntity> {
|
||||
return this.service.findOne(id);
|
||||
@@ -51,6 +169,96 @@ export class BusinessesController {
|
||||
return this.service.create(payload);
|
||||
}
|
||||
|
||||
@Post(':id/images')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
||||
@UseInterceptors(
|
||||
FilesInterceptor('files', 10, {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
uploadImages(
|
||||
@Param('id') id: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<BusinessImageEntity[]> {
|
||||
return this.service.uploadImages(id, files, user);
|
||||
}
|
||||
|
||||
@Delete(':id/images/:imageId')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
||||
async removeImage(
|
||||
@Param('id') id: string,
|
||||
@Param('imageId') imageId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<{ success: true }> {
|
||||
await this.service.removeImage(id, imageId, user);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post(':id/logo-light')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
uploadLogoLight(
|
||||
@Param('id') id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<BusinessEntity> {
|
||||
return this.logos.upload(id, 'light', file, user);
|
||||
}
|
||||
|
||||
@Post(':id/logo-dark')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
uploadLogoDark(
|
||||
@Param('id') id: string,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<BusinessEntity> {
|
||||
return this.logos.upload(id, 'dark', file, user);
|
||||
}
|
||||
|
||||
@Delete(':id/logo-light')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
||||
removeLogoLight(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<BusinessEntity> {
|
||||
return this.logos.remove(id, 'light', user);
|
||||
}
|
||||
|
||||
@Delete(':id/logo-dark')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
||||
removeLogoDark(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
): Promise<BusinessEntity> {
|
||||
return this.logos.remove(id, 'dark', user);
|
||||
}
|
||||
|
||||
@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)
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BusinessCategoryEntity } from '../../entities/business-category.entity';
|
||||
import { BusinessImageEntity } from '../../entities/business-image.entity';
|
||||
import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { BookingReviewEntity } from '../../entities/booking-review.entity';
|
||||
import { UserEntity } from '../../entities/user.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BusinessImagesService } from './business-images.service';
|
||||
import { BusinessLogosService } from './business-logos.service';
|
||||
import { BusinessesController } from './businesses.controller';
|
||||
import { BusinessesService } from './businesses.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([BusinessEntity, BusinessCategoryEntity, UserEntity]),
|
||||
TypeOrmModule.forFeature([
|
||||
BusinessEntity,
|
||||
BookingReviewEntity,
|
||||
BusinessCategoryEntity,
|
||||
UserEntity,
|
||||
BusinessImageEntity,
|
||||
BusinessAvailabilitySlotEntity,
|
||||
]),
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [BusinessesController],
|
||||
providers: [BusinessesService],
|
||||
providers: [BusinessesService, BusinessImagesService, BusinessLogosService],
|
||||
exports: [BusinessImagesService],
|
||||
})
|
||||
export class BusinessesModule {}
|
||||
|
||||
@@ -6,14 +6,28 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from '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';
|
||||
import { BusinessEntity } from '../../entities/business.entity';
|
||||
import { BookingReviewEntity } from '../../entities/booking-review.entity';
|
||||
import { BusinessImageEntity } from '../../entities/business-image.entity';
|
||||
import { UserEntity, UserRole } from '../../entities/user.entity';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { BusinessImagesService } from './business-images.service';
|
||||
import { BusinessLogosService } from './business-logos.service';
|
||||
import { AssignBusinessOwnerDto } from './dto/assign-business-owner.dto';
|
||||
import { CreateBusinessDto } from './dto/create-business.dto';
|
||||
import { SetAvailabilityDto } from './dto/set-availability.dto';
|
||||
import { UpdateBusinessDto } from './dto/update-business.dto';
|
||||
import { sanitizeBusinessHighlightKeys } from '../../common/constants/business-highlights';
|
||||
|
||||
type FindAllFilters = {
|
||||
categoryId?: string;
|
||||
@@ -25,10 +39,19 @@ export class BusinessesService {
|
||||
constructor(
|
||||
@InjectRepository(BusinessEntity)
|
||||
private readonly repo: Repository<BusinessEntity>,
|
||||
@InjectRepository(BusinessAvailabilitySlotEntity)
|
||||
private readonly availabilityRepo: Repository<BusinessAvailabilitySlotEntity>,
|
||||
@InjectRepository(BusinessCategoryEntity)
|
||||
private readonly categoryRepo: Repository<BusinessCategoryEntity>,
|
||||
@InjectRepository(BusinessImageEntity)
|
||||
private readonly imageRepo: Repository<BusinessImageEntity>,
|
||||
@InjectRepository(UserEntity)
|
||||
private readonly userRepo: Repository<UserEntity>,
|
||||
@InjectRepository(BookingReviewEntity)
|
||||
private readonly reviewRepo: Repository<BookingReviewEntity>,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly images: BusinessImagesService,
|
||||
private readonly logos: BusinessLogosService,
|
||||
) {}
|
||||
|
||||
async findAll(filters: FindAllFilters): Promise<BusinessEntity[]> {
|
||||
@@ -49,34 +72,64 @@ export class BusinessesService {
|
||||
});
|
||||
}
|
||||
|
||||
return query.getMany();
|
||||
const businesses = await query.getMany();
|
||||
await this.attachCoverImages(businesses);
|
||||
await this.attachReviewStats(businesses);
|
||||
return businesses;
|
||||
}
|
||||
|
||||
async findMine(ownerId: string): Promise<BusinessEntity[]> {
|
||||
const businesses = await this.repo.find({
|
||||
where: { ownerId },
|
||||
relations: { category: true, images: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
for (const business of businesses) {
|
||||
business.images = this.sortImages(business.images ?? []);
|
||||
}
|
||||
return businesses;
|
||||
}
|
||||
|
||||
async findOne(id: string): Promise<BusinessEntity> {
|
||||
const business = await this.repo.findOne({
|
||||
where: { id },
|
||||
relations: { category: true },
|
||||
relations: { category: true, images: true },
|
||||
});
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
business.images = this.sortImages(business.images ?? []);
|
||||
await this.attachReviewStats([business]);
|
||||
return business;
|
||||
}
|
||||
|
||||
async create(payload: CreateBusinessDto): Promise<BusinessEntity> {
|
||||
const slugTaken = await this.repo.findOne({ where: { slug: payload.slug } });
|
||||
if (slugTaken) {
|
||||
throw new ConflictException('Slug already exists');
|
||||
const explicitSlug = normalizeSlugInput(payload.slug);
|
||||
if (explicitSlug) {
|
||||
const taken = await this.repo.findOne({ where: { slug: explicitSlug } });
|
||||
if (taken) {
|
||||
throw new ConflictException('Slug already exists');
|
||||
}
|
||||
}
|
||||
|
||||
const slug = await resolveUniqueSlug(
|
||||
payload.title,
|
||||
async (candidate) => Boolean(await this.repo.findOne({ where: { slug: candidate } })),
|
||||
payload.slug,
|
||||
'business',
|
||||
);
|
||||
|
||||
await this.ensureCategoryExists(payload.categoryId);
|
||||
|
||||
const contactPhone = this.normalizeOptionalContactPhone(payload.contactPhone);
|
||||
|
||||
const entity = this.repo.create({
|
||||
title: payload.title,
|
||||
slug: payload.slug,
|
||||
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,
|
||||
@@ -84,7 +137,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(
|
||||
@@ -106,15 +160,109 @@ 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);
|
||||
|
||||
if (payload.heroHighlightKeys !== undefined) {
|
||||
const sanitized = sanitizeBusinessHighlightKeys(payload.heroHighlightKeys);
|
||||
business.heroHighlightKeys = sanitized.length ? sanitized : null;
|
||||
}
|
||||
|
||||
if (business.promoDiscountEnabled) {
|
||||
if (
|
||||
!business.promoDiscountPercent ||
|
||||
business.promoDiscountPercent < 1 ||
|
||||
business.promoDiscountPercent > 100
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'promoDiscountPercent must be between 1 and 100 when discount is enabled',
|
||||
);
|
||||
}
|
||||
const title = business.promoDiscountTitle?.trim();
|
||||
if (!title) {
|
||||
throw new BadRequestException(
|
||||
'promoDiscountTitle is required when discount is enabled',
|
||||
);
|
||||
}
|
||||
business.promoDiscountTitle = title;
|
||||
if (
|
||||
business.promoDiscountMode !== 'first_booking' &&
|
||||
business.promoDiscountMode !== 'always'
|
||||
) {
|
||||
business.promoDiscountMode = 'first_booking';
|
||||
}
|
||||
const code = business.promoDiscountCode?.trim();
|
||||
if (!code) {
|
||||
business.promoDiscountCode = `NEW${business.promoDiscountPercent}`;
|
||||
} else {
|
||||
business.promoDiscountCode = code.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
const saved = await this.repo.save(business);
|
||||
return this.ensureOwnerFromContactPhone(saved);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const result = await this.repo.delete(id);
|
||||
if (!result.affected) {
|
||||
throw new NotFoundException('Business not found');
|
||||
const business = await this.findOne(id);
|
||||
|
||||
const imageUrls = await this.images.listUrlsForBusiness(id);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await deleteBusinessCascade(manager, id);
|
||||
});
|
||||
|
||||
this.images.deleteFiles(imageUrls);
|
||||
this.logos.deleteLogoFiles(business);
|
||||
}
|
||||
|
||||
async getAvailability(businessId: string) {
|
||||
await this.findOne(businessId);
|
||||
const rows = await this.availabilityRepo.find({
|
||||
where: { businessId },
|
||||
order: { dayOfWeek: 'ASC', startMinute: 'ASC' },
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
dayOfWeek: row.dayOfWeek,
|
||||
startTime: this.minuteToHm(row.startMinute),
|
||||
endTime: this.minuteToHm(row.endMinute),
|
||||
}));
|
||||
}
|
||||
|
||||
async setAvailability(
|
||||
businessId: string,
|
||||
dto: SetAvailabilityDto,
|
||||
actor?: AuthUser,
|
||||
) {
|
||||
const business = await this.findOne(businessId);
|
||||
this.assertCanManageAvailability(business, actor);
|
||||
|
||||
const entities: BusinessAvailabilitySlotEntity[] = [];
|
||||
for (const slot of dto.slots) {
|
||||
const startMinute = parseHmToMinute(slot.startTime);
|
||||
const endMinute = parseHmToMinute(slot.endTime);
|
||||
if (endMinute <= startMinute) {
|
||||
throw new BadRequestException('endTime must be after startTime');
|
||||
}
|
||||
entities.push(
|
||||
this.availabilityRepo.create({
|
||||
businessId,
|
||||
dayOfWeek: slot.dayOfWeek,
|
||||
startMinute,
|
||||
endMinute,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await this.availabilityRepo.delete({ businessId });
|
||||
if (entities.length) {
|
||||
await this.availabilityRepo.save(entities);
|
||||
}
|
||||
return this.getAvailability(businessId);
|
||||
}
|
||||
|
||||
async assignOwner(
|
||||
@@ -122,13 +270,22 @@ export class BusinessesService {
|
||||
dto: AssignBusinessOwnerDto,
|
||||
): Promise<BusinessEntity> {
|
||||
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,
|
||||
});
|
||||
@@ -136,17 +293,243 @@ 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<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> {
|
||||
if (!businesses.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = businesses.map((b) => b.id);
|
||||
const rows = (await this.imageRepo.query(
|
||||
`SELECT DISTINCT ON ("businessId") id, "businessId", url, "originalName", "sortOrder", "isCover", "createdAt"
|
||||
FROM business_images
|
||||
WHERE "businessId" = ANY($1::uuid[])
|
||||
ORDER BY "businessId", "isCover" DESC, "sortOrder" ASC`,
|
||||
[ids],
|
||||
)) as Array<{
|
||||
id: string;
|
||||
businessId: string;
|
||||
url: string;
|
||||
originalName: string | null;
|
||||
sortOrder: number;
|
||||
isCover: boolean;
|
||||
createdAt: Date;
|
||||
}>;
|
||||
|
||||
const coverByBusiness = new Map<string, BusinessImageEntity[]>();
|
||||
for (const row of rows) {
|
||||
coverByBusiness.set(row.businessId, [
|
||||
this.imageRepo.create({
|
||||
id: row.id,
|
||||
businessId: row.businessId,
|
||||
url: row.url,
|
||||
originalName: row.originalName,
|
||||
sortOrder: row.sortOrder,
|
||||
isCover: row.isCover,
|
||||
createdAt: row.createdAt,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
for (const business of businesses) {
|
||||
business.images = coverByBusiness.get(business.id) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
private minuteToHm(minute: number): string {
|
||||
const h = Math.floor(minute / 60);
|
||||
const m = minute % 60;
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async uploadImages(
|
||||
id: string,
|
||||
files: Express.Multer.File[],
|
||||
actor: AuthUser,
|
||||
): Promise<BusinessImageEntity[]> {
|
||||
const business = await this.findOne(id);
|
||||
this.assertCanManageBusiness(business, actor);
|
||||
return this.images.upload(id, files);
|
||||
}
|
||||
|
||||
async uploadMyImages(
|
||||
files: Express.Multer.File[],
|
||||
actor: AuthUser,
|
||||
): Promise<BusinessImageEntity[]> {
|
||||
const business = await this.requireOwnedBusiness(actor);
|
||||
return this.images.upload(business.id, files);
|
||||
}
|
||||
|
||||
async uploadMyLogo(
|
||||
variant: 'light' | 'dark',
|
||||
file: Express.Multer.File,
|
||||
actor: AuthUser,
|
||||
): Promise<BusinessEntity> {
|
||||
const business = await this.requireOwnedBusiness(actor);
|
||||
return this.logos.upload(business.id, variant, file, actor);
|
||||
}
|
||||
|
||||
async removeMyLogo(
|
||||
variant: 'light' | 'dark',
|
||||
actor: AuthUser,
|
||||
): Promise<BusinessEntity> {
|
||||
const business = await this.requireOwnedBusiness(actor);
|
||||
return this.logos.remove(business.id, variant, actor);
|
||||
}
|
||||
|
||||
async removeImage(
|
||||
id: string,
|
||||
imageId: string,
|
||||
actor: AuthUser,
|
||||
): Promise<void> {
|
||||
const business = await this.findOne(id);
|
||||
this.assertCanManageBusiness(business, actor);
|
||||
await this.images.remove(id, imageId);
|
||||
}
|
||||
|
||||
async removeMyImage(imageId: string, actor: AuthUser): Promise<void> {
|
||||
const business = await this.requireOwnedBusiness(actor);
|
||||
await this.images.remove(business.id, imageId);
|
||||
}
|
||||
|
||||
async setMyCoverImage(
|
||||
imageId: string,
|
||||
actor: AuthUser,
|
||||
): Promise<BusinessImageEntity> {
|
||||
const business = await this.requireOwnedBusiness(actor);
|
||||
return this.images.setCover(business.id, imageId);
|
||||
}
|
||||
|
||||
private async requireOwnedBusiness(actor: AuthUser): Promise<BusinessEntity> {
|
||||
if (actor.role === UserRole.ADMIN) {
|
||||
throw new ForbiddenException('Use admin image endpoints for this account');
|
||||
}
|
||||
if (actor.role !== UserRole.BUSINESS_OWNER) {
|
||||
throw new ForbiddenException('Insufficient role');
|
||||
}
|
||||
|
||||
const business = await this.repo.findOne({
|
||||
where: { ownerId: actor.id },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
if (!business) {
|
||||
throw new NotFoundException('No business linked to your account');
|
||||
}
|
||||
return business;
|
||||
}
|
||||
|
||||
private sortImages(images: BusinessImageEntity[]): BusinessImageEntity[] {
|
||||
return [...images].sort((a, b) => {
|
||||
if (a.isCover !== b.isCover) {
|
||||
return a.isCover ? -1 : 1;
|
||||
}
|
||||
return a.sortOrder - b.sortOrder;
|
||||
});
|
||||
}
|
||||
|
||||
private assertCanManageBusiness(
|
||||
business: BusinessEntity,
|
||||
actor?: AuthUser,
|
||||
): void {
|
||||
if (!actor || actor.role === UserRole.ADMIN) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
actor.role !== UserRole.BUSINESS_OWNER ||
|
||||
business.ownerId !== actor.id
|
||||
) {
|
||||
throw new ForbiddenException('You can only manage your own business');
|
||||
}
|
||||
}
|
||||
|
||||
private assertCanManageAvailability(
|
||||
business: BusinessEntity,
|
||||
actor?: AuthUser,
|
||||
): void {
|
||||
if (!actor || actor.role === UserRole.ADMIN) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
actor.role !== UserRole.BUSINESS_OWNER ||
|
||||
business.ownerId !== actor.id
|
||||
) {
|
||||
throw new ForbiddenException('You can only manage your own business');
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureCategoryExists(categoryId: string): Promise<void> {
|
||||
const category = await this.categoryRepo.findOne({ where: { id: categoryId } });
|
||||
if (!category) {
|
||||
@@ -154,12 +537,42 @@ 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');
|
||||
private async attachReviewStats(businesses: BusinessEntity[]): Promise<void> {
|
||||
if (!businesses.length) return;
|
||||
|
||||
const ids = businesses.map((business) => business.id);
|
||||
const rows = await this.reviewRepo
|
||||
.createQueryBuilder('review')
|
||||
.select('review.businessId', 'businessId')
|
||||
.addSelect('AVG(review.rating)', 'averageRating')
|
||||
.addSelect('COUNT(review.id)', 'reviewCount')
|
||||
.where('review.businessId IN (:...ids)', { ids })
|
||||
.groupBy('review.businessId')
|
||||
.getRawMany<{
|
||||
businessId: string;
|
||||
averageRating: string;
|
||||
reviewCount: string;
|
||||
}>();
|
||||
|
||||
const statsById = new Map(
|
||||
rows.map((row) => [
|
||||
row.businessId,
|
||||
{
|
||||
averageRating: Number.parseFloat(row.averageRating),
|
||||
reviewCount: Number.parseInt(row.reviewCount, 10),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
for (const business of businesses) {
|
||||
const stats = statsById.get(business.id);
|
||||
(business as BusinessEntity & {
|
||||
averageRating?: number | null;
|
||||
reviewCount?: number;
|
||||
}).averageRating = stats?.averageRating ?? null;
|
||||
(business as BusinessEntity & { reviewCount?: number }).reviewCount =
|
||||
stats?.reviewCount ?? 0;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private assertCanUpdateBusiness(
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { IsIranMobile } from '../../../common/decorators/is-iran-mobile.decorator';
|
||||
|
||||
export class AssignBusinessOwnerDto {
|
||||
@ApiProperty({ example: '09111111111' })
|
||||
@IsString()
|
||||
@Matches(/^(\+?\d{10,15}|09\d{9})$/, {
|
||||
message: 'mobile must be a valid mobile number',
|
||||
})
|
||||
@IsIranMobile()
|
||||
mobile!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Business Admin', nullable: true })
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user