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,502 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { AuthUser, ASSIGNABLE_GLOBAL_ROLES } from '../auth/auth.types';
|
||||
import { PermissionsService } from '../auth/permissions.service';
|
||||
import { SmsService } from '../auth/sms.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AdminResetPasswordDto } from './dto/admin-reset-password.dto';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { ListUsersDto } from './dto/list-users.dto';
|
||||
import { SearchUsersDto } from './dto/search-users.dto';
|
||||
import { SendUserMessageDto } from './dto/send-user-message.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { UpdateUserRoleDto } from './dto/update-user-role.dto';
|
||||
|
||||
type UserListRow = {
|
||||
id: bigint;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
cellNumber: string;
|
||||
roles: string | null;
|
||||
roleSlug: string | null;
|
||||
businesses: string | null;
|
||||
businessMemberId: bigint | null;
|
||||
isBusinessOwner: boolean | null;
|
||||
teamRole: string | null;
|
||||
createdAt: Date;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
private readonly sms: SmsService,
|
||||
) {}
|
||||
|
||||
private async assertSuperAdmin(actor: AuthUser) {
|
||||
if (!(await this.permissions.isSuperAdmin(actor.id))) {
|
||||
throw new ForbiddenException('Super admin access required');
|
||||
}
|
||||
}
|
||||
|
||||
async updateRole(userIdRaw: string, dto: UpdateUserRoleDto, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const userId = BigInt(userIdRaw);
|
||||
if (userId === actor.id && dto.roleSlug !== 'super_admin') {
|
||||
throw new BadRequestException('You cannot change your own role');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user || !user.isActive) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
const targetRole = await this.prisma.role.findUnique({
|
||||
where: { slug: dto.roleSlug },
|
||||
});
|
||||
if (!targetRole) {
|
||||
throw new BadRequestException('Invalid role');
|
||||
}
|
||||
|
||||
const replaceableRoles = await this.prisma.role.findMany({
|
||||
where: { slug: { in: [...ASSIGNABLE_GLOBAL_ROLES] } },
|
||||
select: { id: true },
|
||||
});
|
||||
const replaceableRoleIds = replaceableRoles.map((role) => role.id);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.userRole.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
roleId: { in: replaceableRoleIds },
|
||||
},
|
||||
});
|
||||
|
||||
await tx.userRole.create({
|
||||
data: {
|
||||
userId,
|
||||
roleId: targetRole.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const userRoles = await this.prisma.userRole.findMany({
|
||||
where: { userId },
|
||||
include: { role: true },
|
||||
});
|
||||
|
||||
return {
|
||||
id: userId,
|
||||
role: dto.roleSlug,
|
||||
roles: userRoles.map((entry) => entry.role.slug),
|
||||
};
|
||||
}
|
||||
|
||||
async list(query: ListUsersDto, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 24;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const nameLike = query.name?.trim() ? `%${query.name.trim()}%` : null;
|
||||
const cellLike = query.cellNumber?.trim() ? `%${query.cellNumber.trim()}%` : null;
|
||||
const roleSlug = query.role?.trim() || null;
|
||||
const businessId = query.businessId ?? null;
|
||||
const membership = query.membership ?? null;
|
||||
|
||||
const where = Prisma.sql`
|
||||
WHERE 1=1
|
||||
${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}
|
||||
${roleSlug ? Prisma.sql`
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = u.id AND r.slug = ${roleSlug}
|
||||
)
|
||||
` : Prisma.empty}
|
||||
${businessId && membership === 'staff' ? Prisma.sql`
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM business_users bu
|
||||
WHERE bu.user_id = u.id AND bu.business_id = ${businessId}
|
||||
)
|
||||
` : Prisma.empty}
|
||||
${businessId && membership === 'all' ? Prisma.sql`
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM (
|
||||
SELECT user_id FROM business_users WHERE business_id = ${businessId}
|
||||
UNION
|
||||
SELECT user_id FROM business_customers WHERE business_id = ${businessId}
|
||||
) ub
|
||||
WHERE ub.user_id = u.id
|
||||
)
|
||||
` : Prisma.empty}
|
||||
`;
|
||||
|
||||
const [items, totalRow] = await Promise.all([
|
||||
this.prisma.$queryRaw<UserListRow[]>(Prisma.sql`
|
||||
SELECT
|
||||
u.id AS "id",
|
||||
u.first_name AS "firstName",
|
||||
u.last_name AS "lastName",
|
||||
u.cell_number AS "cellNumber",
|
||||
u.created_at AS "createdAt",
|
||||
u.is_active AS "isActive",
|
||||
roles.roles AS "roles",
|
||||
global_role.slug AS "roleSlug",
|
||||
biz.businesses AS "businesses",
|
||||
biz_member.member_id AS "businessMemberId",
|
||||
biz_member.is_owner AS "isBusinessOwner",
|
||||
biz_member.team_slug AS "teamRole"
|
||||
FROM users u
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT string_agg(DISTINCT r.name, ', ' ORDER BY r.name) AS roles
|
||||
FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = u.id
|
||||
) roles ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT r.slug
|
||||
FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = u.id
|
||||
AND r.slug IN (${Prisma.join([...ASSIGNABLE_GLOBAL_ROLES])})
|
||||
ORDER BY r.name
|
||||
LIMIT 1
|
||||
) global_role ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT string_agg(DISTINCT b.name, ', ' ORDER BY b.name) AS businesses
|
||||
FROM (
|
||||
SELECT business_id FROM business_users WHERE user_id = u.id
|
||||
UNION
|
||||
SELECT business_id FROM business_customers WHERE user_id = u.id
|
||||
) ub
|
||||
JOIN businesses b ON b.id = ub.business_id
|
||||
) biz ON TRUE
|
||||
${businessId ? Prisma.sql`
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT bu.id AS member_id, bu.is_owner, r.slug AS team_slug
|
||||
FROM business_users bu
|
||||
LEFT JOIN roles r ON r.id = bu.role_id
|
||||
WHERE bu.user_id = u.id AND bu.business_id = ${businessId}
|
||||
LIMIT 1
|
||||
) biz_member ON TRUE
|
||||
` : Prisma.sql`
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT NULL::bigint AS member_id, NULL::boolean AS is_owner, NULL::text AS team_slug
|
||||
) biz_member ON TRUE
|
||||
`}
|
||||
${where}
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT ${pageSize} OFFSET ${skip}
|
||||
`),
|
||||
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
|
||||
SELECT COUNT(*)::int AS "total"
|
||||
FROM users u
|
||||
${where}
|
||||
`),
|
||||
]);
|
||||
|
||||
return {
|
||||
items,
|
||||
total: totalRow[0]?.total ?? 0,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async create(dto: CreateUserDto, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const businessId = BigInt(dto.businessId);
|
||||
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 user = 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() },
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
const verifiedAt = user.cellVerifiedAt ?? new Date();
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
cellNumber: user.cellNumber,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
cellVerifiedAt: verifiedAt,
|
||||
isVerified: verifiedAt !== null,
|
||||
businessId,
|
||||
role: 'customer',
|
||||
};
|
||||
}
|
||||
|
||||
async search(query: SearchUsersDto, 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;
|
||||
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 users u
|
||||
WHERE 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,
|
||||
cellNumber: user.cellNumber,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
email: user.email,
|
||||
label: this.formatLabel(user),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async update(userIdRaw: string, dto: UpdateUserDto, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const userId = BigInt(userIdRaw);
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
const updated = 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(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
firstName: updated.firstName,
|
||||
lastName: updated.lastName,
|
||||
email: updated.email,
|
||||
cellNumber: updated.cellNumber,
|
||||
};
|
||||
}
|
||||
|
||||
async resetPassword(userIdRaw: string, dto: AdminResetPasswordDto, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const userId = BigInt(userIdRaw);
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash },
|
||||
});
|
||||
|
||||
return { message: 'Password reset successfully' };
|
||||
}
|
||||
|
||||
async remove(userIdRaw: string, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const userId = BigInt(userIdRaw);
|
||||
if (userId === actor.id) {
|
||||
throw new BadRequestException('You cannot remove your own account');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
return { message: 'User removed' };
|
||||
}
|
||||
|
||||
async sendMessage(userIdRaw: string, dto: SendUserMessageDto, actor: AuthUser) {
|
||||
await this.assertSuperAdmin(actor);
|
||||
|
||||
const userId = BigInt(userIdRaw);
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
if (!this.sms.isEnabled()) {
|
||||
return {
|
||||
enabled: false,
|
||||
message: 'SMS is disabled. Message was not sent.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sms.sendMessage(user.cellNumber, dto.message.trim());
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('SMS provider is not configured yet');
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
message: 'Message sent successfully',
|
||||
};
|
||||
}
|
||||
|
||||
private formatLabel(user: {
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
cellNumber: string;
|
||||
}): string {
|
||||
const name = [user.firstName, user.lastName].filter(Boolean).join(' ').trim();
|
||||
return name ? `${name} (${user.cellNumber})` : user.cellNumber;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user