mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
Initial commit: Meshkee CMS API
NestJS backend with Prisma, Docker Compose for Postgres/Redis, and deploy docs for the production VM.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateWebsiteBrandGroupDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
brandIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateWebsiteBrandGroupDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
brandIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class ListWebsiteBrandGroupsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateWebsiteCategoryGroupDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
categoryIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateWebsiteCategoryGroupDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
categoryIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class ListWebsiteCategoryGroupsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUrl,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class WebsiteSliderSlideInputDto {
|
||||
@IsString()
|
||||
imageMediaId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
@IsUrl({ require_protocol: true }, { message: 'linkUrl must be a valid URL' })
|
||||
linkUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class CreateWebsiteSliderDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WebsiteSliderSlideInputDto)
|
||||
slides?: WebsiteSliderSlideInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateWebsiteSliderDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(255)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WebsiteSliderSlideInputDto)
|
||||
slides?: WebsiteSliderSlideInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class ListWebsiteSlidersDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
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 {
|
||||
CreateWebsiteBrandGroupDto,
|
||||
ListWebsiteBrandGroupsDto,
|
||||
UpdateWebsiteBrandGroupDto,
|
||||
} from './dto/website-brand-groups.dto';
|
||||
import { WebsiteBrandGroupsService } from './website-brand-groups.service';
|
||||
|
||||
@Controller('tenants/:host/website/brand-groups')
|
||||
export class PublicWebsiteBrandGroupsController {
|
||||
constructor(private readonly service: WebsiteBrandGroupsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('host') host: string) {
|
||||
return this.service.listPublic(host);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('businesses/:businessId/website/brand-groups')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class WebsiteBrandGroupsController {
|
||||
constructor(private readonly service: WebsiteBrandGroupsService) {}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('website.read')
|
||||
list(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: ListWebsiteBrandGroupsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.list(businessId, query, user);
|
||||
}
|
||||
|
||||
@Get(':groupId')
|
||||
@RequireBusinessPermission('website.read')
|
||||
getOne(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('groupId') groupId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.getOne(businessId, groupId, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireBusinessPermission('website.update')
|
||||
create(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: CreateWebsiteBrandGroupDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.create(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':groupId')
|
||||
@RequireBusinessPermission('website.update')
|
||||
update(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('groupId') groupId: string,
|
||||
@Body() dto: UpdateWebsiteBrandGroupDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.update(businessId, groupId, dto, user);
|
||||
}
|
||||
|
||||
@Delete(':groupId')
|
||||
@RequireBusinessPermission('website.update')
|
||||
remove(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('groupId') groupId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.remove(businessId, groupId, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import {
|
||||
CreateWebsiteBrandGroupDto,
|
||||
ListWebsiteBrandGroupsDto,
|
||||
UpdateWebsiteBrandGroupDto,
|
||||
} from './dto/website-brand-groups.dto';
|
||||
|
||||
const groupInclude = {
|
||||
website_brand_group_items: {
|
||||
orderBy: [{ sort_order: 'asc' as const }, { id: 'asc' as const }],
|
||||
include: {
|
||||
brands: {
|
||||
include: { imageMedia: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.website_brand_groupsInclude;
|
||||
|
||||
type GroupWithItems = Prisma.website_brand_groupsGetPayload<{
|
||||
include: typeof groupInclude;
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class WebsiteBrandGroupsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly tenant: TenantService,
|
||||
) {}
|
||||
|
||||
async listPublic(host: string) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const groups = await this.prisma.website_brand_groups.findMany({
|
||||
where: { business_id: business.id, is_active: true },
|
||||
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||||
include: groupInclude,
|
||||
});
|
||||
|
||||
return {
|
||||
items: groups.map((group) => this.serializeGroup(group)),
|
||||
};
|
||||
}
|
||||
|
||||
async list(
|
||||
businessIdRaw: string,
|
||||
query: ListWebsiteBrandGroupsDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'website.read');
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.website_brand_groupsWhereInput = {
|
||||
business_id: businessId,
|
||||
...(query.isActive !== undefined ? { is_active: query.isActive } : {}),
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.website_brand_groups.findMany({
|
||||
where,
|
||||
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: groupInclude,
|
||||
}),
|
||||
this.prisma.website_brand_groups.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((group) => this.serializeGroup(group)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getOne(
|
||||
businessIdRaw: string,
|
||||
groupIdRaw: string,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const groupId = BigInt(groupIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'website.read');
|
||||
|
||||
const group = await this.findGroupOrThrow(businessId, groupId);
|
||||
return { group: this.serializeGroup(group) };
|
||||
}
|
||||
|
||||
async create(
|
||||
businessIdRaw: string,
|
||||
dto: CreateWebsiteBrandGroupDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'website.update');
|
||||
|
||||
const brandIds = this.parseUniqueIds(dto.brandIds ?? []);
|
||||
if (brandIds.length > 0) {
|
||||
await this.assertBrandsBelongToBusiness(businessId, brandIds);
|
||||
}
|
||||
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const group = await tx.website_brand_groups.create({
|
||||
data: {
|
||||
business_id: businessId,
|
||||
title: dto.title.trim(),
|
||||
sort_order: dto.sortOrder ?? 0,
|
||||
is_active: dto.isActive ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.replaceItems(tx, group.id, brandIds);
|
||||
return tx.website_brand_groups.findUniqueOrThrow({
|
||||
where: { id: group.id },
|
||||
include: groupInclude,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Website brand group created successfully',
|
||||
group: this.serializeGroup(created),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
businessIdRaw: string,
|
||||
groupIdRaw: string,
|
||||
dto: UpdateWebsiteBrandGroupDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const groupId = BigInt(groupIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'website.update');
|
||||
|
||||
await this.findGroupOrThrow(businessId, groupId);
|
||||
|
||||
let brandIds: bigint[] | undefined;
|
||||
if (dto.brandIds !== undefined) {
|
||||
brandIds = this.parseUniqueIds(dto.brandIds);
|
||||
await this.assertBrandsBelongToBusiness(businessId, brandIds);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.website_brand_groups.update({
|
||||
where: { id: groupId },
|
||||
data: {
|
||||
...(dto.title !== undefined ? { title: dto.title.trim() } : {}),
|
||||
...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}),
|
||||
...(dto.isActive !== undefined ? { is_active: dto.isActive } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (brandIds !== undefined) {
|
||||
await this.replaceItems(tx, groupId, brandIds);
|
||||
}
|
||||
|
||||
return tx.website_brand_groups.findUniqueOrThrow({
|
||||
where: { id: groupId },
|
||||
include: groupInclude,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Website brand group updated successfully',
|
||||
group: this.serializeGroup(updated),
|
||||
};
|
||||
}
|
||||
|
||||
async remove(
|
||||
businessIdRaw: string,
|
||||
groupIdRaw: string,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const groupId = BigInt(groupIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'website.update');
|
||||
|
||||
await this.findGroupOrThrow(businessId, groupId);
|
||||
await this.prisma.website_brand_groups.delete({ where: { id: groupId } });
|
||||
|
||||
return { message: 'Website brand group deleted successfully' };
|
||||
}
|
||||
|
||||
private async findGroupOrThrow(businessId: bigint, groupId: bigint) {
|
||||
const group = await this.prisma.website_brand_groups.findFirst({
|
||||
where: { id: groupId, business_id: businessId },
|
||||
include: groupInclude,
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
throw new NotFoundException('Website brand group not found');
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private parseUniqueIds(ids: string[]) {
|
||||
const unique = [...new Set(ids.map((id) => id.trim()).filter(Boolean))];
|
||||
return unique.map((id) => BigInt(id));
|
||||
}
|
||||
|
||||
private async assertBrandsBelongToBusiness(
|
||||
businessId: bigint,
|
||||
brandIds: bigint[],
|
||||
) {
|
||||
const found = await this.prisma.brand.findMany({
|
||||
where: { businessId, id: { in: brandIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (found.length !== brandIds.length) {
|
||||
throw new BadRequestException(
|
||||
'One or more brands were not found for this business',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceItems(
|
||||
tx: Prisma.TransactionClient,
|
||||
groupId: bigint,
|
||||
brandIds: bigint[],
|
||||
) {
|
||||
await tx.website_brand_group_items.deleteMany({ where: { group_id: groupId } });
|
||||
|
||||
if (brandIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await tx.website_brand_group_items.createMany({
|
||||
data: brandIds.map((brandId, index) => ({
|
||||
group_id: groupId,
|
||||
brand_id: brandId,
|
||||
sort_order: index,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
private serializeGroup(group: GroupWithItems) {
|
||||
const items = group.website_brand_group_items.map((entry) => {
|
||||
const brand = entry.brands;
|
||||
return {
|
||||
id: brand.id.toString(),
|
||||
nameEn: brand.nameEn,
|
||||
nameFa: brand.nameFa,
|
||||
slug: brand.slug,
|
||||
about: brand.about,
|
||||
imageMediaId: brand.imageMediaId?.toString() ?? null,
|
||||
imageUrl: brand.imageMedia?.publicUrl ?? null,
|
||||
sortOrder: entry.sort_order,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: group.id.toString(),
|
||||
title: group.title,
|
||||
sortOrder: group.sort_order,
|
||||
isActive: group.is_active,
|
||||
createdAt: group.created_at,
|
||||
updatedAt: group.updated_at,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(
|
||||
`Missing permission: ${permission} for this business`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { WebsiteBusinessInfoService } from './website-business-info.service';
|
||||
|
||||
@Controller('tenants/:host/website/business-info')
|
||||
export class PublicWebsiteBusinessInfoController {
|
||||
constructor(private readonly service: WebsiteBusinessInfoService) {}
|
||||
|
||||
@Get()
|
||||
get(@Param('host') host: string) {
|
||||
return this.service.getPublic(host);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
normalizeEmails,
|
||||
normalizePhoneNumbers,
|
||||
normalizeSocialMedia,
|
||||
} from '../business-profile/business-profile.util';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
|
||||
@Injectable()
|
||||
export class WebsiteBusinessInfoService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly tenant: TenantService,
|
||||
) {}
|
||||
|
||||
async getPublic(host: string) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
|
||||
const record = await this.prisma.business.findUnique({
|
||||
where: { id: business.id },
|
||||
include: {
|
||||
logoMedia: true,
|
||||
faviconMedia: true,
|
||||
addresses: { orderBy: { createdAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
return {
|
||||
id: record.id.toString(),
|
||||
name: record.name,
|
||||
nameFa: record.nameFa ?? '',
|
||||
about: record.about ?? '',
|
||||
vision: record.vision ?? '',
|
||||
logoUrl: record.logoMedia?.publicUrl ?? null,
|
||||
faviconUrl:
|
||||
record.faviconMedia?.publicUrl ?? record.logoMedia?.publicUrl ?? null,
|
||||
emails: normalizeEmails(record.emails),
|
||||
phoneNumbers: normalizePhoneNumbers(record.phoneNumbers),
|
||||
socialMedia: normalizeSocialMedia(record.socialMedia),
|
||||
addresses: record.addresses.map((address) => ({
|
||||
id: address.id.toString(),
|
||||
province: address.province,
|
||||
city: address.city,
|
||||
address: address.address,
|
||||
postalCode: address.postalCode,
|
||||
landline: address.landline,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
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 {
|
||||
CreateWebsiteCategoryGroupDto,
|
||||
ListWebsiteCategoryGroupsDto,
|
||||
UpdateWebsiteCategoryGroupDto,
|
||||
} from './dto/website-category-groups.dto';
|
||||
import { WebsiteCategoryGroupsService } from './website-category-groups.service';
|
||||
|
||||
@Controller('tenants/:host/website/category-groups')
|
||||
export class PublicWebsiteCategoryGroupsController {
|
||||
constructor(private readonly service: WebsiteCategoryGroupsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('host') host: string) {
|
||||
return this.service.listPublic(host);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('businesses/:businessId/website/category-groups')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class WebsiteCategoryGroupsController {
|
||||
constructor(private readonly service: WebsiteCategoryGroupsService) {}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('website.read')
|
||||
list(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: ListWebsiteCategoryGroupsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.list(businessId, query, user);
|
||||
}
|
||||
|
||||
@Get(':groupId')
|
||||
@RequireBusinessPermission('website.read')
|
||||
getOne(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('groupId') groupId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.getOne(businessId, groupId, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireBusinessPermission('website.update')
|
||||
create(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: CreateWebsiteCategoryGroupDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.create(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':groupId')
|
||||
@RequireBusinessPermission('website.update')
|
||||
update(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('groupId') groupId: string,
|
||||
@Body() dto: UpdateWebsiteCategoryGroupDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.update(businessId, groupId, dto, user);
|
||||
}
|
||||
|
||||
@Delete(':groupId')
|
||||
@RequireBusinessPermission('website.update')
|
||||
remove(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('groupId') groupId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.remove(businessId, groupId, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { MediaEntityType, Prisma } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import {
|
||||
CreateWebsiteCategoryGroupDto,
|
||||
ListWebsiteCategoryGroupsDto,
|
||||
UpdateWebsiteCategoryGroupDto,
|
||||
} from './dto/website-category-groups.dto';
|
||||
|
||||
const groupInclude = {
|
||||
website_category_group_items: {
|
||||
orderBy: [{ sort_order: 'asc' as const }, { id: 'asc' as const }],
|
||||
include: {
|
||||
categories: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.website_category_groupsInclude;
|
||||
|
||||
type GroupWithItems = Prisma.website_category_groupsGetPayload<{
|
||||
include: typeof groupInclude;
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class WebsiteCategoryGroupsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly tenant: TenantService,
|
||||
) {}
|
||||
|
||||
async listPublic(host: string) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const groups = await this.prisma.website_category_groups.findMany({
|
||||
where: { business_id: business.id, is_active: true },
|
||||
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||||
include: groupInclude,
|
||||
});
|
||||
|
||||
return {
|
||||
items: groups.map((group) => this.serializeGroup(group, true)),
|
||||
};
|
||||
}
|
||||
|
||||
async list(
|
||||
businessIdRaw: string,
|
||||
query: ListWebsiteCategoryGroupsDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'website.read');
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.website_category_groupsWhereInput = {
|
||||
business_id: businessId,
|
||||
...(query.isActive !== undefined ? { is_active: query.isActive } : {}),
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.website_category_groups.findMany({
|
||||
where,
|
||||
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: groupInclude,
|
||||
}),
|
||||
this.prisma.website_category_groups.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((group) => this.serializeGroup(group, false)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getOne(
|
||||
businessIdRaw: string,
|
||||
groupIdRaw: string,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const groupId = BigInt(groupIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'website.read');
|
||||
|
||||
const group = await this.findGroupOrThrow(businessId, groupId);
|
||||
return { group: this.serializeGroup(group, false) };
|
||||
}
|
||||
|
||||
async create(
|
||||
businessIdRaw: string,
|
||||
dto: CreateWebsiteCategoryGroupDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'website.update');
|
||||
|
||||
const categoryIds = this.parseUniqueIds(dto.categoryIds ?? []);
|
||||
if (categoryIds.length > 0) {
|
||||
await this.assertCategoriesBelongToBusiness(businessId, categoryIds);
|
||||
}
|
||||
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const group = await tx.website_category_groups.create({
|
||||
data: {
|
||||
business_id: businessId,
|
||||
title: dto.title.trim(),
|
||||
sort_order: dto.sortOrder ?? 0,
|
||||
is_active: dto.isActive ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.replaceItems(tx, group.id, categoryIds);
|
||||
return tx.website_category_groups.findUniqueOrThrow({
|
||||
where: { id: group.id },
|
||||
include: groupInclude,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Website category group created successfully',
|
||||
group: this.serializeGroup(created, false),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
businessIdRaw: string,
|
||||
groupIdRaw: string,
|
||||
dto: UpdateWebsiteCategoryGroupDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const groupId = BigInt(groupIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'website.update');
|
||||
|
||||
await this.findGroupOrThrow(businessId, groupId);
|
||||
|
||||
let categoryIds: bigint[] | undefined;
|
||||
if (dto.categoryIds !== undefined) {
|
||||
categoryIds = this.parseUniqueIds(dto.categoryIds);
|
||||
await this.assertCategoriesBelongToBusiness(businessId, categoryIds);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.website_category_groups.update({
|
||||
where: { id: groupId },
|
||||
data: {
|
||||
...(dto.title !== undefined ? { title: dto.title.trim() } : {}),
|
||||
...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}),
|
||||
...(dto.isActive !== undefined ? { is_active: dto.isActive } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (categoryIds !== undefined) {
|
||||
await this.replaceItems(tx, groupId, categoryIds);
|
||||
}
|
||||
|
||||
return tx.website_category_groups.findUniqueOrThrow({
|
||||
where: { id: groupId },
|
||||
include: groupInclude,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Website category group updated successfully',
|
||||
group: this.serializeGroup(updated, false),
|
||||
};
|
||||
}
|
||||
|
||||
async remove(
|
||||
businessIdRaw: string,
|
||||
groupIdRaw: string,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const groupId = BigInt(groupIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'website.update');
|
||||
|
||||
await this.findGroupOrThrow(businessId, groupId);
|
||||
await this.prisma.website_category_groups.delete({ where: { id: groupId } });
|
||||
|
||||
return { message: 'Website category group deleted successfully' };
|
||||
}
|
||||
|
||||
private async findGroupOrThrow(businessId: bigint, groupId: bigint) {
|
||||
const group = await this.prisma.website_category_groups.findFirst({
|
||||
where: { id: groupId, business_id: businessId },
|
||||
include: groupInclude,
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
throw new NotFoundException('Website category group not found');
|
||||
}
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
private parseUniqueIds(ids: string[]) {
|
||||
const unique = [...new Set(ids.map((id) => id.trim()).filter(Boolean))];
|
||||
return unique.map((id) => BigInt(id));
|
||||
}
|
||||
|
||||
private async assertCategoriesBelongToBusiness(
|
||||
businessId: bigint,
|
||||
categoryIds: bigint[],
|
||||
) {
|
||||
const found = await this.prisma.category.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
entityType: MediaEntityType.product,
|
||||
id: { in: categoryIds },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (found.length !== categoryIds.length) {
|
||||
throw new BadRequestException(
|
||||
'One or more product categories were not found for this business',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceItems(
|
||||
tx: Prisma.TransactionClient,
|
||||
groupId: bigint,
|
||||
categoryIds: bigint[],
|
||||
) {
|
||||
await tx.website_category_group_items.deleteMany({ where: { group_id: groupId } });
|
||||
|
||||
if (categoryIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await tx.website_category_group_items.createMany({
|
||||
data: categoryIds.map((categoryId, index) => ({
|
||||
group_id: groupId,
|
||||
category_id: categoryId,
|
||||
sort_order: index,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
private serializeGroup(group: GroupWithItems, publicView: boolean) {
|
||||
const items = group.website_category_group_items
|
||||
.filter((entry) => !publicView || entry.categories.isActive)
|
||||
.map((entry) => ({
|
||||
id: entry.categories.id.toString(),
|
||||
name: entry.categories.name,
|
||||
nameFa: entry.categories.nameFa,
|
||||
slug: entry.categories.slug,
|
||||
description: entry.categories.description,
|
||||
sortOrder: entry.sort_order,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: group.id.toString(),
|
||||
title: group.title,
|
||||
sortOrder: group.sort_order,
|
||||
isActive: group.is_active,
|
||||
createdAt: group.created_at,
|
||||
updatedAt: group.updated_at,
|
||||
items,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(
|
||||
`Missing permission: ${permission} for this business`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
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 {
|
||||
CreateWebsiteSliderDto,
|
||||
ListWebsiteSlidersDto,
|
||||
UpdateWebsiteSliderDto,
|
||||
} from './dto/website-sliders.dto';
|
||||
import { WebsiteSlidersService } from './website-sliders.service';
|
||||
|
||||
@Controller('tenants/:host/website/sliders')
|
||||
export class PublicWebsiteSlidersController {
|
||||
constructor(private readonly service: WebsiteSlidersService) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('host') host: string) {
|
||||
return this.service.listPublic(host);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('businesses/:businessId/website/sliders')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class WebsiteSlidersController {
|
||||
constructor(private readonly service: WebsiteSlidersService) {}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('website.read')
|
||||
list(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: ListWebsiteSlidersDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.list(businessId, query, user);
|
||||
}
|
||||
|
||||
@Get(':sliderId')
|
||||
@RequireBusinessPermission('website.read')
|
||||
getOne(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('sliderId') sliderId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.getOne(businessId, sliderId, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireBusinessPermission('website.update')
|
||||
create(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: CreateWebsiteSliderDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.create(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':sliderId')
|
||||
@RequireBusinessPermission('website.update')
|
||||
update(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('sliderId') sliderId: string,
|
||||
@Body() dto: UpdateWebsiteSliderDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.update(businessId, sliderId, dto, user);
|
||||
}
|
||||
|
||||
@Delete(':sliderId')
|
||||
@RequireBusinessPermission('website.update')
|
||||
remove(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('sliderId') sliderId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.remove(businessId, sliderId, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
import {
|
||||
CreateWebsiteSliderDto,
|
||||
ListWebsiteSlidersDto,
|
||||
UpdateWebsiteSliderDto,
|
||||
WebsiteSliderSlideInputDto,
|
||||
} from './dto/website-sliders.dto';
|
||||
|
||||
const sliderInclude = {
|
||||
website_slider_slides: {
|
||||
orderBy: [{ sort_order: 'asc' as const }, { id: 'asc' as const }],
|
||||
include: {
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.website_slidersInclude;
|
||||
|
||||
type SliderWithSlides = Prisma.website_slidersGetPayload<{
|
||||
include: typeof sliderInclude;
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class WebsiteSlidersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly tenant: TenantService,
|
||||
) {}
|
||||
|
||||
async listPublic(host: string) {
|
||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||
const sliders = await this.prisma.website_sliders.findMany({
|
||||
where: { business_id: business.id, is_active: true },
|
||||
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||||
include: sliderInclude,
|
||||
});
|
||||
|
||||
return {
|
||||
items: sliders.map((slider) => this.serializeSlider(slider, true)),
|
||||
};
|
||||
}
|
||||
|
||||
async list(
|
||||
businessIdRaw: string,
|
||||
query: ListWebsiteSlidersDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'website.read');
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.website_slidersWhereInput = {
|
||||
business_id: businessId,
|
||||
...(query.isActive !== undefined ? { is_active: query.isActive } : {}),
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.website_sliders.findMany({
|
||||
where,
|
||||
orderBy: [{ sort_order: 'asc' }, { created_at: 'asc' }],
|
||||
skip,
|
||||
take: pageSize,
|
||||
include: sliderInclude,
|
||||
}),
|
||||
this.prisma.website_sliders.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((slider) => this.serializeSlider(slider, false)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getOne(
|
||||
businessIdRaw: string,
|
||||
sliderIdRaw: string,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const sliderId = BigInt(sliderIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'website.read');
|
||||
|
||||
const slider = await this.findSliderOrThrow(businessId, sliderId);
|
||||
return { slider: this.serializeSlider(slider, false) };
|
||||
}
|
||||
|
||||
async create(
|
||||
businessIdRaw: string,
|
||||
dto: CreateWebsiteSliderDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'website.update');
|
||||
|
||||
const slides = dto.slides ?? [];
|
||||
await this.assertSlideMedia(businessId, slides);
|
||||
|
||||
const created = await this.prisma.$transaction(async (tx) => {
|
||||
const slider = await tx.website_sliders.create({
|
||||
data: {
|
||||
business_id: businessId,
|
||||
title: dto.title.trim(),
|
||||
sort_order: dto.sortOrder ?? 0,
|
||||
is_active: dto.isActive ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.replaceSlides(tx, slider.id, slides);
|
||||
return tx.website_sliders.findUniqueOrThrow({
|
||||
where: { id: slider.id },
|
||||
include: sliderInclude,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Website slider created successfully',
|
||||
slider: this.serializeSlider(created, false),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
businessIdRaw: string,
|
||||
sliderIdRaw: string,
|
||||
dto: UpdateWebsiteSliderDto,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const sliderId = BigInt(sliderIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'website.update');
|
||||
|
||||
await this.findSliderOrThrow(businessId, sliderId);
|
||||
|
||||
let slides: WebsiteSliderSlideInputDto[] | undefined;
|
||||
if (dto.slides !== undefined) {
|
||||
slides = dto.slides;
|
||||
await this.assertSlideMedia(businessId, slides);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.website_sliders.update({
|
||||
where: { id: sliderId },
|
||||
data: {
|
||||
...(dto.title !== undefined ? { title: dto.title.trim() } : {}),
|
||||
...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}),
|
||||
...(dto.isActive !== undefined ? { is_active: dto.isActive } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (slides !== undefined) {
|
||||
await this.replaceSlides(tx, sliderId, slides);
|
||||
}
|
||||
|
||||
return tx.website_sliders.findUniqueOrThrow({
|
||||
where: { id: sliderId },
|
||||
include: sliderInclude,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Website slider updated successfully',
|
||||
slider: this.serializeSlider(updated, false),
|
||||
};
|
||||
}
|
||||
|
||||
async remove(
|
||||
businessIdRaw: string,
|
||||
sliderIdRaw: string,
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
const sliderId = BigInt(sliderIdRaw);
|
||||
await this.assertPermission(businessId, actor.id, 'website.update');
|
||||
|
||||
await this.findSliderOrThrow(businessId, sliderId);
|
||||
await this.prisma.website_sliders.delete({ where: { id: sliderId } });
|
||||
|
||||
return { message: 'Website slider deleted successfully' };
|
||||
}
|
||||
|
||||
private async findSliderOrThrow(businessId: bigint, sliderId: bigint) {
|
||||
const slider = await this.prisma.website_sliders.findFirst({
|
||||
where: { id: sliderId, business_id: businessId },
|
||||
include: sliderInclude,
|
||||
});
|
||||
|
||||
if (!slider) {
|
||||
throw new NotFoundException('Website slider not found');
|
||||
}
|
||||
|
||||
return slider;
|
||||
}
|
||||
|
||||
private async assertSlideMedia(
|
||||
businessId: bigint,
|
||||
slides: WebsiteSliderSlideInputDto[],
|
||||
) {
|
||||
if (slides.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaIds = slides.map((slide) => BigInt(slide.imageMediaId));
|
||||
const uniqueMediaIds = [...new Set(mediaIds.map((id) => id.toString()))].map(
|
||||
(id) => BigInt(id),
|
||||
);
|
||||
|
||||
const found = await this.prisma.media.findMany({
|
||||
where: {
|
||||
businessId,
|
||||
id: { in: uniqueMediaIds },
|
||||
mimeType: { startsWith: 'image/' },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (found.length !== uniqueMediaIds.length) {
|
||||
throw new BadRequestException(
|
||||
'One or more slide images were not found for this business',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceSlides(
|
||||
tx: Prisma.TransactionClient,
|
||||
sliderId: bigint,
|
||||
slides: WebsiteSliderSlideInputDto[],
|
||||
) {
|
||||
await tx.website_slider_slides.deleteMany({ where: { slider_id: sliderId } });
|
||||
|
||||
if (slides.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await tx.website_slider_slides.createMany({
|
||||
data: slides.map((slide, index) => ({
|
||||
slider_id: sliderId,
|
||||
image_media_id: BigInt(slide.imageMediaId),
|
||||
title: slide.title?.trim() || null,
|
||||
link_url: slide.linkUrl?.trim() || null,
|
||||
sort_order: index,
|
||||
is_active: slide.isActive ?? true,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
private serializeSlider(slider: SliderWithSlides, publicView: boolean) {
|
||||
const slides = slider.website_slider_slides
|
||||
.filter((slide) => !publicView || slide.is_active)
|
||||
.map((slide) => ({
|
||||
id: slide.id.toString(),
|
||||
imageMediaId: slide.image_media_id.toString(),
|
||||
imageUrl: slide.media.publicUrl,
|
||||
title: slide.title,
|
||||
linkUrl: slide.link_url,
|
||||
sortOrder: slide.sort_order,
|
||||
isActive: slide.is_active,
|
||||
createdAt: slide.created_at,
|
||||
updatedAt: slide.updated_at,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: slider.id.toString(),
|
||||
title: slider.title,
|
||||
sortOrder: slider.sort_order,
|
||||
isActive: slider.is_active,
|
||||
createdAt: slider.created_at,
|
||||
updatedAt: slider.updated_at,
|
||||
slides,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
permission: string,
|
||||
) {
|
||||
const allowed = await this.permissions.hasBusinessPermission(
|
||||
userId,
|
||||
businessId,
|
||||
permission,
|
||||
);
|
||||
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(
|
||||
`Missing permission: ${permission} for this business`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
import {
|
||||
PublicWebsiteBrandGroupsController,
|
||||
WebsiteBrandGroupsController,
|
||||
} from './website-brand-groups.controller';
|
||||
import { WebsiteBrandGroupsService } from './website-brand-groups.service';
|
||||
import {
|
||||
PublicWebsiteCategoryGroupsController,
|
||||
WebsiteCategoryGroupsController,
|
||||
} from './website-category-groups.controller';
|
||||
import { WebsiteCategoryGroupsService } from './website-category-groups.service';
|
||||
import {
|
||||
PublicWebsiteBusinessInfoController,
|
||||
} from './website-business-info.controller';
|
||||
import { WebsiteBusinessInfoService } from './website-business-info.service';
|
||||
import {
|
||||
PublicWebsiteSlidersController,
|
||||
WebsiteSlidersController,
|
||||
} from './website-sliders.controller';
|
||||
import { WebsiteSlidersService } from './website-sliders.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TenantModule],
|
||||
controllers: [
|
||||
PublicWebsiteCategoryGroupsController,
|
||||
WebsiteCategoryGroupsController,
|
||||
PublicWebsiteBrandGroupsController,
|
||||
WebsiteBrandGroupsController,
|
||||
PublicWebsiteSlidersController,
|
||||
WebsiteSlidersController,
|
||||
PublicWebsiteBusinessInfoController,
|
||||
],
|
||||
providers: [
|
||||
WebsiteCategoryGroupsService,
|
||||
WebsiteBrandGroupsService,
|
||||
WebsiteSlidersService,
|
||||
WebsiteBusinessInfoService,
|
||||
],
|
||||
})
|
||||
export class WebsiteModule {}
|
||||
Reference in New Issue
Block a user