diff --git a/database/migrations/089_business_categories_tags.sql b/database/migrations/089_business_categories_tags.sql new file mode 100644 index 0000000..e5f7666 --- /dev/null +++ b/database/migrations/089_business_categories_tags.sql @@ -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); diff --git a/database/migrations/090_business_banner.sql b/database/migrations/090_business_banner.sql new file mode 100644 index 0000000..dea52e7 --- /dev/null +++ b/database/migrations/090_business_banner.sql @@ -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).'; diff --git a/database/migrations/091_business_gallery.sql b/database/migrations/091_business_gallery.sql new file mode 100644 index 0000000..e7a7e02 --- /dev/null +++ b/database/migrations/091_business_gallery.sql @@ -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'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 55a5257..93c20a7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") } diff --git a/src/app.module.ts b/src/app.module.ts index c6120ba..a81a9e5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, diff --git a/src/business-admin/business-admin.service.ts b/src/business-admin/business-admin.service.ts index b5eb327..8711beb 100644 --- a/src/business-admin/business-admin.service.ts +++ b/src/business-admin/business-admin.service.ts @@ -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, diff --git a/src/business-admin/business-categories.controller.ts b/src/business-admin/business-categories.controller.ts index 6a02c67..026002d 100644 --- a/src/business-admin/business-categories.controller.ts +++ b/src/business-admin/business-categories.controller.ts @@ -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); } } diff --git a/src/business-admin/business-categories.service.ts b/src/business-admin/business-categories.service.ts index 1cebb94..e30caf5 100644 --- a/src/business-admin/business-categories.service.ts +++ b/src/business-admin/business-categories.service.ts @@ -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, }, }); diff --git a/src/business-profile/business-profile.service.ts b/src/business-profile/business-profile.service.ts index 5ad18ab..7d49945 100644 --- a/src/business-profile/business-profile.service.ts +++ b/src/business-profile/business-profile.service.ts @@ -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, diff --git a/src/business-profile/business-profile.types.ts b/src/business-profile/business-profile.types.ts index 506a3cc..c3a113a 100644 --- a/src/business-profile/business-profile.types.ts +++ b/src/business-profile/business-profile.types.ts @@ -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; diff --git a/src/business-profile/business-profile.util.ts b/src/business-profile/business-profile.util.ts index b5c6a1a..8aabc2c 100644 --- a/src/business-profile/business-profile.util.ts +++ b/src/business-profile/business-profile.util.ts @@ -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) { diff --git a/src/business-profile/dto/update-business-profile.dto.ts b/src/business-profile/dto/update-business-profile.dto.ts index 155138c..9f228f0 100644 --- a/src/business-profile/dto/update-business-profile.dto.ts +++ b/src/business-profile/dto/update-business-profile.dto.ts @@ -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) diff --git a/src/business-settings/website-schema.util.ts b/src/business-settings/website-schema.util.ts index 23d02fb..10338e7 100644 --- a/src/business-settings/website-schema.util.ts +++ b/src/business-settings/website-schema.util.ts @@ -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; diff --git a/src/gallery/dto/gallery.dto.ts b/src/gallery/dto/gallery.dto.ts new file mode 100644 index 0000000..f216591 --- /dev/null +++ b/src/gallery/dto/gallery.dto.ts @@ -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[]; +} diff --git a/src/gallery/gallery.controller.ts b/src/gallery/gallery.controller.ts new file mode 100644 index 0000000..c85122f --- /dev/null +++ b/src/gallery/gallery.controller.ts @@ -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); + } +} diff --git a/src/gallery/gallery.module.ts b/src/gallery/gallery.module.ts new file mode 100644 index 0000000..8832ba8 --- /dev/null +++ b/src/gallery/gallery.module.ts @@ -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 {} diff --git a/src/gallery/gallery.service.ts b/src/gallery/gallery.service.ts new file mode 100644 index 0000000..f73c773 --- /dev/null +++ b/src/gallery/gallery.service.ts @@ -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(); + 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', + ); + } + } +} diff --git a/src/media/media.service.ts b/src/media/media.service.ts index a3366f3..6552b00 100644 --- a/src/media/media.service.ts +++ b/src/media/media.service.ts @@ -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; diff --git a/src/website/website-business-info.service.ts b/src/website/website-business-info.service.ts index 2bf0ebf..58e43d1 100644 --- a/src/website/website-business-info.service.ts +++ b/src/website/website-business-info.service.ts @@ -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,