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
+467
View File
@@ -0,0 +1,467 @@
import {
BadRequestException,
ConflictException,
Injectable,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';
import { RedisService } from '../redis/redis.service';
import { TenantService } from '../tenant/tenant.service';
import {
AuthJwtPayload,
AuthUser,
DashboardType,
UserProfile,
resolvePrimaryRole,
} from './auth.types';
import { ChangePasswordDto } from './dto/change-password.dto';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { PermissionsService } from './permissions.service';
import { parseUserProfile } from './profile.util';
import { SmsService } from './sms.service';
const OTP_TTL_SECONDS = 300;
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly redis: RedisService,
private readonly sms: SmsService,
private readonly tenant: TenantService,
private readonly permissions: PermissionsService,
) {}
async register(dto: RegisterDto) {
const business = await this.tenant.resolveBusinessByDomain(dto.domain);
const passwordHash = await bcrypt.hash(dto.password, 10);
const smsEnabled = this.sms.isEnabled();
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: business.id } },
},
});
if (existingUser?.businessCustomers.length) {
throw new ConflictException(
'This cell number is already registered on this website',
);
}
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: smsEnabled ? null : new Date(),
},
}));
if (existingUser) {
const passwordValid = await bcrypt.compare(
dto.password,
existingUser.passwordHash,
);
if (!passwordValid) {
throw new ConflictException(
'Cell number exists on another account. Use login or reset password.',
);
}
}
await tx.businessCustomer.create({
data: {
businessId: business.id,
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 authUser = await this.getAuthUser(user.id);
const tokens = await this.issueTokens(authUser);
return {
message: smsEnabled
? 'Registration successful. Please verify your cell number with OTP.'
: 'Registration successful. SMS verification is disabled — account auto-verified.',
smsEnabled,
user: this.serializeUser(authUser),
registeredBusiness: {
id: business.id,
name: business.name,
slug: business.slug,
},
...tokens,
};
}
async login(dto: LoginDto) {
const user = await this.prisma.user.findUnique({
where: { cellNumber: dto.cellNumber },
});
if (!user || !user.isActive) {
throw new UnauthorizedException('Invalid cell number or password');
}
const passwordValid = await bcrypt.compare(dto.password, user.passwordHash);
if (!passwordValid) {
throw new UnauthorizedException('Invalid cell number or password');
}
if (this.sms.isEnabled() && !user.cellVerifiedAt) {
throw new UnauthorizedException(
'Cell number is not verified. Please complete OTP verification.',
);
}
await this.prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
});
const authUser = await this.getAuthUser(user.id);
const tokens = await this.issueTokens(authUser);
return {
message: 'Login successful',
user: this.serializeUser(authUser),
...tokens,
};
}
async refresh(refreshToken: string) {
let payload: AuthJwtPayload;
try {
payload = await this.jwt.verifyAsync<AuthJwtPayload>(refreshToken, {
secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
});
} catch {
throw new UnauthorizedException('Invalid or expired refresh token');
}
if (payload.type !== 'refresh') {
throw new UnauthorizedException('Invalid token type');
}
const authUser = await this.getAuthUser(BigInt(payload.sub));
const tokens = await this.issueTokens(authUser);
return {
message: 'Token refreshed',
user: this.serializeUser(authUser),
...tokens,
};
}
async me(user: AuthUser) {
return { user: this.serializeUser(user) };
}
async updateProfile(user: AuthUser, dto: UpdateProfileDto) {
const currentProfile = user.profile;
const nextProfile: UserProfile = {
...currentProfile,
about: dto.about ?? currentProfile.about,
city: dto.city ?? currentProfile.city,
address: dto.address ?? currentProfile.address,
landline: dto.landline ?? currentProfile.landline,
backupPhone: dto.backupPhone ?? currentProfile.backupPhone,
postalCode: dto.postalCode ?? currentProfile.postalCode,
instagram: dto.instagram ?? currentProfile.instagram,
telegramId: dto.telegramId ?? currentProfile.telegramId,
linkedin: dto.linkedin ?? currentProfile.linkedin,
};
await this.prisma.user.update({
where: { id: user.id },
data: {
firstName: dto.firstName ?? user.firstName,
lastName: dto.lastName ?? user.lastName,
email: dto.email ?? user.email,
profile: nextProfile as object,
},
});
const authUser = await this.getAuthUser(user.id);
return {
message: 'Profile updated successfully',
user: this.serializeUser(authUser),
};
}
async changePassword(user: AuthUser, dto: ChangePasswordDto) {
const account = await this.prisma.user.findUnique({
where: { id: user.id },
});
if (!account) {
throw new UnauthorizedException('User not found');
}
const passwordValid = await bcrypt.compare(
dto.currentPassword,
account.passwordHash,
);
if (!passwordValid) {
throw new BadRequestException('Current password is incorrect');
}
if (dto.currentPassword === dto.newPassword) {
throw new BadRequestException(
'New password must be different from the current password',
);
}
const passwordHash = await bcrypt.hash(dto.newPassword, 10);
await this.prisma.user.update({
where: { id: user.id },
data: { passwordHash },
});
return { message: 'Password changed successfully' };
}
async sendOtp(cellNumber: string) {
if (!this.sms.isEnabled()) {
return {
enabled: false,
message:
'SMS verification is currently disabled. Register and login work without OTP.',
};
}
const user = await this.prisma.user.findUnique({
where: { cellNumber },
});
if (!user) {
throw new UnauthorizedException('Cell number is not registered');
}
const code = this.generateOtpCode();
await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS);
try {
await this.sms.sendVerificationCode(cellNumber, code);
} catch {
throw new ServiceUnavailableException(
'SMS provider is not configured yet',
);
}
return {
enabled: true,
message: 'Verification code sent',
expiresInSeconds: OTP_TTL_SECONDS,
};
}
async verifyOtp(cellNumber: string, code: string) {
if (!this.sms.isEnabled()) {
const user = await this.prisma.user.findUnique({
where: { cellNumber },
});
if (!user) {
throw new UnauthorizedException('Cell number is not registered');
}
if (!user.cellVerifiedAt) {
await this.prisma.user.update({
where: { id: user.id },
data: { cellVerifiedAt: new Date() },
});
}
return {
enabled: false,
verified: true,
message: 'SMS verification is disabled — cell number marked as verified.',
};
}
const storedCode = await this.redis.getOtp(cellNumber);
if (!storedCode || storedCode !== code) {
throw new UnauthorizedException('Invalid or expired verification code');
}
const user = await this.prisma.user.findUnique({
where: { cellNumber },
});
if (!user) {
throw new UnauthorizedException('Cell number is not registered');
}
await this.prisma.user.update({
where: { id: user.id },
data: { cellVerifiedAt: new Date() },
});
await this.redis.deleteOtp(cellNumber);
return {
enabled: true,
verified: true,
message: 'Cell number verified successfully',
};
}
private async getAuthUser(userId: bigint): Promise<AuthUser> {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
userRoles: { include: { role: true } },
businessUsers: { include: { business: true } },
businessCustomers: { include: { business: true } },
},
});
if (!user) {
throw new UnauthorizedException('User not found');
}
const roles = user.userRoles.map((ur) => ur.role.slug);
const businesses = await this.permissions.getBusinessMemberships(user.id);
const customerBusinesses = user.businessCustomers.map((bc) => ({
id: bc.business.id,
name: bc.business.name,
slug: bc.business.slug,
}));
return {
id: user.id,
cellNumber: user.cellNumber,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
cellVerifiedAt: user.cellVerifiedAt,
roles,
dashboard: this.resolveDashboard(roles, businesses.length),
profile: parseUserProfile(user.profile),
businesses,
customerBusinesses,
};
}
private resolveDashboard(roles: string[], businessCount: number): DashboardType {
if (roles.includes('super_admin')) {
return 'super_admin';
}
if (
roles.includes('business_owner') ||
roles.includes('business_staff') ||
roles.includes('owner') ||
businessCount > 0
) {
return 'business';
}
return 'customer';
}
private async issueTokens(user: AuthUser) {
const accessPayload: AuthJwtPayload = {
sub: user.id.toString(),
cellNumber: user.cellNumber,
roles: user.roles,
dashboard: user.dashboard,
type: 'access',
};
const refreshPayload: AuthJwtPayload = {
sub: user.id.toString(),
cellNumber: user.cellNumber,
roles: user.roles,
dashboard: user.dashboard,
type: 'refresh',
};
const accessExpiresIn = this.config.get<string>('JWT_ACCESS_EXPIRES_IN', '15m');
const refreshExpiresIn = this.config.get<string>('JWT_REFRESH_EXPIRES_IN', '7d');
const [accessToken, refreshToken] = await Promise.all([
this.jwt.signAsync(accessPayload, {
secret: this.config.getOrThrow<string>('JWT_ACCESS_SECRET'),
expiresIn: accessExpiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`,
}),
this.jwt.signAsync(refreshPayload, {
secret: this.config.getOrThrow<string>('JWT_REFRESH_SECRET'),
expiresIn: refreshExpiresIn as `${number}${'s' | 'm' | 'h' | 'd'}`,
}),
]);
return { accessToken, refreshToken };
}
private serializeUser(user: AuthUser) {
const primaryRole = resolvePrimaryRole(user.roles);
return {
id: user.id,
cellNumber: user.cellNumber,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
cellVerifiedAt: user.cellVerifiedAt,
roles: user.roles,
dashboard: user.dashboard,
primaryRole: primaryRole.slug,
roleLabel: primaryRole.label,
isSuperAdmin: primaryRole.isSuperAdmin,
profile: user.profile,
businesses: user.businesses,
customerBusinesses: user.customerBusinesses,
};
}
private generateOtpCode(): string {
return Math.floor(100000 + Math.random() * 900000).toString();
}
}