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
@@ -0,0 +1,646 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuthUser } from '../auth/auth.types';
import { AddDomainDto } from './dto/add-domain.dto';
import { UpdateDomainDto } from './dto/update-domain.dto';
import { CreateBusinessDto } from './dto/create-business.dto';
import { DisableBusinessDto } from './dto/disable-business.dto';
import { UpdateBusinessDto } from './dto/update-business.dto';
import { ListBusinessesDto } from './dto/list-businesses.dto';
import { SearchBusinessesDto } from './dto/search-businesses.dto';
import { normalizeBusinessPrimaryColorId } from '../business-settings/business-primary-colors';
import type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors';
type BusinessRow = {
id: bigint;
name: string;
nameFa: string | null;
about: string | null;
slug: string;
createdAt: Date;
isActive: boolean;
domainId: bigint | null;
domain: string | null;
sslEnabled: boolean | null;
ownerUserId: bigint | null;
ownerName: string | null;
ownerCellNumber: string | null;
primaryColor: string | null;
};
function slugify(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
}
@Injectable()
export class BusinessAdminService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
) {}
private async assertSuperAdmin(actor: AuthUser) {
if (!(await this.permissions.isSuperAdmin(actor.id))) {
throw new ForbiddenException('Super admin access required');
}
}
async list(query: ListBusinessesDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 10;
const skip = (page - 1) * pageSize;
const nameLike = query.name ? `%${query.name.trim()}%` : null;
const domainLike = query.domain ? `%${query.domain.trim()}%` : null;
const categoryLike = query.category ? `%${query.category.trim()}%` : null;
const where = Prisma.sql`
WHERE 1=1
${nameLike ? Prisma.sql`AND (b.name ILIKE ${nameLike} OR b.name_fa ILIKE ${nameLike})` : Prisma.empty}
${domainLike ? Prisma.sql`
AND EXISTS (
SELECT 1 FROM domains d
WHERE d.business_id = b.id AND d.host ILIKE ${domainLike}
)
` : Prisma.empty}
${categoryLike ? Prisma.sql`
AND EXISTS (
SELECT 1
FROM business_category_assignments bca
JOIN business_categories bc ON bc.id = bca.category_id
WHERE bca.business_id = b.id
AND (bc.slug ILIKE ${categoryLike} OR bc.name ILIKE ${categoryLike})
)
` : Prisma.empty}
`;
const [items, totalRow] = await Promise.all([
this.prisma.$queryRaw<BusinessRow[]>(Prisma.sql`
SELECT
b.id AS "id",
b.name AS "name",
b.name_fa AS "nameFa",
b.about AS "about",
b.slug AS "slug",
b.created_at AS "createdAt",
b.is_active AS "isActive",
dom.id AS "domainId",
dom.host AS "domain",
dom.ssl_enabled AS "sslEnabled",
own."ownerUserId" AS "ownerUserId",
own."ownerName" AS "ownerName",
own."ownerCellNumber" AS "ownerCellNumber",
b.settings->'branding'->>'primaryColor' AS "primaryColor"
FROM businesses b
LEFT JOIN LATERAL (
SELECT d.id, d.host, d.ssl_enabled
FROM domains d
WHERE d.business_id = b.id
ORDER BY d.is_primary DESC, d.created_at DESC
LIMIT 1
) dom ON TRUE
LEFT JOIN LATERAL (
SELECT
u.id AS "ownerUserId",
(u.first_name || ' ' || u.last_name) AS "ownerName",
u.cell_number AS "ownerCellNumber"
FROM business_users bu
JOIN users u ON u.id = bu.user_id
WHERE bu.business_id = b.id AND bu.is_owner = TRUE
LIMIT 1
) own ON TRUE
${where}
ORDER BY b.created_at DESC
LIMIT ${pageSize} OFFSET ${skip}
`),
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
SELECT COUNT(*)::int AS "total"
FROM businesses b
${where}
`),
]);
return {
items: items.map((item) => ({
...item,
primaryColor: normalizeBusinessPrimaryColorId(
item.primaryColor,
) as BusinessPrimaryColorId,
})),
total: totalRow[0]?.total ?? 0,
page,
pageSize,
};
}
async search(query: SearchBusinessesDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const q = query.q.trim();
const limit = Math.min(Math.max(query.limit ?? 20, 1), 50);
const like = `%${q}%`;
const items = await this.prisma.$queryRaw<
{
id: bigint;
name: string;
nameFa: string | null;
slug: string;
}[]
>(Prisma.sql`
SELECT DISTINCT
b.id AS "id",
b.name AS "name",
b.name_fa AS "nameFa",
b.slug AS "slug"
FROM businesses b
LEFT JOIN domains d ON d.business_id = b.id
WHERE b.is_active = TRUE
AND (
b.name ILIKE ${like}
OR b.name_fa ILIKE ${like}
OR b.slug ILIKE ${like}
OR d.host ILIKE ${like}
)
ORDER BY b.name ASC
LIMIT ${limit}
`);
return {
items: items.map((business) => ({
id: business.id,
name: business.name,
nameFa: business.nameFa,
slug: business.slug,
label: this.formatBusinessLabel(business),
})),
};
}
async listStaff(businessIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(businessIdRaw);
const business = await this.prisma.business.findUnique({
where: { id: businessId },
});
if (!business) {
throw new NotFoundException('Business not found');
}
const members = await this.prisma.businessUser.findMany({
where: { businessId },
include: {
user: {
select: {
id: true,
cellNumber: true,
firstName: true,
lastName: true,
email: true,
cellVerifiedAt: true,
isActive: true,
},
},
role: true,
inviter: { select: { id: true, firstName: true, lastName: true } },
},
orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }],
});
return {
items: members.map((member) => ({
id: member.id,
userId: member.user.id,
cellNumber: member.user.cellNumber,
firstName: member.user.firstName,
lastName: member.user.lastName,
email: member.user.email,
isActive: member.user.isActive,
isVerified: member.user.cellVerifiedAt !== null,
isOwner: member.isOwner,
teamRole: member.isOwner ? 'business_owner' : member.role?.slug ?? null,
invitedBy: member.inviter,
createdAt: member.createdAt,
})),
};
}
async getOne(businessIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(businessIdRaw);
const business = await this.prisma.business.findUnique({
where: { id: businessId },
include: {
categoryAssignments: {
include: { category: true },
},
businessUsers: {
where: { isOwner: true },
include: { user: true },
take: 1,
},
domains: { orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] },
},
});
if (!business) {
throw new NotFoundException('Business not found');
}
return this.serializeBusiness(business);
}
async create(dto: CreateBusinessDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const slug = dto.slug?.trim() || slugify(dto.name);
if (!slug) {
throw new BadRequestException('Could not generate slug from name');
}
await this.assertSlugAvailable(slug);
await this.validateCategoryIds(dto.categoryIds);
const business = await this.prisma.$transaction(async (tx) => {
const owner = await this.createOwnerUser(tx, dto);
const created = await tx.business.create({
data: {
name: dto.name.trim(),
nameFa: dto.nameFa.trim(),
about: dto.about?.trim() ?? null,
slug,
},
});
await tx.businessCategoryAssignment.createMany({
data: dto.categoryIds.map((id) => ({
businessId: created.id,
categoryId: BigInt(id),
})),
});
await this.assignOwner(tx, created.id, owner.id, actor.id);
return created;
});
return this.getOne(business.id.toString(), actor);
}
async update(businessIdRaw: string, dto: UpdateBusinessDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(businessIdRaw);
const business = await this.prisma.business.findUnique({
where: { id: businessId },
});
if (!business) {
throw new NotFoundException('Business not found');
}
const nextName = dto.name?.trim() ?? business.name;
const nextNameFa = dto.nameFa?.trim() ?? business.nameFa ?? business.name;
const nextSlug =
dto.slug?.trim() ?? (dto.name ? slugify(dto.name) : business.slug);
if (nextSlug !== business.slug) {
await this.assertSlugAvailable(nextSlug, businessId);
}
if (dto.categoryIds) {
await this.validateCategoryIds(dto.categoryIds);
}
if (dto.ownerUserId !== undefined && dto.ownerUserId !== null) {
await this.findOwnerUser(dto.ownerUserId);
}
await this.prisma.$transaction(async (tx) => {
await tx.business.update({
where: { id: businessId },
data: {
name: nextName,
nameFa: nextNameFa,
about: dto.about !== undefined ? dto.about.trim() || null : undefined,
slug: nextSlug,
},
});
if (dto.categoryIds) {
await tx.businessCategoryAssignment.deleteMany({ where: { businessId } });
await tx.businessCategoryAssignment.createMany({
data: dto.categoryIds.map((id) => ({
businessId,
categoryId: BigInt(id),
})),
});
}
if (dto.ownerUserId !== undefined && dto.ownerUserId !== null) {
await tx.businessUser.deleteMany({
where: { businessId, isOwner: true },
});
await this.assignOwner(tx, businessId, BigInt(dto.ownerUserId), actor.id);
}
});
return this.getOne(businessIdRaw, actor);
}
async addDomain(businessIdRaw: string, dto: AddDomainDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(businessIdRaw);
const host = dto.host.trim();
if (!host) {
throw new BadRequestException('host is required');
}
const business = await this.prisma.business.findUnique({ where: { id: businessId } });
if (!business) {
throw new NotFoundException('Business not found');
}
const hasPrimary = await this.prisma.domain.findFirst({
where: { businessId, isPrimary: true },
select: { id: true },
});
const isPrimary = dto.isPrimary ?? !hasPrimary;
return this.prisma.domain.create({
data: {
businessId,
host,
isPrimary,
isVerified: false,
sslEnabled: false,
},
});
}
async updateDomain(
businessIdRaw: string,
domainIdRaw: string,
dto: UpdateDomainDto,
actor: AuthUser,
) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(businessIdRaw);
const domainId = BigInt(domainIdRaw);
const host = dto.host.trim();
if (!host) {
throw new BadRequestException('host is required');
}
const domain = await this.prisma.domain.findFirst({
where: { id: domainId, businessId },
});
if (!domain) {
throw new NotFoundException('Domain not found');
}
const existing = await this.prisma.domain.findUnique({ where: { host } });
if (existing && existing.id !== domainId) {
throw new ConflictException('Domain host is already taken');
}
return this.prisma.domain.update({
where: { id: domainId },
data: { host },
});
}
async disable(businessIdRaw: string, dto: DisableBusinessDto, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(businessIdRaw);
const business = await this.prisma.business.findUnique({ where: { id: businessId } });
if (!business) {
throw new NotFoundException('Business not found');
}
return this.prisma.business.update({
where: { id: businessId },
data: { isActive: dto.isActive },
});
}
async remove(businessIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor);
const businessId = BigInt(businessIdRaw);
const business = await this.prisma.business.findUnique({ where: { id: businessId } });
if (!business) {
throw new NotFoundException('Business not found');
}
await this.prisma.business.delete({ where: { id: businessId } });
return { message: 'Business removed' };
}
private async assertSlugAvailable(slug: string, excludeId?: bigint) {
const existing = await this.prisma.business.findUnique({ where: { slug } });
if (existing && existing.id !== excludeId) {
throw new ConflictException('Business slug is already taken');
}
}
private async validateCategoryIds(categoryIds: number[]) {
const ids = [...new Set(categoryIds)].map((id) => BigInt(id));
const count = await this.prisma.businessCategory.count({
where: { id: { in: ids }, isActive: true },
});
if (count !== ids.length) {
throw new BadRequestException('One or more categoryIds are invalid');
}
}
private async createOwnerUser(
tx: Prisma.TransactionClient,
dto: Pick<
CreateBusinessDto,
'ownerFirstName' | 'ownerLastName' | 'ownerCellNumber' | 'ownerPassword'
>,
) {
const existing = await tx.user.findUnique({
where: { cellNumber: dto.ownerCellNumber },
});
if (existing) {
throw new ConflictException('A user with this cell number already exists');
}
const passwordHash = await bcrypt.hash(dto.ownerPassword, 10);
return tx.user.create({
data: {
cellNumber: dto.ownerCellNumber,
passwordHash,
firstName: dto.ownerFirstName.trim(),
lastName: dto.ownerLastName.trim(),
cellVerifiedAt: new Date(),
},
});
}
private async findOwnerUser(ownerUserId: number) {
const owner = await this.prisma.user.findUnique({
where: { id: BigInt(ownerUserId) },
});
if (!owner || !owner.isActive) {
throw new NotFoundException('Owner user not found');
}
return owner;
}
private async assignOwner(
tx: Prisma.TransactionClient,
businessId: bigint,
ownerUserId: bigint,
invitedBy: bigint,
) {
const businessOwnerRole = await tx.role.findUnique({
where: { slug: 'business_owner' },
});
if (!businessOwnerRole) {
throw new Error('business_owner role is missing');
}
await tx.businessUser.upsert({
where: {
businessId_userId: { businessId, userId: ownerUserId },
},
create: {
businessId,
userId: ownerUserId,
isOwner: true,
invitedBy,
},
update: {
isOwner: true,
roleId: null,
invitedBy,
},
});
const hasRole = await tx.userRole.findUnique({
where: {
userId_roleId: {
userId: ownerUserId,
roleId: businessOwnerRole.id,
},
},
});
if (!hasRole) {
await tx.userRole.create({
data: { userId: ownerUserId, roleId: businessOwnerRole.id },
});
}
}
private serializeBusiness(
business: {
id: bigint;
name: string;
nameFa: string | null;
about: string | null;
slug: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
categoryAssignments: {
category: {
id: bigint;
name: string;
slug: string;
parentId: bigint | null;
};
}[];
businessUsers: {
user: {
id: bigint;
cellNumber: string;
firstName: string | null;
lastName: string | null;
email: string | null;
};
}[];
domains: {
id: bigint;
host: string;
isPrimary: boolean;
isVerified: boolean;
sslEnabled: boolean;
}[];
},
) {
const owner = business.businessUsers[0]?.user ?? null;
return {
id: business.id,
name: business.name,
nameFa: business.nameFa,
about: business.about,
slug: business.slug,
isActive: business.isActive,
createdAt: business.createdAt,
updatedAt: business.updatedAt,
categories: business.categoryAssignments.map((a) => ({
id: a.category.id,
name: a.category.name,
slug: a.category.slug,
parentId: a.category.parentId,
})),
categoryIds: business.categoryAssignments.map((a) => a.category.id),
owner: owner
? {
id: owner.id,
cellNumber: owner.cellNumber,
firstName: owner.firstName,
lastName: owner.lastName,
email: owner.email,
}
: null,
ownerUserId: owner?.id ?? null,
domains: business.domains,
};
}
private formatBusinessLabel(business: {
name: string;
nameFa: string | null;
slug: string;
}): string {
if (business.nameFa && business.nameFa !== business.name) {
return `${business.name} / ${business.nameFa}`;
}
return business.name;
}
}