first commit

This commit is contained in:
dwindown
2025-10-09 12:52:41 +07:00
commit 0da6071eb3
205 changed files with 30980 additions and 0 deletions

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

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

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

View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

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

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

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

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

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

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

View File

@@ -0,0 +1,8 @@
import { IsString, IsNotEmpty, MaxLength } from 'class-validator';
export class CreateCategoryDto {
@IsString()
@IsNotEmpty()
@MaxLength(50)
name: string;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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