mirror of
https://github.com/acedanger/finance.git
synced 2025-12-05 22:50:12 -08:00
Merge branch 'main' of github.com:acedanger/finance
This commit is contained in:
@@ -5,6 +5,8 @@ import { currentAccountId as currentAccountIdStore, refreshKey } from '../stores
|
||||
import type { Account } from '../types';
|
||||
import { formatCurrency } from '../utils';
|
||||
|
||||
type AccountSummaryProps = {};
|
||||
|
||||
export default function AccountSummary() {
|
||||
const currentAccountId = useStore(currentAccountIdStore);
|
||||
const refreshCounter = useStore(refreshKey);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { useStore } from '@nanostores/react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
currentAccountId as currentAccountIdStore,
|
||||
@@ -9,7 +10,9 @@ import {
|
||||
import type { Transaction } from '../types';
|
||||
import { formatCurrency, formatDate } from '../utils';
|
||||
|
||||
export default function TransactionTable() {
|
||||
type TransactionTableProps = {};
|
||||
|
||||
export default function TransactionTable({}: TransactionTableProps) {
|
||||
const currentAccountId = useStore(currentAccountIdStore);
|
||||
const refreshCounter = useStore(refreshKey);
|
||||
|
||||
|
||||
249
src/server.ts
Normal file
249
src/server.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
// src/server.ts
|
||||
|
||||
import { IncomingMessage, Server, ServerResponse } from 'http';
|
||||
import { type BankAccount, PrismaClient } from '@prisma/client';
|
||||
import dotenv from 'dotenv';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import type { ZodTypeProvider } from 'fastify-type-provider-zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Base schema for common fields, useful for reuse
|
||||
const bankAccountBaseSchema = z.object({
|
||||
name: z.string().min(1, { message: 'Name cannot be empty' }),
|
||||
bankName: z.string().min(1, { message: 'Bank name cannot be empty' }),
|
||||
accountNumber: z.string().min(1, { message: 'Account number cannot be empty' }),
|
||||
});
|
||||
|
||||
// Schema for creating a bank account (all fields required)
|
||||
const createBankAccountSchema = bankAccountBaseSchema;
|
||||
|
||||
// Schema for request parameters containing an ID
|
||||
const paramsSchema = z.object({
|
||||
// Use coerce to automatically convert string param to number
|
||||
id: z.coerce.number().int().positive({ message: 'ID must be a positive integer' }),
|
||||
});
|
||||
|
||||
// Schema for updating a bank account (all fields optional)
|
||||
const updateBankAccountSchema = bankAccountBaseSchema.partial(); // Makes all fields optional
|
||||
|
||||
// --- Fastify Server Instance with Zod Type Provider ---
|
||||
const server: FastifyInstance = Fastify({
|
||||
logger: true,
|
||||
}).withTypeProvider<ZodTypeProvider>(); // Enable Zod validation and typing
|
||||
|
||||
// --- API Routes ---
|
||||
|
||||
const API_PREFIX = '/api/bank-account';
|
||||
|
||||
// 1. Create Bank Account
|
||||
server.post(
|
||||
`${API_PREFIX}/create`,
|
||||
{
|
||||
schema: {
|
||||
// Define Zod schema for the request body
|
||||
body: createBankAccountSchema,
|
||||
},
|
||||
},
|
||||
async (request, reply): Promise<BankAccount> => {
|
||||
try {
|
||||
// request.body is now typed and validated by Zod!
|
||||
const newAccount = await prisma.bankAccount.create({
|
||||
data: request.body, // Pass validated body directly
|
||||
});
|
||||
reply.code(201);
|
||||
return newAccount;
|
||||
} catch (error: any) {
|
||||
server.log.error(error);
|
||||
if (error.code === 'P2002' && error.meta?.target?.includes('accountNumber')) {
|
||||
reply.code(409);
|
||||
const body = createBankAccountSchema.parse(request.body);
|
||||
throw new Error(`Bank account with number ${body.accountNumber} already exists.`);
|
||||
}
|
||||
reply.code(500);
|
||||
throw new Error('Failed to create bank account.');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 2. Update Bank Account
|
||||
server.post(
|
||||
`${API_PREFIX}/update/:id`,
|
||||
{
|
||||
schema: {
|
||||
// Define Zod schemas for params and body
|
||||
params: paramsSchema,
|
||||
body: updateBankAccountSchema,
|
||||
},
|
||||
},
|
||||
async (request, reply): Promise<BankAccount> => {
|
||||
try {
|
||||
// request.params.id is now a validated number
|
||||
// request.body is now a validated partial object
|
||||
const { id } = request.params;
|
||||
const updateData = request.body;
|
||||
|
||||
// Prevent updating with an empty object
|
||||
if (Object.keys(updateData).length === 0) {
|
||||
reply.code(400);
|
||||
throw new Error('Request body cannot be empty for update.');
|
||||
}
|
||||
|
||||
const updatedAccount = await prisma.bankAccount.update({
|
||||
where: { id: id }, // Use the validated numeric ID
|
||||
data: updateData,
|
||||
});
|
||||
return updatedAccount;
|
||||
} catch (error: any) {
|
||||
server.log.error(error);
|
||||
if (error.code === 'P2025') {
|
||||
// Record to update not found
|
||||
reply.code(404);
|
||||
throw new Error(`Bank account with ID ${request.params.id} not found.`);
|
||||
}
|
||||
if (error.code === 'P2002' && error.meta?.target?.includes('accountNumber')) {
|
||||
reply.code(409);
|
||||
// Access accountNumber safely as it's optional in update
|
||||
const attemptedNumber = request.body.accountNumber || '(unchanged)';
|
||||
throw new Error(`Bank account with number ${attemptedNumber} already exists.`);
|
||||
}
|
||||
// Handle Zod validation errors specifically if needed (though Fastify usually does)
|
||||
if (error instanceof z.ZodError) {
|
||||
reply.code(400);
|
||||
throw new Error(`Validation Error: ${error.errors.map((e) => e.message).join(', ')}`);
|
||||
}
|
||||
reply.code(500);
|
||||
throw new Error('Failed to update bank account.');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 3. Delete Bank Account
|
||||
server.delete(
|
||||
`${API_PREFIX}/delete/:id`,
|
||||
{
|
||||
schema: {
|
||||
// Define Zod schema for params
|
||||
params: paramsSchema,
|
||||
},
|
||||
},
|
||||
async (request, reply): Promise<{ message: string; deletedAccount: BankAccount }> => {
|
||||
try {
|
||||
// request.params.id is now a validated number
|
||||
const { id } = request.params;
|
||||
|
||||
const deletedAccount = await prisma.bankAccount.delete({
|
||||
where: { id: id }, // Use the validated numeric ID
|
||||
});
|
||||
return { message: `Bank account with ID ${id} deleted successfully.`, deletedAccount };
|
||||
} catch (error: any) {
|
||||
server.log.error(error);
|
||||
if (error.code === 'P2025') {
|
||||
// Record to delete not found
|
||||
reply.code(404);
|
||||
throw new Error(`Bank account with ID ${request.params.id} not found.`);
|
||||
}
|
||||
// Handle Zod validation errors
|
||||
if (error instanceof z.ZodError) {
|
||||
reply.code(400);
|
||||
throw new Error(`Validation Error: ${error.errors.map((e) => e.message).join(', ')}`);
|
||||
}
|
||||
reply.code(500);
|
||||
throw new Error('Failed to delete bank account.');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 4. Get All Bank Accounts
|
||||
server.get(`${API_PREFIX}/`, async (request, reply): Promise<BankAccount[]> => {
|
||||
// No input validation needed for getting all items usually
|
||||
try {
|
||||
const accounts = await prisma.bankAccount.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return accounts;
|
||||
} catch (error: any) {
|
||||
server.log.error(error);
|
||||
reply.code(500);
|
||||
throw new Error('Failed to retrieve bank accounts.');
|
||||
}
|
||||
});
|
||||
|
||||
// Optional: Get Single Bank Account by ID
|
||||
server.get(
|
||||
`${API_PREFIX}/:id`,
|
||||
{
|
||||
schema: {
|
||||
// Define Zod schema for params
|
||||
params: paramsSchema,
|
||||
},
|
||||
},
|
||||
async (request, reply): Promise<BankAccount> => {
|
||||
try {
|
||||
// request.params.id is now a validated number
|
||||
const { id } = request.params;
|
||||
|
||||
const account = await prisma.bankAccount.findUnique({
|
||||
where: { id: id }, // Use the validated numeric ID
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
reply.code(404);
|
||||
throw new Error(`Bank account with ID ${id} not found.`);
|
||||
}
|
||||
return account;
|
||||
} catch (error: any) {
|
||||
// Handle Zod validation errors (though should be caught by Fastify earlier)
|
||||
if (error instanceof z.ZodError) {
|
||||
reply.code(400);
|
||||
throw new Error(`Validation Error: ${error.errors.map((e) => e.message).join(', ')}`);
|
||||
}
|
||||
// If Prisma throws or other errors occur after validation
|
||||
if (!reply.sent) {
|
||||
// Specific check for Prisma's RecordNotFound (though findUnique returns null, not throws P2025 by default)
|
||||
// The !account check above handles the "not found" case for findUnique
|
||||
|
||||
server.log.error(error); // Log other unexpected errors
|
||||
reply.code(500);
|
||||
throw new Error('Failed to retrieve bank account.');
|
||||
}
|
||||
// If reply already sent (e.g., 404), just rethrow the original error
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// --- Graceful Shutdown ---
|
||||
const gracefulShutdown = async (signal: string) => {
|
||||
console.log(`*^! Received signal ${signal}. Shutting down...`);
|
||||
try {
|
||||
await server.close();
|
||||
console.log('Fastify server closed.');
|
||||
await prisma.$disconnect();
|
||||
console.log('Prisma client disconnected.');
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error('Error during shutdown:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||
|
||||
// --- Start Server (unchanged) ---
|
||||
const start = async () => {
|
||||
try {
|
||||
const host = process.env.API_HOST || '0.0.0.0';
|
||||
const port = Number.parseInt(process.env.API_PORT || '3000', 10);
|
||||
await server.listen({ port, host });
|
||||
} catch (err) {
|
||||
server.log.error(err);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
start();
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { APIContext } from 'astro';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { listAccounts } from '../pages/api/accounts';
|
||||
import { getAccount } from '../pages/api/accounts/[id]';
|
||||
import { listTransactions } from '../pages/api/accounts/[id]/transactions';
|
||||
import { GET as getAccount } from '../pages/api/accounts/[id]/index';
|
||||
import { GET as listTransactions } from '../pages/api/accounts/[id]/transactions/index';
|
||||
import { GET as listAccounts } from '../pages/api/accounts/index';
|
||||
import { createMockAPIContext } from './setup';
|
||||
|
||||
describe('Accounts API', () => {
|
||||
|
||||
@@ -7,12 +7,13 @@
|
||||
// - Add load testing for API endpoints
|
||||
// - Implement test data factories
|
||||
|
||||
import type { APIContext } from 'astro';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { accounts, transactions } from '../data/store';
|
||||
import { createTransaction } from '../pages/api/transactions';
|
||||
import { updateTransaction } from '../pages/api/transactions/[id]';
|
||||
import { DELETE as deleteTransaction } from '../pages/api/transactions/[id]/index';
|
||||
import {
|
||||
DELETE as deleteTransaction,
|
||||
PUT as updateTransaction,
|
||||
} from '../pages/api/transactions/[id]/index';
|
||||
import { POST as createTransaction } from '../pages/api/transactions/index';
|
||||
import type { Transaction } from '../types';
|
||||
import { createMockAPIContext } from './setup';
|
||||
|
||||
@@ -272,7 +273,7 @@ describe('Transactions API', () => {
|
||||
const initialCount = transactions.length;
|
||||
|
||||
const response = await deleteTransaction(
|
||||
createMockAPIContext({ params: { id: '1' } }) as APIContext,
|
||||
createMockAPIContext({ params: { id: '1' } }) as any,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
@@ -291,7 +292,7 @@ describe('Transactions API', () => {
|
||||
|
||||
it('should return 404 for non-existent transaction', async () => {
|
||||
const response = await deleteTransaction(
|
||||
createMockAPIContext({ params: { id: '999' } }) as APIContext,
|
||||
createMockAPIContext({ params: { id: '999' } }) as any,
|
||||
);
|
||||
|
||||
const error = await response.json();
|
||||
@@ -312,7 +313,7 @@ describe('Transactions API', () => {
|
||||
transactions.push(testTransaction);
|
||||
|
||||
const response = await deleteTransaction(
|
||||
createMockAPIContext({ params: { id: 'test-delete' } }) as APIContext,
|
||||
createMockAPIContext({ params: { id: 'test-delete' } }) as any,
|
||||
);
|
||||
|
||||
const error = await response.json();
|
||||
|
||||
Reference in New Issue
Block a user