diff --git a/src/components/AccountSummary.tsx b/src/components/AccountSummary.tsx index 96158f0..4c38849 100644 --- a/src/components/AccountSummary.tsx +++ b/src/components/AccountSummary.tsx @@ -1,12 +1,11 @@ -import React, { useState, useEffect } from 'react'; import { useStore } from '@nanostores/react'; +import type React from 'react'; +import { useEffect, useState } from 'react'; +import { currentAccountId as currentAccountIdStore, refreshKey } from '../stores/transactionStore'; import type { Account } from '../types'; import { formatCurrency } from '../utils'; -import { currentAccountId as currentAccountIdStore, refreshKey } from '../stores/transactionStore'; -interface AccountSummaryProps { - // No props needed, data comes from store and fetch -} +type AccountSummaryProps = {}; export default function AccountSummary({}: AccountSummaryProps) { const currentAccountId = useStore(currentAccountIdStore); diff --git a/src/components/AddTransactionForm.tsx b/src/components/AddTransactionForm.tsx index 03f6b1b..230838c 100644 --- a/src/components/AddTransactionForm.tsx +++ b/src/components/AddTransactionForm.tsx @@ -1,16 +1,17 @@ -import React, { useState, useEffect } from 'react'; import { useStore } from '@nanostores/react'; -import type { Transaction } from '../types'; +import type React from 'react'; +import { useEffect, useState } from 'react'; // Import store atoms and actions import { - currentAccountId as currentAccountIdStore, - transactionToEdit as transactionToEditStore, cancelEditingTransaction, + currentAccountId as currentAccountIdStore, transactionSaved, + transactionToEdit as transactionToEditStore, } from '../stores/transactionStore'; +import type { Transaction } from '../types'; // Remove props that now come from the store -interface AddTransactionFormProps {} +type AddTransactionFormProps = {}; export default function AddTransactionForm({}: AddTransactionFormProps) { // --- Read state from store --- @@ -87,7 +88,7 @@ export default function AddTransactionForm({}: AddTransactionFormProps) { if (!amount) { errors.push('Amount is required'); } else { - const amountNum = parseFloat(amount); + const amountNum = Number.parseFloat(amount); if (isNaN(amountNum)) { errors.push('Amount must be a valid number'); } else if (amountNum === 0) { @@ -134,7 +135,7 @@ export default function AddTransactionForm({}: AddTransactionFormProps) { accountId: currentAccountId, date: date, // Send as YYYY-MM-DD string description: description.trim(), - amount: parseFloat(amount), + amount: Number.parseFloat(amount), }; const method = editingId ? 'PUT' : 'POST'; diff --git a/src/components/TransactionTable.tsx b/src/components/TransactionTable.tsx index ff4ce84..4a19a79 100644 --- a/src/components/TransactionTable.tsx +++ b/src/components/TransactionTable.tsx @@ -1,15 +1,15 @@ -import React, { useState, useEffect } from 'react'; import { useStore } from '@nanostores/react'; +import React, { useState, useEffect } from 'react'; +import { + currentAccountId as currentAccountIdStore, + refreshKey, + startEditingTransaction, + triggerRefresh, +} from '../stores/transactionStore'; import type { Transaction } from '../types'; import { formatCurrency, formatDate } from '../utils'; -import { - startEditingTransaction, - currentAccountId as currentAccountIdStore, - triggerRefresh, - refreshKey, -} from '../stores/transactionStore'; -interface TransactionTableProps {} +type TransactionTableProps = {}; export default function TransactionTable({}: TransactionTableProps) { const currentAccountId = useStore(currentAccountIdStore); @@ -47,7 +47,7 @@ export default function TransactionTable({}: TransactionTableProps) { }, [currentAccountId, refreshCounter]); const sortedTransactions = [...transactions].sort( - (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() + (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(), ); const handleDelete = async (txnId: string) => { @@ -76,7 +76,7 @@ export default function TransactionTable({}: TransactionTableProps) { console.log(`Transaction ${txnId} deleted successfully.`); setTransactions((currentTransactions) => - currentTransactions.filter((txn) => txn.id !== txnId) + currentTransactions.filter((txn) => txn.id !== txnId), ); triggerRefresh(); diff --git a/src/pages/api/transactions/[id]/index.ts b/src/pages/api/transactions/[id]/index.ts index 5fd1f24..e0c3266 100644 --- a/src/pages/api/transactions/[id]/index.ts +++ b/src/pages/api/transactions/[id]/index.ts @@ -1,5 +1,5 @@ import type { APIRoute } from 'astro'; -import { transactions, accounts } from '../../../../data/store'; +import { accounts, transactions } from '../../../../data/store'; import type { Transaction } from '../../../../types'; export const PUT: APIRoute = async ({ request, params }) => { diff --git a/src/pages/api/transactions/index.ts b/src/pages/api/transactions/index.ts index 212b461..57e1814 100644 --- a/src/pages/api/transactions/index.ts +++ b/src/pages/api/transactions/index.ts @@ -11,7 +11,7 @@ */ import type { APIRoute } from 'astro'; -import { transactions, accounts } from '../../../data/store'; +import { accounts, transactions } from '../../../data/store'; import type { Transaction } from '../../../types'; /** diff --git a/src/server.ts b/src/server.ts index 8b62898..52310d9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,11 +1,11 @@ // src/server.ts -import Fastify, { FastifyInstance } from 'fastify'; -import { Server, IncomingMessage, ServerResponse } from 'http'; -import { PrismaClient, BankAccount } from '@prisma/client'; +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'; -import { ZodTypeProvider } from 'fastify-type-provider-zod'; dotenv.config(); @@ -13,9 +13,9 @@ 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" }), + 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) @@ -23,8 +23,8 @@ 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" }) + // 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) @@ -32,7 +32,7 @@ const updateBankAccountSchema = bankAccountBaseSchema.partial(); // Makes all fi // --- Fastify Server Instance with Zod Type Provider --- const server: FastifyInstance = Fastify({ - logger: true + logger: true, }).withTypeProvider(); // Enable Zod validation and typing // --- API Routes --- @@ -41,206 +41,209 @@ 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 - } + `${API_PREFIX}/create`, + { + schema: { + // Define Zod schema for the request body + body: createBankAccountSchema, }, - async (request, reply): Promise => { - 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.'); - } + }, + async (request, reply): Promise => { + 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 - } + `${API_PREFIX}/update/:id`, + { + schema: { + // Define Zod schemas for params and body + params: paramsSchema, + body: updateBankAccountSchema, }, - async (request, reply): Promise => { - 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; + }, + async (request, reply): Promise => { + 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."); - } + // 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.'); - } + 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 - } + `${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; + }, + 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.'); - } + 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 => { - // 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.'); - } - } -); +server.get(`${API_PREFIX}/`, async (request, reply): Promise => { + // 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 - } + `${API_PREFIX}/:id`, + { + schema: { + // Define Zod schema for params + params: paramsSchema, }, - async (request, reply): Promise => { - try { - // request.params.id is now a validated number - const { id } = request.params; + }, + async (request, reply): Promise => { + 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 - }); + 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 + 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; - } + 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); - } + 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 = parseInt(process.env.API_PORT || '3000', 10); - await server.listen({ port, host }); - } catch (err) { - server.log.error(err); - await prisma.$disconnect(); - process.exit(1); - } + 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(); diff --git a/src/test/accounts.test.ts b/src/test/accounts.test.ts index 34b37e3..94aeb49 100644 --- a/src/test/accounts.test.ts +++ b/src/test/accounts.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect } from 'vitest'; -import { GET as listAccounts } from '../pages/api/accounts/index'; +import { describe, expect, it } from 'vitest'; 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', () => { @@ -48,7 +48,7 @@ describe('Accounts API', () => { it('should return empty array for account with no transactions', async () => { const response = await listTransactions( - createMockAPIContext({ params: { id: '999' } }) as any + createMockAPIContext({ params: { id: '999' } }) as any, ); const transactions = await response.json(); diff --git a/src/test/setup.ts b/src/test/setup.ts index 9764888..2a41165 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,6 +1,6 @@ +import type { APIContext } from 'astro'; import { beforeEach } from 'vitest'; import { accounts, transactions } from '../data/store'; -import type { APIContext } from 'astro'; // Create a mock APIContext factory export function createMockAPIContext = Record>({ @@ -43,7 +43,7 @@ beforeEach(() => { name: 'Test Savings', last4: '5678', balance: 5000.0, - } + }, ); // Reset transactions to initial state @@ -62,6 +62,6 @@ beforeEach(() => { date: '2025-04-24', description: 'Test Transaction 2', amount: 100.0, - } + }, ); }); diff --git a/src/test/transactions.test.ts b/src/test/transactions.test.ts index bb5da92..ff659db 100644 --- a/src/test/transactions.test.ts +++ b/src/test/transactions.test.ts @@ -7,13 +7,13 @@ // - Add load testing for API endpoints // - Implement test data factories -import { describe, it, expect } from 'vitest'; -import { POST as createTransaction } from '../pages/api/transactions/index'; -import { - PUT as updateTransaction, - DELETE as deleteTransaction, -} from '../pages/api/transactions/[id]/index'; +import { describe, expect, it } from 'vitest'; import { accounts, transactions } from '../data/store'; +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'; @@ -273,7 +273,7 @@ describe('Transactions API', () => { const initialCount = transactions.length; const response = await deleteTransaction( - createMockAPIContext({ params: { id: '1' } }) as any + createMockAPIContext({ params: { id: '1' } }) as any, ); expect(response.status).toBe(204); @@ -292,7 +292,7 @@ describe('Transactions API', () => { it('should return 404 for non-existent transaction', async () => { const response = await deleteTransaction( - createMockAPIContext({ params: { id: '999' } }) as any + createMockAPIContext({ params: { id: '999' } }) as any, ); const error = await response.json(); @@ -313,7 +313,7 @@ describe('Transactions API', () => { transactions.push(testTransaction); const response = await deleteTransaction( - createMockAPIContext({ params: { id: 'test-delete' } }) as any + createMockAPIContext({ params: { id: 'test-delete' } }) as any, ); const error = await response.json();