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,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';
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user