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
+108
View File
@@ -0,0 +1,108 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { CurrentUser } from './decorators/current-user.decorator';
import { ChangePasswordDto } from './dto/change-password.dto';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { RegisterDto } from './dto/register.dto';
import { SendOtpDto } from './dto/send-otp.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { UpsertUserAddressDto } from './dto/upsert-user-address.dto';
import { VerifyOtpDto } from './dto/verify-otp.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { AuthUser } from './auth.types';
import { UserAddressesService } from './user-addresses.service';
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly userAddresses: UserAddressesService,
) {}
@Post('register')
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Post('login')
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Post('refresh')
refresh(@Body() dto: RefreshTokenDto) {
return this.authService.refresh(dto.refreshToken);
}
@Get('me')
@UseGuards(JwtAuthGuard)
me(@CurrentUser() user: AuthUser) {
return this.authService.me(user);
}
@Patch('profile')
@UseGuards(JwtAuthGuard)
updateProfile(@CurrentUser() user: AuthUser, @Body() dto: UpdateProfileDto) {
return this.authService.updateProfile(user, dto);
}
@Post('change-password')
@UseGuards(JwtAuthGuard)
changePassword(@CurrentUser() user: AuthUser, @Body() dto: ChangePasswordDto) {
return this.authService.changePassword(user, dto);
}
@Post('send-otp')
sendOtp(@Body() dto: SendOtpDto) {
return this.authService.sendOtp(dto.cellNumber);
}
@Post('verify-otp')
verifyOtp(@Body() dto: VerifyOtpDto) {
return this.authService.verifyOtp(dto.cellNumber, dto.code);
}
@Get('addresses')
@UseGuards(JwtAuthGuard)
listAddresses(@CurrentUser() user: AuthUser) {
return this.userAddresses.list(user);
}
@Post('addresses')
@UseGuards(JwtAuthGuard)
createAddress(
@CurrentUser() user: AuthUser,
@Body() dto: UpsertUserAddressDto,
) {
return this.userAddresses.create(user, dto);
}
@Patch('addresses/:addressId')
@UseGuards(JwtAuthGuard)
updateAddress(
@CurrentUser() user: AuthUser,
@Param('addressId') addressId: string,
@Body() dto: UpsertUserAddressDto,
) {
return this.userAddresses.update(user, addressId, dto);
}
@Delete('addresses/:addressId')
@UseGuards(JwtAuthGuard)
removeAddress(
@CurrentUser() user: AuthUser,
@Param('addressId') addressId: string,
) {
return this.userAddresses.remove(user, addressId);
}
}
+42
View File
@@ -0,0 +1,42 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { PrismaModule } from '../prisma/prisma.module';
import { TenantModule } from '../tenant/tenant.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { BusinessPermissionGuard } from './guards/business-permission.guard';
import { PermissionsService } from './permissions.service';
import { SmsService } from './sms.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { UserAddressesService } from './user-addresses.service';
@Module({
imports: [
PrismaModule,
TenantModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
signOptions: {
expiresIn: config.get<string>('JWT_ACCESS_EXPIRES_IN', '15m') as `${number}${'s' | 'm' | 'h' | 'd'}`,
},
}),
}),
],
controllers: [AuthController],
providers: [
AuthService,
UserAddressesService,
SmsService,
PermissionsService,
BusinessPermissionGuard,
JwtStrategy,
],
exports: [AuthService, PermissionsService, BusinessPermissionGuard, SmsService],
})
export class AuthModule {}
+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();
}
}
+98
View File
@@ -0,0 +1,98 @@
export type DashboardType = 'super_admin' | 'business' | 'customer';
export interface AuthJwtPayload {
sub: string;
cellNumber: string;
roles: string[];
dashboard: DashboardType;
type: 'access' | 'refresh';
}
export interface BusinessMembership {
id: bigint;
name: string;
slug: string;
isOwner: boolean;
teamRole: string | null;
permissions: string[];
}
export interface UserProfile {
about: string;
city: string;
address: string;
landline: string;
backupPhone: string;
postalCode: string;
instagram: string;
telegramId: string;
linkedin: string;
}
export interface AuthUser {
id: bigint;
cellNumber: string;
email: string | null;
firstName: string | null;
lastName: string | null;
cellVerifiedAt: Date | null;
roles: string[];
dashboard: DashboardType;
profile: UserProfile;
businesses: BusinessMembership[];
customerBusinesses: { id: bigint; name: string; slug: string }[];
}
/** Roles a business owner can assign to team members */
export const ASSIGNABLE_TEAM_ROLES = ['admin', 'editor', 'viewer'] as const;
export type AssignableTeamRole = (typeof ASSIGNABLE_TEAM_ROLES)[number];
/** Global roles a super admin can assign to users */
export const ASSIGNABLE_GLOBAL_ROLES = [
'super_admin',
'business_owner',
'customer',
] as const;
export type AssignableGlobalRole = (typeof ASSIGNABLE_GLOBAL_ROLES)[number];
export const ROLE_LABELS: Record<string, string> = {
super_admin: 'Super Admin',
business_owner: 'Business Owner',
business_staff: 'Business Staff',
customer: 'Customer',
owner: 'Business Owner',
admin: 'Admin',
editor: 'Editor',
viewer: 'Viewer',
};
/** Highest global role for UI display (badge in header). */
export function resolvePrimaryRole(roles: string[]): {
slug: string;
label: string;
isSuperAdmin: boolean;
} {
if (roles.includes('super_admin')) {
return { slug: 'super_admin', label: ROLE_LABELS.super_admin, isSuperAdmin: true };
}
if (roles.includes('business_owner') || roles.includes('owner')) {
return {
slug: 'business_owner',
label: ROLE_LABELS.business_owner,
isSuperAdmin: false,
};
}
if (roles.includes('business_staff')) {
return {
slug: 'business_staff',
label: ROLE_LABELS.business_staff,
isSuperAdmin: false,
};
}
if (roles.includes('customer')) {
return { slug: 'customer', label: ROLE_LABELS.customer, isSuperAdmin: false };
}
const slug = roles[0] ?? 'customer';
return { slug, label: ROLE_LABELS[slug] ?? 'User', isSuperAdmin: false };
}
@@ -0,0 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { AuthUser } from '../auth.types';
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthUser => {
const request = ctx.switchToHttp().getRequest<{ user: AuthUser }>();
return request.user;
},
);
@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
export const BUSINESS_PERMISSION_KEY = 'business_permission';
export const RequireBusinessPermission = (permission: string) =>
SetMetadata(BUSINESS_PERMISSION_KEY, permission);
+10
View File
@@ -0,0 +1,10 @@
import { IsString, MinLength } from 'class-validator';
export class ChangePasswordDto {
@IsString()
currentPassword!: string;
@IsString()
@MinLength(8, { message: 'newPassword must be at least 8 characters' })
newPassword!: string;
}
+13
View File
@@ -0,0 +1,13 @@
import { IsString, Matches, MinLength } from 'class-validator';
export class LoginDto {
@IsString()
@Matches(/^\+[1-9]\d{6,14}$/, {
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
})
cellNumber!: string;
@IsString()
@MinLength(8)
password!: string;
}
+6
View File
@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class RefreshTokenDto {
@IsString()
refreshToken!: string;
}
+30
View File
@@ -0,0 +1,30 @@
import { IsEmail, IsOptional, IsString, Matches, MinLength } from 'class-validator';
export class RegisterDto {
@IsString()
@Matches(/^\+[1-9]\d{6,14}$/, {
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
})
cellNumber!: string;
@IsString()
@MinLength(8, { message: 'password must be at least 8 characters' })
password!: string;
@IsString()
@MinLength(2)
firstName!: string;
@IsString()
@MinLength(2)
lastName!: string;
@IsOptional()
@IsEmail()
email?: string;
/** Domain of the business website (e.g. shop-a.local). Resolves tenant for customer registration. */
@IsString()
@MinLength(3)
domain!: string;
}
+9
View File
@@ -0,0 +1,9 @@
import { IsString, Matches } from 'class-validator';
export class SendOtpDto {
@IsString()
@Matches(/^\+[1-9]\d{6,14}$/, {
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
})
cellNumber!: string;
}
+63
View File
@@ -0,0 +1,63 @@
import { IsEmail, IsOptional, IsString, MaxLength } from 'class-validator';
export class UpdateProfileDto {
@IsOptional()
@IsString()
@MaxLength(100)
firstName?: string;
@IsOptional()
@IsString()
@MaxLength(100)
lastName?: string;
@IsOptional()
@IsEmail()
@MaxLength(255)
email?: string;
@IsOptional()
@IsString()
@MaxLength(1000)
about?: string;
@IsOptional()
@IsString()
@MaxLength(100)
city?: string;
@IsOptional()
@IsString()
@MaxLength(255)
address?: string;
@IsOptional()
@IsString()
@MaxLength(30)
landline?: string;
@IsOptional()
@IsString()
@MaxLength(30)
backupPhone?: string;
@IsOptional()
@IsString()
@MaxLength(20)
postalCode?: string;
@IsOptional()
@IsString()
@MaxLength(100)
instagram?: string;
@IsOptional()
@IsString()
@MaxLength(100)
telegramId?: string;
@IsOptional()
@IsString()
@MaxLength(255)
linkedin?: string;
}
+33
View File
@@ -0,0 +1,33 @@
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class UpsertUserAddressDto {
@IsOptional()
@IsString()
@MaxLength(100)
label?: string;
@IsString()
@MinLength(1)
@MaxLength(100)
province!: string;
@IsString()
@MinLength(1)
@MaxLength(100)
city!: string;
@IsString()
@MinLength(1)
@MaxLength(500)
address!: string;
@IsOptional()
@IsString()
@MaxLength(20)
postalCode?: string;
@IsOptional()
@IsString()
@MaxLength(30)
landline?: string;
}
+14
View File
@@ -0,0 +1,14 @@
import { IsString, Length, Matches } from 'class-validator';
export class VerifyOtpDto {
@IsString()
@Matches(/^\+[1-9]\d{6,14}$/, {
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
})
cellNumber!: string;
@IsString()
@Length(6, 6)
@Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' })
code!: string;
}
@@ -0,0 +1,53 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthUser } from '../auth.types';
import { BUSINESS_PERMISSION_KEY } from '../decorators/require-business-permission.decorator';
import { PermissionsService } from '../permissions.service';
@Injectable()
export class BusinessPermissionGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly permissions: PermissionsService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const permission = this.reflector.get<string>(
BUSINESS_PERMISSION_KEY,
context.getHandler(),
);
if (!permission) {
return true;
}
const request = context.switchToHttp().getRequest<{
user: AuthUser;
params: { businessId?: string };
}>();
const businessIdRaw = request.params.businessId;
if (!businessIdRaw) {
throw new ForbiddenException('Business context is required');
}
const allowed = await this.permissions.hasBusinessPermission(
request.user.id,
BigInt(businessIdRaw),
permission,
);
if (!allowed) {
throw new ForbiddenException(
`Missing permission: ${permission} for this business`,
);
}
return true;
}
}
+5
View File
@@ -0,0 +1,5 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
+98
View File
@@ -0,0 +1,98 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class PermissionsService {
constructor(private readonly prisma: PrismaService) {}
async isSuperAdmin(userId: bigint): Promise<boolean> {
const count = await this.prisma.userRole.count({
where: {
userId,
role: { slug: 'super_admin' },
},
});
return count > 0;
}
async getBusinessMemberships(userId: bigint) {
const memberships = await this.prisma.businessUser.findMany({
where: { userId },
include: { business: true, role: true },
});
return Promise.all(
memberships.map(async (m) => ({
id: m.business.id,
name: m.business.name,
slug: m.business.slug,
isOwner: m.isOwner,
teamRole: m.isOwner ? 'business_owner' : m.role?.slug ?? null,
permissions: await this.getPermissionsForBusiness(userId, m.businessId),
})),
);
}
async getPermissionsForBusiness(
userId: bigint,
businessId: bigint,
): Promise<string[]> {
if (await this.isSuperAdmin(userId)) {
return this.getAllPermissionSlugs();
}
const membership = await this.prisma.businessUser.findUnique({
where: {
businessId_userId: { businessId, userId },
},
include: {
role: {
include: {
rolePermissions: { include: { permission: true } },
},
},
},
});
if (!membership) {
return [];
}
if (membership.isOwner) {
return this.getRolePermissionSlugs('business_owner');
}
if (!membership.role) {
return [];
}
return membership.role.rolePermissions.map((rp) => rp.permission.slug);
}
async hasBusinessPermission(
userId: bigint,
businessId: bigint,
permission: string,
): Promise<boolean> {
const permissions = await this.getPermissionsForBusiness(userId, businessId);
return permissions.includes(permission);
}
private async getRolePermissionSlugs(roleSlug: string): Promise<string[]> {
const role = await this.prisma.role.findUnique({
where: { slug: roleSlug },
include: {
rolePermissions: { include: { permission: true } },
},
});
return role?.rolePermissions.map((rp) => rp.permission.slug) ?? [];
}
private async getAllPermissionSlugs(): Promise<string[]> {
const permissions = await this.prisma.permission.findMany({
select: { slug: true },
});
return permissions.map((p) => p.slug);
}
}
+18
View File
@@ -0,0 +1,18 @@
import { UserProfile } from './auth.types';
export function parseUserProfile(value: unknown): UserProfile {
const source =
value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
return {
about: typeof source.about === 'string' ? source.about : '',
city: typeof source.city === 'string' ? source.city : '',
address: typeof source.address === 'string' ? source.address : '',
landline: typeof source.landline === 'string' ? source.landline : '',
backupPhone: typeof source.backupPhone === 'string' ? source.backupPhone : '',
postalCode: typeof source.postalCode === 'string' ? source.postalCode : '',
instagram: typeof source.instagram === 'string' ? source.instagram : '',
telegramId: typeof source.telegramId === 'string' ? source.telegramId : '',
linkedin: typeof source.linkedin === 'string' ? source.linkedin : '',
};
}
+37
View File
@@ -0,0 +1,37 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class SmsService {
private readonly logger = new Logger(SmsService.name);
constructor(private readonly config: ConfigService) {}
isEnabled(): boolean {
return this.config.get<string>('SMS_ENABLED', 'false') === 'true';
}
async sendVerificationCode(cellNumber: string, code: string): Promise<void> {
if (!this.isEnabled()) {
this.logger.warn(
`SMS disabled — verification code for ${cellNumber} not sent (code: ${code})`,
);
return;
}
// TODO: integrate real SMS provider when API credentials are available
this.logger.log(`Sending SMS verification code to ${cellNumber}`);
throw new Error('SMS provider is not configured yet');
}
async sendMessage(cellNumber: string, message: string): Promise<void> {
if (!this.isEnabled()) {
this.logger.warn(`SMS disabled — message for ${cellNumber} not sent: ${message}`);
return;
}
// TODO: integrate real SMS provider when API credentials are available
this.logger.log(`Sending SMS message to ${cellNumber}: ${message}`);
throw new Error('SMS provider is not configured yet');
}
}
+82
View File
@@ -0,0 +1,82 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PrismaService } from '../../prisma/prisma.service';
import {
AuthJwtPayload,
AuthUser,
DashboardType,
} from '../auth.types';
import { PermissionsService } from '../permissions.service';
import { parseUserProfile } from '../profile.util';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
config: ConfigService,
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.getOrThrow<string>('JWT_ACCESS_SECRET'),
});
}
async validate(payload: AuthJwtPayload): Promise<AuthUser> {
if (payload.type !== 'access') {
throw new UnauthorizedException('Invalid token type');
}
const user = await this.prisma.user.findUnique({
where: { id: BigInt(payload.sub) },
include: {
userRoles: { include: { role: true } },
businessCustomers: { include: { business: true } },
},
});
if (!user || !user.isActive) {
throw new UnauthorizedException('User not found or inactive');
}
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';
}
}
+109
View File
@@ -0,0 +1,109 @@
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuthUser } from './auth.types';
import { UpsertUserAddressDto } from './dto/upsert-user-address.dto';
@Injectable()
export class UserAddressesService {
constructor(private readonly prisma: PrismaService) {}
async list(actor: AuthUser) {
const items = await this.prisma.address.findMany({
where: { userId: actor.id, businessId: null },
orderBy: { createdAt: 'asc' },
});
return { items: items.map((item) => this.serialize(item)) };
}
async create(actor: AuthUser, dto: UpsertUserAddressDto) {
const created = await this.prisma.address.create({
data: {
userId: actor.id,
label: dto.label?.trim() || null,
province: dto.province.trim(),
city: dto.city.trim(),
address: dto.address.trim(),
postalCode: dto.postalCode?.trim() || null,
landline: dto.landline?.trim() || null,
},
});
return { address: this.serialize(created) };
}
async update(
actor: AuthUser,
addressIdRaw: string,
dto: UpsertUserAddressDto,
) {
const address = await this.findOwnedAddress(actor, addressIdRaw);
const updated = await this.prisma.address.update({
where: { id: address.id },
data: {
label: dto.label?.trim() || null,
province: dto.province.trim(),
city: dto.city.trim(),
address: dto.address.trim(),
postalCode: dto.postalCode?.trim() || null,
landline: dto.landline?.trim() || null,
},
});
return { address: this.serialize(updated) };
}
async remove(actor: AuthUser, addressIdRaw: string) {
const address = await this.findOwnedAddress(actor, addressIdRaw);
await this.prisma.address.delete({
where: { id: address.id },
});
return { message: 'Address removed.' };
}
private async findOwnedAddress(actor: AuthUser, addressIdRaw: string) {
const address = await this.prisma.address.findFirst({
where: {
id: BigInt(addressIdRaw),
userId: actor.id,
businessId: null,
},
});
if (!address) {
throw new NotFoundException('Address not found');
}
return address;
}
private serialize(address: {
id: bigint;
label: string | null;
province: string;
city: string;
address: string;
postalCode: string | null;
landline: string | null;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: address.id.toString(),
label: address.label,
province: address.province,
city: address.city,
address: address.address,
postalCode: address.postalCode,
landline: address.landline,
createdAt: address.createdAt,
updatedAt: address.updatedAt,
};
}
}