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:
Ali Reza
2026-07-21 17:52:36 +03:30
commit bb59d5e9ba
254 changed files with 37031 additions and 0 deletions
@@ -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[];
}