mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
Initial commit: Meshkee CMS API
NestJS backend with Prisma, Docker Compose for Postgres/Redis, and deploy docs for the production VM.
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { Body, Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { BusinessProfileService } from './business-profile.service';
|
||||
import { UpdateBusinessProfileDto } from './dto/update-business-profile.dto';
|
||||
|
||||
@Controller('businesses/:businessId/profile')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class BusinessProfileController {
|
||||
constructor(private readonly service: BusinessProfileService) {}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('business.read')
|
||||
get(@Param('businessId') businessId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.service.get(businessId, user);
|
||||
}
|
||||
|
||||
@Patch()
|
||||
@RequireBusinessPermission('business.update')
|
||||
update(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: UpdateBusinessProfileDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.update(businessId, dto, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BusinessProfileController } from './business-profile.controller';
|
||||
import { BusinessProfileService } from './business-profile.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [BusinessProfileController],
|
||||
providers: [BusinessProfileService],
|
||||
})
|
||||
export class BusinessProfileModule {}
|
||||
@@ -0,0 +1,510 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { MediaType, Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import sharp from 'sharp';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { UpdateBusinessProfileDto } from './dto/update-business-profile.dto';
|
||||
import {
|
||||
BusinessAddress,
|
||||
BusinessProfile,
|
||||
DEFAULT_BUSINESS_SOCIAL_MEDIA,
|
||||
} from './business-profile.types';
|
||||
import {
|
||||
normalizeEmails,
|
||||
normalizePhoneNumbers,
|
||||
normalizeSocialMedia,
|
||||
toPrismaJsonEmails,
|
||||
toPrismaJsonPhoneNumbers,
|
||||
toPrismaJsonSocialMedia,
|
||||
} from './business-profile.util';
|
||||
|
||||
const FAVICON_SIZE = 64;
|
||||
const FAVICON_RADIUS = 14;
|
||||
const FAVICON_PADDING = 8;
|
||||
|
||||
function roundedRectSvg(size: number, radius: number) {
|
||||
return Buffer.from(
|
||||
`<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="${size}" height="${size}" rx="${radius}" ry="${radius}" fill="#fff"/>
|
||||
</svg>`,
|
||||
);
|
||||
}
|
||||
|
||||
function roundedMaskSvg(size: number, radius: number) {
|
||||
return Buffer.from(
|
||||
`<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="${size}" height="${size}" rx="${radius}" ry="${radius}" fill="#fff"/>
|
||||
</svg>`,
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BusinessProfileService {
|
||||
private readonly logger = new Logger(BusinessProfileService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly storage: StorageService,
|
||||
) {}
|
||||
|
||||
async get(businessIdRaw: string, actor: AuthUser) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'business.read');
|
||||
|
||||
let business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
include: {
|
||||
categoryAssignments: true,
|
||||
addresses: { orderBy: { createdAt: 'asc' } },
|
||||
logoMedia: true,
|
||||
faviconMedia: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
// Backfill or upgrade favicon when logo exists but favicon is missing / outdated.
|
||||
if (business.logoMediaId) {
|
||||
const faviconMeta = business.faviconMedia?.metadata as
|
||||
| { faviconStyle?: string }
|
||||
| null
|
||||
| undefined;
|
||||
const needsFavicon =
|
||||
!business.faviconMediaId || faviconMeta?.faviconStyle !== 'rounded-v1';
|
||||
|
||||
if (needsFavicon) {
|
||||
try {
|
||||
await this.syncFaviconFromLogo(
|
||||
businessId,
|
||||
business.logoMediaId,
|
||||
actor.id,
|
||||
business.faviconMediaId,
|
||||
);
|
||||
business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
include: {
|
||||
categoryAssignments: true,
|
||||
addresses: { orderBy: { createdAt: 'asc' } },
|
||||
logoMedia: true,
|
||||
faviconMedia: true,
|
||||
},
|
||||
});
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Favicon sync failed for business ${businessIdRaw}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
return {
|
||||
businessId: business.id.toString(),
|
||||
profile: this.serializeProfile(business),
|
||||
addresses: business.addresses.map((item) => this.serializeAddress(item)),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
businessIdRaw: string,
|
||||
dto: UpdateBusinessProfileDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'business.update');
|
||||
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
include: {
|
||||
categoryAssignments: true,
|
||||
addresses: true,
|
||||
logoMedia: true,
|
||||
faviconMedia: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!business) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
if (dto.categoryIds) {
|
||||
await this.validateCategoryIds(dto.categoryIds);
|
||||
}
|
||||
|
||||
if (dto.logoMediaId !== undefined && dto.logoMediaId !== null) {
|
||||
await this.assertLogoMedia(businessId, BigInt(dto.logoMediaId));
|
||||
}
|
||||
|
||||
const previousFaviconMediaId = business.faviconMediaId;
|
||||
const logoChanged =
|
||||
dto.logoMediaId !== undefined &&
|
||||
(dto.logoMediaId === null
|
||||
? business.logoMediaId !== null
|
||||
: business.logoMediaId?.toString() !== String(dto.logoMediaId));
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const data: Prisma.BusinessUpdateInput = {};
|
||||
|
||||
if (dto.nameEn !== undefined) data.name = dto.nameEn.trim();
|
||||
if (dto.nameFa !== undefined) data.nameFa = dto.nameFa.trim();
|
||||
if (dto.about !== undefined) data.about = dto.about.trim() || null;
|
||||
if (dto.vision !== undefined) data.vision = dto.vision.trim() || null;
|
||||
if (dto.emails !== undefined) {
|
||||
data.emails = toPrismaJsonEmails(
|
||||
dto.emails.map((item) => item.trim()).filter(Boolean),
|
||||
);
|
||||
}
|
||||
if (dto.phoneNumbers !== undefined) {
|
||||
data.phoneNumbers = toPrismaJsonPhoneNumbers(dto.phoneNumbers);
|
||||
}
|
||||
if (dto.socialMedia !== undefined) {
|
||||
data.socialMedia = toPrismaJsonSocialMedia({
|
||||
...DEFAULT_BUSINESS_SOCIAL_MEDIA,
|
||||
...normalizeSocialMedia(business.socialMedia),
|
||||
...dto.socialMedia,
|
||||
});
|
||||
}
|
||||
if (dto.logoMediaId !== undefined) {
|
||||
data.logoMedia =
|
||||
dto.logoMediaId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.logoMediaId) } };
|
||||
|
||||
if (dto.logoMediaId === null) {
|
||||
data.faviconMedia = { disconnect: true };
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(data).length > 0) {
|
||||
await tx.business.update({
|
||||
where: { id: businessId },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.categoryIds) {
|
||||
await tx.businessCategoryAssignment.deleteMany({
|
||||
where: { businessId },
|
||||
});
|
||||
await tx.businessCategoryAssignment.createMany({
|
||||
data: dto.categoryIds.map((id) => ({
|
||||
businessId,
|
||||
categoryId: BigInt(id),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (dto.addresses) {
|
||||
await this.syncAddresses(tx, businessId, dto.addresses);
|
||||
}
|
||||
});
|
||||
|
||||
if (logoChanged) {
|
||||
if (dto.logoMediaId === null) {
|
||||
await this.deleteFaviconMedia(previousFaviconMediaId);
|
||||
} else if (dto.logoMediaId != null) {
|
||||
try {
|
||||
await this.syncFaviconFromLogo(
|
||||
businessId,
|
||||
BigInt(dto.logoMediaId),
|
||||
actor.id,
|
||||
previousFaviconMediaId,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to generate favicon for business ${businessIdRaw}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return this.get(businessIdRaw, actor);
|
||||
}
|
||||
|
||||
private async syncFaviconFromLogo(
|
||||
businessId: bigint,
|
||||
logoMediaId: bigint,
|
||||
uploadedBy: bigint,
|
||||
previousFaviconMediaId: bigint | null,
|
||||
) {
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
select: { slug: true },
|
||||
});
|
||||
if (!business) return;
|
||||
|
||||
const logo = await this.prisma.media.findFirst({
|
||||
where: { id: logoMediaId, businessId },
|
||||
});
|
||||
if (!logo) {
|
||||
throw new BadRequestException('Logo media not found for this business');
|
||||
}
|
||||
|
||||
const sourceBuffer = await this.storage.getBuffer(
|
||||
logo.storagePath,
|
||||
logo.storageDisk,
|
||||
);
|
||||
|
||||
const innerSize = FAVICON_SIZE - FAVICON_PADDING * 2;
|
||||
const logoLayer = await sharp(sourceBuffer)
|
||||
.resize(innerSize, innerSize, {
|
||||
fit: 'contain',
|
||||
background: { r: 255, g: 255, b: 255, alpha: 0 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
// White rounded card + centered logo, then clip to rounded alpha
|
||||
// so the tab icon shows soft corners (ChatGPT-style).
|
||||
const composed = await sharp(roundedRectSvg(FAVICON_SIZE, FAVICON_RADIUS))
|
||||
.composite([
|
||||
{
|
||||
input: logoLayer,
|
||||
top: FAVICON_PADDING,
|
||||
left: FAVICON_PADDING,
|
||||
},
|
||||
])
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const faviconBuffer = await sharp(composed)
|
||||
.composite([
|
||||
{
|
||||
input: await sharp(roundedMaskSvg(FAVICON_SIZE, FAVICON_RADIUS))
|
||||
.png()
|
||||
.toBuffer(),
|
||||
blend: 'dest-in',
|
||||
},
|
||||
])
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const fileName = `${randomUUID()}-favicon.png`;
|
||||
const storageKey = `businesses/${business.slug}/${businessId}/media/${fileName}`;
|
||||
const stored = await this.storage.upload({
|
||||
key: storageKey,
|
||||
body: faviconBuffer,
|
||||
contentType: 'image/png',
|
||||
});
|
||||
|
||||
const favicon = await this.prisma.media.create({
|
||||
data: {
|
||||
businessId,
|
||||
uploadedBy,
|
||||
mediaType: MediaType.image,
|
||||
storageDisk: stored.storageDisk,
|
||||
storagePath: stored.storagePath,
|
||||
publicUrl: stored.publicUrl,
|
||||
fileName,
|
||||
originalFileName: 'favicon.png',
|
||||
mimeType: 'image/png',
|
||||
fileSizeBytes: BigInt(faviconBuffer.length),
|
||||
width: FAVICON_SIZE,
|
||||
height: FAVICON_SIZE,
|
||||
altText: 'Business favicon',
|
||||
metadata: {
|
||||
derivedFrom: 'logo',
|
||||
sourceMediaId: logoMediaId.toString(),
|
||||
purpose: 'favicon',
|
||||
faviconStyle: 'rounded-v1',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.business.update({
|
||||
where: { id: businessId },
|
||||
data: { faviconMediaId: favicon.id },
|
||||
});
|
||||
|
||||
if (
|
||||
previousFaviconMediaId &&
|
||||
previousFaviconMediaId.toString() !== favicon.id.toString()
|
||||
) {
|
||||
await this.deleteFaviconMedia(previousFaviconMediaId);
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteFaviconMedia(faviconMediaId: bigint | null) {
|
||||
if (!faviconMediaId) return;
|
||||
|
||||
const media = await this.prisma.media.findUnique({
|
||||
where: { id: faviconMediaId },
|
||||
});
|
||||
if (!media) return;
|
||||
|
||||
await this.prisma.media.delete({ where: { id: faviconMediaId } }).catch(() => {
|
||||
// already removed or still referenced
|
||||
});
|
||||
|
||||
try {
|
||||
await this.storage.delete(media.storagePath, media.storageDisk);
|
||||
} catch {
|
||||
// orphaned object can be cleaned later
|
||||
}
|
||||
}
|
||||
|
||||
private serializeProfile(business: {
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
about: string | null;
|
||||
vision: string | null;
|
||||
emails: unknown;
|
||||
phoneNumbers: unknown;
|
||||
socialMedia: unknown;
|
||||
logoMediaId: bigint | null;
|
||||
logoMedia: { publicUrl: string } | null;
|
||||
faviconMediaId: bigint | null;
|
||||
faviconMedia: { publicUrl: string } | null;
|
||||
categoryAssignments: { categoryId: bigint }[];
|
||||
}): BusinessProfile {
|
||||
return {
|
||||
nameEn: business.name,
|
||||
nameFa: business.nameFa ?? '',
|
||||
about: business.about ?? '',
|
||||
vision: business.vision ?? '',
|
||||
emails: normalizeEmails(business.emails),
|
||||
phoneNumbers: normalizePhoneNumbers(business.phoneNumbers),
|
||||
socialMedia: normalizeSocialMedia(business.socialMedia),
|
||||
logoMediaId: business.logoMediaId?.toString() ?? null,
|
||||
logoUrl: business.logoMedia?.publicUrl ?? null,
|
||||
faviconMediaId: business.faviconMediaId?.toString() ?? null,
|
||||
faviconUrl:
|
||||
business.faviconMedia?.publicUrl ??
|
||||
business.logoMedia?.publicUrl ??
|
||||
null,
|
||||
categoryIds: business.categoryAssignments.map((item) =>
|
||||
item.categoryId.toString(),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private serializeAddress(address: {
|
||||
id: bigint;
|
||||
province: string;
|
||||
city: string;
|
||||
address: string;
|
||||
postalCode: string | null;
|
||||
landline: string | null;
|
||||
}): BusinessAddress {
|
||||
return {
|
||||
id: address.id.toString(),
|
||||
province: address.province,
|
||||
city: address.city,
|
||||
address: address.address,
|
||||
postalCode: address.postalCode,
|
||||
landline: address.landline,
|
||||
};
|
||||
}
|
||||
|
||||
private async syncAddresses(
|
||||
tx: Prisma.TransactionClient,
|
||||
businessId: bigint,
|
||||
addresses: UpdateBusinessProfileDto['addresses'],
|
||||
) {
|
||||
if (!addresses) return;
|
||||
|
||||
const existing = await tx.address.findMany({
|
||||
where: { businessId },
|
||||
select: { id: true },
|
||||
});
|
||||
const existingIds = new Set(existing.map((item) => item.id.toString()));
|
||||
const keepIds = new Set<string>();
|
||||
|
||||
for (const item of addresses) {
|
||||
const payload = {
|
||||
province: item.province.trim(),
|
||||
city: item.city.trim(),
|
||||
address: item.address.trim(),
|
||||
postalCode: item.postalCode.trim(),
|
||||
landline: item.landline?.trim() || null,
|
||||
};
|
||||
|
||||
if (item.id && existingIds.has(item.id)) {
|
||||
keepIds.add(item.id);
|
||||
await tx.address.update({
|
||||
where: { id: BigInt(item.id) },
|
||||
data: payload,
|
||||
});
|
||||
} else {
|
||||
await tx.address.create({
|
||||
data: {
|
||||
businessId,
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const removeIds = [...existingIds].filter((id) => !keepIds.has(id));
|
||||
if (removeIds.length > 0) {
|
||||
await tx.address.deleteMany({
|
||||
where: {
|
||||
businessId,
|
||||
id: { in: removeIds.map((id) => BigInt(id)) },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async validateCategoryIds(categoryIds: number[]) {
|
||||
const found = await this.prisma.businessCategory.count({
|
||||
where: {
|
||||
id: { in: categoryIds.map((id) => BigInt(id)) },
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (found !== categoryIds.length) {
|
||||
throw new BadRequestException('One or more activity categories are invalid');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertLogoMedia(businessId: bigint, mediaId: bigint) {
|
||||
const media = await this.prisma.media.findFirst({
|
||||
where: { id: mediaId, businessId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
throw new BadRequestException('Logo media not found for this business');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException('Insufficient permissions');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
export type BusinessPhoneType = 'landline' | 'cell';
|
||||
|
||||
export type BusinessPhoneNumber = {
|
||||
type: BusinessPhoneType;
|
||||
number: string;
|
||||
};
|
||||
|
||||
export type BusinessSocialMedia = {
|
||||
whatsapp: string;
|
||||
telegram: string;
|
||||
instagram: string;
|
||||
linkedin: string;
|
||||
youtube: string;
|
||||
aparat: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_BUSINESS_SOCIAL_MEDIA: BusinessSocialMedia = {
|
||||
whatsapp: '',
|
||||
telegram: '',
|
||||
instagram: '',
|
||||
linkedin: '',
|
||||
youtube: '',
|
||||
aparat: '',
|
||||
};
|
||||
|
||||
export type BusinessProfile = {
|
||||
nameEn: string;
|
||||
nameFa: string;
|
||||
about: string;
|
||||
vision: string;
|
||||
emails: string[];
|
||||
phoneNumbers: BusinessPhoneNumber[];
|
||||
socialMedia: BusinessSocialMedia;
|
||||
logoMediaId: string | null;
|
||||
logoUrl: string | null;
|
||||
faviconMediaId: string | null;
|
||||
faviconUrl: string | null;
|
||||
categoryIds: string[];
|
||||
};
|
||||
|
||||
export type BusinessAddress = {
|
||||
id: string;
|
||||
province: string;
|
||||
city: string;
|
||||
address: string;
|
||||
postalCode: string | null;
|
||||
landline: string | null;
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
BusinessPhoneNumber,
|
||||
BusinessSocialMedia,
|
||||
DEFAULT_BUSINESS_SOCIAL_MEDIA,
|
||||
} from './business-profile.types';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(value: unknown, fallback = '') {
|
||||
return typeof value === 'string' ? value : fallback;
|
||||
}
|
||||
|
||||
export function normalizeEmails(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function normalizePhoneNumbers(raw: unknown): BusinessPhoneNumber[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
return raw
|
||||
.filter(isRecord)
|
||||
.map((item) => ({
|
||||
type: (item.type === 'landline' ? 'landline' : 'cell') as BusinessPhoneNumber['type'],
|
||||
number: readString(item.number).trim(),
|
||||
}))
|
||||
.filter((item) => item.number.length > 0);
|
||||
}
|
||||
|
||||
export function normalizeSocialMedia(raw: unknown): BusinessSocialMedia {
|
||||
const source = isRecord(raw) ? raw : {};
|
||||
|
||||
return {
|
||||
whatsapp: readString(source.whatsapp),
|
||||
telegram: readString(source.telegram),
|
||||
instagram: readString(source.instagram),
|
||||
linkedin: readString(source.linkedin),
|
||||
youtube: readString(source.youtube),
|
||||
aparat: readString(source.aparat),
|
||||
};
|
||||
}
|
||||
|
||||
export function toPrismaJsonEmails(emails: string[]) {
|
||||
return emails;
|
||||
}
|
||||
|
||||
export function toPrismaJsonPhoneNumbers(phoneNumbers: BusinessPhoneNumber[]) {
|
||||
return phoneNumbers;
|
||||
}
|
||||
|
||||
export function toPrismaJsonSocialMedia(socialMedia: BusinessSocialMedia) {
|
||||
return socialMedia ?? DEFAULT_BUSINESS_SOCIAL_MEDIA;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
class BusinessPhoneNumberDto {
|
||||
@IsIn(['landline', 'cell'])
|
||||
type!: 'landline' | 'cell';
|
||||
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
number!: string;
|
||||
}
|
||||
|
||||
class BusinessSocialMediaDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
whatsapp?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
telegram?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
instagram?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
linkedin?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
youtube?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
aparat?: string;
|
||||
}
|
||||
|
||||
class BusinessAddressDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
id?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
province!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
city!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
address!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
postalCode!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
landline?: string | null;
|
||||
}
|
||||
|
||||
export class UpdateBusinessProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
nameEn?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
nameFa?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
about?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
vision?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
emails?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BusinessPhoneNumberDto)
|
||||
phoneNumbers?: BusinessPhoneNumberDto[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => BusinessSocialMediaDto)
|
||||
socialMedia?: BusinessSocialMediaDto;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
logoMediaId?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@Type(() => Number)
|
||||
@IsInt({ each: true })
|
||||
categoryIds?: number[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => BusinessAddressDto)
|
||||
addresses?: BusinessAddressDto[];
|
||||
}
|
||||
Reference in New Issue
Block a user