diff --git a/package-lock.json b/package-lock.json index 24954d2..67a3b93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -359,6 +359,15 @@ "uid": "2.0.2" } }, + "@nestjs/jwt": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-10.2.0.tgz", + "integrity": "sha512-x8cG90SURkEiLOehNaN2aRlotxT0KZESUliOPKKnjWiyJOcWurkF3w345WOX0P4MgFzUjGoZ1Sy0aZnxeihT0g==", + "requires": { + "@types/jsonwebtoken": "9.0.5", + "jsonwebtoken": "9.0.2" + } + }, "@nestjs/platform-express": { "version": "10.4.22", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-10.4.22.tgz", @@ -499,11 +508,18 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "@types/jsonwebtoken": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", + "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "requires": { + "@types/node": "*" + } + }, "@types/node": { "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", - "dev": true, "requires": { "undici-types": "~6.21.0" } @@ -924,6 +940,11 @@ "ieee754": "^1.2.1" } }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1363,6 +1384,14 @@ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2150,6 +2179,42 @@ "universalify": "^2.0.0" } }, + "jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "requires": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + } + }, + "jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "requires": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", + "requires": { + "jwa": "^1.4.2", + "safe-buffer": "^5.0.1" + } + }, "lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -2167,6 +2232,41 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -2751,8 +2851,7 @@ "semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==" }, "send": { "version": "0.19.2", @@ -3279,8 +3378,7 @@ "undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" }, "universalify": { "version": "2.0.1", diff --git a/package.json b/package.json index 0f372d3..e7d30c5 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@nestjs/common": "^10.4.8", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.4.8", + "@nestjs/jwt": "^10.2.0", "@nestjs/platform-express": "^10.4.8", "@nestjs/typeorm": "^10.0.2", "dotenv": "^16.4.7", diff --git a/src/app.module.ts b/src/app.module.ts index 4776993..19ef0ee 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,6 +11,8 @@ import { BusinessServicesModule } from './modules/business-services/business-ser import { AttributeDefinitionsModule } from './modules/attribute-definitions/attribute-definitions.module'; import { BusinessAttributeValuesModule } from './modules/business-attribute-values/business-attribute-values.module'; import { BookingsModule } from './modules/bookings/bookings.module'; +import { AuthModule } from './modules/auth/auth.module'; +import { UsersModule } from './modules/users/users.module'; @Module({ imports: [ @@ -23,6 +25,8 @@ import { BookingsModule } from './modules/bookings/bookings.module'; AttributeDefinitionsModule, BusinessAttributeValuesModule, BookingsModule, + AuthModule, + UsersModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/entities/auth-otp.entity.ts b/src/entities/auth-otp.entity.ts new file mode 100644 index 0000000..fcc257c --- /dev/null +++ b/src/entities/auth-otp.entity.ts @@ -0,0 +1,32 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity('auth_otps') +export class AuthOtpEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Index() + @Column({ length: 20 }) + mobile!: string; + + @Column({ length: 128 }) + codeHash!: string; + + @Column({ type: 'timestamptz' }) + expiresAt!: Date; + + @Column({ type: 'timestamptz', nullable: true }) + consumedAt!: Date | null; + + @Column({ type: 'int', default: 0 }) + attempts!: number; + + @CreateDateColumn() + createdAt!: Date; +} diff --git a/src/modules/attribute-definitions/attribute-definitions.controller.ts b/src/modules/attribute-definitions/attribute-definitions.controller.ts index 1707cc6..b65b14a 100644 --- a/src/modules/attribute-definitions/attribute-definitions.controller.ts +++ b/src/modules/attribute-definitions/attribute-definitions.controller.ts @@ -7,8 +7,13 @@ import { Patch, Post, Query, + UseGuards, } from '@nestjs/common'; import { AttributeDefinitionEntity } from '../../entities/attribute-definition.entity'; +import { UserRole } from '../../entities/user.entity'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; import { AttributeDefinitionsService } from './attribute-definitions.service'; import { CreateAttributeDefinitionDto } from './dto/create-attribute-definition.dto'; import { UpdateAttributeDefinitionDto } from './dto/update-attribute-definition.dto'; @@ -30,6 +35,8 @@ export class AttributeDefinitionsController { } @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) create( @Body() payload: CreateAttributeDefinitionDto, ): Promise { @@ -37,6 +44,8 @@ export class AttributeDefinitionsController { } @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) update( @Param('id') id: string, @Body() payload: UpdateAttributeDefinitionDto, @@ -45,6 +54,8 @@ export class AttributeDefinitionsController { } @Delete(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) async remove(@Param('id') id: string): Promise<{ success: true }> { await this.service.remove(id); return { success: true }; diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..5bf48d4 --- /dev/null +++ b/src/modules/auth/auth.controller.ts @@ -0,0 +1,28 @@ +import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { AuthUser } from './auth.types'; +import { CurrentUser } from './decorators/current-user.decorator'; +import { RequestOtpDto } from './dto/request-otp.dto'; +import { VerifyOtpDto } from './dto/verify-otp.dto'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; + +@Controller('auth') +export class AuthController { + constructor(private readonly auth: AuthService) {} + + @Post('request-otp') + requestOtp(@Body() payload: RequestOtpDto) { + return this.auth.requestOtp(payload); + } + + @Post('verify-otp') + verifyOtp(@Body() payload: VerifyOtpDto) { + return this.auth.verifyOtp(payload); + } + + @Get('me') + @UseGuards(JwtAuthGuard) + me(@CurrentUser() user: AuthUser) { + return this.auth.me(user.id); + } +} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..80db99e --- /dev/null +++ b/src/modules/auth/auth.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthOtpEntity } from '../../entities/auth-otp.entity'; +import { UserEntity } from '../../entities/user.entity'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { RolesGuard } from './guards/roles.guard'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([AuthOtpEntity, UserEntity]), + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (config: ConfigService) => ({ + secret: config.get('JWT_SECRET') ?? 'montime-dev-secret-change-me', + signOptions: { + expiresIn: config.get('JWT_EXPIRES_IN') ?? '30d', + }, + }), + }), + ], + controllers: [AuthController], + providers: [AuthService, JwtAuthGuard, RolesGuard], + exports: [JwtAuthGuard, RolesGuard, JwtModule], +}) +export class AuthModule {} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..ef5b7b0 --- /dev/null +++ b/src/modules/auth/auth.service.ts @@ -0,0 +1,196 @@ +import { + BadRequestException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { InjectRepository } from '@nestjs/typeorm'; +import { createHash, randomInt } from 'crypto'; +import { IsNull, MoreThan, Repository } from 'typeorm'; +import { AuthOtpEntity } from '../../entities/auth-otp.entity'; +import { UserEntity, UserRole } from '../../entities/user.entity'; +import { JwtPayload } from './auth.types'; +import { RequestOtpDto } from './dto/request-otp.dto'; +import { VerifyOtpDto } from './dto/verify-otp.dto'; + +type RequestOtpResponse = { + expiresInSeconds: number; + otpCode?: string; +}; + +type VerifyOtpResponse = { + accessToken: string; + user: Pick; +}; + +@Injectable() +export class AuthService { + private readonly otpTtlSeconds: number; + private readonly maxAttempts: number; + + constructor( + private readonly config: ConfigService, + private readonly jwt: JwtService, + @InjectRepository(AuthOtpEntity) + private readonly otps: Repository, + @InjectRepository(UserEntity) + private readonly users: Repository, + ) { + this.otpTtlSeconds = this.readPositiveInt('OTP_TTL_SECONDS', 120); + this.maxAttempts = this.readPositiveInt('OTP_MAX_ATTEMPTS', 5); + } + + async requestOtp(dto: RequestOtpDto): Promise { + const mobile = this.normalizeMobile(dto.mobile); + const code = this.generateOtpCode(); + const expiresAt = new Date(Date.now() + this.otpTtlSeconds * 1000); + + await this.otps.update( + { mobile, consumedAt: IsNull() }, + { consumedAt: new Date() }, + ); + + await this.otps.save( + this.otps.create({ + mobile, + codeHash: this.hashOtp(mobile, code), + expiresAt, + }), + ); + + const response: RequestOtpResponse = { + expiresInSeconds: this.otpTtlSeconds, + }; + + if (this.config.get('AUTH_RETURN_OTP') === 'true') { + response.otpCode = code; + } + + return response; + } + + async verifyOtp(dto: VerifyOtpDto): Promise { + const mobile = this.normalizeMobile(dto.mobile); + const code = String(dto.code ?? '').trim(); + + if (!/^\d{4,8}$/.test(code)) { + throw new BadRequestException('Invalid OTP code'); + } + + const otp = await this.otps.findOne({ + where: { + mobile, + consumedAt: IsNull(), + expiresAt: MoreThan(new Date()), + }, + order: { createdAt: 'DESC' }, + }); + + if (!otp) { + throw new UnauthorizedException('OTP expired or not requested'); + } + + if (otp.attempts >= this.maxAttempts) { + throw new UnauthorizedException('Too many OTP attempts'); + } + + if (otp.codeHash !== this.hashOtp(mobile, code)) { + otp.attempts += 1; + await this.otps.save(otp); + throw new UnauthorizedException('Invalid OTP code'); + } + + otp.consumedAt = new Date(); + await this.otps.save(otp); + + const user = await this.findOrCreateCustomer(mobile); + const payload: JwtPayload = { + sub: user.id, + mobile: user.mobile, + role: user.role, + }; + + return { + accessToken: await this.jwt.signAsync(payload), + user: this.serializeUser(user), + }; + } + + async me(userId: string) { + const user = await this.users.findOne({ where: { id: userId } }); + if (!user) { + throw new UnauthorizedException('User not found'); + } + return this.serializeUser(user); + } + + private async findOrCreateCustomer(mobile: string): Promise { + const existing = await this.users.findOne({ where: { mobile } }); + if (existing) { + if (!existing.isActive) { + throw new UnauthorizedException('User is inactive'); + } + if (this.isConfiguredSuperAdminMobile(mobile) && existing.role !== UserRole.ADMIN) { + existing.role = UserRole.ADMIN; + return this.users.save(existing); + } + return existing; + } + + return this.users.save( + this.users.create({ + mobile, + fullName: null, + role: this.isConfiguredSuperAdminMobile(mobile) + ? UserRole.ADMIN + : UserRole.CUSTOMER, + isActive: true, + }), + ); + } + + private serializeUser(user: UserEntity) { + return { + id: user.id, + mobile: user.mobile, + fullName: user.fullName, + role: user.role, + isActive: user.isActive, + }; + } + + private normalizeMobile(mobile: string): string { + const normalized = String(mobile ?? '').trim(); + if (!/^(\+?\d{10,15}|09\d{9})$/.test(normalized)) { + throw new BadRequestException('Invalid mobile number'); + } + return normalized; + } + + private generateOtpCode(): string { + return String(randomInt(100000, 1000000)); + } + + private hashOtp(mobile: string, code: string): string { + const pepper = + this.config.get('OTP_HASH_SECRET') ?? + this.config.get('JWT_SECRET') ?? + 'montime-dev-secret-change-me'; + + return createHash('sha256') + .update(`${mobile}:${code}:${pepper}`) + .digest('hex'); + } + + private isConfiguredSuperAdminMobile(mobile: string): boolean { + const configured = this.config.get('SUPER_ADMIN_MOBILE'); + return configured ? this.normalizeMobile(configured) === mobile : false; + } + + private readPositiveInt(key: string, fallback: number): number { + const raw = this.config.get(key); + const parsed = raw ? Number.parseInt(raw, 10) : NaN; + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + } +} diff --git a/src/modules/auth/auth.types.ts b/src/modules/auth/auth.types.ts new file mode 100644 index 0000000..a3c1191 --- /dev/null +++ b/src/modules/auth/auth.types.ts @@ -0,0 +1,13 @@ +import { UserRole } from '../../entities/user.entity'; + +export type AuthUser = { + id: string; + mobile: string; + role: UserRole; +}; + +export type JwtPayload = { + sub: string; + mobile: string; + role: UserRole; +}; diff --git a/src/modules/auth/decorators/current-user.decorator.ts b/src/modules/auth/decorators/current-user.decorator.ts new file mode 100644 index 0000000..f46d33f --- /dev/null +++ b/src/modules/auth/decorators/current-user.decorator.ts @@ -0,0 +1,13 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { AuthUser } from '../auth.types'; + +type RequestWithUser = Request & { + user?: AuthUser; +}; + +export const CurrentUser = createParamDecorator( + (_data: unknown, context: ExecutionContext): AuthUser | undefined => { + const request = context.switchToHttp().getRequest(); + return request.user; + }, +); diff --git a/src/modules/auth/decorators/roles.decorator.ts b/src/modules/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..4bc33ee --- /dev/null +++ b/src/modules/auth/decorators/roles.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; +import { UserRole } from '../../../entities/user.entity'; + +export const ROLES_KEY = 'roles'; + +export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/modules/auth/dto/request-otp.dto.ts b/src/modules/auth/dto/request-otp.dto.ts new file mode 100644 index 0000000..502dda1 --- /dev/null +++ b/src/modules/auth/dto/request-otp.dto.ts @@ -0,0 +1,3 @@ +export class RequestOtpDto { + mobile!: string; +} diff --git a/src/modules/auth/dto/verify-otp.dto.ts b/src/modules/auth/dto/verify-otp.dto.ts new file mode 100644 index 0000000..da499aa --- /dev/null +++ b/src/modules/auth/dto/verify-otp.dto.ts @@ -0,0 +1,4 @@ +export class VerifyOtpDto { + mobile!: string; + code!: string; +} diff --git a/src/modules/auth/guards/jwt-auth.guard.ts b/src/modules/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..1d325fc --- /dev/null +++ b/src/modules/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,64 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UserEntity } from '../../../entities/user.entity'; +import { AuthUser, JwtPayload } from '../auth.types'; + +type RequestWithUser = Request & { + headers: { + authorization?: string; + }; + user?: AuthUser; +}; + +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor( + private readonly jwt: JwtService, + @InjectRepository(UserEntity) + private readonly users: Repository, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = this.extractBearerToken(request.headers.authorization); + + if (!token) { + throw new UnauthorizedException('Missing bearer token'); + } + + try { + const payload = await this.jwt.verifyAsync(token); + const user = await this.users.findOne({ + where: { id: payload.sub, isActive: true }, + }); + + if (!user) { + throw new UnauthorizedException('User is inactive or not found'); + } + + request.user = { + id: user.id, + mobile: user.mobile, + role: user.role, + }; + return true; + } catch { + throw new UnauthorizedException('Invalid bearer token'); + } + } + + private extractBearerToken(header?: string): string | null { + if (!header) { + return null; + } + const [type, token] = header.split(' '); + return type === 'Bearer' && token ? token : null; + } +} diff --git a/src/modules/auth/guards/roles.guard.ts b/src/modules/auth/guards/roles.guard.ts new file mode 100644 index 0000000..52dc81b --- /dev/null +++ b/src/modules/auth/guards/roles.guard.ts @@ -0,0 +1,39 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { UserRole } from '../../../entities/user.entity'; +import { AuthUser } from '../auth.types'; +import { ROLES_KEY } from '../decorators/roles.decorator'; + +type RequestWithUser = Request & { + user?: AuthUser; +}; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride( + ROLES_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user = request.user; + + if (!user || !requiredRoles.includes(user.role)) { + throw new ForbiddenException('Insufficient role'); + } + + return true; + } +} diff --git a/src/modules/bookings/bookings.controller.ts b/src/modules/bookings/bookings.controller.ts index 729f717..ec9dc55 100644 --- a/src/modules/bookings/bookings.controller.ts +++ b/src/modules/bookings/bookings.controller.ts @@ -7,8 +7,15 @@ import { Patch, Post, Query, + UseGuards, } from '@nestjs/common'; import { BookingEntity, BookingStatus } from '../../entities/booking.entity'; +import { UserRole } from '../../entities/user.entity'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; import { BookingsService } from './bookings.service'; import { CreateBookingDto } from './dto/create-booking.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; @@ -18,33 +25,51 @@ export class BookingsController { constructor(private readonly service: BookingsService) {} @Get() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER) findAll( @Query('customerId') customerId?: string, @Query('businessId') businessId?: string, @Query('status') status?: BookingStatus, + @CurrentUser() user?: AuthUser, ): Promise { - return this.service.findAll({ customerId, businessId, status }); + return this.service.findAll({ customerId, businessId, status }, user); } @Get(':id') - findOne(@Param('id') id: string): Promise { - return this.service.findOne(id); + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER) + findOne( + @Param('id') id: string, + @CurrentUser() user: AuthUser, + ): Promise { + return this.service.findOne(id, user); } @Post() - create(@Body() payload: CreateBookingDto): Promise { - return this.service.create(payload); + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.CUSTOMER) + create( + @Body() payload: CreateBookingDto, + @CurrentUser() user: AuthUser, + ): Promise { + return this.service.create(payload, user); } @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) update( @Param('id') id: string, @Body() payload: UpdateBookingDto, + @CurrentUser() user: AuthUser, ): Promise { - return this.service.update(id, payload); + return this.service.update(id, payload, user); } @Delete(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) async remove(@Param('id') id: string): Promise<{ success: true }> { await this.service.remove(id); return { success: true }; diff --git a/src/modules/bookings/bookings.service.ts b/src/modules/bookings/bookings.service.ts index 7364f65..4d18e26 100644 --- a/src/modules/bookings/bookings.service.ts +++ b/src/modules/bookings/bookings.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; @@ -9,7 +10,8 @@ import { Repository } from 'typeorm'; import { BookingEntity, BookingStatus } from '../../entities/booking.entity'; import { BusinessServiceEntity } from '../../entities/business-service.entity'; import { BusinessEntity } from '../../entities/business.entity'; -import { UserEntity } from '../../entities/user.entity'; +import { UserEntity, UserRole } from '../../entities/user.entity'; +import { AuthUser } from '../auth/auth.types'; import { CreateBookingDto } from './dto/create-booking.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; @@ -39,7 +41,10 @@ export class BookingsService { this.minLeadMinutes = Number.isFinite(parsed) && parsed >= 0 ? parsed : 60; } - async findAll(filters: FindAllFilters): Promise { + async findAll( + filters: FindAllFilters, + actor?: AuthUser, + ): Promise { const qb = this.repo .createQueryBuilder('b') .leftJoinAndSelect('b.customer', 'customer') @@ -48,7 +53,15 @@ export class BookingsService { .leftJoinAndSelect('businessService.serviceTemplate', 'template') .orderBy('b.scheduledAt', 'ASC'); - if (filters.customerId) { + if (actor?.role === UserRole.CUSTOMER) { + qb.andWhere('b.customerId = :actorCustomerId', { + actorCustomerId: actor.id, + }); + } else if (actor?.role === UserRole.BUSINESS_OWNER) { + qb.andWhere('business.ownerId = :actorOwnerId', { + actorOwnerId: actor.id, + }); + } else if (filters.customerId) { qb.andWhere('b.customerId = :customerId', { customerId: filters.customerId, }); @@ -65,7 +78,7 @@ export class BookingsService { return qb.getMany(); } - async findOne(id: string): Promise { + async findOne(id: string, actor?: AuthUser): Promise { const row = await this.repo.findOne({ where: { id }, relations: { @@ -77,10 +90,18 @@ export class BookingsService { if (!row) { throw new NotFoundException('Booking not found'); } + this.assertCanViewBooking(row, actor); return row; } - async create(dto: CreateBookingDto): Promise { + async create(dto: CreateBookingDto, actor?: AuthUser): Promise { + if ( + actor?.role === UserRole.CUSTOMER && + dto.customerId !== actor.id + ) { + throw new ForbiddenException('Customers can only book for themselves'); + } + const customer = await this.userRepo.findOne({ where: { id: dto.customerId }, }); @@ -124,8 +145,13 @@ export class BookingsService { return this.repo.save(entity); } - async update(id: string, dto: UpdateBookingDto): Promise { + async update( + id: string, + dto: UpdateBookingDto, + actor?: AuthUser, + ): Promise { const row = await this.findOne(id); + this.assertCanManageBooking(row, actor); if (dto.scheduledAt !== undefined) { const scheduledAt = this.parseScheduledAt(dto.scheduledAt); @@ -168,4 +194,38 @@ export class BookingsService { ); } } + + private assertCanViewBooking(row: BookingEntity, actor?: AuthUser): void { + if (!actor || actor.role === UserRole.ADMIN) { + return; + } + + if (actor.role === UserRole.CUSTOMER && row.customerId === actor.id) { + return; + } + + if ( + actor.role === UserRole.BUSINESS_OWNER && + row.business?.ownerId === actor.id + ) { + return; + } + + throw new ForbiddenException('You cannot access this booking'); + } + + private assertCanManageBooking(row: BookingEntity, actor?: AuthUser): void { + if (!actor || actor.role === UserRole.ADMIN) { + return; + } + + if ( + actor.role === UserRole.BUSINESS_OWNER && + row.business?.ownerId === actor.id + ) { + return; + } + + throw new ForbiddenException('You can only manage your own business bookings'); + } } diff --git a/src/modules/business-attribute-values/business-attribute-values.controller.ts b/src/modules/business-attribute-values/business-attribute-values.controller.ts index 3107ee1..411a1e3 100644 --- a/src/modules/business-attribute-values/business-attribute-values.controller.ts +++ b/src/modules/business-attribute-values/business-attribute-values.controller.ts @@ -7,8 +7,15 @@ import { Patch, Post, Query, + UseGuards, } from '@nestjs/common'; import { BusinessAttributeValueEntity } from '../../entities/business-attribute-value.entity'; +import { UserRole } from '../../entities/user.entity'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; import { BusinessAttributeValuesService } from './business-attribute-values.service'; import { CreateBusinessAttributeValueDto } from './dto/create-business-attribute-value.dto'; import { UpdateBusinessAttributeValueDto } from './dto/update-business-attribute-value.dto'; @@ -30,23 +37,34 @@ export class BusinessAttributeValuesController { } @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) create( @Body() payload: CreateBusinessAttributeValueDto, + @CurrentUser() user: AuthUser, ): Promise { - return this.service.create(payload); + return this.service.create(payload, user); } @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) update( @Param('id') id: string, @Body() payload: UpdateBusinessAttributeValueDto, + @CurrentUser() user: AuthUser, ): Promise { - return this.service.update(id, payload); + return this.service.update(id, payload, user); } @Delete(':id') - async remove(@Param('id') id: string): Promise<{ success: true }> { - await this.service.remove(id); + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) + async remove( + @Param('id') id: string, + @CurrentUser() user: AuthUser, + ): Promise<{ success: true }> { + await this.service.remove(id, user); return { success: true }; } } diff --git a/src/modules/business-attribute-values/business-attribute-values.service.ts b/src/modules/business-attribute-values/business-attribute-values.service.ts index 0564b3d..eb9cde6 100644 --- a/src/modules/business-attribute-values/business-attribute-values.service.ts +++ b/src/modules/business-attribute-values/business-attribute-values.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ConflictException, + ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; @@ -12,6 +13,8 @@ import { } from '../../entities/attribute-definition.entity'; import { BusinessAttributeValueEntity } from '../../entities/business-attribute-value.entity'; import { BusinessEntity } from '../../entities/business.entity'; +import { UserRole } from '../../entities/user.entity'; +import { AuthUser } from '../auth/auth.types'; import { CreateBusinessAttributeValueDto } from './dto/create-business-attribute-value.dto'; import { UpdateBusinessAttributeValueDto } from './dto/update-business-attribute-value.dto'; @@ -53,6 +56,7 @@ export class BusinessAttributeValuesService { async create( dto: CreateBusinessAttributeValueDto, + actor?: AuthUser, ): Promise { const business = await this.businessRepo.findOne({ where: { id: dto.businessId }, @@ -60,6 +64,7 @@ export class BusinessAttributeValuesService { if (!business) { throw new NotFoundException('Business not found'); } + this.assertCanManageBusiness(business, actor); const def = await this.defRepo.findOne({ where: { id: dto.attributeDefinitionId }, @@ -100,8 +105,10 @@ export class BusinessAttributeValuesService { async update( id: string, dto: UpdateBusinessAttributeValueDto, + actor?: AuthUser, ): Promise { const row = await this.findOne(id); + this.assertCanManageBusiness(row.business, actor); const def = row.attributeDefinition ?? @@ -120,7 +127,10 @@ export class BusinessAttributeValuesService { return this.repo.save(row); } - async remove(id: string): Promise { + async remove(id: string, actor?: AuthUser): Promise { + const row = await this.findOne(id); + this.assertCanManageBusiness(row.business, actor); + const result = await this.repo.delete(id); if (!result.affected) { throw new NotFoundException('Business attribute value not found'); @@ -174,4 +184,20 @@ export class BusinessAttributeValuesService { return s; } } + + private assertCanManageBusiness( + business: BusinessEntity, + actor?: AuthUser, + ): void { + if (!actor || actor.role === UserRole.ADMIN) { + return; + } + + if ( + actor.role !== UserRole.BUSINESS_OWNER || + business.ownerId !== actor.id + ) { + throw new ForbiddenException('You can only manage your own business'); + } + } } diff --git a/src/modules/business-categories/business-categories.controller.ts b/src/modules/business-categories/business-categories.controller.ts index 5ffca4a..33873fe 100644 --- a/src/modules/business-categories/business-categories.controller.ts +++ b/src/modules/business-categories/business-categories.controller.ts @@ -6,8 +6,13 @@ import { Param, Patch, Post, + UseGuards, } from '@nestjs/common'; import { BusinessCategoryEntity } from '../../entities/business-category.entity'; +import { UserRole } from '../../entities/user.entity'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; import { BusinessCategoriesService } from './business-categories.service'; import { CreateBusinessCategoryDto } from './dto/create-business-category.dto'; import { UpdateBusinessCategoryDto } from './dto/update-business-category.dto'; @@ -27,6 +32,8 @@ export class BusinessCategoriesController { } @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) create( @Body() payload: CreateBusinessCategoryDto, ): Promise { @@ -34,6 +41,8 @@ export class BusinessCategoriesController { } @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) update( @Param('id') id: string, @Body() payload: UpdateBusinessCategoryDto, @@ -42,6 +51,8 @@ export class BusinessCategoriesController { } @Delete(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) async remove(@Param('id') id: string): Promise<{ success: true }> { await this.service.remove(id); return { success: true }; diff --git a/src/modules/business-services/business-services.controller.ts b/src/modules/business-services/business-services.controller.ts index bd8d518..e70004d 100644 --- a/src/modules/business-services/business-services.controller.ts +++ b/src/modules/business-services/business-services.controller.ts @@ -7,8 +7,15 @@ import { Patch, Post, Query, + UseGuards, } from '@nestjs/common'; import { BusinessServiceEntity } from '../../entities/business-service.entity'; +import { UserRole } from '../../entities/user.entity'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; import { BusinessServicesService } from './business-services.service'; import { CreateBusinessServiceDto } from './dto/create-business-service.dto'; import { UpdateBusinessServiceDto } from './dto/update-business-service.dto'; @@ -39,23 +46,34 @@ export class BusinessServicesController { } @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) create( @Body() payload: CreateBusinessServiceDto, + @CurrentUser() user: AuthUser, ): Promise { - return this.service.create(payload); + return this.service.create(payload, user); } @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) update( @Param('id') id: string, @Body() payload: UpdateBusinessServiceDto, + @CurrentUser() user: AuthUser, ): Promise { - return this.service.update(id, payload); + return this.service.update(id, payload, user); } @Delete(':id') - async remove(@Param('id') id: string): Promise<{ success: true }> { - await this.service.remove(id); + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) + async remove( + @Param('id') id: string, + @CurrentUser() user: AuthUser, + ): Promise<{ success: true }> { + await this.service.remove(id, user); return { success: true }; } } diff --git a/src/modules/business-services/business-services.service.ts b/src/modules/business-services/business-services.service.ts index 559b13d..29b2e59 100644 --- a/src/modules/business-services/business-services.service.ts +++ b/src/modules/business-services/business-services.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ConflictException, + ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; @@ -9,6 +10,8 @@ import { Repository } from 'typeorm'; import { BusinessEntity } from '../../entities/business.entity'; import { BusinessServiceEntity } from '../../entities/business-service.entity'; import { ServiceTemplateEntity } from '../../entities/service-template.entity'; +import { UserRole } from '../../entities/user.entity'; +import { AuthUser } from '../auth/auth.types'; import { CreateBusinessServiceDto } from './dto/create-business-service.dto'; import { UpdateBusinessServiceDto } from './dto/update-business-service.dto'; @@ -73,13 +76,17 @@ export class BusinessServicesService { return row; } - async create(dto: CreateBusinessServiceDto): Promise { + async create( + dto: CreateBusinessServiceDto, + actor?: AuthUser, + ): Promise { const business = await this.businessRepo.findOne({ where: { id: dto.businessId }, }); if (!business) { throw new NotFoundException('Business not found'); } + this.assertCanManageBusiness(business, actor); const template = await this.templateRepo.findOne({ where: { id: dto.serviceTemplateId }, @@ -123,6 +130,7 @@ export class BusinessServicesService { async update( id: string, dto: UpdateBusinessServiceDto, + actor?: AuthUser, ): Promise { const row = await this.findOne(id); @@ -137,6 +145,7 @@ export class BusinessServicesService { if (!business) { throw new NotFoundException('Business not found'); } + this.assertCanManageBusiness(business, actor); const template = await this.templateRepo.findOne({ where: { id: nextTemplateId }, @@ -184,10 +193,35 @@ export class BusinessServicesService { return this.repo.save(row); } - async remove(id: string): Promise { + async remove(id: string, actor?: AuthUser): Promise { + const row = await this.findOne(id); + const business = await this.businessRepo.findOne({ + where: { id: row.businessId }, + }); + if (!business) { + throw new NotFoundException('Business not found'); + } + this.assertCanManageBusiness(business, actor); + const result = await this.repo.delete(id); if (!result.affected) { throw new NotFoundException('Business service not found'); } } + + private assertCanManageBusiness( + business: BusinessEntity, + actor?: AuthUser, + ): void { + if (!actor || actor.role === UserRole.ADMIN) { + return; + } + + if ( + actor.role !== UserRole.BUSINESS_OWNER || + business.ownerId !== actor.id + ) { + throw new ForbiddenException('You can only manage your own business'); + } + } } diff --git a/src/modules/businesses/businesses.controller.ts b/src/modules/businesses/businesses.controller.ts index 43ee0b2..e6d52cf 100644 --- a/src/modules/businesses/businesses.controller.ts +++ b/src/modules/businesses/businesses.controller.ts @@ -7,8 +7,15 @@ import { Patch, Post, Query, + UseGuards, } from '@nestjs/common'; import { BusinessEntity } from '../../entities/business.entity'; +import { UserRole } from '../../entities/user.entity'; +import { AuthUser } from '../auth/auth.types'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; import { BusinessesService } from './businesses.service'; import { CreateBusinessDto } from './dto/create-business.dto'; import { UpdateBusinessDto } from './dto/update-business.dto'; @@ -37,19 +44,26 @@ export class BusinessesController { } @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) create(@Body() payload: CreateBusinessDto): Promise { return this.service.create(payload); } @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) update( @Param('id') id: string, @Body() payload: UpdateBusinessDto, + @CurrentUser() user: AuthUser, ): Promise { - return this.service.update(id, payload); + return this.service.update(id, payload, user); } @Delete(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) async remove(@Param('id') id: string): Promise<{ success: true }> { await this.service.remove(id); return { success: true }; diff --git a/src/modules/businesses/businesses.service.ts b/src/modules/businesses/businesses.service.ts index 40150d8..a070503 100644 --- a/src/modules/businesses/businesses.service.ts +++ b/src/modules/businesses/businesses.service.ts @@ -1,5 +1,6 @@ import { ConflictException, + ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; @@ -7,6 +8,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { BusinessCategoryEntity } from '../../entities/business-category.entity'; import { BusinessEntity } from '../../entities/business.entity'; +import { UserRole } from '../../entities/user.entity'; +import { AuthUser } from '../auth/auth.types'; import { CreateBusinessDto } from './dto/create-business.dto'; import { UpdateBusinessDto } from './dto/update-business.dto'; @@ -80,8 +83,13 @@ export class BusinessesService { return this.repo.save(entity); } - async update(id: string, payload: UpdateBusinessDto): Promise { + async update( + id: string, + payload: UpdateBusinessDto, + actor?: AuthUser, + ): Promise { const business = await this.findOne(id); + this.assertCanUpdateBusiness(business, payload, actor); if (payload.slug && payload.slug !== business.slug) { const slugTaken = await this.repo.findOne({ where: { slug: payload.slug } }); @@ -111,4 +119,31 @@ export class BusinessesService { throw new NotFoundException('Business category not found'); } } + + private assertCanUpdateBusiness( + business: BusinessEntity, + payload: UpdateBusinessDto, + actor?: AuthUser, + ): void { + if (!actor || actor.role === UserRole.ADMIN) { + return; + } + + if ( + actor.role !== UserRole.BUSINESS_OWNER || + business.ownerId !== actor.id + ) { + throw new ForbiddenException('You can only manage your own business'); + } + + if ( + payload.ownerId !== undefined || + payload.categoryId !== undefined || + payload.isActive !== undefined + ) { + throw new ForbiddenException( + 'Only the super admin can change owner, category, or active status', + ); + } + } } diff --git a/src/modules/service-templates/service-templates.controller.ts b/src/modules/service-templates/service-templates.controller.ts index 0e65f1a..97a5042 100644 --- a/src/modules/service-templates/service-templates.controller.ts +++ b/src/modules/service-templates/service-templates.controller.ts @@ -7,8 +7,13 @@ import { Patch, Post, Query, + UseGuards, } from '@nestjs/common'; import { ServiceTemplateEntity } from '../../entities/service-template.entity'; +import { UserRole } from '../../entities/user.entity'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; import { ServiceTemplatesService } from './service-templates.service'; import { CreateServiceTemplateDto } from './dto/create-service-template.dto'; import { UpdateServiceTemplateDto } from './dto/update-service-template.dto'; @@ -37,6 +42,8 @@ export class ServiceTemplatesController { } @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) create( @Body() payload: CreateServiceTemplateDto, ): Promise { @@ -44,6 +51,8 @@ export class ServiceTemplatesController { } @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) update( @Param('id') id: string, @Body() payload: UpdateServiceTemplateDto, @@ -52,6 +61,8 @@ export class ServiceTemplatesController { } @Delete(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) async remove(@Param('id') id: string): Promise<{ success: true }> { await this.service.remove(id); return { success: true }; diff --git a/src/modules/users/dto/update-user.dto.ts b/src/modules/users/dto/update-user.dto.ts new file mode 100644 index 0000000..af75af4 --- /dev/null +++ b/src/modules/users/dto/update-user.dto.ts @@ -0,0 +1,7 @@ +import { UserRole } from '../../../entities/user.entity'; + +export class UpdateUserDto { + fullName?: string | null; + role?: UserRole; + isActive?: boolean; +} diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts new file mode 100644 index 0000000..ea6d94b --- /dev/null +++ b/src/modules/users/users.controller.ts @@ -0,0 +1,39 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + UseGuards, +} from '@nestjs/common'; +import { UserEntity, UserRole } from '../../entities/user.entity'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { UsersService } from './users.service'; + +@Controller('users') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +export class UsersController { + constructor(private readonly service: UsersService) {} + + @Get() + findAll(): Promise { + return this.service.findAll(); + } + + @Get(':id') + findOne(@Param('id') id: string): Promise { + return this.service.findOne(id); + } + + @Patch(':id') + update( + @Param('id') id: string, + @Body() payload: UpdateUserDto, + ): Promise { + return this.service.update(id, payload); + } +} diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts new file mode 100644 index 0000000..c853fdf --- /dev/null +++ b/src/modules/users/users.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { UserEntity } from '../../entities/user.entity'; +import { AuthModule } from '../auth/auth.module'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([UserEntity]), AuthModule], + controllers: [UsersController], + providers: [UsersService], +}) +export class UsersModule {} diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts new file mode 100644 index 0000000..7ac929a --- /dev/null +++ b/src/modules/users/users.service.ts @@ -0,0 +1,41 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UserEntity } from '../../entities/user.entity'; +import { UpdateUserDto } from './dto/update-user.dto'; + +@Injectable() +export class UsersService { + constructor( + @InjectRepository(UserEntity) + private readonly repo: Repository, + ) {} + + findAll(): Promise { + return this.repo.find({ order: { createdAt: 'DESC' } }); + } + + async findOne(id: string): Promise { + const user = await this.repo.findOne({ where: { id } }); + if (!user) { + throw new NotFoundException('User not found'); + } + return user; + } + + async update(id: string, dto: UpdateUserDto): Promise { + const user = await this.findOne(id); + + if (dto.fullName !== undefined) { + user.fullName = dto.fullName; + } + if (dto.role !== undefined) { + user.role = dto.role; + } + if (dto.isActive !== undefined) { + user.isActive = dto.isActive; + } + + return this.repo.save(user); + } +}