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
+78
View File
@@ -0,0 +1,78 @@
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 { CustomersService } from './customers.service';
import { CreateCustomerDto } from './dto/create-customer.dto';
import { ListCustomersDto } from './dto/list-customers.dto';
import { SearchCustomersDto } from './dto/search-customers.dto';
import { UpdateCustomerDto } from './dto/update-customer.dto';
@Controller('businesses/:businessId/customers')
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
export class CustomersController {
constructor(private readonly service: CustomersService) {}
@Get()
@RequireBusinessPermission('orders.read')
list(
@Param('businessId') businessId: string,
@Query() query: ListCustomersDto,
@CurrentUser() user: AuthUser,
) {
return this.service.list(businessId, query, user);
}
@Get('search')
@RequireBusinessPermission('orders.create')
search(
@Param('businessId') businessId: string,
@Query() query: SearchCustomersDto,
@CurrentUser() user: AuthUser,
) {
return this.service.search(businessId, query, user);
}
@Post()
@RequireBusinessPermission('orders.create')
create(
@Param('businessId') businessId: string,
@Body() body: CreateCustomerDto,
@CurrentUser() user: AuthUser,
) {
return this.service.create(businessId, body, user);
}
@Patch(':userId')
@RequireBusinessPermission('orders.update')
update(
@Param('businessId') businessId: string,
@Param('userId') userId: string,
@Body() body: UpdateCustomerDto,
@CurrentUser() user: AuthUser,
) {
return this.service.update(businessId, userId, body, user);
}
@Delete(':userId')
@RequireBusinessPermission('orders.update')
remove(
@Param('businessId') businessId: string,
@Param('userId') userId: string,
@CurrentUser() user: AuthUser,
) {
return this.service.remove(businessId, userId, user);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { CustomersController } from './customers.controller';
import { CustomersService } from './customers.service';
@Module({
imports: [AuthModule],
controllers: [CustomersController],
providers: [CustomersService],
})
export class CustomersModule {}
+402
View File
@@ -0,0 +1,402 @@
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreateCustomerDto } from './dto/create-customer.dto';
import { ListCustomersDto } from './dto/list-customers.dto';
import { SearchCustomersDto } from './dto/search-customers.dto';
import { UpdateCustomerDto } from './dto/update-customer.dto';
type CustomerListRow = {
id: bigint;
cellNumber: string;
firstName: string | null;
lastName: string | null;
email: string | null;
createdAt: Date;
isEnabled: boolean;
};
@Injectable()
export class CustomersService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
) {}
async list(
businessIdRaw: string,
query: ListCustomersDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'orders.read');
const page = query.page ?? 1;
const pageSize = Math.min(Math.max(query.pageSize ?? 24, 1), 100);
const skip = (page - 1) * pageSize;
const nameLike = query.name?.trim() ? `%${query.name.trim()}%` : null;
const cellLike = query.cellNumber?.trim() ? `%${query.cellNumber.trim()}%` : null;
const where = Prisma.sql`
WHERE bc.business_id = ${businessId}
${nameLike ? Prisma.sql`
AND (
u.first_name ILIKE ${nameLike}
OR u.last_name ILIKE ${nameLike}
OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${nameLike}
)
` : Prisma.empty}
${cellLike ? Prisma.sql`AND u.cell_number ILIKE ${cellLike}` : Prisma.empty}
`;
const [items, totalRow] = await Promise.all([
this.prisma.$queryRaw<CustomerListRow[]>(Prisma.sql`
SELECT
u.id AS "id",
u.cell_number AS "cellNumber",
u.first_name AS "firstName",
u.last_name AS "lastName",
u.email AS "email",
bc.created_at AS "createdAt",
bc.is_enabled AS "isEnabled"
FROM business_customers bc
JOIN users u ON u.id = bc.user_id
${where}
ORDER BY bc.created_at DESC
LIMIT ${pageSize} OFFSET ${skip}
`),
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
SELECT COUNT(*)::int AS "total"
FROM business_customers bc
JOIN users u ON u.id = bc.user_id
${where}
`),
]);
return {
items: items.map((user) => ({
id: user.id.toString(),
cellNumber: user.cellNumber,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
label: this.formatLabel(user),
createdAt: user.createdAt.toISOString(),
isEnabled: user.isEnabled,
})),
total: totalRow[0]?.total ?? 0,
page,
pageSize,
};
}
async search(
businessIdRaw: string,
query: SearchCustomersDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'orders.create');
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;
cellNumber: string;
firstName: string | null;
lastName: string | null;
email: string | null;
}[]
>(Prisma.sql`
SELECT
u.id AS "id",
u.cell_number AS "cellNumber",
u.first_name AS "firstName",
u.last_name AS "lastName",
u.email AS "email"
FROM business_customers bc
JOIN users u ON u.id = bc.user_id
WHERE bc.business_id = ${businessId}
AND bc.is_enabled = TRUE
AND u.is_active = TRUE
AND (
u.cell_number ILIKE ${like}
OR u.first_name ILIKE ${like}
OR u.last_name ILIKE ${like}
OR u.email ILIKE ${like}
OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${like}
)
ORDER BY u.first_name ASC NULLS LAST, u.last_name ASC NULLS LAST
LIMIT ${limit}
`);
return {
items: items.map((user) => ({
id: user.id.toString(),
cellNumber: user.cellNumber,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
label: this.formatLabel(user),
})),
};
}
async create(
businessIdRaw: string,
dto: CreateCustomerDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'orders.create');
const business = await this.prisma.business.findUnique({
where: { id: businessId },
});
if (!business?.isActive) {
throw new NotFoundException('Business not found');
}
const customerRole = await this.prisma.role.findUnique({
where: { slug: 'customer' },
});
if (!customerRole) {
throw new Error('Customer role is missing. Run database migrations first.');
}
const existingUser = await this.prisma.user.findUnique({
where: { cellNumber: dto.cellNumber },
include: {
businessCustomers: { where: { businessId } },
},
});
if (existingUser?.businessCustomers.length) {
throw new ConflictException('User is already a customer of this business');
}
if (existingUser) {
const staffMembership = await this.prisma.businessUser.findUnique({
where: {
businessId_userId: { businessId, userId: existingUser.id },
},
});
if (staffMembership) {
throw new ConflictException('User is already staff of this business');
}
}
if (!existingUser && !dto.password) {
throw new BadRequestException('password is required for new users');
}
const passwordHash = existingUser
? existingUser.passwordHash
: await bcrypt.hash(dto.password!, 10);
const membership = await this.prisma.$transaction(async (tx) => {
const account =
existingUser ??
(await tx.user.create({
data: {
cellNumber: dto.cellNumber,
passwordHash,
email: dto.email,
firstName: dto.firstName,
lastName: dto.lastName,
cellVerifiedAt: new Date(),
},
}));
if (existingUser && !existingUser.cellVerifiedAt) {
await tx.user.update({
where: { id: account.id },
data: { cellVerifiedAt: new Date() },
});
}
const link = await tx.businessCustomer.create({
data: {
businessId,
userId: account.id,
},
});
const hasCustomerRole = await tx.userRole.findUnique({
where: {
userId_roleId: {
userId: account.id,
roleId: customerRole.id,
},
},
});
if (!hasCustomerRole) {
await tx.userRole.create({
data: {
userId: account.id,
roleId: customerRole.id,
},
});
}
return { account, link };
});
const user = membership.account;
const verifiedAt = user.cellVerifiedAt ?? new Date();
return {
id: user.id.toString(),
cellNumber: user.cellNumber,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
label: this.formatLabel(user),
createdAt: membership.link.createdAt.toISOString(),
isEnabled: membership.link.isEnabled,
isVerified: verifiedAt !== null,
role: 'customer',
};
}
async update(
businessIdRaw: string,
userIdRaw: string,
dto: UpdateCustomerDto,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const userId = BigInt(userIdRaw);
await this.assertPermission(businessId, actor.id, 'orders.update');
const membership = await this.prisma.businessCustomer.findUnique({
where: {
businessId_userId: { businessId, userId },
},
include: { user: true },
});
if (!membership) {
throw new NotFoundException('Customer not found for this business');
}
if (dto.isEnabled !== undefined) {
await this.prisma.businessCustomer.update({
where: { id: membership.id },
data: { isEnabled: dto.isEnabled },
});
}
const hasProfileUpdate =
dto.firstName !== undefined ||
dto.lastName !== undefined ||
dto.email !== undefined ||
dto.cellNumber !== undefined;
if (hasProfileUpdate) {
const user = membership.user;
if (dto.cellNumber && dto.cellNumber !== user.cellNumber) {
const existing = await this.prisma.user.findUnique({
where: { cellNumber: dto.cellNumber },
});
if (existing && existing.id !== userId) {
throw new BadRequestException('Cell number is already in use');
}
}
await this.prisma.user.update({
where: { id: userId },
data: {
firstName: dto.firstName?.trim(),
lastName: dto.lastName?.trim(),
email: dto.email !== undefined ? dto.email.trim() || null : undefined,
cellNumber: dto.cellNumber?.trim(),
},
});
}
const refreshed = await this.prisma.businessCustomer.findUnique({
where: { id: membership.id },
include: { user: true },
});
if (!refreshed) {
throw new NotFoundException('Customer not found for this business');
}
const user = refreshed.user;
return {
id: user.id.toString(),
cellNumber: user.cellNumber,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
label: this.formatLabel(user),
isEnabled: refreshed.isEnabled,
};
}
async remove(businessIdRaw: string, userIdRaw: string, actor: AuthUser) {
const businessId = BigInt(businessIdRaw);
const userId = BigInt(userIdRaw);
await this.assertPermission(businessId, actor.id, 'orders.update');
const membership = await this.prisma.businessCustomer.findUnique({
where: {
businessId_userId: { businessId, userId },
},
});
if (!membership) {
throw new NotFoundException('Customer not found for this business');
}
await this.prisma.businessCustomer.delete({
where: { id: membership.id },
});
return { success: true };
}
private formatLabel(user: {
firstName: string | null;
lastName: string | null;
cellNumber: string;
email: string | null;
}) {
const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim();
if (name) {
return `${name} · ${user.cellNumber}`;
}
return user.email ? `${user.email} · ${user.cellNumber}` : user.cellNumber;
}
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`,
);
}
}
}
+32
View File
@@ -0,0 +1,32 @@
import {
IsEmail,
IsOptional,
IsString,
Matches,
MinLength,
} from 'class-validator';
export class CreateCustomerDto {
@IsString()
@Matches(/^\+[1-9]\d{6,14}$/, {
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
})
cellNumber!: string;
@IsOptional()
@IsString()
@MinLength(8)
password?: string;
@IsString()
@MinLength(2)
firstName!: string;
@IsString()
@MinLength(2)
lastName!: string;
@IsOptional()
@IsEmail()
email?: string;
}
+24
View File
@@ -0,0 +1,24 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Min } from 'class-validator';
export class ListCustomersDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pageSize?: number;
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
cellNumber?: string;
}
+12
View File
@@ -0,0 +1,12 @@
import { IsOptional, IsString, MinLength } from 'class-validator';
import { Type } from 'class-transformer';
export class SearchCustomersDto {
@IsString()
@MinLength(2, { message: 'q must be at least 2 characters' })
q!: string;
@IsOptional()
@Type(() => Number)
limit?: number = 20;
}
+26
View File
@@ -0,0 +1,26 @@
import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator';
export class UpdateCustomerDto {
@IsOptional()
@IsBoolean()
isEnabled?: boolean;
@IsOptional()
@IsString()
@MinLength(1)
firstName?: string;
@IsOptional()
@IsString()
@MinLength(1)
lastName?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsString()
@MinLength(10)
cellNumber?: string;
}