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
+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',
);
}
}
}