feat: remove OTP gate from transactions, fix categories auth, add implementation plan
- Remove OtpGateGuard from transactions controller (OTP verified at login) - Fix categories controller to use authenticated user instead of TEMP_USER_ID - Add comprehensive implementation plan document - Update .env.example with WEB_APP_URL - Prepare for admin dashboard development
This commit is contained in:
@@ -8,6 +8,7 @@ import { UsersModule } from './users/users.module';
|
||||
import { WalletsModule } from './wallets/wallets.module';
|
||||
import { TransactionsModule } from './transactions/transactions.module';
|
||||
import { CategoriesModule } from './categories/categories.module';
|
||||
import { OtpModule } from './otp/otp.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -24,8 +25,9 @@ import { CategoriesModule } from './categories/categories.module';
|
||||
WalletsModule,
|
||||
TransactionsModule,
|
||||
CategoriesModule,
|
||||
OtpModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [],
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule {}
|
||||
|
||||
104
apps/api/src/auth/auth.controller.ts
Normal file
104
apps/api/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
UseGuards,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard as JwtAuthGuard } from './auth.guard';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AuthService } from './auth.service';
|
||||
import type { Response } from 'express';
|
||||
|
||||
interface RequestWithUser {
|
||||
user: {
|
||||
userId: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
async register(
|
||||
@Body() body: { email: string; password: string; name?: string },
|
||||
) {
|
||||
return this.authService.register(body.email, body.password, body.name);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
async login(@Body() body: { email: string; password: string }) {
|
||||
return this.authService.login(body.email, body.password);
|
||||
}
|
||||
|
||||
@Post('verify-otp')
|
||||
async verifyOtp(
|
||||
@Body()
|
||||
body: {
|
||||
tempToken: string;
|
||||
otpCode: string;
|
||||
method: 'email' | 'totp';
|
||||
},
|
||||
) {
|
||||
return this.authService.verifyOtpAndLogin(
|
||||
body.tempToken,
|
||||
body.otpCode,
|
||||
body.method,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('google')
|
||||
@UseGuards(AuthGuard('google'))
|
||||
async googleAuth() {
|
||||
// Initiates Google OAuth flow
|
||||
}
|
||||
|
||||
@Get('google/callback')
|
||||
@UseGuards(AuthGuard('google'))
|
||||
async googleAuthCallback(@Req() req: any, @Res() res: Response) {
|
||||
// Handle Google OAuth callback
|
||||
const result = await this.authService.googleLogin(req.user);
|
||||
|
||||
// Redirect to frontend with token or OTP requirement
|
||||
const frontendUrl = process.env.WEB_APP_URL || 'http://localhost:5174';
|
||||
|
||||
if (result.requiresOtp) {
|
||||
// Redirect to OTP page with temp token
|
||||
res.redirect(
|
||||
`${frontendUrl}/auth/otp?token=${result.tempToken}&methods=${JSON.stringify(result.availableMethods)}`,
|
||||
);
|
||||
} else {
|
||||
// Redirect to app with full token
|
||||
res.redirect(`${frontendUrl}/auth/callback?token=${result.token}`);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async getProfile(@Req() req: RequestWithUser) {
|
||||
return this.authService.getUserProfile(req.user.userId);
|
||||
}
|
||||
|
||||
@Post('change-password')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async changePassword(
|
||||
@Req() req: RequestWithUser,
|
||||
@Body()
|
||||
body: {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
isSettingPassword?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.authService.changePassword(
|
||||
req.user.userId,
|
||||
body.currentPassword,
|
||||
body.newPassword,
|
||||
body.isSettingPassword,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,24 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { FirebaseService } from './firebase.service';
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { AuthGuard as PassportAuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(private firebaseService: FirebaseService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
// If Firebase is not configured, allow all requests (development mode)
|
||||
if (!this.firebaseService.isFirebaseConfigured()) {
|
||||
console.warn('⚠️ Firebase not configured - allowing request without auth');
|
||||
return true;
|
||||
}
|
||||
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
try {
|
||||
const decodedToken = await this.firebaseService.verifyIdToken(token);
|
||||
request.user = decodedToken;
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
export class AuthGuard extends PassportAuthGuard('jwt') {
|
||||
constructor(private reflector: Reflector) {
|
||||
super();
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
canActivate(context: ExecutionContext) {
|
||||
// Check if route is marked as public
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FirebaseService } from './firebase.service';
|
||||
import { AuthGuard } from './auth.guard';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { GoogleStrategy } from './google.strategy';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { OtpModule } from '../otp/otp.module';
|
||||
|
||||
@Module({
|
||||
providers: [FirebaseService, AuthGuard],
|
||||
exports: [FirebaseService, AuthGuard],
|
||||
imports: [
|
||||
PrismaModule,
|
||||
PassportModule,
|
||||
forwardRef(() => OtpModule),
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'your-secret-key',
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, GoogleStrategy],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
479
apps/api/src/auth/auth.service.ts
Normal file
479
apps/api/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { OtpService } from '../otp/otp.service';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import axios from 'axios';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
@Inject(forwardRef(() => OtpService))
|
||||
private readonly otpService: OtpService,
|
||||
) {}
|
||||
|
||||
async register(email: string, password: string, name?: string) {
|
||||
// Check if user already exists
|
||||
const existing = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
throw new ConflictException('Email already registered');
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
// Create user
|
||||
const user = await this.prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
name,
|
||||
emailVerified: false, // Will be verified via OTP
|
||||
},
|
||||
});
|
||||
|
||||
// Generate JWT token
|
||||
const token = this.generateToken(user.id, user.email);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
avatarUrl: user.avatarUrl,
|
||||
emailVerified: user.emailVerified,
|
||||
},
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
// Find user
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
passwordHash: true,
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
emailVerified: true,
|
||||
otpEmailEnabled: true,
|
||||
otpWhatsappEnabled: true,
|
||||
otpTotpEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user || !user.passwordHash) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
// Check if OTP is required
|
||||
const requiresOtp =
|
||||
user.otpEmailEnabled || user.otpWhatsappEnabled || user.otpTotpEnabled;
|
||||
|
||||
if (requiresOtp) {
|
||||
// Send email OTP if enabled
|
||||
if (user.otpEmailEnabled) {
|
||||
try {
|
||||
await this.otpService.sendEmailOtp(user.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to send email OTP during login:', error);
|
||||
// Continue anyway - user can request resend
|
||||
}
|
||||
}
|
||||
|
||||
// Send WhatsApp OTP if enabled (use 'live' mode for login)
|
||||
if (user.otpWhatsappEnabled) {
|
||||
try {
|
||||
await this.otpService.sendWhatsappOtp(user.id, 'live');
|
||||
} catch (error) {
|
||||
console.error('Failed to send WhatsApp OTP during login:', error);
|
||||
// Continue anyway - user can request resend
|
||||
}
|
||||
}
|
||||
|
||||
// Return temporary token that requires OTP verification
|
||||
return {
|
||||
requiresOtp: true,
|
||||
availableMethods: {
|
||||
email: user.otpEmailEnabled,
|
||||
whatsapp: user.otpWhatsappEnabled,
|
||||
totp: user.otpTotpEnabled,
|
||||
},
|
||||
tempToken: this.generateTempToken(user.id, user.email),
|
||||
};
|
||||
}
|
||||
|
||||
// Generate full JWT token
|
||||
const token = this.generateToken(user.id, user.email);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
avatarUrl: user.avatarUrl,
|
||||
emailVerified: user.emailVerified,
|
||||
},
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
async googleLogin(googleProfile: {
|
||||
googleId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
}) {
|
||||
// Find or create user
|
||||
let user = await this.prisma.user.findUnique({
|
||||
where: { email: googleProfile.email },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// Create new user from Google profile
|
||||
user = await this.prisma.user.create({
|
||||
data: {
|
||||
email: googleProfile.email,
|
||||
name: googleProfile.name,
|
||||
avatarUrl: googleProfile.avatarUrl,
|
||||
emailVerified: true, // Google emails are pre-verified
|
||||
authAccounts: {
|
||||
create: {
|
||||
provider: 'google',
|
||||
issuer: 'google.com',
|
||||
subject: googleProfile.googleId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Update existing user with Google account if not already linked
|
||||
const existingAuth = await this.prisma.authAccount.findUnique({
|
||||
where: {
|
||||
issuer_subject: {
|
||||
issuer: 'google.com',
|
||||
subject: googleProfile.googleId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingAuth) {
|
||||
await this.prisma.authAccount.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
provider: 'google',
|
||||
issuer: 'google.com',
|
||||
subject: googleProfile.googleId,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Update user info from Google (always update to get latest avatar)
|
||||
console.log('Updating user with Google profile:', {
|
||||
name: googleProfile.name,
|
||||
avatarUrl: googleProfile.avatarUrl,
|
||||
});
|
||||
|
||||
// Download and store avatar locally to avoid Google rate limits
|
||||
let avatarUrl = user.avatarUrl;
|
||||
if (googleProfile.avatarUrl) {
|
||||
try {
|
||||
avatarUrl = await this.downloadAndStoreAvatar(
|
||||
googleProfile.avatarUrl,
|
||||
user.id,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to download avatar:', error);
|
||||
// Fallback to Google URL
|
||||
avatarUrl = googleProfile.avatarUrl;
|
||||
}
|
||||
}
|
||||
|
||||
user = await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
name: googleProfile.name || user.name,
|
||||
avatarUrl: avatarUrl || user.avatarUrl,
|
||||
emailVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log('User updated, avatar:', user.avatarUrl);
|
||||
}
|
||||
|
||||
// Check if OTP is required
|
||||
const requiresOtp =
|
||||
user.otpEmailEnabled || user.otpWhatsappEnabled || user.otpTotpEnabled;
|
||||
|
||||
if (requiresOtp) {
|
||||
// Send email OTP if enabled
|
||||
if (user.otpEmailEnabled) {
|
||||
try {
|
||||
await this.otpService.sendEmailOtp(user.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to send email OTP during Google login:', error);
|
||||
// Continue anyway - user can request resend
|
||||
}
|
||||
}
|
||||
|
||||
// Send WhatsApp OTP if enabled (use 'live' mode for login)
|
||||
if (user.otpWhatsappEnabled) {
|
||||
try {
|
||||
await this.otpService.sendWhatsappOtp(user.id, 'live');
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Failed to send WhatsApp OTP during Google login:',
|
||||
error,
|
||||
);
|
||||
// Continue anyway - user can request resend
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requiresOtp: true,
|
||||
availableMethods: {
|
||||
email: user.otpEmailEnabled,
|
||||
whatsapp: user.otpWhatsappEnabled,
|
||||
totp: user.otpTotpEnabled,
|
||||
},
|
||||
tempToken: this.generateTempToken(user.id, user.email),
|
||||
};
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
const token = this.generateToken(user.id, user.email);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
avatarUrl: user.avatarUrl,
|
||||
emailVerified: user.emailVerified,
|
||||
},
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyOtpAndLogin(
|
||||
tempToken: string,
|
||||
otpCode: string,
|
||||
method: 'email' | 'whatsapp' | 'totp',
|
||||
) {
|
||||
// Verify temp token
|
||||
let payload: {
|
||||
temp?: boolean;
|
||||
userId?: string;
|
||||
sub?: string;
|
||||
email?: string;
|
||||
};
|
||||
try {
|
||||
payload = this.jwtService.verify(tempToken);
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid or expired token');
|
||||
}
|
||||
|
||||
if (!payload.temp) {
|
||||
throw new UnauthorizedException('Invalid token type');
|
||||
}
|
||||
|
||||
const userId = payload.userId || payload.sub;
|
||||
const email = payload.email;
|
||||
|
||||
if (!userId || !email) {
|
||||
throw new UnauthorizedException('Invalid token payload');
|
||||
}
|
||||
|
||||
// Get user to verify OTP
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
// Verify OTP code based on method
|
||||
if (method === 'email') {
|
||||
// Verify email OTP using OTP service
|
||||
const isValid = this.otpService.verifyEmailOtpForLogin(userId, otpCode);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Invalid or expired email OTP code');
|
||||
}
|
||||
} else if (method === 'whatsapp') {
|
||||
// Verify WhatsApp OTP using OTP service
|
||||
const isValid = this.otpService.verifyWhatsappOtpForLogin(
|
||||
userId,
|
||||
otpCode,
|
||||
);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Invalid or expired WhatsApp OTP code');
|
||||
}
|
||||
} else if (method === 'totp') {
|
||||
// Verify TOTP
|
||||
if (!user.otpTotpSecret) {
|
||||
throw new UnauthorizedException('TOTP not set up');
|
||||
}
|
||||
|
||||
const { authenticator } = await import('otplib');
|
||||
const isValid = authenticator.verify({
|
||||
token: otpCode,
|
||||
secret: user.otpTotpSecret,
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Invalid TOTP code');
|
||||
}
|
||||
}
|
||||
|
||||
// Generate full JWT token
|
||||
const token = this.generateToken(userId, email);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
avatarUrl: user.avatarUrl,
|
||||
emailVerified: user.emailVerified,
|
||||
},
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
private generateToken(userId: string, email: string): string {
|
||||
return this.jwtService.sign({
|
||||
sub: userId,
|
||||
email,
|
||||
});
|
||||
}
|
||||
|
||||
private generateTempToken(userId: string, email: string): string {
|
||||
return this.jwtService.sign(
|
||||
{ userId, email, temp: true },
|
||||
{ expiresIn: '5m' }, // Temp token expires in 5 minutes
|
||||
);
|
||||
}
|
||||
|
||||
async getUserProfile(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
avatarUrl: true,
|
||||
emailVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('User not found');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
userId: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
isSettingPassword?: boolean,
|
||||
) {
|
||||
// Get user with password hash
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { passwordHash: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
|
||||
// If setting password for Google user (no existing password)
|
||||
if (isSettingPassword && !user.passwordHash) {
|
||||
// Hash new password
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
// Set password
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash: newPasswordHash },
|
||||
});
|
||||
|
||||
return { message: 'Password set successfully' };
|
||||
}
|
||||
|
||||
// Otherwise, changing existing password
|
||||
if (!user.passwordHash) {
|
||||
throw new BadRequestException('Cannot change password for this account');
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isValid = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Current password is incorrect');
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, 10);
|
||||
|
||||
// Update password
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash: newPasswordHash },
|
||||
});
|
||||
|
||||
return { message: 'Password changed successfully' };
|
||||
}
|
||||
|
||||
private async downloadAndStoreAvatar(
|
||||
avatarUrl: string,
|
||||
userId: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Create uploads directory if it doesn't exist
|
||||
const uploadsDir = path.join(process.cwd(), 'public', 'avatars');
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Download image
|
||||
const response = await axios.get(avatarUrl, {
|
||||
responseType: 'arraybuffer',
|
||||
});
|
||||
|
||||
// Generate filename
|
||||
const ext = 'jpg'; // Google avatars are usually JPG
|
||||
const filename = `${userId}.${ext}`;
|
||||
const filepath = path.join(uploadsDir, filename);
|
||||
|
||||
// Save file
|
||||
fs.writeFileSync(filepath, response.data);
|
||||
|
||||
// Return public URL
|
||||
return `/avatars/${filename}`;
|
||||
} catch (error) {
|
||||
console.error('Error downloading avatar:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as admin from 'firebase-admin';
|
||||
|
||||
@Injectable()
|
||||
export class FirebaseService {
|
||||
private app: admin.app.App | null = null;
|
||||
private isConfigured: boolean = false;
|
||||
|
||||
constructor() {
|
||||
// Only initialize Firebase if credentials are available
|
||||
const projectId = process.env.FIREBASE_PROJECT_ID;
|
||||
const clientEmail = process.env.FIREBASE_CLIENT_EMAIL;
|
||||
const privateKey = process.env.FIREBASE_PRIVATE_KEY;
|
||||
|
||||
if (projectId && clientEmail && privateKey) {
|
||||
try {
|
||||
if (!admin.apps.length) {
|
||||
this.app = admin.initializeApp({
|
||||
credential: admin.credential.cert({
|
||||
projectId,
|
||||
clientEmail,
|
||||
privateKey: privateKey.replace(/\\n/g, '\n'),
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
this.app = admin.app();
|
||||
}
|
||||
this.isConfigured = true;
|
||||
console.log('✅ Firebase Admin initialized successfully');
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Firebase Admin initialization failed:', error.message);
|
||||
this.isConfigured = false;
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ Firebase credentials not found. Auth will use fallback mode.');
|
||||
this.isConfigured = false;
|
||||
}
|
||||
}
|
||||
|
||||
async verifyIdToken(idToken: string): Promise<admin.auth.DecodedIdToken> {
|
||||
if (!this.isConfigured || !this.app) {
|
||||
throw new Error('Firebase not configured');
|
||||
}
|
||||
try {
|
||||
return await admin.auth().verifyIdToken(idToken);
|
||||
} catch (error) {
|
||||
throw new Error('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
async getUser(uid: string): Promise<admin.auth.UserRecord> {
|
||||
if (!this.isConfigured || !this.app) {
|
||||
throw new Error('Firebase not configured');
|
||||
}
|
||||
try {
|
||||
return await admin.auth().getUser(uid);
|
||||
} catch (error) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
}
|
||||
|
||||
isFirebaseConfigured(): boolean {
|
||||
return this.isConfigured;
|
||||
}
|
||||
}
|
||||
35
apps/api/src/auth/google.strategy.ts
Normal file
35
apps/api/src/auth/google.strategy.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy, VerifyCallback } from 'passport-google-oauth20';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
constructor() {
|
||||
super({
|
||||
clientID: process.env.GOOGLE_CLIENT_ID || '',
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
|
||||
callbackURL:
|
||||
process.env.GOOGLE_CALLBACK_URL ||
|
||||
'http://localhost:3001/api/auth/google/callback',
|
||||
scope: ['email', 'profile'],
|
||||
});
|
||||
}
|
||||
|
||||
async validate(
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
profile: any,
|
||||
done: VerifyCallback,
|
||||
): Promise<any> {
|
||||
const { id, name, emails, photos } = profile;
|
||||
|
||||
const user = {
|
||||
googleId: id,
|
||||
email: emails[0].value,
|
||||
name: name.givenName + ' ' + name.familyName,
|
||||
avatarUrl: photos[0]?.value,
|
||||
};
|
||||
|
||||
done(null, user);
|
||||
}
|
||||
}
|
||||
25
apps/api/src/auth/jwt.strategy.ts
Normal file
25
apps/api/src/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string; // user ID
|
||||
email: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor() {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: process.env.JWT_SECRET || 'your-secret-key-change-this',
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: JwtPayload) {
|
||||
return { userId: payload.sub, email: payload.email };
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,34 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Delete,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Get, Post, Body, Param, Delete, Req, UseGuards } from '@nestjs/common';
|
||||
import { CategoriesService } from '../categories/categories.service';
|
||||
import { CreateCategoryDto } from '../categories/dto/create-category.dto';
|
||||
import { getTempUserId } from '../common/user.util';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
|
||||
interface RequestWithUser {
|
||||
user: {
|
||||
userId: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Controller('categories')
|
||||
@UseGuards(AuthGuard)
|
||||
export class CategoriesController {
|
||||
constructor(private readonly categoriesService: CategoriesService) {}
|
||||
|
||||
private userId(): string {
|
||||
return getTempUserId();
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() createCategoryDto: CreateCategoryDto) {
|
||||
create(@Req() req: RequestWithUser, @Body() createCategoryDto: CreateCategoryDto) {
|
||||
return this.categoriesService.create({
|
||||
...createCategoryDto,
|
||||
userId: this.userId(),
|
||||
userId: req.user.userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.categoriesService.findAll(this.userId());
|
||||
findAll(@Req() req: RequestWithUser) {
|
||||
return this.categoriesService.findAll(req.user.userId);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.categoriesService.remove(id, this.userId());
|
||||
remove(@Req() req: RequestWithUser, @Param('id') id: string) {
|
||||
return this.categoriesService.remove(id, req.user.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||
|
||||
@@ -45,7 +49,7 @@ export class CategoriesService {
|
||||
|
||||
async findOrCreate(names: string[], userId: string) {
|
||||
const categories: any[] = [];
|
||||
|
||||
|
||||
for (const name of names) {
|
||||
let category = await this.prisma.category.findFirst({
|
||||
where: { name, userId },
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
export function getTempUserId(): string {
|
||||
const id = process.env.TEMP_USER_ID?.trim();
|
||||
if (!id) {
|
||||
throw new Error('TEMP_USER_ID is not set. Run the seed and set it in apps/api/.env');
|
||||
}
|
||||
return id;
|
||||
const id = process.env.TEMP_USER_ID?.trim();
|
||||
if (!id) {
|
||||
throw new Error(
|
||||
'TEMP_USER_ID is not set. Run the seed and set it in apps/api/.env',
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function getUserIdFromRequest(request: any): string {
|
||||
// If Firebase user is authenticated, use their UID
|
||||
if (request.user?.uid) {
|
||||
return request.user.uid;
|
||||
}
|
||||
|
||||
|
||||
// Fallback to temp user for development
|
||||
return getTempUserId();
|
||||
}
|
||||
@@ -21,4 +23,4 @@ export function createUserDecorator() {
|
||||
// This is a placeholder for a proper decorator implementation
|
||||
// In a real app, you'd create a proper parameter decorator
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,4 +16,4 @@ export class HealthController {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
return { db: 'connected' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { join } from 'path';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
// Serve static files from public directory
|
||||
app.useStaticAssets(join(__dirname, '..', 'public'));
|
||||
|
||||
// Allow web app to call API in dev
|
||||
const webOrigin = process.env.WEB_APP_URL ?? 'http://localhost:5173';
|
||||
@@ -16,7 +21,8 @@ async function bootstrap() {
|
||||
|
||||
const port = process.env.PORT ? Number(process.env.PORT) : 3000;
|
||||
await app.listen(port);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`API listening on http://localhost:${port}`);
|
||||
|
||||
console.log(`API listening on ${await app.getUrl()}`);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
void bootstrap();
|
||||
|
||||
72
apps/api/src/otp/otp-gate.guard.ts
Normal file
72
apps/api/src/otp/otp-gate.guard.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { OtpService } from './otp.service';
|
||||
|
||||
interface RequestWithUser {
|
||||
user: {
|
||||
userId: string;
|
||||
};
|
||||
headers: Record<string, string>;
|
||||
body?: {
|
||||
otpCode?: string;
|
||||
otpMethod?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OtpGateGuard implements CanActivate {
|
||||
constructor(private otpService: OtpService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<RequestWithUser>();
|
||||
|
||||
// Get userId from JWT (set by AuthGuard)
|
||||
const userId = request.user?.userId;
|
||||
if (!userId) {
|
||||
// If no user, let AuthGuard handle it
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if user has OTP enabled
|
||||
const status = await this.otpService.getStatus(userId);
|
||||
|
||||
// If no OTP methods are enabled, allow access
|
||||
if (!status.emailEnabled && !status.totpEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for OTP verification in headers or body
|
||||
const otpCode = request.headers['x-otp-code'] || request.body?.otpCode;
|
||||
const otpMethod = (request.headers['x-otp-method'] ||
|
||||
request.body?.otpMethod ||
|
||||
'totp') as 'email' | 'totp';
|
||||
|
||||
if (!otpCode) {
|
||||
throw new UnauthorizedException({
|
||||
message: 'OTP verification required',
|
||||
requiresOtp: true,
|
||||
availableMethods: {
|
||||
email: status.emailEnabled,
|
||||
totp: status.totpEnabled,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the OTP
|
||||
const isValid = await this.otpService.verifyOtpGate(
|
||||
userId,
|
||||
otpCode,
|
||||
otpMethod,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Invalid OTP code');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
150
apps/api/src/otp/otp.controller.ts
Normal file
150
apps/api/src/otp/otp.controller.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
UseGuards,
|
||||
Req,
|
||||
UnauthorizedException,
|
||||
SetMetadata,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
import { OtpService } from './otp.service';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
interface RequestWithUser extends Request {
|
||||
user: {
|
||||
userId: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Controller('otp')
|
||||
@UseGuards(AuthGuard)
|
||||
export class OtpController {
|
||||
constructor(
|
||||
private readonly otpService: OtpService,
|
||||
private readonly jwtService: JwtService,
|
||||
) {}
|
||||
|
||||
@Get('status')
|
||||
async getStatus(@Req() req: RequestWithUser) {
|
||||
return this.otpService.getStatus(req.user.userId);
|
||||
}
|
||||
|
||||
@Post('email/send')
|
||||
async sendEmailOtp(@Req() req: RequestWithUser) {
|
||||
return this.otpService.sendEmailOtp(req.user.userId);
|
||||
}
|
||||
|
||||
@Post('email/verify')
|
||||
async verifyEmailOtp(
|
||||
@Req() req: RequestWithUser,
|
||||
@Body() body: { code: string },
|
||||
) {
|
||||
return this.otpService.verifyEmailOtp(req.user.userId, body.code);
|
||||
}
|
||||
|
||||
@Post('email/disable')
|
||||
async disableEmailOtp(@Req() req: RequestWithUser) {
|
||||
return this.otpService.disableEmailOtp(req.user.userId);
|
||||
}
|
||||
|
||||
@Post('totp/setup')
|
||||
async setupTotp(@Req() req: RequestWithUser) {
|
||||
return this.otpService.setupTotp(req.user.userId);
|
||||
}
|
||||
|
||||
@Post('totp/verify')
|
||||
async verifyTotp(
|
||||
@Req() req: RequestWithUser,
|
||||
@Body() body: { code: string },
|
||||
) {
|
||||
return this.otpService.verifyTotp(req.user.userId, body.code);
|
||||
}
|
||||
|
||||
@Post('totp/disable')
|
||||
async disableTotp(@Req() req: RequestWithUser) {
|
||||
return this.otpService.disableTotp(req.user.userId);
|
||||
}
|
||||
|
||||
@Post('whatsapp/send')
|
||||
async sendWhatsappOtp(
|
||||
@Req() req: RequestWithUser,
|
||||
@Body() body: { mode?: 'test' | 'live' },
|
||||
) {
|
||||
return this.otpService.sendWhatsappOtp(
|
||||
req.user.userId,
|
||||
body.mode || 'test',
|
||||
);
|
||||
}
|
||||
|
||||
@Post('whatsapp/verify')
|
||||
async verifyWhatsappOtp(
|
||||
@Req() req: RequestWithUser,
|
||||
@Body() body: { code: string },
|
||||
) {
|
||||
return this.otpService.verifyWhatsappOtp(req.user.userId, body.code);
|
||||
}
|
||||
|
||||
@Post('whatsapp/disable')
|
||||
async disableWhatsappOtp(@Req() req: RequestWithUser) {
|
||||
return this.otpService.disableWhatsappOtp(req.user.userId);
|
||||
}
|
||||
|
||||
@Post('whatsapp/check')
|
||||
async checkWhatsappNumber(@Body() body: { phone: string }) {
|
||||
return this.otpService.checkWhatsappNumber(body.phone);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('email/resend')
|
||||
async resendEmailOtp(@Body() body: { tempToken: string }) {
|
||||
try {
|
||||
// Verify temp token
|
||||
const payload = this.jwtService.verify(body.tempToken);
|
||||
|
||||
if (!payload.temp) {
|
||||
throw new UnauthorizedException('Invalid token type');
|
||||
}
|
||||
|
||||
const userId = payload.userId || payload.sub;
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException('Invalid token payload');
|
||||
}
|
||||
|
||||
// Send OTP
|
||||
return this.otpService.sendEmailOtp(userId);
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid or expired token');
|
||||
}
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('whatsapp/resend')
|
||||
async resendWhatsappOtp(@Body() body: { tempToken: string }) {
|
||||
try {
|
||||
// Verify temp token
|
||||
const payload = this.jwtService.verify(body.tempToken);
|
||||
|
||||
if (!payload.temp) {
|
||||
throw new UnauthorizedException('Invalid token type');
|
||||
}
|
||||
|
||||
const userId = payload.userId || payload.sub;
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException('Invalid token payload');
|
||||
}
|
||||
|
||||
// Send WhatsApp OTP (live mode for login)
|
||||
return this.otpService.sendWhatsappOtp(userId, 'live');
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid or expired token');
|
||||
}
|
||||
}
|
||||
}
|
||||
22
apps/api/src/otp/otp.module.ts
Normal file
22
apps/api/src/otp/otp.module.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { OtpController } from './otp.controller';
|
||||
import { OtpService } from './otp.service';
|
||||
import { OtpGateGuard } from './otp-gate.guard';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
forwardRef(() => AuthModule),
|
||||
PrismaModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'your-secret-key',
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
],
|
||||
controllers: [OtpController],
|
||||
providers: [OtpService, OtpGateGuard],
|
||||
exports: [OtpService, OtpGateGuard],
|
||||
})
|
||||
export class OtpModule {}
|
||||
436
apps/api/src/otp/otp.service.ts
Normal file
436
apps/api/src/otp/otp.service.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { authenticator } from 'otplib';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import axios from 'axios';
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
@Injectable()
|
||||
export class OtpService {
|
||||
private emailOtpStore = new Map<string, { code: string; expiresAt: Date }>();
|
||||
private whatsappOtpStore = new Map<
|
||||
string,
|
||||
{ code: string; expiresAt: Date }
|
||||
>();
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async sendEmailOtp(
|
||||
userId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
|
||||
const code = this.generateOtpCode();
|
||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
|
||||
|
||||
// Store the code
|
||||
this.emailOtpStore.set(userId, { code, expiresAt });
|
||||
|
||||
// Send via webhook (you'll handle the actual email sending)
|
||||
try {
|
||||
await this.sendOtpViaWebhook(user.email, code);
|
||||
return { success: true, message: 'OTP sent to your email' };
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to send OTP via webhook:', error);
|
||||
// For development, log the code
|
||||
console.log(`📧 OTP Code for ${user.email}: ${code}`);
|
||||
return {
|
||||
success: true,
|
||||
message: 'OTP sent (check console for dev code)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Verify email OTP for login (doesn't enable the feature)
|
||||
verifyEmailOtpForLogin(userId: string, code: string): boolean {
|
||||
const stored = this.emailOtpStore.get(userId);
|
||||
|
||||
if (!stored) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (new Date() > stored.expiresAt) {
|
||||
this.emailOtpStore.delete(userId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (stored.code !== code) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clean up
|
||||
this.emailOtpStore.delete(userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Verify and enable email OTP (for setup)
|
||||
async verifyEmailOtp(
|
||||
userId: string,
|
||||
code: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const stored = this.emailOtpStore.get(userId);
|
||||
|
||||
if (!stored) {
|
||||
throw new BadRequestException('No OTP found. Please request a new one.');
|
||||
}
|
||||
|
||||
if (new Date() > stored.expiresAt) {
|
||||
this.emailOtpStore.delete(userId);
|
||||
throw new BadRequestException(
|
||||
'OTP has expired. Please request a new one.',
|
||||
);
|
||||
}
|
||||
|
||||
if (stored.code !== code) {
|
||||
throw new BadRequestException('Invalid OTP code.');
|
||||
}
|
||||
|
||||
// Enable email OTP in database
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { otpEmailEnabled: true },
|
||||
});
|
||||
|
||||
// Clean up
|
||||
this.emailOtpStore.delete(userId);
|
||||
|
||||
return { success: true, message: 'Email OTP enabled successfully' };
|
||||
}
|
||||
|
||||
async disableEmailOtp(
|
||||
userId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { otpEmailEnabled: false },
|
||||
});
|
||||
|
||||
return { success: true, message: 'Email OTP disabled' };
|
||||
}
|
||||
|
||||
async setupTotp(userId: string): Promise<{ secret: string; qrCode: string }> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
|
||||
const secret = authenticator.generateSecret();
|
||||
|
||||
// Store the secret in database (not yet enabled)
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { otpTotpSecret: secret },
|
||||
});
|
||||
|
||||
// Generate QR code URL for Google Authenticator
|
||||
const serviceName = 'Tabungin';
|
||||
const accountName = user.email;
|
||||
const otpauthUrl = authenticator.keyuri(accountName, serviceName, secret);
|
||||
|
||||
// Generate QR code as data URL
|
||||
const qrCodeDataUrl = await QRCode.toDataURL(otpauthUrl);
|
||||
|
||||
return {
|
||||
secret,
|
||||
qrCode: qrCodeDataUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyTotp(
|
||||
userId: string,
|
||||
code: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { otpTotpSecret: true },
|
||||
});
|
||||
|
||||
if (!user?.otpTotpSecret) {
|
||||
throw new BadRequestException(
|
||||
'No TOTP setup found. Please setup TOTP first.',
|
||||
);
|
||||
}
|
||||
|
||||
const isValid = authenticator.verify({
|
||||
token: code,
|
||||
secret: user.otpTotpSecret,
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
throw new BadRequestException('Invalid TOTP code.');
|
||||
}
|
||||
|
||||
// Enable TOTP in database
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { otpTotpEnabled: true },
|
||||
});
|
||||
|
||||
return { success: true, message: 'TOTP enabled successfully' };
|
||||
}
|
||||
|
||||
async disableTotp(
|
||||
userId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
otpTotpEnabled: false,
|
||||
otpTotpSecret: null,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, message: 'TOTP disabled' };
|
||||
}
|
||||
|
||||
async getStatus(userId: string) {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
phone: true,
|
||||
otpEmailEnabled: true,
|
||||
otpWhatsappEnabled: true,
|
||||
otpTotpEnabled: true,
|
||||
otpTotpSecret: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
emailEnabled: false,
|
||||
whatsappEnabled: false,
|
||||
totpEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
phone: user.phone,
|
||||
emailEnabled: user.otpEmailEnabled,
|
||||
whatsappEnabled: user.otpWhatsappEnabled,
|
||||
totpEnabled: user.otpTotpEnabled,
|
||||
totpSecret: user.otpTotpSecret,
|
||||
};
|
||||
}
|
||||
|
||||
// OTP Gate - verify user's OTP during login
|
||||
async verifyOtpGate(
|
||||
userId: string,
|
||||
code: string,
|
||||
method: 'email' | 'totp',
|
||||
): Promise<boolean> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
otpEmailEnabled: true,
|
||||
otpTotpEnabled: true,
|
||||
otpTotpSecret: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (method === 'email' && user.otpEmailEnabled) {
|
||||
// For login, we'd need to send a fresh OTP first
|
||||
const stored = this.emailOtpStore.get(userId);
|
||||
if (stored && new Date() <= stored.expiresAt && stored.code === code) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (method === 'totp' && user.otpTotpEnabled && user.otpTotpSecret) {
|
||||
return authenticator.verify({ token: code, secret: user.otpTotpSecret });
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private generateOtpCode(): string {
|
||||
return Math.floor(100000 + Math.random() * 900000).toString();
|
||||
}
|
||||
|
||||
private async sendOtpViaWebhook(
|
||||
email: string,
|
||||
code: string,
|
||||
mode: 'test' | 'live' = 'test',
|
||||
): Promise<void> {
|
||||
// Use test webhook if available, otherwise use production webhook
|
||||
const webhookUrl =
|
||||
process.env.OTP_SEND_WEBHOOK_URL_TEST || process.env.OTP_SEND_WEBHOOK_URL;
|
||||
|
||||
if (!webhookUrl) {
|
||||
throw new Error(
|
||||
'OTP_SEND_WEBHOOK_URL or OTP_SEND_WEBHOOK_URL_TEST not configured',
|
||||
);
|
||||
}
|
||||
|
||||
await axios.post(webhookUrl, {
|
||||
method: 'email',
|
||||
mode, // 'test' or 'live'
|
||||
to: email,
|
||||
subject: 'Tabungin - Your OTP Code',
|
||||
message: `Your OTP code is: ${code}. This code will expire in 10 minutes.`,
|
||||
code,
|
||||
});
|
||||
}
|
||||
|
||||
// WhatsApp OTP methods
|
||||
async sendWhatsappOtp(
|
||||
userId: string,
|
||||
mode: 'test' | 'live' = 'test',
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
|
||||
if (!user.phone) {
|
||||
throw new BadRequestException('Phone number not set');
|
||||
}
|
||||
|
||||
const code = this.generateOtpCode();
|
||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
|
||||
|
||||
// Store the code
|
||||
this.whatsappOtpStore.set(userId, { code, expiresAt });
|
||||
|
||||
// Send via webhook
|
||||
try {
|
||||
await this.sendWhatsappOtpViaWebhook(user.phone, code, mode);
|
||||
return { success: true, message: 'OTP sent to your WhatsApp' };
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to send WhatsApp OTP via webhook:', error);
|
||||
// For development, log the code
|
||||
console.log(`📱 WhatsApp OTP Code for ${user.phone}: ${code}`);
|
||||
return {
|
||||
success: true,
|
||||
message: 'OTP sent (check console for dev code)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async verifyWhatsappOtp(
|
||||
userId: string,
|
||||
code: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const stored = this.whatsappOtpStore.get(userId);
|
||||
|
||||
if (!stored) {
|
||||
throw new BadRequestException('No OTP found. Please request a new one.');
|
||||
}
|
||||
|
||||
if (new Date() > stored.expiresAt) {
|
||||
this.whatsappOtpStore.delete(userId);
|
||||
throw new BadRequestException(
|
||||
'OTP has expired. Please request a new one.',
|
||||
);
|
||||
}
|
||||
|
||||
if (stored.code !== code) {
|
||||
throw new BadRequestException('Invalid OTP code');
|
||||
}
|
||||
|
||||
// OTP is valid, enable WhatsApp OTP
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { otpWhatsappEnabled: true },
|
||||
});
|
||||
|
||||
// Clear the OTP
|
||||
this.whatsappOtpStore.delete(userId);
|
||||
|
||||
return { success: true, message: 'WhatsApp OTP enabled successfully' };
|
||||
}
|
||||
|
||||
verifyWhatsappOtpForLogin(userId: string, code: string): boolean {
|
||||
const stored = this.whatsappOtpStore.get(userId);
|
||||
|
||||
if (!stored) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (new Date() > stored.expiresAt) {
|
||||
this.whatsappOtpStore.delete(userId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (stored.code !== code) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clear the OTP
|
||||
this.whatsappOtpStore.delete(userId);
|
||||
return true;
|
||||
}
|
||||
|
||||
async disableWhatsappOtp(
|
||||
userId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { otpWhatsappEnabled: false },
|
||||
});
|
||||
|
||||
return { success: true, message: 'WhatsApp OTP disabled' };
|
||||
}
|
||||
|
||||
async checkWhatsappNumber(
|
||||
phone: string,
|
||||
): Promise<{ success: boolean; isRegistered: boolean; message: string }> {
|
||||
// Send check request to webhook
|
||||
try {
|
||||
const webhookUrl =
|
||||
process.env.OTP_SEND_WEBHOOK_URL_TEST ||
|
||||
process.env.OTP_SEND_WEBHOOK_URL;
|
||||
|
||||
if (!webhookUrl) {
|
||||
throw new Error('Webhook URL not configured');
|
||||
}
|
||||
|
||||
const response = await axios.post(webhookUrl, {
|
||||
method: 'whatsapp',
|
||||
mode: 'checknumber',
|
||||
phone,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
isRegistered: response.data?.isRegistered || false,
|
||||
message: response.data?.message || 'Number checked',
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to check WhatsApp number:', error);
|
||||
// For development, assume number is valid
|
||||
console.log(`📱 Checking WhatsApp number: ${phone} - Assumed valid`);
|
||||
return {
|
||||
success: true,
|
||||
isRegistered: true,
|
||||
message: 'Number is valid (dev mode)',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async sendWhatsappOtpViaWebhook(
|
||||
phone: string,
|
||||
code: string,
|
||||
mode: 'test' | 'live' = 'test',
|
||||
): Promise<void> {
|
||||
const webhookUrl =
|
||||
process.env.OTP_SEND_WEBHOOK_URL_TEST || process.env.OTP_SEND_WEBHOOK_URL;
|
||||
|
||||
if (!webhookUrl) {
|
||||
throw new Error('Webhook URL not configured');
|
||||
}
|
||||
|
||||
await axios.post(webhookUrl, {
|
||||
method: 'whatsapp',
|
||||
mode, // 'test' or 'live'
|
||||
phone,
|
||||
message: `Your Tabungin OTP code is: ${code}. This code will expire in 10 minutes.`,
|
||||
code,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,4 +6,4 @@ import { PrismaService } from './prisma.service';
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
export class PrismaModule {}
|
||||
|
||||
@@ -13,4 +13,4 @@ export class PrismaService extends PrismaClient implements OnModuleInit {
|
||||
await app.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const adminSeeder = {
|
||||
email: 'dwindi.ramadhana@gmail.com',
|
||||
password: 'tabungin2k25!@#',
|
||||
}
|
||||
|
||||
const TEMP_USER_ID =
|
||||
process.env.TEMP_USER_ID || '16b74848-daa3-4dc9-8de2-3cf59e08f8e3';
|
||||
|
||||
async function main() {
|
||||
const userId = '16b74848-daa3-4dc9-8de2-3cf59e08f8e3';
|
||||
const user = await prisma.user.upsert({
|
||||
where: { id: userId },
|
||||
where: { id: TEMP_USER_ID },
|
||||
update: {},
|
||||
create: {
|
||||
id: userId,
|
||||
id: TEMP_USER_ID,
|
||||
email: 'temp@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
// create a sample money wallet if none
|
||||
const existing = await prisma.wallet.findFirst({
|
||||
where: { userId: user.id, kind: 'money' },
|
||||
});
|
||||
const existing = await prisma.wallet.findFirst({});
|
||||
|
||||
if (!existing) {
|
||||
await prisma.wallet.create({
|
||||
@@ -38,4 +43,4 @@ main()
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,9 @@ import { z } from 'zod';
|
||||
|
||||
export const TransactionUpdateSchema = z.object({
|
||||
amount: z.number().positive().optional(),
|
||||
direction: z.enum(['in','out']).optional(),
|
||||
date: z.string().datetime().optional(), // ISO string
|
||||
direction: z.enum(['in', 'out']).optional(),
|
||||
date: z.string().datetime().optional(), // ISO string
|
||||
category: z.string().min(1).nullable().optional(),
|
||||
memo: z.string().min(1).nullable().optional(),
|
||||
});
|
||||
export type TransactionUpdateDto = z.infer<typeof TransactionUpdateSchema>;
|
||||
export type TransactionUpdateDto = z.infer<typeof TransactionUpdateSchema>;
|
||||
|
||||
@@ -1,39 +1,77 @@
|
||||
import { BadRequestException, Body, Controller, Get, Param, Post, Query, Res, Put, Delete } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Param,
|
||||
Delete,
|
||||
UseGuards,
|
||||
Query,
|
||||
Res,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
import { TransactionsService } from './transactions.service';
|
||||
import { TransactionUpdateSchema } from './transaction.dto';
|
||||
|
||||
interface RequestWithUser {
|
||||
user: {
|
||||
userId: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Controller('wallets/:walletId/transactions')
|
||||
@UseGuards(AuthGuard)
|
||||
export class TransactionsController {
|
||||
constructor(private readonly tx: TransactionsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('walletId') walletId: string) {
|
||||
return this.tx.list(walletId);
|
||||
list(@Req() req: RequestWithUser, @Param('walletId') walletId: string) {
|
||||
return this.tx.list(req.user.userId, walletId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@Req() req: RequestWithUser,
|
||||
@Param('walletId') walletId: string,
|
||||
@Body() body: { amount: number | string; direction: 'in' | 'out'; date?: string; category?: string; memo?: string }
|
||||
@Body()
|
||||
body: {
|
||||
amount: number | string;
|
||||
direction: 'in' | 'out';
|
||||
date?: string;
|
||||
category?: string;
|
||||
memo?: string;
|
||||
},
|
||||
) {
|
||||
return this.tx.create(walletId, body);
|
||||
return this.tx.create(req.user.userId, walletId, body);
|
||||
}
|
||||
|
||||
@Get('export.csv')
|
||||
async exportCsv(
|
||||
@Req() req: RequestWithUser,
|
||||
@Param('walletId') walletId: string,
|
||||
@Query('from') from: string | undefined,
|
||||
@Query('to') to: string | undefined,
|
||||
@Query('category') category: string | undefined,
|
||||
@Query('direction') direction: 'in' | 'out' | undefined,
|
||||
@Res() res: Response
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const rows = await this.tx.listWithFilters(walletId, { from, to, category, direction });
|
||||
const rows = await this.tx.listWithFilters(req.user.userId, walletId, {
|
||||
from,
|
||||
to,
|
||||
category,
|
||||
direction,
|
||||
});
|
||||
|
||||
// CSV headers
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="transactions_${walletId}.csv"`);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="transactions_${walletId}.csv"`,
|
||||
);
|
||||
|
||||
// Write CSV header row
|
||||
res.write(`date,category,memo,direction,amount\n`);
|
||||
@@ -60,17 +98,27 @@ export class TransactionsController {
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(@Param('walletId') walletId: string, @Param('id') id: string, @Body() body: unknown) {
|
||||
async update(
|
||||
@Req() req: RequestWithUser,
|
||||
@Param('walletId') walletId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
try {
|
||||
const parsed = TransactionUpdateSchema.parse(body);
|
||||
return this.tx.update(walletId, id, parsed);
|
||||
} catch (e: any) {
|
||||
throw new BadRequestException(e?.errors ?? 'Invalid payload');
|
||||
return this.tx.update(req.user.userId, walletId, id, parsed);
|
||||
} catch (e) {
|
||||
const error = e as { errors?: unknown };
|
||||
throw new BadRequestException(error?.errors ?? 'Invalid payload');
|
||||
}
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
delete(@Param('walletId') walletId: string, @Param('id') id: string) {
|
||||
return this.tx.delete(walletId, id);
|
||||
delete(
|
||||
@Req() req: RequestWithUser,
|
||||
@Param('walletId') walletId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.tx.delete(req.user.userId, walletId, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
|
||||
import { TransactionsService } from './transactions.service';
|
||||
import { TransactionsController } from './transactions.controller';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { OtpModule } from '../otp/otp.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, OtpModule],
|
||||
providers: [TransactionsService],
|
||||
controllers: [TransactionsController],
|
||||
exports: [TransactionsService],
|
||||
})
|
||||
export class TransactionsModule {}
|
||||
export class TransactionsModule {}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { getTempUserId } from '../common/user.util';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { TransactionUpdateDto } from './transaction.dto';
|
||||
|
||||
@@ -8,35 +7,37 @@ import type { TransactionUpdateDto } from './transaction.dto';
|
||||
export class TransactionsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
private userId(): string {
|
||||
return getTempUserId();
|
||||
}
|
||||
|
||||
list(walletId: string) {
|
||||
list(userId: string, walletId: string) {
|
||||
return this.prisma.transaction.findMany({
|
||||
where: { userId: this.userId(), walletId },
|
||||
where: { userId, walletId },
|
||||
orderBy: { date: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
}
|
||||
|
||||
listAll() {
|
||||
listAll(userId: string) {
|
||||
return this.prisma.transaction.findMany({
|
||||
where: { userId: this.userId() },
|
||||
where: { userId },
|
||||
orderBy: { date: 'desc' },
|
||||
take: 1000,
|
||||
});
|
||||
}
|
||||
|
||||
listWithFilters(
|
||||
userId: string,
|
||||
walletId: string,
|
||||
filters: { from?: string; to?: string; category?: string; direction?: 'in' | 'out' }
|
||||
filters: {
|
||||
from?: string;
|
||||
to?: string;
|
||||
category?: string;
|
||||
direction?: 'in' | 'out';
|
||||
},
|
||||
) {
|
||||
const where: Prisma.TransactionWhereInput = {
|
||||
userId: getTempUserId(),
|
||||
userId,
|
||||
walletId,
|
||||
};
|
||||
|
||||
|
||||
if (filters.direction) where.direction = filters.direction;
|
||||
if (filters.category) where.category = filters.category;
|
||||
if (filters.from || filters.to) {
|
||||
@@ -44,32 +45,39 @@ export class TransactionsService {
|
||||
if (filters.from) (where.date as any).gte = new Date(filters.from);
|
||||
if (filters.to) (where.date as any).lte = new Date(filters.to);
|
||||
}
|
||||
|
||||
|
||||
return this.prisma.transaction.findMany({
|
||||
where,
|
||||
orderBy: { date: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(walletId: string, input: {
|
||||
amount: string | number; direction: 'in' | 'out';
|
||||
date?: string; category?: string; memo?: string;
|
||||
}) {
|
||||
const amountNum = typeof input.amount === 'string' ? Number(input.amount) : input.amount;
|
||||
async create(
|
||||
userId: string,
|
||||
walletId: string,
|
||||
input: {
|
||||
amount: string | number;
|
||||
direction: 'in' | 'out';
|
||||
date?: string;
|
||||
category?: string;
|
||||
memo?: string;
|
||||
},
|
||||
) {
|
||||
const amountNum =
|
||||
typeof input.amount === 'string' ? Number(input.amount) : input.amount;
|
||||
if (!Number.isFinite(amountNum)) throw new Error('amount must be a number');
|
||||
|
||||
const date = input.date ? new Date(input.date) : new Date();
|
||||
|
||||
const wallet = await this.prisma.wallet.findFirst({
|
||||
where: { id: walletId, userId: this.userId(), deletedAt: null },
|
||||
where: { id: walletId, userId, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!wallet) throw new Error('wallet not found');
|
||||
|
||||
|
||||
return this.prisma.transaction.create({
|
||||
data: {
|
||||
userId: this.userId(),
|
||||
userId,
|
||||
walletId,
|
||||
amount: amountNum,
|
||||
direction: input.direction,
|
||||
@@ -80,14 +88,18 @@ export class TransactionsService {
|
||||
});
|
||||
}
|
||||
|
||||
async update(walletId: string, id: string, dto: TransactionUpdateDto) {
|
||||
async update(
|
||||
userId: string,
|
||||
walletId: string,
|
||||
id: string,
|
||||
dto: TransactionUpdateDto,
|
||||
) {
|
||||
// ensure the row exists and belongs to the current user + wallet
|
||||
const existing = await this.prisma.transaction.findFirst({
|
||||
where: { id, walletId, userId: this.userId() },
|
||||
where: { id, walletId, userId },
|
||||
});
|
||||
if (!existing) throw new Error('transaction not found');
|
||||
|
||||
|
||||
// normalize inputs
|
||||
const data: any = {};
|
||||
if (dto.amount !== undefined) data.amount = Number(dto.amount);
|
||||
@@ -95,17 +107,17 @@ export class TransactionsService {
|
||||
if (dto.category !== undefined) data.category = dto.category || null;
|
||||
if (dto.memo !== undefined) data.memo = dto.memo || null;
|
||||
if (dto.date !== undefined) data.date = new Date(dto.date);
|
||||
|
||||
|
||||
return this.prisma.transaction.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
async delete(walletId: string, id: string) {
|
||||
async delete(userId: string, walletId: string, id: string) {
|
||||
// ensure the row exists and belongs to the current user + wallet
|
||||
const existing = await this.prisma.transaction.findFirst({
|
||||
where: { id, walletId, userId: this.userId() },
|
||||
where: { id, walletId, userId },
|
||||
});
|
||||
if (!existing) throw new Error('transaction not found');
|
||||
|
||||
@@ -113,4 +125,4 @@ export class TransactionsService {
|
||||
where: { id: existing.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Controller, Get, Put, Delete, Body, Req, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
interface RequestWithUser extends Request {
|
||||
user: {
|
||||
userId: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Controller('users')
|
||||
@UseGuards(AuthGuard)
|
||||
export class UsersController {
|
||||
constructor(private readonly users: UsersService) {}
|
||||
|
||||
@@ -9,4 +18,25 @@ export class UsersController {
|
||||
me() {
|
||||
return this.users.me();
|
||||
}
|
||||
}
|
||||
|
||||
@Put('profile')
|
||||
async updateProfile(
|
||||
@Req() req: RequestWithUser,
|
||||
@Body() body: { name?: string; phone?: string },
|
||||
) {
|
||||
return this.users.updateProfile(req.user.userId, body);
|
||||
}
|
||||
|
||||
@Get('auth-info')
|
||||
async getAuthInfo(@Req() req: RequestWithUser) {
|
||||
return this.users.getAuthInfo(req.user.userId);
|
||||
}
|
||||
|
||||
@Delete('account')
|
||||
async deleteAccount(
|
||||
@Req() req: RequestWithUser,
|
||||
@Body() body: { password: string },
|
||||
) {
|
||||
return this.users.deleteAccount(req.user.userId, body.password);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,4 +9,4 @@ import { PrismaModule } from '../prisma/prisma.module';
|
||||
controllers: [UsersController],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
export class UsersModule {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { getTempUserId } from '../common/user.util';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
@@ -10,4 +11,101 @@ export class UsersService {
|
||||
const userId = getTempUserId();
|
||||
return this.prisma.user.findUnique({ where: { id: userId } });
|
||||
}
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, data: { name?: string; phone?: string }) {
|
||||
try {
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
...(data.name !== undefined && { name: data.name }),
|
||||
...(data.phone !== undefined && { phone: data.phone }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Profile updated successfully',
|
||||
user,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error.code === 'P2002') {
|
||||
throw new BadRequestException('Phone number already in use');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthInfo(userId: string) {
|
||||
// Get user with password hash and avatar
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
passwordHash: true,
|
||||
avatarUrl: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Check if user has Google OAuth (avatar from Google or starts with /avatars/)
|
||||
const hasGoogleAuth =
|
||||
user?.avatarUrl?.includes('googleusercontent.com') ||
|
||||
user?.avatarUrl?.startsWith('/avatars/') ||
|
||||
false;
|
||||
|
||||
return {
|
||||
hasGoogleAuth,
|
||||
hasPassword: user?.passwordHash !== null,
|
||||
};
|
||||
}
|
||||
|
||||
async deleteAccount(userId: string, password: string) {
|
||||
// Get user with password hash
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
passwordHash: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException('User not found');
|
||||
}
|
||||
|
||||
if (!user.passwordHash) {
|
||||
throw new BadRequestException(
|
||||
'Cannot delete account without password. Please set a password first.',
|
||||
);
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!isValid) {
|
||||
throw new UnauthorizedException('Incorrect password');
|
||||
}
|
||||
|
||||
// Delete related data first (to avoid foreign key constraint errors)
|
||||
// Delete AuthAccount records
|
||||
await this.prisma.authAccount.deleteMany({
|
||||
where: { userId: userId },
|
||||
});
|
||||
|
||||
// Delete other related data if any
|
||||
// Add more deleteMany calls here for other tables that reference User
|
||||
|
||||
// Finally, delete the user
|
||||
await this.prisma.user.delete({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Account deleted successfully',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,80 @@
|
||||
import { Body, Controller, Get, Post, Put, Delete, Param } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
UseGuards,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { WalletsService } from './wallets.service';
|
||||
import { TransactionsService } from '../transactions/transactions.service';
|
||||
import { AuthGuard } from '../auth/auth.guard';
|
||||
|
||||
interface RequestWithUser {
|
||||
user: {
|
||||
userId: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Controller('wallets')
|
||||
@UseGuards(AuthGuard)
|
||||
export class WalletsController {
|
||||
constructor(
|
||||
private readonly wallets: WalletsService,
|
||||
private readonly transactions: TransactionsService
|
||||
private readonly transactions: TransactionsService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.wallets.list();
|
||||
list(@Req() req: RequestWithUser) {
|
||||
return this.wallets.list(req.user.userId);
|
||||
}
|
||||
|
||||
@Get('transactions')
|
||||
async getAllTransactions() {
|
||||
return this.transactions.listAll();
|
||||
async getAllTransactions(@Req() req: RequestWithUser) {
|
||||
return this.transactions.listAll(req.user.userId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() body: { name: string; currency?: string; kind?: 'money' | 'asset'; unit?: string; initialAmount?: number; pricePerUnit?: number }) {
|
||||
create(
|
||||
@Req() req: RequestWithUser,
|
||||
@Body()
|
||||
body: {
|
||||
name: string;
|
||||
currency?: string;
|
||||
kind?: 'money' | 'asset';
|
||||
unit?: string;
|
||||
initialAmount?: number;
|
||||
pricePerUnit?: number;
|
||||
},
|
||||
) {
|
||||
if (!body?.name) {
|
||||
return { error: 'name is required' };
|
||||
}
|
||||
return this.wallets.create(body);
|
||||
return this.wallets.create(req.user.userId, body);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('id') id: string, @Body() body: { name?: string; currency?: string; kind?: 'money' | 'asset'; unit?: string; initialAmount?: number; pricePerUnit?: number }) {
|
||||
return this.wallets.update(id, body);
|
||||
update(
|
||||
@Req() req: RequestWithUser,
|
||||
@Param('id') id: string,
|
||||
@Body()
|
||||
body: {
|
||||
name?: string;
|
||||
currency?: string;
|
||||
kind?: 'money' | 'asset';
|
||||
unit?: string;
|
||||
initialAmount?: number;
|
||||
pricePerUnit?: number;
|
||||
},
|
||||
) {
|
||||
return this.wallets.update(req.user.userId, id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
delete(@Param('id') id: string) {
|
||||
return this.wallets.delete(id);
|
||||
delete(@Req() req: RequestWithUser, @Param('id') id: string) {
|
||||
return this.wallets.delete(req.user.userId, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,4 +10,4 @@ import { PrismaModule } from '../prisma/prisma.module';
|
||||
controllers: [WalletsController],
|
||||
exports: [WalletsService],
|
||||
})
|
||||
export class WalletsModule {}
|
||||
export class WalletsModule {}
|
||||
|
||||
@@ -1,40 +1,56 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { getTempUserId } from '../common/user.util';
|
||||
|
||||
@Injectable()
|
||||
export class WalletsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
private userId() {
|
||||
return getTempUserId();
|
||||
}
|
||||
|
||||
list() {
|
||||
list(userId: string) {
|
||||
return this.prisma.wallet.findMany({
|
||||
where: { userId: this.userId(), deletedAt: null },
|
||||
where: { userId, deletedAt: null },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
create(input: { name: string; currency?: string; kind?: 'money' | 'asset'; unit?: string; initialAmount?: number; pricePerUnit?: number }) {
|
||||
create(
|
||||
userId: string,
|
||||
input: {
|
||||
name: string;
|
||||
currency?: string;
|
||||
kind?: 'money' | 'asset';
|
||||
unit?: string;
|
||||
initialAmount?: number;
|
||||
pricePerUnit?: number;
|
||||
},
|
||||
) {
|
||||
const kind = input.kind ?? 'money';
|
||||
return this.prisma.wallet.create({
|
||||
data: {
|
||||
userId: this.userId(),
|
||||
userId,
|
||||
name: input.name,
|
||||
kind,
|
||||
currency: kind === 'money' ? (input.currency ?? 'IDR') : null,
|
||||
unit: kind === 'asset' ? (input.unit ?? null) : null,
|
||||
initialAmount: input.initialAmount || null,
|
||||
pricePerUnit: kind === 'asset' ? (input.pricePerUnit || null) : null,
|
||||
pricePerUnit: kind === 'asset' ? input.pricePerUnit || null : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
update(id: string, input: { name?: string; currency?: string; kind?: 'money' | 'asset'; unit?: string; initialAmount?: number; pricePerUnit?: number }) {
|
||||
update(
|
||||
userId: string,
|
||||
id: string,
|
||||
input: {
|
||||
name?: string;
|
||||
currency?: string;
|
||||
kind?: 'money' | 'asset';
|
||||
unit?: string;
|
||||
initialAmount?: number;
|
||||
pricePerUnit?: number;
|
||||
},
|
||||
) {
|
||||
const updateData: any = {};
|
||||
|
||||
|
||||
if (input.name !== undefined) updateData.name = input.name;
|
||||
if (input.kind !== undefined) {
|
||||
updateData.kind = input.kind;
|
||||
@@ -53,20 +69,22 @@ export class WalletsService {
|
||||
}
|
||||
|
||||
// Handle initialAmount and pricePerUnit
|
||||
if (input.initialAmount !== undefined) updateData.initialAmount = input.initialAmount || null;
|
||||
if (input.pricePerUnit !== undefined) updateData.pricePerUnit = input.pricePerUnit || null;
|
||||
if (input.initialAmount !== undefined)
|
||||
updateData.initialAmount = input.initialAmount || null;
|
||||
if (input.pricePerUnit !== undefined)
|
||||
updateData.pricePerUnit = input.pricePerUnit || null;
|
||||
|
||||
return this.prisma.wallet.update({
|
||||
where: { id, userId: this.userId() },
|
||||
where: { id, userId },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
delete(id: string) {
|
||||
delete(userId: string, id: string) {
|
||||
// Soft delete by setting deletedAt
|
||||
return this.prisma.wallet.update({
|
||||
where: { id, userId: this.userId() },
|
||||
where: { id, userId },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user