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:
co-authored by
Cursor
parent
d68ef2d08a
commit
3aef62c1b8
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user