Compare commits

...
37 Commits
Author SHA1 Message Date
Mahdi 67506e2d98 Performing logins, mobile cards, and other tasks 2026-07-16 16:48:13 +03:30
Mahdi 61c8653af6 logo set 2026-07-16 13:32:53 +03:30
Mahdi 893035cef2 AddFirstBookingDiscount 2026-07-16 12:40:52 +03:30
Mahdi 74815cb05c AddServiceLoyalty 2026-07-15 20:41:04 +03:30
Mahdi aa32dae736 add business_owner 2026-07-15 19:14:12 +03:30
Mahdi 3844594506 tipsEnabled 2026-06-29 19:31:26 +03:30
Mahdi 2ba959b3ae awaiting_payment set 2026-06-29 17:11:39 +03:30
Mahdi 2eb6718bf4 awaiting_payment 2026-06-29 15:46:24 +03:30
Mahdi bb5f2ef2b3 edit ios & api 2026-06-13 16:50:46 +03:30
Mahdi 9ec1a476a0 add cancellationReason 2026-06-13 13:42:52 +03:30
Mahdi 1d685b3338 edit image business 2026-06-13 12:39:35 +03:30
Mahdi 4e33d3f8e1 landing desktop1 2026-06-12 20:37:33 +03:30
Mahdi d9b18ba0ea landing desktop 2026-06-12 20:17:59 +03:30
Mahdi d3d61f5e5a add new lagin desktop 2026-06-11 16:20:46 +03:30
Mahdi ea0860d176 add sub title 2026-06-11 12:19:28 +03:30
Mahdi 49bf3118c9 edit business 2026-06-02 11:35:54 +03:30
Mahdi 0d90703827 update concurrentCapacity 2026-06-01 13:26:25 +03:30
Mahdi 150b189be5 update rezerv 2026-05-31 21:36:19 +03:30
Mahdi 7affab06f1 update password 2026-05-31 20:43:11 +03:30
Mahdi 9afd54b522 delete business 2026-05-31 17:02:41 +03:30
Mahdi a4e9761825 edit one business 2026-05-31 16:39:55 +03:30
Mahdi 390df394e9 edit admin 2026-05-31 15:42:20 +03:30
Mahdi a87478d5b9 edit login & otp 2026-05-31 14:06:42 +03:30
Mahdi 9eddcbd2a7 Server-Timing 2026-05-31 09:18:34 +03:30
Mahdi 0673729a4d change web service sms 2026-05-30 11:13:52 +03:30
Mahdi f213ad2453 add business category 2026-05-25 21:37:01 +03:30
Mahdi c502fd86d7 add password in admin 2026-05-25 20:51:19 +03:30
Mahdi f9bce4d872 add & edit time rezerv 2026-05-25 20:22:04 +03:30
Mahdi a8c255a3a4 edit time rezerv 2026-05-25 20:06:10 +03:30
Mahdi 8263198d1e Add time rezerv 2026-05-25 19:12:32 +03:30
Mahdi 26c3e02d6f Treat 0098 SOAP positive SendSMSResult as success 2026-05-25 15:50:32 +03:30
Mahdi 27bd903e9d Restore 0098 SOAP SMS with link fallback and clearer errors 2026-05-25 13:22:13 +03:30
MahdiandCursor 1da070969e Switch OTP SMS to 0098 SOAP API because link endpoint returns HTTP 500
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 18:27:49 +03:30
MahdiandCursor 58dde62f04 Fix 0098 SMS response parsing, logging, and OTP delivery reliability
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 17:57:05 +03:30
MahdiandCursor 1522ff62f5 Make slug optional on create and auto-generate from title when omitted
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 15:58:56 +03:30
MahdiandCursor aa990c3204 Add business registration requests with approval flow, SMS notifications, and owner businesses endpoint.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 18:13:09 +03:30
MahdiandCursor 97a9ee022f Add password login, set-password, business images migration, and user serialization.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 12:10:30 +03:30
109 changed files with 7026 additions and 213 deletions
+26 -5
View File
@@ -25,12 +25,33 @@ UPLOAD_DIR=uploads
# 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
# 0098sms.com — used when AUTH_RETURN_OTP is not true
# From panel: account username, password (or web service password), approved sender line (FROM)
# 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 # فقط وقتی مرچنت دارید و می‌خواهید درگاه واقعی برود
+143
View File
@@ -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);
});
+109
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
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
View File
@@ -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,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}`,
);
}
}),
);
}
}
+224
View File
@@ -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;
}
+8 -7
View File
@@ -4,24 +4,26 @@ import { AttributeDefinitionEntity } from '../../entities/attribute-definition.e
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 all rows that block FK deletes in production DB. */
/** Remove a business and dependent rows that block FK deletes. */
export async function deleteBusinessCascade(
manager: EntityManager,
businessId: string,
onBeforeDeleteBusiness?: (businessId: string) => Promise<void>,
): 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 });
if (onBeforeDeleteBusiness) {
await onBeforeDeleteBusiness(businessId);
}
const result = await manager.delete(BusinessEntity, businessId);
if (!result.affected) {
@@ -34,10 +36,9 @@ export async function deleteCategoryCascade(
manager: EntityManager,
categoryId: string,
businessIds: string[],
onBeforeDeleteBusiness?: (businessId: string) => Promise<void>,
): Promise<void> {
for (const businessId of businessIds) {
await deleteBusinessCascade(manager, businessId, onBeforeDeleteBusiness);
await deleteBusinessCascade(manager, businessId);
}
await manager.delete(ServiceTemplateEntity, { categoryId });
+35
View File
@@ -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;
}
}
+58
View File
@@ -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');
}
+25
View File
@@ -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"
`);
}
}
+53
View File
@@ -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;
}
+44
View File
@@ -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,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;
}
+12
View File
@@ -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;
+59
View File
@@ -10,8 +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';
@@ -26,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;
@@ -44,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',
})
@@ -71,12 +122,20 @@ export class BusinessEntity {
@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;
}
+23
View File
@@ -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;
}
+2 -2
View File
@@ -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;
+3
View File
@@ -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[];
+15 -3
View File
@@ -5,17 +5,28 @@ 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,
});
}
}
@@ -32,10 +43,11 @@ async function bootstrap() {
}),
);
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(
{
+16
View File
@@ -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) {
+1 -1
View File
@@ -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 {}
+91 -30
View File
@@ -16,10 +16,17 @@ 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';
@@ -28,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()
@@ -91,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}`,
);
}
}
@@ -113,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');
}
@@ -148,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> {
@@ -193,16 +264,6 @@ 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 = normalizeIranMobile(mobile);
if (!isValidIranMobile(normalized)) {
@@ -212,7 +273,7 @@ export class AuthService {
}
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;
}
+16
View File
@@ -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;
}
+3 -3
View File
@@ -8,10 +8,10 @@ export class VerifyOtpDto {
@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
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
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 '***';
+132 -3
View File
@@ -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')
+15
View File
@@ -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;
}
@@ -6,6 +6,7 @@ import {
import { InjectRepository } from '@nestjs/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';
@@ -38,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);
@@ -77,10 +89,16 @@ export class BusinessCategoriesService {
});
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, async (businessId) => {
await this.images.removeAllForBusiness(businessId);
});
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()
@@ -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;
}
@@ -92,6 +92,19 @@ export class BusinessImagesService {
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 },
@@ -116,11 +129,23 @@ export class BusinessImagesService {
}
}
async removeAllForBusiness(businessId: string): Promise<void> {
const images = await this.imageRepo.find({ where: { businessId } });
for (const image of images) {
this.deleteFile(image.url);
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 });
}
@@ -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);
}
}
}
+179 -7
View File
@@ -6,12 +6,14 @@ import {
Param,
Patch,
Post,
Put,
Query,
UploadedFile,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { BusinessImageEntity } from '../../entities/business-image.entity';
import { BusinessEntity } from '../../entities/business.entity';
@@ -21,17 +23,18 @@ 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 { BusinessImagesService } from './business-images.service';
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,
private readonly images: BusinessImagesService,
private readonly logos: BusinessLogosService,
) {}
@Get()
@@ -48,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);
@@ -62,7 +171,7 @@ export class BusinessesController {
@Post(':id/images')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
@UseInterceptors(
FilesInterceptor('files', 10, {
storage: memoryStorage(),
@@ -72,21 +181,84 @@ export class BusinessesController {
uploadImages(
@Param('id') id: string,
@UploadedFiles() files: Express.Multer.File[],
@CurrentUser() user: AuthUser,
): Promise<BusinessImageEntity[]> {
return this.images.upload(id, files);
return this.service.uploadImages(id, files, user);
}
@Delete(':id/images/:imageId')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
async removeImage(
@Param('id') id: string,
@Param('imageId') imageId: string,
@CurrentUser() user: AuthUser,
): Promise<{ success: true }> {
await this.images.remove(id, imageId);
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)
+6 -1
View File
@@ -2,10 +2,13 @@ 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';
@@ -13,14 +16,16 @@ import { BusinessesService } from './businesses.service';
imports: [
TypeOrmModule.forFeature([
BusinessEntity,
BookingReviewEntity,
BusinessCategoryEntity,
UserEntity,
BusinessImageEntity,
BusinessAvailabilitySlotEntity,
]),
AuthModule,
],
controllers: [BusinessesController],
providers: [BusinessesService, BusinessImagesService],
providers: [BusinessesService, BusinessImagesService, BusinessLogosService],
exports: [BusinessImagesService],
})
export class BusinessesModule {}
+429 -26
View File
@@ -8,14 +8,26 @@ import {
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { deleteBusinessCascade } from '../../common/utils/business-cascade.util';
import { BusinessImagesService } from './business-images.service';
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;
@@ -27,22 +39,26 @@ 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[]> {
const query = this.repo
.createQueryBuilder('business')
.leftJoinAndSelect('business.category', 'category')
.leftJoinAndSelect('business.images', 'images')
.orderBy('business.createdAt', 'DESC')
.addOrderBy('images.isCover', 'DESC')
.addOrderBy('images.sortOrder', 'ASC');
.orderBy('business.createdAt', 'DESC');
if (filters.categoryId) {
query.andWhere('business.categoryId = :categoryId', {
@@ -56,7 +72,22 @@ 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> {
@@ -67,23 +98,38 @@ export class BusinessesService {
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,
@@ -91,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(
@@ -113,18 +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> {
await this.findOne(id);
const business = await this.findOne(id);
const imageUrls = await this.images.listUrlsForBusiness(id);
await this.dataSource.transaction(async (manager) => {
await deleteBusinessCascade(manager, id, async (businessId) => {
await this.images.removeAllForBusiness(businessId);
});
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(
@@ -132,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,
});
@@ -146,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) {
@@ -164,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(
@@ -16,18 +16,25 @@ export class CreateBusinessDto {
@MaxLength(150)
title!: string;
@ApiProperty({ example: 'shine-carwash' })
@ApiPropertyOptional({ example: 'shine-carwash' })
@IsOptional()
@IsString()
@MaxLength(120)
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
message: 'slug must be lowercase words separated by hyphens',
})
slug!: string;
slug?: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
categoryId!: string;
@ApiPropertyOptional({ example: 'تمیزی که می‌درخشد' })
@IsOptional()
@IsString()
@MaxLength(150)
subtitle?: string;
@ApiPropertyOptional({ example: 'Premium carwash service' })
@IsOptional()
@IsString()
@@ -0,0 +1,37 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsInt,
IsString,
Max,
Min,
ValidateNested,
} from 'class-validator';
export class AvailabilitySlotDto {
@ApiProperty({ description: '0=شنبه … 6=جمعه', minimum: 0, maximum: 6 })
@Type(() => Number)
@IsInt()
@Min(0)
@Max(6)
dayOfWeek!: number;
@ApiProperty({ example: '09:00', description: 'HH:mm 24h (9:00 also accepted)' })
@IsString()
startTime!: string;
@ApiProperty({ example: '17:00', description: 'HH:mm 24h (17:30 also accepted)' })
@IsString()
endTime!: string;
}
export class SetAvailabilityDto {
@ApiProperty({ type: [AvailabilitySlotDto] })
@IsArray()
@ArrayMaxSize(70)
@ValidateNested({ each: true })
@Type(() => AvailabilitySlotDto)
slots!: AvailabilitySlotDto[];
}
@@ -1,13 +1,18 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import {
IsArray,
ArrayMaxSize,
IsBoolean,
IsInt,
IsLatitude,
IsLongitude,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
MaxLength,
Min,
} from 'class-validator';
export class UpdateBusinessDto {
@@ -31,6 +36,12 @@ export class UpdateBusinessDto {
@IsUUID()
categoryId?: string;
@ApiPropertyOptional({ example: 'تمیزی که می‌درخشد', nullable: true })
@IsOptional()
@IsString()
@MaxLength(150)
subtitle?: string | null;
@ApiPropertyOptional({ example: 'Premium carwash service', nullable: true })
@IsOptional()
@IsString()
@@ -68,4 +79,117 @@ export class UpdateBusinessDto {
@IsOptional()
@IsBoolean()
isActive?: boolean;
@ApiPropertyOptional({
example: false,
description: 'When true, services are priced per owner-defined class (e.g. car size)',
})
@IsOptional()
@IsBoolean()
serviceClassesEnabled?: boolean;
@ApiPropertyOptional({
example: 1,
description: 'How many customers can be served at the same time',
minimum: 1,
maximum: 20,
})
@IsOptional()
@IsInt()
@Min(1)
@Max(20)
concurrentCapacity?: number;
@ApiPropertyOptional({
example: 30,
description: 'Minutes between available booking slot start times',
minimum: 5,
maximum: 120,
})
@IsOptional()
@IsInt()
@Min(5)
@Max(120)
slotStepMinutes?: number;
@ApiPropertyOptional({
example: false,
description:
'When true, customer bookings are approved automatically and proceed to online payment',
})
@IsOptional()
@IsBoolean()
autoApproveBookings?: boolean;
@ApiPropertyOptional({
example: false,
description:
'When true, customers see an optional gratuity (tip) step after rating a completed booking',
})
@IsOptional()
@IsBoolean()
tipsEnabled?: boolean;
@ApiPropertyOptional({
example: true,
description: 'When true, a promotional percentage discount is active for this business',
})
@IsOptional()
@IsBoolean()
promoDiscountEnabled?: boolean;
@ApiPropertyOptional({
example: 'اولین رزرو شما',
nullable: true,
description: 'Display title for the promo (owner-defined)',
})
@IsOptional()
@IsString()
@MaxLength(150)
promoDiscountTitle?: string | null;
@ApiPropertyOptional({
example: 10,
nullable: true,
description: 'Discount percent (1–100)',
minimum: 1,
maximum: 100,
})
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
promoDiscountPercent?: number | null;
@ApiPropertyOptional({
example: 'NEW10',
nullable: true,
description: 'Display code shown to customers (auto-applied when eligible)',
})
@IsOptional()
@IsString()
@MaxLength(40)
promoDiscountCode?: string | null;
@ApiPropertyOptional({
example: 'first_booking',
enum: ['first_booking', 'always'],
description:
'first_booking = only the customer’s first booking at this business; always = every booking while enabled',
})
@IsOptional()
@IsString()
@Matches(/^(first_booking|always)$/)
promoDiscountMode?: 'first_booking' | 'always';
@ApiPropertyOptional({
type: [String],
example: ['customer_satisfaction', 'professional_staff', 'online_booking'],
description: 'Up to 3 highlight keys from the platform catalog for the landing hero',
})
@IsOptional()
@IsArray()
@ArrayMaxSize(3)
@IsString({ each: true })
heroHighlightKeys?: string[] | null;
}
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { ZarinpalService } from './zarinpal.service';
@Module({
providers: [ZarinpalService],
exports: [ZarinpalService],
})
export class PaymentsModule {}
+149
View File
@@ -0,0 +1,149 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
type ZarinpalRequestResponse = {
data?: {
code: number;
message: string;
authority?: string;
fee_type?: string;
fee?: number;
};
errors?: unknown;
};
type ZarinpalVerifyResponse = {
data?: {
code: number;
message: string;
ref_id?: number;
card_pan?: string;
fee_type?: string;
fee?: number;
};
errors?: unknown;
};
@Injectable()
export class ZarinpalService {
private readonly logger = new Logger(ZarinpalService.name);
private readonly merchantId: string;
private readonly sandbox: boolean;
private readonly devMode: boolean;
constructor(private readonly config: ConfigService) {
this.merchantId = this.config.get<string>('ZARINPAL_MERCHANT_ID') ?? '';
this.sandbox = this.config.get<string>('ZARINPAL_SANDBOX') === 'true';
const explicitMock = this.config.get<string>('PAYMENT_MOCK_MODE');
// بدون مرچنت ID، پیش‌فرض پرداخت سوری است (مناسب تست). PAYMENT_MOCK_MODE=false آن را خاموش می‌کند.
this.devMode =
explicitMock === 'true' ||
(explicitMock !== 'false' && !this.merchantId);
if (this.devMode) {
this.logger.log('Payment mock mode is active (no real gateway)');
}
}
isMockMode(): boolean {
return this.devMode;
}
isConfigured(): boolean {
return Boolean(this.merchantId) || this.devMode;
}
getStartUrl(authority: string): string {
const host = this.sandbox ? 'https://sandbox.zarinpal.com' : 'https://www.zarinpal.com';
return `${host}/pg/StartPay/${authority}`;
}
async requestPayment(params: {
amountToman: number;
description: string;
callbackUrl: string;
mobile?: string | null;
}): Promise<{ authority: string; startUrl: string; mock?: boolean }> {
if (!this.devMode && params.amountToman < 1000) {
throw new BadRequestException('Payment amount must be at least 1000 toman');
}
if (this.devMode) {
const authority = `mock-${Date.now()}`;
return { authority, startUrl: '', mock: true };
}
if (!this.merchantId) {
throw new BadRequestException('Online payment is not configured');
}
const base = this.sandbox
? 'https://sandbox.zarinpal.com/pg/v4/payment'
: 'https://api.zarinpal.com/pg/v4/payment';
const body = {
merchant_id: this.merchantId,
amount: params.amountToman,
callback_url: params.callbackUrl,
description: params.description,
metadata: params.mobile ? { mobile: params.mobile } : undefined,
};
const res = await fetch(`${base}/request.json`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = (await res.json()) as ZarinpalRequestResponse;
const code = json.data?.code;
const authority = json.data?.authority;
if (code !== 100 || !authority) {
this.logger.warn(`Zarinpal request failed: ${JSON.stringify(json)}`);
throw new BadRequestException(
json.data?.message ?? 'Could not initiate online payment',
);
}
return { authority, startUrl: this.getStartUrl(authority) };
}
async verifyPayment(params: {
authority: string;
amountToman: number;
}): Promise<{ refId: string }> {
if (this.devMode && params.authority.startsWith('mock-')) {
return { refId: params.authority };
}
if (!this.merchantId) {
throw new BadRequestException('Online payment is not configured');
}
const base = this.sandbox
? 'https://sandbox.zarinpal.com/pg/v4/payment'
: 'https://api.zarinpal.com/pg/v4/payment';
const res = await fetch(`${base}/verify.json`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
merchant_id: this.merchantId,
amount: params.amountToman,
authority: params.authority,
}),
});
const json = (await res.json()) as ZarinpalVerifyResponse;
const code = json.data?.code;
if (code !== 100 && code !== 101) {
this.logger.warn(`Zarinpal verify failed: ${JSON.stringify(json)}`);
throw new BadRequestException(
json.data?.message ?? 'Payment verification failed',
);
}
return { refId: String(json.data?.ref_id ?? '') };
}
}
@@ -0,0 +1,83 @@
import {
Controller,
Delete,
Get,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { UserRole } from '../../entities/user.entity';
import { Roles } from '../auth/decorators/roles.decorator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { PlatformSettingsDto } from './platform-settings.dto';
import { PlatformSettingsService } from './platform-settings.service';
const imageUpload = FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 5 * 1024 * 1024 },
});
@Controller('platform-settings')
export class PlatformSettingsController {
constructor(private readonly service: PlatformSettingsService) {}
@Get()
getSettings(): Promise<PlatformSettingsDto> {
return this.service.getSettings();
}
@Post('login-hero-image')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseInterceptors(imageUpload)
uploadLoginHeroImage(
@UploadedFile() file: Express.Multer.File,
): Promise<PlatformSettingsDto> {
return this.service.uploadLoginHeroImage(file);
}
@Delete('login-hero-image')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
removeLoginHeroImage(): Promise<PlatformSettingsDto> {
return this.service.removeLoginHeroImage();
}
@Post('logo-light')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseInterceptors(imageUpload)
uploadLogoLight(
@UploadedFile() file: Express.Multer.File,
): Promise<PlatformSettingsDto> {
return this.service.uploadLogo('light', file);
}
@Delete('logo-light')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
removeLogoLight(): Promise<PlatformSettingsDto> {
return this.service.removeLogo('light');
}
@Post('logo-dark')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
@UseInterceptors(imageUpload)
uploadLogoDark(
@UploadedFile() file: Express.Multer.File,
): Promise<PlatformSettingsDto> {
return this.service.uploadLogo('dark', file);
}
@Delete('logo-dark')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
removeLogoDark(): Promise<PlatformSettingsDto> {
return this.service.removeLogo('dark');
}
}
@@ -0,0 +1,19 @@
import { PlatformSettingsEntity } from '../../entities/platform-settings.entity';
export type PlatformSettingsDto = {
loginHeroImageUrl: string | null;
logoLightUrl: string | null;
logoDarkUrl: string | null;
};
export type LogoVariant = 'light' | 'dark';
export function toPlatformSettingsDto(
row: PlatformSettingsEntity,
): PlatformSettingsDto {
return {
loginHeroImageUrl: row.loginHeroImageUrl,
logoLightUrl: row.logoLightUrl,
logoDarkUrl: row.logoDarkUrl,
};
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PlatformSettingsEntity } from '../../entities/platform-settings.entity';
import { AuthModule } from '../auth/auth.module';
import { PlatformSettingsController } from './platform-settings.controller';
import { PlatformSettingsService } from './platform-settings.service';
@Module({
imports: [TypeOrmModule.forFeature([PlatformSettingsEntity]), AuthModule],
controllers: [PlatformSettingsController],
providers: [PlatformSettingsService],
exports: [PlatformSettingsService],
})
export class PlatformSettingsModule {}
@@ -0,0 +1,154 @@
import { BadRequestException, Injectable } 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 { PlatformSettingsEntity } from '../../entities/platform-settings.entity';
import {
LogoVariant,
PlatformSettingsDto,
toPlatformSettingsDto,
} from './platform-settings.dto';
const ALLOWED_MIME = new Set([
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
]);
const MAX_FILE_BYTES = 5 * 1024 * 1024;
const SETTINGS_ID = 1;
@Injectable()
export class PlatformSettingsService {
private readonly uploadRoot: string;
constructor(
@InjectRepository(PlatformSettingsEntity)
private readonly repo: Repository<PlatformSettingsEntity>,
) {
this.uploadRoot = join(process.cwd(), process.env.UPLOAD_DIR ?? 'uploads');
}
async getSettings(): Promise<PlatformSettingsDto> {
const row = await this.ensureRow();
return toPlatformSettingsDto(row);
}
async uploadLoginHeroImage(
file: Express.Multer.File,
): Promise<PlatformSettingsDto> {
this.validateFile(file);
const row = await this.ensureRow();
const dir = join(this.uploadRoot, 'platform');
mkdirSync(dir, { recursive: true });
if (row.loginHeroImageUrl) {
this.deleteStoredFile(row.loginHeroImageUrl);
}
const relativeUrl = this.saveFile(dir, 'login-hero', file);
row.loginHeroImageUrl = relativeUrl;
row.updatedAt = new Date();
await this.repo.save(row);
return toPlatformSettingsDto(row);
}
async removeLoginHeroImage(): Promise<PlatformSettingsDto> {
const row = await this.ensureRow();
if (row.loginHeroImageUrl) {
this.deleteStoredFile(row.loginHeroImageUrl);
row.loginHeroImageUrl = null;
row.updatedAt = new Date();
await this.repo.save(row);
}
return toPlatformSettingsDto(row);
}
async uploadLogo(
variant: LogoVariant,
file: Express.Multer.File,
): Promise<PlatformSettingsDto> {
this.validateFile(file);
const row = await this.ensureRow();
const dir = join(this.uploadRoot, 'platform');
mkdirSync(dir, { recursive: true });
const field = variant === 'light' ? 'logoLightUrl' : 'logoDarkUrl';
if (row[field]) {
this.deleteStoredFile(row[field]!);
}
const relativeUrl = this.saveFile(dir, `logo-${variant}`, file);
row[field] = relativeUrl;
row.updatedAt = new Date();
await this.repo.save(row);
return toPlatformSettingsDto(row);
}
async removeLogo(variant: LogoVariant): Promise<PlatformSettingsDto> {
const row = await this.ensureRow();
const field = variant === 'light' ? 'logoLightUrl' : 'logoDarkUrl';
if (row[field]) {
this.deleteStoredFile(row[field]!);
row[field] = null;
row.updatedAt = new Date();
await this.repo.save(row);
}
return toPlatformSettingsDto(row);
}
private async ensureRow(): Promise<PlatformSettingsEntity> {
let row = await this.repo.findOne({ where: { id: SETTINGS_ID } });
if (!row) {
row = this.repo.create({
id: SETTINGS_ID,
loginHeroImageUrl: null,
logoLightUrl: null,
logoDarkUrl: null,
});
await this.repo.save(row);
}
return row;
}
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,
prefix: string,
file: Express.Multer.File,
): string {
const ext = extname(file.originalname).toLowerCase() || '.jpg';
const filename = `${prefix}-${randomUUID()}${ext}`;
const relativeUrl = `platform/${filename}`;
writeFileSync(join(dir, filename), file.buffer);
return relativeUrl;
}
private deleteStoredFile(relativeUrl: string): void {
const absolutePath = join(this.uploadRoot, relativeUrl);
if (existsSync(absolutePath)) {
unlinkSync(absolutePath);
}
}
}

Some files were not shown because too many files have changed in this diff Show More