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,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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@IsString()
|
||||
refreshToken!: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 : '',
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user