Add business gallery API and marketplace profile fields.

Expose list/add/remove gallery endpoints via media_attachments, plus profile title, banner, and category tag/nameFa support needed by the marketplace dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-11 14:41:32 +03:30
co-authored by Cursor
parent d68ef2d08a
commit 3aef62c1b8
19 changed files with 648 additions and 13 deletions
@@ -70,6 +70,7 @@ export class BusinessProfileService {
logoMedia: true,
logoDarkMedia: true,
faviconMedia: true,
bannerMedia: true,
},
});
@@ -102,6 +103,7 @@ export class BusinessProfileService {
logoMedia: true,
logoDarkMedia: true,
faviconMedia: true,
bannerMedia: true,
},
});
if (!business) {
@@ -144,6 +146,7 @@ export class BusinessProfileService {
logoMedia: true,
logoDarkMedia: true,
faviconMedia: true,
bannerMedia: true,
},
});
@@ -167,6 +170,10 @@ export class BusinessProfileService {
await this.assertLogoMedia(businessId, BigInt(dto.faviconMediaId));
}
if (dto.bannerMediaId !== undefined && dto.bannerMediaId !== null) {
await this.assertBannerMedia(businessId, BigInt(dto.bannerMediaId));
}
const previousFaviconMediaId = business.faviconMediaId;
const faviconExplicitlySet = dto.faviconMediaId !== undefined;
const logoChanged =
@@ -180,6 +187,7 @@ export class BusinessProfileService {
if (dto.nameEn !== undefined) data.name = dto.nameEn.trim();
if (dto.nameFa !== undefined) data.nameFa = dto.nameFa.trim();
if (dto.title !== undefined) data.description = dto.title.trim() || null;
if (dto.about !== undefined) data.about = dto.about.trim() || null;
if (dto.vision !== undefined) data.vision = dto.vision.trim() || null;
if (dto.emails !== undefined) {
@@ -188,7 +196,9 @@ export class BusinessProfileService {
);
}
if (dto.phoneNumbers !== undefined) {
data.phoneNumbers = toPrismaJsonPhoneNumbers(dto.phoneNumbers);
data.phoneNumbers = toPrismaJsonPhoneNumbers(
normalizePhoneNumbers(dto.phoneNumbers),
);
}
if (dto.socialMedia !== undefined) {
data.socialMedia = toPrismaJsonSocialMedia({
@@ -222,6 +232,13 @@ export class BusinessProfileService {
: { connect: { id: BigInt(dto.faviconMediaId) } };
}
if (dto.bannerMediaId !== undefined) {
data.bannerMedia =
dto.bannerMediaId === null
? { disconnect: true }
: { connect: { id: BigInt(dto.bannerMediaId) } };
}
if (Object.keys(data).length > 0) {
await tx.business.update({
where: { id: businessId },
@@ -395,6 +412,7 @@ export class BusinessProfileService {
private serializeProfile(business: {
name: string;
nameFa: string | null;
description: string | null;
about: string | null;
vision: string | null;
emails: unknown;
@@ -406,11 +424,14 @@ export class BusinessProfileService {
logoDarkMedia: { publicUrl: string } | null;
faviconMediaId: bigint | null;
faviconMedia: { publicUrl: string } | null;
bannerMediaId: bigint | null;
bannerMedia: { publicUrl: string } | null;
categoryAssignments: { categoryId: bigint }[];
}): BusinessProfile {
return {
nameEn: business.name,
nameFa: business.nameFa ?? '',
title: business.description ?? '',
about: business.about ?? '',
vision: business.vision ?? '',
emails: normalizeEmails(business.emails),
@@ -425,6 +446,8 @@ export class BusinessProfileService {
business.faviconMedia?.publicUrl ??
business.logoMedia?.publicUrl ??
null,
bannerMediaId: business.bannerMediaId?.toString() ?? null,
bannerUrl: business.bannerMedia?.publicUrl ?? null,
categoryIds: business.categoryAssignments.map((item) =>
item.categoryId.toString(),
),
@@ -433,6 +456,7 @@ export class BusinessProfileService {
private serializeAddress(address: {
id: bigint;
label: string | null;
province: string;
city: string;
address: string;
@@ -441,6 +465,7 @@ export class BusinessProfileService {
}): BusinessAddress {
return {
id: address.id.toString(),
name: address.label ?? '',
province: address.province,
city: address.city,
address: address.address,
@@ -465,6 +490,7 @@ export class BusinessProfileService {
for (const item of addresses) {
const payload = {
label: item.name?.trim() || null,
province: item.province.trim(),
city: item.city.trim(),
address: item.address.trim(),
@@ -523,6 +549,28 @@ export class BusinessProfileService {
}
}
/** Recommended banner aspect 18:4.5 (4:1). Soft-check when dimensions exist. */
private async assertBannerMedia(businessId: bigint, mediaId: bigint) {
const media = await this.prisma.media.findFirst({
where: { id: mediaId, businessId },
select: { id: true, width: true, height: true },
});
if (!media) {
throw new BadRequestException('Banner media not found for this business');
}
if (media.width && media.height && media.height > 0) {
const ratio = media.width / media.height;
const target = 18 / 4.5; // 4
if (Math.abs(ratio - target) / target > 0.12) {
throw new BadRequestException(
'Banner aspect ratio should be about 18:4.5 (4:1)',
);
}
}
}
private async assertPermission(
businessId: bigint,
userId: bigint,
@@ -1,6 +1,8 @@
export type BusinessPhoneType = 'landline' | 'cell';
export type BusinessPhoneNumber = {
/** Display label — presets like Cell/Landline or any custom text */
label: string;
type: BusinessPhoneType;
number: string;
};
@@ -26,6 +28,8 @@ export const DEFAULT_BUSINESS_SOCIAL_MEDIA: BusinessSocialMedia = {
export type BusinessProfile = {
nameEn: string;
nameFa: string;
/** Single-line business title / tagline (stored as businesses.description). */
title: string;
about: string;
vision: string;
emails: string[];
@@ -37,11 +41,15 @@ export type BusinessProfile = {
logoDarkUrl: string | null;
faviconMediaId: string | null;
faviconUrl: string | null;
/** Recommended aspect ratio 18:4.5 (4:1). */
bannerMediaId: string | null;
bannerUrl: string | null;
categoryIds: string[];
};
export type BusinessAddress = {
id: string;
name: string;
province: string;
city: string;
address: string;
+20 -5
View File
@@ -25,10 +25,17 @@ export function normalizePhoneNumbers(raw: unknown): BusinessPhoneNumber[] {
return raw
.filter(isRecord)
.map((item) => ({
type: (item.type === 'landline' ? 'landline' : 'cell') as BusinessPhoneNumber['type'],
number: readString(item.number).trim(),
}))
.map((item) => {
const type = (
item.type === 'landline' ? 'landline' : 'cell'
) as BusinessPhoneNumber['type'];
const number = readString(item.number).trim();
const labelRaw = readString(item.label).trim();
const label =
labelRaw || (type === 'landline' ? 'Landline' : 'Cell');
return { label, type, number };
})
.filter((item) => item.number.length > 0);
}
@@ -50,7 +57,15 @@ export function toPrismaJsonEmails(emails: string[]) {
}
export function toPrismaJsonPhoneNumbers(phoneNumbers: BusinessPhoneNumber[]) {
return phoneNumbers;
return phoneNumbers.map((item) => {
const label =
item.label?.trim() || (item.type === 'landline' ? 'Landline' : 'Cell');
return {
label,
type: item.type === 'landline' ? 'landline' : 'cell',
number: item.number.trim(),
};
});
}
export function toPrismaJsonSocialMedia(socialMedia: BusinessSocialMedia) {
@@ -5,11 +5,17 @@ import {
IsInt,
IsOptional,
IsString,
MaxLength,
MinLength,
ValidateNested,
} from 'class-validator';
class BusinessPhoneNumberDto {
@IsOptional()
@IsString()
@MinLength(1)
label?: string;
@IsIn(['landline', 'cell'])
type!: 'landline' | 'cell';
@@ -49,6 +55,10 @@ class BusinessAddressDto {
@IsString()
id?: string;
@IsOptional()
@IsString()
name?: string | null;
@IsString()
@MinLength(1)
province!: string;
@@ -81,6 +91,12 @@ export class UpdateBusinessProfileDto {
@MinLength(2)
nameFa?: string;
/** Single-line business title / tagline. */
@IsOptional()
@IsString()
@MaxLength(255)
title?: string;
@IsOptional()
@IsString()
about?: string;
@@ -120,6 +136,12 @@ export class UpdateBusinessProfileDto {
@IsInt()
faviconMediaId?: number | null;
/** Marketplace/storefront banner; recommended aspect 18:4.5 (4:1). */
@IsOptional()
@Type(() => Number)
@IsInt()
bannerMediaId?: number | null;
@IsOptional()
@IsArray()
@Type(() => Number)