first commit
This commit is contained in:
22
apps/api/src/app.controller.spec.ts
Normal file
22
apps/api/src/app.controller.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
12
apps/api/src/app.controller.ts
Normal file
12
apps/api/src/app.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
||||
31
apps/api/src/app.module.ts
Normal file
31
apps/api/src/app.module.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import * as path from 'path';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { HealthController } from './health/health.controller';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { WalletsModule } from './wallets/wallets.module';
|
||||
import { TransactionsModule } from './transactions/transactions.module';
|
||||
import { CategoriesModule } from './categories/categories.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: [
|
||||
path.resolve(process.cwd(), '.env'),
|
||||
path.resolve(process.cwd(), '../../.env'),
|
||||
],
|
||||
}),
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
WalletsModule,
|
||||
TransactionsModule,
|
||||
CategoriesModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [],
|
||||
})
|
||||
export class AppModule {}
|
||||
8
apps/api/src/app.service.ts
Normal file
8
apps/api/src/app.service.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
36
apps/api/src/auth/auth.guard.ts
Normal file
36
apps/api/src/auth/auth.guard.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { FirebaseService } from './firebase.service';
|
||||
|
||||
@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');
|
||||
}
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
9
apps/api/src/auth/auth.module.ts
Normal file
9
apps/api/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FirebaseService } from './firebase.service';
|
||||
import { AuthGuard } from './auth.guard';
|
||||
|
||||
@Module({
|
||||
providers: [FirebaseService, AuthGuard],
|
||||
exports: [FirebaseService, AuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
65
apps/api/src/auth/firebase.service.ts
Normal file
65
apps/api/src/auth/firebase.service.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
38
apps/api/src/categories/categories.controller.ts
Normal file
38
apps/api/src/categories/categories.controller.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Delete,
|
||||
} from '@nestjs/common';
|
||||
import { CategoriesService } from '../categories/categories.service';
|
||||
import { CreateCategoryDto } from '../categories/dto/create-category.dto';
|
||||
import { getTempUserId } from '../common/user.util';
|
||||
|
||||
@Controller('categories')
|
||||
export class CategoriesController {
|
||||
constructor(private readonly categoriesService: CategoriesService) {}
|
||||
|
||||
private userId(): string {
|
||||
return getTempUserId();
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() createCategoryDto: CreateCategoryDto) {
|
||||
return this.categoriesService.create({
|
||||
...createCategoryDto,
|
||||
userId: this.userId(),
|
||||
});
|
||||
}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.categoriesService.findAll(this.userId());
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.categoriesService.remove(id, this.userId());
|
||||
}
|
||||
}
|
||||
13
apps/api/src/categories/categories.module.ts
Normal file
13
apps/api/src/categories/categories.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CategoriesService } from './categories.service';
|
||||
import { CategoriesController } from './categories.controller';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService],
|
||||
exports: [CategoriesService],
|
||||
})
|
||||
export class CategoriesModule {}
|
||||
65
apps/api/src/categories/categories.service.ts
Normal file
65
apps/api/src/categories/categories.service.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async create(data: CreateCategoryDto & { userId: string }) {
|
||||
try {
|
||||
return await this.prisma.category.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
userId: data.userId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'P2002') {
|
||||
throw new ConflictException('Category already exists');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findAll(userId: string) {
|
||||
return this.prisma.category.findMany({
|
||||
where: { userId },
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: string, userId: string) {
|
||||
const category = await this.prisma.category.findFirst({
|
||||
where: { id, userId },
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
throw new NotFoundException('Category not found');
|
||||
}
|
||||
|
||||
return this.prisma.category.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
async findOrCreate(names: string[], userId: string) {
|
||||
const categories: any[] = [];
|
||||
|
||||
for (const name of names) {
|
||||
let category = await this.prisma.category.findFirst({
|
||||
where: { name, userId },
|
||||
});
|
||||
|
||||
if (!category) {
|
||||
category = await this.prisma.category.create({
|
||||
data: { name, userId },
|
||||
});
|
||||
}
|
||||
|
||||
categories.push(category);
|
||||
}
|
||||
|
||||
return categories;
|
||||
}
|
||||
}
|
||||
8
apps/api/src/categories/dto/create-category.dto.ts
Normal file
8
apps/api/src/categories/dto/create-category.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateCategoryDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(50)
|
||||
name: string;
|
||||
}
|
||||
24
apps/api/src/common/user.util.ts
Normal file
24
apps/api/src/common/user.util.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
export function createUserDecorator() {
|
||||
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
|
||||
// This is a placeholder for a proper decorator implementation
|
||||
// In a real app, you'd create a proper parameter decorator
|
||||
};
|
||||
}
|
||||
19
apps/api/src/health/health.controller.ts
Normal file
19
apps/api/src/health/health.controller.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Get()
|
||||
ok() {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
|
||||
@Get('db')
|
||||
async db() {
|
||||
// Simple connectivity check
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
return { db: 'connected' };
|
||||
}
|
||||
}
|
||||
22
apps/api/src/main.ts
Normal file
22
apps/api/src/main.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Allow web app to call API in dev
|
||||
const webOrigin = process.env.WEB_APP_URL ?? 'http://localhost:5173';
|
||||
app.enableCors({
|
||||
origin: webOrigin,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Prefix all routes with /api
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
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}`);
|
||||
}
|
||||
bootstrap();
|
||||
9
apps/api/src/prisma/prisma.module.ts
Normal file
9
apps/api/src/prisma/prisma.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
16
apps/api/src/prisma/prisma.service.ts
Normal file
16
apps/api/src/prisma/prisma.service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { INestApplication, Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
// Optional: gracefully close Nest when Node is about to exit
|
||||
async enableShutdownHooks(app: INestApplication) {
|
||||
process.on('beforeExit', async () => {
|
||||
await app.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
41
apps/api/src/seed.ts
Normal file
41
apps/api/src/seed.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const userId = '16b74848-daa3-4dc9-8de2-3cf59e08f8e3';
|
||||
const user = await prisma.user.upsert({
|
||||
where: { id: userId },
|
||||
update: {},
|
||||
create: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
// create a sample money wallet if none
|
||||
const existing = await prisma.wallet.findFirst({
|
||||
where: { userId: user.id, kind: 'money' },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
await prisma.wallet.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
kind: 'money',
|
||||
name: 'Cash',
|
||||
currency: 'IDR',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Seed complete. TEMP_USER_ID=', user.id);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
10
apps/api/src/transactions/transaction.dto.ts
Normal file
10
apps/api/src/transactions/transaction.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
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
|
||||
category: z.string().min(1).nullable().optional(),
|
||||
memo: z.string().min(1).nullable().optional(),
|
||||
});
|
||||
export type TransactionUpdateDto = z.infer<typeof TransactionUpdateSchema>;
|
||||
76
apps/api/src/transactions/transactions.controller.ts
Normal file
76
apps/api/src/transactions/transactions.controller.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { BadRequestException, Body, Controller, Get, Param, Post, Query, Res, Put, Delete } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { TransactionsService } from './transactions.service';
|
||||
import { TransactionUpdateSchema } from './transaction.dto';
|
||||
|
||||
@Controller('wallets/:walletId/transactions')
|
||||
export class TransactionsController {
|
||||
constructor(private readonly tx: TransactionsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('walletId') walletId: string) {
|
||||
return this.tx.list(walletId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@Param('walletId') walletId: string,
|
||||
@Body() body: { amount: number | string; direction: 'in' | 'out'; date?: string; category?: string; memo?: string }
|
||||
) {
|
||||
return this.tx.create(walletId, body);
|
||||
}
|
||||
|
||||
@Get('export.csv')
|
||||
async exportCsv(
|
||||
@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
|
||||
) {
|
||||
const rows = await this.tx.listWithFilters(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"`);
|
||||
|
||||
// Write CSV header row
|
||||
res.write(`date,category,memo,direction,amount\n`);
|
||||
|
||||
// Utility to escape CSV fields
|
||||
const esc = (v: any) => {
|
||||
if (v === null || v === undefined) return '';
|
||||
const s = String(v);
|
||||
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
};
|
||||
|
||||
for (const r of rows) {
|
||||
const line = [
|
||||
r.date.toISOString(),
|
||||
esc(r.category ?? ''),
|
||||
esc(r.memo ?? ''),
|
||||
r.direction,
|
||||
r.amount.toString(),
|
||||
].join(',');
|
||||
res.write(line + '\n');
|
||||
}
|
||||
|
||||
res.end();
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
async update(@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');
|
||||
}
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
delete(@Param('walletId') walletId: string, @Param('id') id: string) {
|
||||
return this.tx.delete(walletId, id);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/transactions/transactions.module.ts
Normal file
12
apps/api/src/transactions/transactions.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TransactionsService } from './transactions.service';
|
||||
import { TransactionsController } from './transactions.controller';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [TransactionsService],
|
||||
controllers: [TransactionsController],
|
||||
exports: [TransactionsService],
|
||||
})
|
||||
export class TransactionsModule {}
|
||||
116
apps/api/src/transactions/transactions.service.ts
Normal file
116
apps/api/src/transactions/transactions.service.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class TransactionsService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
private userId(): string {
|
||||
return getTempUserId();
|
||||
}
|
||||
|
||||
list(walletId: string) {
|
||||
return this.prisma.transaction.findMany({
|
||||
where: { userId: this.userId(), walletId },
|
||||
orderBy: { date: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
}
|
||||
|
||||
listAll() {
|
||||
return this.prisma.transaction.findMany({
|
||||
where: { userId: this.userId() },
|
||||
orderBy: { date: 'desc' },
|
||||
take: 1000,
|
||||
});
|
||||
}
|
||||
|
||||
listWithFilters(
|
||||
walletId: string,
|
||||
filters: { from?: string; to?: string; category?: string; direction?: 'in' | 'out' }
|
||||
) {
|
||||
const where: Prisma.TransactionWhereInput = {
|
||||
userId: getTempUserId(),
|
||||
walletId,
|
||||
};
|
||||
|
||||
if (filters.direction) where.direction = filters.direction;
|
||||
if (filters.category) where.category = filters.category;
|
||||
if (filters.from || filters.to) {
|
||||
where.date = {};
|
||||
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;
|
||||
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 },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!wallet) throw new Error('wallet not found');
|
||||
|
||||
|
||||
return this.prisma.transaction.create({
|
||||
data: {
|
||||
userId: this.userId(),
|
||||
walletId,
|
||||
amount: amountNum,
|
||||
direction: input.direction,
|
||||
date,
|
||||
category: input.category ?? null,
|
||||
memo: input.memo ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(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() },
|
||||
});
|
||||
if (!existing) throw new Error('transaction not found');
|
||||
|
||||
|
||||
// normalize inputs
|
||||
const data: any = {};
|
||||
if (dto.amount !== undefined) data.amount = Number(dto.amount);
|
||||
if (dto.direction) data.direction = dto.direction;
|
||||
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) {
|
||||
// ensure the row exists and belongs to the current user + wallet
|
||||
const existing = await this.prisma.transaction.findFirst({
|
||||
where: { id, walletId, userId: this.userId() },
|
||||
});
|
||||
if (!existing) throw new Error('transaction not found');
|
||||
|
||||
return this.prisma.transaction.delete({
|
||||
where: { id: existing.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
12
apps/api/src/users/users.controller.ts
Normal file
12
apps/api/src/users/users.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly users: UsersService) {}
|
||||
|
||||
@Get('me')
|
||||
me() {
|
||||
return this.users.me();
|
||||
}
|
||||
}
|
||||
12
apps/api/src/users/users.module.ts
Normal file
12
apps/api/src/users/users.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { UsersController } from './users.controller';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [UsersService],
|
||||
controllers: [UsersController],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
13
apps/api/src/users/users.service.ts
Normal file
13
apps/api/src/users/users.service.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { getTempUserId } from '../common/user.util';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async me() {
|
||||
const userId = getTempUserId();
|
||||
return this.prisma.user.findUnique({ where: { id: userId } });
|
||||
}
|
||||
}
|
||||
39
apps/api/src/wallets/wallets.controller.ts
Normal file
39
apps/api/src/wallets/wallets.controller.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Body, Controller, Get, Post, Put, Delete, Param } from '@nestjs/common';
|
||||
import { WalletsService } from './wallets.service';
|
||||
import { TransactionsService } from '../transactions/transactions.service';
|
||||
|
||||
@Controller('wallets')
|
||||
export class WalletsController {
|
||||
constructor(
|
||||
private readonly wallets: WalletsService,
|
||||
private readonly transactions: TransactionsService
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.wallets.list();
|
||||
}
|
||||
|
||||
@Get('transactions')
|
||||
async getAllTransactions() {
|
||||
return this.transactions.listAll();
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
delete(@Param('id') id: string) {
|
||||
return this.wallets.delete(id);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/wallets/wallets.module.ts
Normal file
13
apps/api/src/wallets/wallets.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WalletsService } from './wallets.service';
|
||||
import { WalletsController } from './wallets.controller';
|
||||
import { TransactionsService } from '../transactions/transactions.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [WalletsService, TransactionsService],
|
||||
controllers: [WalletsController],
|
||||
exports: [WalletsService],
|
||||
})
|
||||
export class WalletsModule {}
|
||||
72
apps/api/src/wallets/wallets.service.ts
Normal file
72
apps/api/src/wallets/wallets.service.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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() {
|
||||
return this.prisma.wallet.findMany({
|
||||
where: { userId: this.userId(), deletedAt: null },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
create(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(),
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
update(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;
|
||||
// Reset currency/unit based on kind
|
||||
if (input.kind === 'money') {
|
||||
updateData.currency = input.currency ?? 'IDR';
|
||||
updateData.unit = null;
|
||||
} else {
|
||||
updateData.unit = input.unit ?? null;
|
||||
updateData.currency = null;
|
||||
}
|
||||
} else {
|
||||
// If kind is not changing, update currency/unit as provided
|
||||
if (input.currency !== undefined) updateData.currency = input.currency;
|
||||
if (input.unit !== undefined) updateData.unit = input.unit;
|
||||
}
|
||||
|
||||
// Handle initialAmount and pricePerUnit
|
||||
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() },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
delete(id: string) {
|
||||
// Soft delete by setting deletedAt
|
||||
return this.prisma.wallet.update({
|
||||
where: { id, userId: this.userId() },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user