mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-12 06:40:58 +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,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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user