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:
dwindown
2025-10-11 14:00:11 +07:00
parent 0da6071eb3
commit 249f3a9d7d
159 changed files with 13748 additions and 3369 deletions

View File

@@ -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 {}

View 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,
);
}
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View 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;
}
}
}

View File

@@ -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;
}
}

View 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);
}
}

View 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 };
}
}

View File

@@ -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);
}
}
}

View File

@@ -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 },

View File

@@ -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
};
}
}

View File

@@ -16,4 +16,4 @@ export class HealthController {
await this.prisma.$queryRaw`SELECT 1`;
return { db: 'connected' };
}
}
}

View File

@@ -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();

View 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;
}
}

View 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');
}
}
}

View 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 {}

View 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,
});
}
}

View File

@@ -6,4 +6,4 @@ import { PrismaService } from './prisma.service';
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
export class PrismaModule {}

View File

@@ -13,4 +13,4 @@ export class PrismaService extends PrismaClient implements OnModuleInit {
await app.close();
});
}
}
}

View File

@@ -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();
});
});

View File

@@ -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>;

View File

@@ -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);
}
}
}

View File

@@ -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 {}

View File

@@ -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 },
});
}
}
}

View File

@@ -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);
}
}

View File

@@ -9,4 +9,4 @@ import { PrismaModule } from '../prisma/prisma.module';
controllers: [UsersController],
exports: [UsersService],
})
export class UsersModule {}
export class UsersModule {}

View File

@@ -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',
};
}
}

View File

@@ -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);
}
}
}

View File

@@ -10,4 +10,4 @@ import { PrismaModule } from '../prisma/prisma.module';
controllers: [WalletsController],
exports: [WalletsService],
})
export class WalletsModule {}
export class WalletsModule {}

View File

@@ -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() },
});
}
}
}