transitioned from astro to react

This commit is contained in:
Peter Wood
2025-06-04 21:03:32 -04:00
parent 570ed2d1b4
commit 52547578a7
32 changed files with 2631 additions and 4865 deletions

283
server/data/db.service.ts Normal file
View File

@@ -0,0 +1,283 @@
import type { Account, Transaction } from '../types.js';
import { prisma } from './prisma';
// Define the enums ourselves since Prisma isn't exporting them
export enum AccountType {
CHECKING = 'CHECKING',
SAVINGS = 'SAVINGS',
CREDIT_CARD = 'CREDIT_CARD',
INVESTMENT = 'INVESTMENT',
OTHER = 'OTHER',
}
export enum AccountStatus {
ACTIVE = 'ACTIVE',
CLOSED = 'CLOSED',
}
export enum TransactionStatus {
PENDING = 'PENDING',
CLEARED = 'CLEARED',
}
export enum TransactionType {
DEPOSIT = 'DEPOSIT',
WITHDRAWAL = 'WITHDRAWAL',
TRANSFER = 'TRANSFER',
UNSPECIFIED = 'UNSPECIFIED',
}
// Account services
export const accountService = {
/**
* Get all accounts
*/
async getAll(): Promise<Account[]> {
return prisma.account.findMany({
orderBy: { name: 'asc' },
}) as Promise<Account[]>;
},
/**
* Get account by ID
*/
async getById(id: string): Promise<Account | null> {
return prisma.account.findUnique({
where: { id },
}) as Promise<Account | null>;
},
/**
* Create a new account
*/
async create(data: {
bankName: string;
accountNumber: string;
name: string;
type?: AccountType;
status?: AccountStatus;
currency?: string;
balance?: number;
notes?: string;
}): Promise<Account> {
return prisma.account.create({
data,
}) as Promise<Account>;
},
/**
* Update an account
*/
async update(
id: string,
data: {
bankName?: string;
accountNumber?: string;
name?: string;
type?: AccountType;
status?: AccountStatus;
currency?: string;
balance?: number;
notes?: string;
},
): Promise<Account | null> {
return prisma.account.update({
where: { id },
data,
}) as Promise<Account>;
},
/**
* Delete an account
*/
async delete(id: string): Promise<Account | null> {
return prisma.account.delete({
where: { id },
}) as Promise<Account>;
},
/**
* Update account balance
*/
async updateBalance(id: string, amount: number): Promise<Account | null> {
const account = await prisma.account.findUnique({
where: { id },
});
if (!account) return null;
return prisma.account.update({
where: { id },
data: {
balance: {
increment: amount,
},
},
}) as Promise<Account>;
},
/**
* Get transactions for an account
*/
async getTransactions(accountId: string): Promise<Transaction[]> {
return prisma.transaction.findMany({
where: { accountId },
orderBy: { date: 'desc' },
}) as Promise<Transaction[]>;
},
};
// Transaction services
export const transactionService = {
/**
* Get all transactions
*/
async getAll(): Promise<Transaction[]> {
return prisma.transaction.findMany({
orderBy: { date: 'desc' },
}) as Promise<Transaction[]>;
},
/**
* Get transactions by account ID
*/
async getByAccountId(accountId: string): Promise<Transaction[]> {
return prisma.transaction.findMany({
where: { accountId },
orderBy: { date: 'desc' },
}) as Promise<Transaction[]>;
},
/**
* Get transaction by ID
*/
async getById(id: string): Promise<Transaction | null> {
return prisma.transaction.findUnique({
where: { id },
}) as Promise<Transaction | null>;
},
/**
* Create a new transaction and update account balance
*/
async create(data: {
accountId: string;
date: Date;
description: string;
amount: number;
category?: string;
status?: TransactionStatus;
type?: TransactionType;
notes?: string;
tags?: string;
}): Promise<Transaction> {
// Use a transaction to ensure data consistency
return prisma.$transaction<Transaction>(async (tx) => {
// Create the transaction
const transaction = await tx.transaction.create({
data,
});
// Update the account balance
await tx.account.update({
where: { id: data.accountId },
data: {
balance: {
increment: data.amount,
},
},
});
return transaction as Transaction;
});
},
/**
* Update a transaction and adjust account balance
*/
async update(
id: string,
data: {
accountId?: string;
date?: Date;
description?: string;
amount?: number;
category?: string;
status?: TransactionStatus;
type?: TransactionType;
notes?: string;
tags?: string;
},
): Promise<Transaction | null> {
// If amount is changing, we need to adjust the account balance
if (typeof data.amount !== 'undefined') {
return prisma.$transaction<Transaction | null>(async (tx) => {
// Get the current transaction to calculate difference
const currentTxn = await tx.transaction.findUnique({
where: { id },
});
if (!currentTxn) return null;
// Amount is guaranteed to be defined at this point since we checked above
const amount = data.amount as number; // Use type assertion instead of non-null assertion
const amountDifference = amount - Number(currentTxn.amount);
// Update transaction
const updatedTxn = await tx.transaction.update({
where: { id },
data,
});
// Update account balance
await tx.account.update({
where: { id: data.accountId || currentTxn.accountId },
data: {
balance: {
increment: amountDifference,
},
},
});
return updatedTxn as Transaction;
});
}
// If amount isn't changing, just update the transaction
return prisma.transaction.update({
where: { id },
data,
}) as Promise<Transaction>;
},
/**
* Delete a transaction and adjust account balance
*/
async delete(id: string): Promise<Transaction | null> {
return prisma.$transaction<Transaction | null>(async (tx) => {
// Get transaction before deleting
const transaction = await tx.transaction.findUnique({
where: { id },
});
if (!transaction) return null;
// Delete the transaction
const deletedTxn = await tx.transaction.delete({
where: { id },
});
// Adjust the account balance (reverse the transaction amount)
await tx.account.update({
where: { id: transaction.accountId },
data: {
balance: {
decrement: Number(transaction.amount),
},
},
});
return deletedTxn as Transaction;
});
},
};

13
server/data/prisma.ts Normal file
View File

@@ -0,0 +1,13 @@
import { PrismaClient } from '@prisma/client';
// Prevent multiple instances of Prisma Client in development
declare global {
// eslint-disable-next-line no-var
var prismaClient: PrismaClient | undefined;
}
export const prisma = global.prismaClient || new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
global.prismaClient = prisma;
}

39
server/index.ts Normal file
View File

@@ -0,0 +1,39 @@
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import cors from 'cors';
import { config } from 'dotenv';
import express from 'express';
// Load environment variables
config();
// Import API routes
import accountsRouter from './routes/accounts.js';
import transactionsRouter from './routes/transactions.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(cors());
app.use(express.json());
// API routes
app.use('/api/accounts', accountsRouter);
app.use('/api/transactions', transactionsRouter);
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
app.use(express.static(join(__dirname, '../client')));
app.get('*', (req, res) => {
res.sendFile(join(__dirname, '../client/index.html'));
});
}
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

98
server/routes/accounts.ts Normal file
View File

@@ -0,0 +1,98 @@
import { Router } from 'express';
import { AccountStatus, AccountType, accountService } from '../data/db.service.js';
import type { Account } from '../types.js';
const router = Router();
// GET /api/accounts - Get all accounts
// biome-ignore lint/suspicious/noExplicitAny: Express handler types require any for req/res
router.get('/', async (req: any, res: any) => {
try {
console.log('GET /api/accounts - Fetching all accounts');
const accounts = await accountService.getAll();
console.log('GET /api/accounts - Found accounts:', accounts?.length ?? 0);
res.json(accounts || []);
} catch (error) {
console.error('Error fetching accounts:', error);
res.status(500).json({
error: 'Failed to fetch accounts',
details: error instanceof Error ? error.message : 'Unknown error',
});
}
});
// POST /api/accounts - Create new account
// biome-ignore lint/suspicious/noExplicitAny: Express handler types require any for req/res
router.post('/', async (req: any, res: any) => {
try {
const accountData = req.body as Omit<Account, 'id' | 'createdAt' | 'updatedAt'>;
// Validate required fields
if (!accountData.name || !accountData.bankName || !accountData.accountNumber) {
return res.status(400).json({
error: 'Missing required fields: name, bankName, and accountNumber are required',
});
}
// Set default values and ensure proper type casting
const data = {
...accountData,
balance: accountData.balance ? Number(accountData.balance) : 0,
type: (accountData.type as AccountType) || AccountType.CHECKING,
status: (accountData.status as AccountStatus) || AccountStatus.ACTIVE,
notes: accountData.notes || undefined,
};
// Create the account
const newAccount = await accountService.create(data);
res.status(201).json(newAccount);
} catch (error) {
console.error('Error creating account:', error);
res.status(500).json({ error: 'Failed to create account' });
}
});
// GET /api/accounts/:id - Get single account
// biome-ignore lint/suspicious/noExplicitAny: Express handler types require any for req/res
router.get('/:id', async (req: any, res: any) => {
try {
const { id } = req.params;
if (!id) {
return res.status(400).json({ error: 'Account ID is required' });
}
const account = await accountService.getById(id);
if (!account) {
return res.status(404).json({ error: 'Account not found' });
}
res.json(account);
} catch (error) {
console.error('Error fetching account:', error);
res.status(500).json({ error: 'Failed to fetch account' });
}
});
// GET /api/accounts/:id/transactions - Get transactions for account
// biome-ignore lint/suspicious/noExplicitAny: Express handler types require any for req/res
router.get('/:id/transactions', async (req: any, res: any) => {
try {
const { id } = req.params;
if (!id) {
return res.status(400).json({ error: 'Account ID is required' });
}
const transactions = await accountService.getTransactions(id);
res.json(transactions || []);
} catch (error) {
console.error('Error fetching account transactions:', error);
res.status(500).json({ error: 'Failed to fetch account transactions' });
}
});
export default router;

View File

@@ -0,0 +1,189 @@
import { Router } from 'express';
import { accountService, transactionService } from '../data/db.service.js';
import type { Transaction, TransactionStatus, TransactionType } from '../types.js';
const router = Router();
// Helper function to transform update data
function transformUpdateData(updateData: Partial<Omit<Transaction, 'id'>>) {
const typedUpdateData: {
accountId?: string;
date?: Date;
description?: string;
amount?: number;
category?: string;
status?: TransactionStatus;
type?: TransactionType;
notes?: string;
tags?: string;
} = {};
// Convert string date to Date object if provided
if (updateData.date) {
typedUpdateData.date =
typeof updateData.date === 'string' ? new Date(updateData.date) : updateData.date;
}
// Convert amount to number if provided
if (updateData.amount !== undefined) {
typedUpdateData.amount = Number(updateData.amount);
}
// Copy other fields
if (updateData.accountId) typedUpdateData.accountId = updateData.accountId;
if (updateData.description) typedUpdateData.description = updateData.description;
if (updateData.category !== undefined)
typedUpdateData.category = updateData.category || undefined;
if (updateData.status) typedUpdateData.status = updateData.status as TransactionStatus;
if (updateData.type) typedUpdateData.type = updateData.type as TransactionType;
if (updateData.notes !== undefined) typedUpdateData.notes = updateData.notes || undefined;
if (updateData.tags !== undefined) typedUpdateData.tags = updateData.tags || undefined;
return typedUpdateData;
}
// POST /api/transactions - Create new transaction
// biome-ignore lint/suspicious/noExplicitAny: Express handler types require any for req/res
router.post('/', async (req: any, res: any) => {
try {
const transaction = req.body as Omit<Transaction, 'id'>;
// Validate required fields
if (
!transaction.accountId ||
!transaction.date ||
!transaction.description ||
transaction.amount === undefined
) {
return res.status(400).json({ error: 'Missing required fields' });
}
// Validate account exists
const account = await accountService.getById(transaction.accountId);
if (!account) {
return res.status(404).json({ error: 'Account not found' });
}
// Convert string date to Date object if needed
const transactionDate =
typeof transaction.date === 'string' ? new Date(transaction.date) : transaction.date;
// Create new transaction with database service
const newTransaction = await transactionService.create({
accountId: transaction.accountId,
date: transactionDate,
description: transaction.description,
amount: Number(transaction.amount),
category: transaction.category || undefined,
status: transaction.status as TransactionStatus,
type: transaction.type as TransactionType,
notes: transaction.notes || undefined,
tags: transaction.tags || undefined,
});
// Convert Decimal to number for response
const response = {
...newTransaction,
amount: Number(newTransaction.amount),
};
res.status(201).json(response);
} catch (error) {
console.error('Error creating transaction:', error);
res.status(500).json({ error: 'Failed to create transaction' });
}
});
// GET /api/transactions/:id - Get single transaction
// biome-ignore lint/suspicious/noExplicitAny: Express handler types require any for req/res
router.get('/:id', async (req: any, res: any) => {
try {
const { id } = req.params;
if (!id) {
return res.status(400).json({ error: 'Transaction ID is required' });
}
const transaction = await transactionService.getById(id);
if (!transaction) {
return res.status(404).json({ error: 'Transaction not found' });
}
// Convert Decimal to number for response
const response = {
...transaction,
amount: Number(transaction.amount),
};
res.json(response);
} catch (error) {
console.error('Error fetching transaction:', error);
res.status(500).json({ error: 'Failed to fetch transaction' });
}
});
// PUT /api/transactions/:id - Update transaction
// biome-ignore lint/suspicious/noExplicitAny: Express handler types require any for req/res
router.put('/:id', async (req: any, res: any) => {
try {
const { id } = req.params;
if (!id) {
return res.status(400).json({ error: 'Transaction ID is required' });
}
// Check if transaction exists
const existingTransaction = await transactionService.getById(id);
if (!existingTransaction) {
return res.status(404).json({ error: 'Transaction not found' });
}
const updateData = req.body as Partial<Omit<Transaction, 'id'>>;
const typedUpdateData = transformUpdateData(updateData);
const updatedTransaction = await transactionService.update(id, typedUpdateData);
if (!updatedTransaction) {
return res.status(404).json({ error: 'Transaction not found or could not be updated' });
}
// Convert Decimal to number for response
const response = {
...updatedTransaction,
amount: Number(updatedTransaction.amount),
};
res.json(response);
} catch (error) {
console.error('Error updating transaction:', error);
res.status(500).json({ error: 'Failed to update transaction' });
}
});
// DELETE /api/transactions/:id - Delete transaction
// biome-ignore lint/suspicious/noExplicitAny: Express handler types require any for req/res
router.delete('/:id', async (req: any, res: any) => {
try {
const { id } = req.params;
if (!id) {
return res.status(400).json({ error: 'Transaction ID is required' });
}
// Check if transaction exists
const existingTransaction = await transactionService.getById(id);
if (!existingTransaction) {
return res.status(404).json({ error: 'Transaction not found' });
}
await transactionService.delete(id);
res.status(204).send();
} catch (error) {
console.error('Error deleting transaction:', error);
res.status(500).json({ error: 'Failed to delete transaction' });
}
});
export default router;

30
server/tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "../dist/server",
"rootDir": ".",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"types": [
"node"
],
"resolveJsonModule": true,
"allowJs": true
},
"include": [
"**/*"
],
"exclude": [
"node_modules",
"../dist",
"../src"
]
}

56
server/types.ts Normal file
View File

@@ -0,0 +1,56 @@
import type { Decimal } from '@prisma/client/runtime/library';
export interface Account {
id: string;
bankName: string;
accountNumber: string; // Last 6 digits
name: string; // Friendly name
type?: string; // CHECKING, SAVINGS, etc.
status?: string; // ACTIVE, CLOSED
currency?: string; // Default: USD
balance: number | Decimal; // Current balance - can be Prisma Decimal or number
notes?: string | null; // Optional notes - accepts null for Prisma compatibility
createdAt?: Date;
updatedAt?: Date;
}
export interface Transaction {
id: string;
accountId: string;
date: string | Date; // ISO date string or Date object
description: string;
amount: number | Decimal; // Amount - can be Prisma Decimal or number
category?: string | null; // Optional category - accepts null for Prisma compatibility
status?: string; // PENDING, CLEARED
type?: string; // DEPOSIT, WITHDRAWAL, TRANSFER
notes?: string | null; // Optional notes - accepts null for Prisma compatibility
tags?: string | null; // Optional comma-separated tags - accepts null for Prisma compatibility
createdAt?: Date;
updatedAt?: Date;
}
// Type definitions for Transaction status and type enums to match db.service.ts
export enum TransactionStatus {
PENDING = 'PENDING',
CLEARED = 'CLEARED',
}
export enum TransactionType {
DEPOSIT = 'DEPOSIT',
WITHDRAWAL = 'WITHDRAWAL',
TRANSFER = 'TRANSFER',
UNSPECIFIED = 'UNSPECIFIED',
}
export enum AccountType {
CHECKING = 'CHECKING',
SAVINGS = 'SAVINGS',
CREDIT_CARD = 'CREDIT_CARD',
INVESTMENT = 'INVESTMENT',
OTHER = 'OTHER',
}
export enum AccountStatus {
ACTIVE = 'ACTIVE',
CLOSED = 'CLOSED',
}