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:
Ali Reza
2026-07-21 17:52:36 +03:30
commit bb59d5e9ba
254 changed files with 37031 additions and 0 deletions
+295
View File
@@ -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`,
);
}
}
}