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
@@ -0,0 +1,9 @@
-- Add search tags + Persian name on platform business categories
ALTER TABLE business_categories
ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}';
ALTER TABLE business_categories
ADD COLUMN IF NOT EXISTS name_fa VARCHAR(255);
CREATE INDEX IF NOT EXISTS idx_business_categories_tags
ON business_categories USING GIN (tags);
@@ -0,0 +1,13 @@
-- Marketplace / storefront banner image for a business (aspect 18:4.5 ≈ 4:1)
ALTER TABLE businesses
ADD COLUMN IF NOT EXISTS banner_media_id BIGINT;
ALTER TABLE businesses
DROP CONSTRAINT IF EXISTS businesses_banner_media_id_fkey;
ALTER TABLE businesses
ADD CONSTRAINT businesses_banner_media_id_fkey
FOREIGN KEY (banner_media_id) REFERENCES media (id) ON DELETE SET NULL;
COMMENT ON COLUMN businesses.banner_media_id IS
'Optional marketplace/storefront banner; recommended aspect ratio 18:4.5 (4:1).';
@@ -0,0 +1,3 @@
-- Business-level gallery (media_attachments with entity_type = gallery, entity_id = business_id)
ALTER TYPE media_entity_type ADD VALUE IF NOT EXISTS 'gallery';
+6
View File
@@ -110,6 +110,7 @@ model Business {
logoMediaId BigInt? @map("logo_media_id")
logoDarkMediaId BigInt? @map("logo_dark_media_id")
faviconMediaId BigInt? @map("favicon_media_id")
bannerMediaId BigInt? @map("banner_media_id")
oldBusinessId BigInt? @map("old_business_id")
annualRenewalAt DateTime? @map("annual_renewal_at") @db.Timestamptz(6)
smsBalance Int @default(0) @map("sms_balance")
@@ -129,6 +130,7 @@ model Business {
faviconMedia Media? @relation("BusinessFavicon", fields: [faviconMediaId], references: [id], onUpdate: NoAction)
logoMedia Media? @relation("BusinessLogo", fields: [logoMediaId], references: [id], onUpdate: NoAction)
logoDarkMedia Media? @relation("BusinessLogoDark", fields: [logoDarkMediaId], references: [id], onUpdate: NoAction)
bannerMedia Media? @relation("BusinessBanner", fields: [bannerMediaId], references: [id], onUpdate: NoAction)
carts Cart[]
categories Category[]
contentCategoryAssignments CategoryAssignment[]
@@ -175,11 +177,13 @@ model BusinessCategory {
id BigInt @id @default(autoincrement())
parentId BigInt? @map("parent_id")
name String @db.VarChar(255)
nameFa String? @map("name_fa") @db.VarChar(255)
slug String @unique(map: "business_categories_slug_unique") @db.VarChar(255)
description String?
icon String? @db.VarChar(100)
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
tags String[] @default([])
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
parent BusinessCategory? @relation("BusinessCategoryTree", fields: [parentId], references: [id], onUpdate: NoAction)
@@ -377,6 +381,7 @@ model Media {
faviconBusinesses Business[] @relation("BusinessFavicon")
logoBusinesses Business[] @relation("BusinessLogo")
logoDarkBusinesses Business[] @relation("BusinessLogoDark")
bannerBusinesses Business[] @relation("BusinessBanner")
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
uploader User? @relation(fields: [uploadedBy], references: [id], onUpdate: NoAction)
attachments MediaAttachment[]
@@ -1611,6 +1616,7 @@ enum MediaEntityType {
video
instruction
workshop
gallery
@@map("media_entity_type")
}
+2
View File
@@ -17,6 +17,7 @@ import { CategoriesModule } from './categories/categories.module';
import { ProductsModule } from './products/products.module';
import { BlogsModule } from './blogs/blogs.module';
import { PortfoliosModule } from './portfolios/portfolios.module';
import { GalleryModule } from './gallery/gallery.module';
import { VideosModule } from './videos/videos.module';
import { InstructionsModule } from './instructions/instructions.module';
import { WorkshopsModule } from './workshops/workshops.module';
@@ -69,6 +70,7 @@ import { TorobModule } from './torob/torob.module';
ProductsModule,
BlogsModule,
PortfoliosModule,
GalleryModule,
VideosModule,
InstructionsModule,
WorkshopsModule,
+25 -1
View File
@@ -276,15 +276,27 @@ export class BusinessAdminService {
name: string;
nameFa: string | null;
slug: string;
ownerName: string | null;
ownerCellNumber: string | null;
}[]
>(Prisma.sql`
SELECT DISTINCT
b.id AS "id",
b.name AS "name",
b.name_fa AS "nameFa",
b.slug AS "slug"
b.slug AS "slug",
NULLIF(TRIM(BOTH ' ' FROM COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')), '') AS "ownerName",
u.cell_number AS "ownerCellNumber"
FROM businesses b
LEFT JOIN domains d ON d.business_id = b.id
LEFT JOIN LATERAL (
SELECT bu.user_id
FROM business_users bu
WHERE bu.business_id = b.id AND bu.is_owner = TRUE
ORDER BY bu.created_at ASC
LIMIT 1
) own ON TRUE
LEFT JOIN users u ON u.id = own.user_id
WHERE b.is_active = TRUE
AND (
b.name ILIKE ${like}
@@ -303,6 +315,8 @@ export class BusinessAdminService {
nameFa: business.nameFa,
slug: business.slug,
label: this.formatBusinessLabel(business),
ownerName: business.ownerName,
ownerCellNumber: business.ownerCellNumber,
})),
};
}
@@ -373,6 +387,8 @@ export class BusinessAdminService {
take: 1,
},
domains: { orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] },
logoMedia: true,
bannerMedia: true,
},
});
@@ -1350,6 +1366,10 @@ export class BusinessAdminService {
isVerified: boolean;
sslEnabled: boolean;
}[];
logoMediaId?: bigint | null;
logoMedia?: { publicUrl: string } | null;
bannerMediaId?: bigint | null;
bannerMedia?: { publicUrl: string } | null;
},
) {
const owner = business.businessUsers[0]?.user ?? null;
@@ -1367,6 +1387,10 @@ export class BusinessAdminService {
annualRenewalAt: business.annualRenewalAt,
createdAt: business.createdAt,
updatedAt: business.updatedAt,
logoMediaId: business.logoMediaId?.toString() ?? null,
logoUrl: business.logoMedia?.publicUrl ?? null,
bannerMediaId: business.bannerMediaId?.toString() ?? null,
bannerUrl: business.bannerMedia?.publicUrl ?? null,
smsSenderNumber: settings.sms.senderNumber,
smsBalance: business.smsBalance ?? 0,
smsSentCount: business.smsSentCount ?? 0,
@@ -1,16 +1,32 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import {
Controller,
Get,
Query,
UseGuards,
} from '@nestjs/common';
import { IsOptional, IsString, MaxLength } from 'class-validator';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { AuthUser } from '../auth/auth.types';
import { BusinessCategoriesService } from './business-categories.service';
class ListBusinessCategoriesDto {
@IsOptional()
@IsString()
@MaxLength(100)
q?: string;
}
@Controller('business-categories')
export class BusinessCategoriesController {
constructor(private readonly service: BusinessCategoriesService) {}
@Get()
@UseGuards(JwtAuthGuard)
list(@CurrentUser() user: AuthUser) {
return this.service.list(user);
list(
@CurrentUser() user: AuthUser,
@Query() query: ListBusinessCategoriesDto,
) {
return this.service.list(user, query.q);
}
}
@@ -1,4 +1,5 @@
import { Injectable, ForbiddenException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { PermissionsService } from '../auth/permissions.service';
import { AuthUser } from '../auth/auth.types';
@@ -10,7 +11,7 @@ export class BusinessCategoriesService {
private readonly permissions: PermissionsService,
) {}
async list(actor: AuthUser) {
async list(actor: AuthUser, q?: string) {
const isSuperAdmin = await this.permissions.isSuperAdmin(actor.id);
const canRead =
isSuperAdmin ||
@@ -21,6 +22,54 @@ export class BusinessCategoriesService {
throw new ForbiddenException('Access denied');
}
const term = q?.trim();
if (term) {
const pattern = `%${term}%`;
const rows = await this.prisma.$queryRaw<
{
id: bigint;
parent_id: bigint | null;
name: string;
name_fa: string | null;
slug: string;
description: string | null;
icon: string | null;
sort_order: number;
tags: string[];
}[]
>(Prisma.sql`
SELECT
id, parent_id, name, name_fa, slug, description, icon, sort_order, tags
FROM business_categories
WHERE is_active = TRUE
AND (
name ILIKE ${pattern}
OR COALESCE(name_fa, '') ILIKE ${pattern}
OR slug ILIKE ${pattern}
OR EXISTS (
SELECT 1
FROM unnest(tags) AS tag
WHERE tag ILIKE ${pattern}
)
)
ORDER BY sort_order ASC, name ASC
`);
return {
items: rows.map((row) => ({
id: row.id,
parentId: row.parent_id,
name: row.name,
nameFa: row.name_fa,
slug: row.slug,
description: row.description,
icon: row.icon,
sortOrder: row.sort_order,
tags: row.tags ?? [],
})),
};
}
const categories = await this.prisma.businessCategory.findMany({
where: { isActive: true },
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }],
@@ -28,10 +77,12 @@ export class BusinessCategoriesService {
id: true,
parentId: true,
name: true,
nameFa: true,
slug: true,
description: true,
icon: true,
sortOrder: true,
tags: true,
},
});
@@ -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)
+1 -1
View File
@@ -108,7 +108,7 @@ export type BuildWebsiteSchemaJsonLdInput = {
about: string;
logoUrl: string | null;
emails: string[];
phoneNumbers: Array<{ type: string; number: string }>;
phoneNumbers: Array<{ type: string; number: string; label?: string }>;
socialMedia: BusinessSocialMedia;
addresses: Array<{
province: string;
+59
View File
@@ -0,0 +1,59 @@
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsInt,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
function toStringArray(value: unknown): string[] | undefined {
if (value == null || value === '') return undefined;
if (Array.isArray(value)) {
return value.map((item) => String(item)).filter(Boolean);
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return undefined;
try {
const parsed = JSON.parse(trimmed) as unknown;
if (Array.isArray(parsed)) {
return parsed.map((item) => String(item)).filter(Boolean);
}
} catch {
// comma-separated fallback
}
return trimmed
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
return undefined;
}
export class ListGalleryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize?: number;
}
export class AddGalleryDto {
/** Attach existing business media (also accepted as multipart field). */
@IsOptional()
@Transform(({ value }) => toStringArray(value))
@IsArray()
@ArrayMaxSize(50)
@IsString({ each: true })
mediaIds?: string[];
}
+67
View File
@@ -0,0 +1,67 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Query,
UploadedFiles,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
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 { AddGalleryDto, ListGalleryDto } from './dto/gallery.dto';
import { GalleryService } from './gallery.service';
@Controller('businesses/:businessId/gallery')
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
export class GalleryController {
constructor(private readonly galleryService: GalleryService) {}
@Get()
@RequireBusinessPermission('media.read')
list(
@Param('businessId') businessId: string,
@Query() query: ListGalleryDto,
@CurrentUser() user: AuthUser,
) {
return this.galleryService.list(businessId, query, user);
}
@Post()
@RequireBusinessPermission('media.create')
@UseInterceptors(
FilesInterceptor('files', 20, {
storage: memoryStorage(),
}),
)
add(
@Param('businessId') businessId: string,
@UploadedFiles() files: Express.Multer.File[],
@Body() dto: AddGalleryDto,
@CurrentUser() user: AuthUser,
) {
return this.galleryService.add(
businessId,
{ files: files ?? [], dto },
user,
);
}
@Delete(':mediaId')
@RequireBusinessPermission('media.delete')
remove(
@Param('businessId') businessId: string,
@Param('mediaId') mediaId: string,
@CurrentUser() user: AuthUser,
) {
return this.galleryService.remove(businessId, mediaId, user);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { MediaModule } from '../media/media.module';
import { GalleryController } from './gallery.controller';
import { GalleryService } from './gallery.service';
@Module({
imports: [AuthModule, MediaModule],
controllers: [GalleryController],
providers: [GalleryService],
exports: [GalleryService],
})
export class GalleryModule {}
+271
View File
@@ -0,0 +1,271 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { MediaEntityType, MediaType } from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { MediaService } from '../media/media.service';
import { PrismaService } from '../prisma/prisma.service';
import { AddGalleryDto, ListGalleryDto } from './dto/gallery.dto';
@Injectable()
export class GalleryService {
constructor(
private readonly prisma: PrismaService,
private readonly media: MediaService,
private readonly permissions: PermissionsService,
) {}
async list(businessIdRaw: string, query: ListGalleryDto, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
await this.assertCanRead(businessId, actor.id);
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 24;
const skip = (page - 1) * pageSize;
const where = {
businessId,
entityType: MediaEntityType.gallery,
entityId: businessId,
};
const [rows, total] = await Promise.all([
this.prisma.mediaAttachment.findMany({
where,
include: { media: true },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
skip,
take: pageSize,
}),
this.prisma.mediaAttachment.count({ where }),
]);
return {
items: rows.map((row) => this.serialize(row)),
total,
page,
pageSize,
};
}
async add(
businessIdRaw: string,
input: {
files?: Express.Multer.File[];
dto?: AddGalleryDto;
},
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertCanCreate(businessId, actor.id);
const business = await this.prisma.business.findUnique({
where: { id: businessId },
select: { id: true, isActive: true },
});
if (!business?.isActive) {
throw new NotFoundException('Business not found');
}
const files = input.files ?? [];
const mediaIdsFromBody = (input.dto?.mediaIds ?? [])
.map((id) => id.trim())
.filter(Boolean);
if (!files.length && !mediaIdsFromBody.length) {
throw new BadRequestException(
'Provide files to upload and/or mediaIds to attach',
);
}
const uploadedIds: bigint[] = [];
if (files.length) {
const uploaded = await this.media.uploadMany(
businessIdRaw,
files,
actor,
);
for (const item of uploaded.items) {
uploadedIds.push(BigInt(item.id));
}
}
const existingIds: bigint[] = [];
for (const raw of mediaIdsFromBody) {
const mediaId = BigInt(raw);
const media = await this.prisma.media.findFirst({
where: { id: mediaId, businessId, mediaType: MediaType.image },
select: { id: true },
});
if (!media) {
throw new BadRequestException(
`Image media ${raw} was not found for this business`,
);
}
existingIds.push(media.id);
}
const candidateIds = [...uploadedIds, ...existingIds];
const uniqueIds: bigint[] = [];
const seen = new Set<string>();
for (const id of candidateIds) {
const key = id.toString();
if (seen.has(key)) continue;
seen.add(key);
uniqueIds.push(id);
}
const alreadyAttached = await this.prisma.mediaAttachment.findMany({
where: {
businessId,
entityType: MediaEntityType.gallery,
entityId: businessId,
mediaId: { in: uniqueIds },
},
select: { mediaId: true },
});
const attachedSet = new Set(
alreadyAttached.map((row) => row.mediaId.toString()),
);
const toAttach = uniqueIds.filter((id) => !attachedSet.has(id.toString()));
if (!toAttach.length) {
const current = await this.prisma.mediaAttachment.findMany({
where: {
businessId,
entityType: MediaEntityType.gallery,
entityId: businessId,
mediaId: { in: uniqueIds },
},
include: { media: true },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
return { items: current.map((row) => this.serialize(row)) };
}
const maxSort = await this.prisma.mediaAttachment.aggregate({
where: {
businessId,
entityType: MediaEntityType.gallery,
entityId: businessId,
},
_max: { sortOrder: true },
});
let nextSort = (maxSort._max.sortOrder ?? -1) + 1;
const created = [];
for (const mediaId of toAttach) {
const row = await this.prisma.mediaAttachment.create({
data: {
businessId,
mediaId,
entityType: MediaEntityType.gallery,
entityId: businessId,
sortOrder: nextSort,
isFeatured: false,
},
include: { media: true },
});
nextSort += 1;
created.push(row);
}
return { items: created.map((row) => this.serialize(row)) };
}
async remove(businessIdRaw: string, mediaIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const mediaId = BigInt(mediaIdRaw);
await this.assertCanDelete(businessId, actor.id);
const attachment = await this.prisma.mediaAttachment.findFirst({
where: {
businessId,
entityType: MediaEntityType.gallery,
entityId: businessId,
mediaId,
},
});
if (!attachment) {
throw new NotFoundException('Gallery item not found');
}
await this.prisma.mediaAttachment.delete({ where: { id: attachment.id } });
await this.media.deleteUnused(businessId, [mediaId]);
return { message: 'Gallery item removed' };
}
private serialize(row: {
id: bigint;
mediaId: bigint;
sortOrder: number;
createdAt: Date;
media: {
id: bigint;
mediaType: MediaType;
publicUrl: string;
altText: string | null;
fileName: string;
width: number | null;
height: number | null;
createdAt: Date;
};
}) {
return {
id: row.media.id.toString(),
attachmentId: row.id.toString(),
mediaId: row.mediaId.toString(),
mediaType: row.media.mediaType,
publicUrl: row.media.publicUrl,
altText: row.media.altText,
fileName: row.media.fileName,
width: row.media.width,
height: row.media.height,
sortOrder: row.sortOrder,
createdAt: row.createdAt.toISOString(),
};
}
private async assertCanRead(businessId: bigint, userId: bigint) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
'media.read',
);
if (!allowed) {
throw new ForbiddenException('You cannot view gallery for this business');
}
}
private async assertCanCreate(businessId: bigint, userId: bigint) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
'media.create',
);
if (!allowed) {
throw new ForbiddenException(
'You cannot add gallery items for this business',
);
}
}
private async assertCanDelete(businessId: bigint, userId: bigint) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
'media.delete',
);
if (!allowed) {
throw new ForbiddenException(
'You cannot remove gallery items for this business',
);
}
}
}
+8 -1
View File
@@ -269,9 +269,15 @@ export class MediaService {
{ logoMediaId: { in: uniqueIds } },
{ logoDarkMediaId: { in: uniqueIds } },
{ faviconMediaId: { in: uniqueIds } },
{ bannerMediaId: { in: uniqueIds } },
],
},
select: { logoMediaId: true, logoDarkMediaId: true, faviconMediaId: true },
select: {
logoMediaId: true,
logoDarkMediaId: true,
faviconMediaId: true,
bannerMediaId: true,
},
}),
]);
@@ -288,6 +294,7 @@ export class MediaService {
addId(business?.logoMediaId);
addId(business?.logoDarkMediaId);
addId(business?.faviconMediaId);
addId(business?.bannerMediaId);
const deletableIds = uniqueIds.filter((id) => !stillLinked.has(id.toString()));
if (!deletableIds.length) return 0;
@@ -39,6 +39,7 @@ export class WebsiteBusinessInfoService {
const socialMedia = normalizeSocialMedia(record.socialMedia);
const addresses = record.addresses.map((address) => ({
id: address.id.toString(),
name: address.label ?? '',
province: address.province,
city: address.city,
address: address.address,