Document that product specs need /technical-info for labels, auto-verify admin-created business owners, and expand category color presets with Farsi aliases. Co-authored-by: Cursor <cursoragent@cursor.com>
594 lines
17 KiB
TypeScript
594 lines
17 KiB
TypeScript
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;
|
|
firstNameEn: string | null;
|
|
lastNameEn: 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;
|
|
};
|
|
|
|
/**
|
|
* ILIKE patterns so local Iranian input (09…) matches stored E.164 (+98…).
|
|
* `09024300` must match `+989024300340`; short fragments like `4300` still match.
|
|
*/
|
|
function cellNumberLikePatterns(input: string): string[] {
|
|
const trimmed = input.trim();
|
|
if (!trimmed) return [];
|
|
|
|
const patterns = new Set<string>();
|
|
patterns.add(`%${trimmed}%`);
|
|
|
|
const digits = trimmed.replace(/\D/g, '');
|
|
if (!digits) return [...patterns];
|
|
|
|
patterns.add(`%${digits}%`);
|
|
|
|
let rest = digits;
|
|
if (rest.startsWith('00')) rest = rest.slice(2);
|
|
|
|
if (rest.startsWith('98')) {
|
|
patterns.add(`%+${rest}%`);
|
|
const national = rest.slice(2);
|
|
if (national) {
|
|
patterns.add(`%0${national}%`);
|
|
patterns.add(`%${national}%`);
|
|
}
|
|
} else if (rest.startsWith('0')) {
|
|
const national = rest.slice(1);
|
|
if (national) {
|
|
patterns.add(`%+98${national}%`);
|
|
patterns.add(`%98${national}%`);
|
|
}
|
|
} else if (rest.startsWith('9')) {
|
|
patterns.add(`%+98${rest}%`);
|
|
patterns.add(`%98${rest}%`);
|
|
patterns.add(`%0${rest}%`);
|
|
}
|
|
|
|
return [...patterns];
|
|
}
|
|
|
|
function cellNumberIlikeFilter(input: string | undefined): Prisma.Sql {
|
|
const patterns = cellNumberLikePatterns(input ?? '');
|
|
if (!patterns.length) return Prisma.empty;
|
|
return Prisma.sql`AND (${Prisma.join(
|
|
patterns.map((pattern) => Prisma.sql`u.cell_number ILIKE ${pattern}`),
|
|
' OR ',
|
|
)})`;
|
|
}
|
|
|
|
@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,
|
|
},
|
|
});
|
|
|
|
if (dto.roleSlug === 'business_owner') {
|
|
await tx.user.update({
|
|
where: { id: userId },
|
|
data: { cellVerifiedAt: user.cellVerifiedAt ?? new Date() },
|
|
});
|
|
}
|
|
});
|
|
|
|
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 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 u.first_name_en ILIKE ${nameLike}
|
|
OR u.last_name_en ILIKE ${nameLike}
|
|
OR (COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) ILIKE ${nameLike}
|
|
OR (COALESCE(u.first_name_en, '') || ' ' || COALESCE(u.last_name_en, '')) ILIKE ${nameLike}
|
|
)
|
|
` : Prisma.empty}
|
|
${cellNumberIlikeFilter(query.cellNumber)}
|
|
${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.first_name_en AS "firstNameEn",
|
|
u.last_name_en AS "lastNameEn",
|
|
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 cellPatterns = cellNumberLikePatterns(q);
|
|
const cellMatch = Prisma.join(
|
|
cellPatterns.map((pattern) => Prisma.sql`u.cell_number ILIKE ${pattern}`),
|
|
' OR ',
|
|
);
|
|
|
|
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 (
|
|
${cellMatch}
|
|
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 isBusinessOwner =
|
|
Boolean(
|
|
await this.prisma.businessUser.findFirst({
|
|
where: { userId, isOwner: true },
|
|
select: { id: true },
|
|
}),
|
|
) ||
|
|
Boolean(
|
|
await this.prisma.userRole.findFirst({
|
|
where: { userId, role: { slug: 'business_owner' } },
|
|
select: { id: true },
|
|
}),
|
|
);
|
|
|
|
const updated = await this.prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
firstName: dto.firstName?.trim(),
|
|
lastName: dto.lastName?.trim(),
|
|
firstNameEn:
|
|
dto.firstNameEn !== undefined ? dto.firstNameEn.trim() || null : undefined,
|
|
lastNameEn:
|
|
dto.lastNameEn !== undefined ? dto.lastNameEn.trim() || null : undefined,
|
|
email: dto.email !== undefined ? dto.email.trim() || null : undefined,
|
|
cellNumber: dto.cellNumber?.trim(),
|
|
...(isBusinessOwner
|
|
? { cellVerifiedAt: user.cellVerifiedAt ?? new Date() }
|
|
: {}),
|
|
},
|
|
});
|
|
|
|
return {
|
|
id: updated.id,
|
|
firstName: updated.firstName,
|
|
lastName: updated.lastName,
|
|
firstNameEn: updated.firstNameEn,
|
|
lastNameEn: updated.lastNameEn,
|
|
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;
|
|
}
|
|
}
|