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,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user