diff --git a/client/src/types/Income.ts b/client/src/types/Income.ts new file mode 100644 index 0000000..14aeec4 --- /dev/null +++ b/client/src/types/Income.ts @@ -0,0 +1,37 @@ +export interface Income { + income_id: number; + amount: number; + date: string; + source: string; + description?: string; + creation_date: string; + user_id: number; + category_id: number; + category?: IncomeCategory; +} + +export interface IncomeCategory { + category_id: number; + category_name: string; + icon_url?: string; + is_custom: boolean; + user_id?: number; + created_at: string; + updated_at: string; +} + +export interface CreateIncomeRequest { + amount: number; + date: string; + source: string; + description?: string; + category_id: number; +} + +export interface UpdateIncomeRequest { + amount?: number; + date?: string; + source?: string; + description?: string; + category_id?: number; +} \ No newline at end of file diff --git a/server/controllers/income.controller.js b/server/controllers/income.controller.js new file mode 100644 index 0000000..2b8dac4 --- /dev/null +++ b/server/controllers/income.controller.js @@ -0,0 +1,154 @@ +import incomeService from "../services/income.service.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; + +//---------------INCOME---------------// + +export const createIncome = asyncHandler(async (req, res) => { + const { amount, date, source, description, category_id } = req.body; + + if (!amount || !source || !category_id) { + return res.status(400).json({ error: "Missing required fields" }); + } + + const incomeData = { + amount: parseFloat(amount), + source, + description, + category: { connect: { category_id: parseInt(category_id) } }, + }; + + if (date) { + const parsedDate = new Date(date); + if (!isNaN(parsedDate.getTime())) { + incomeData.date = parsedDate; + } + } + + const income = await incomeService.createIncome(req.user.user_id, incomeData); + res.status(201).json(income); +}); + +export const getIncomes = asyncHandler(async (req, res) => { + const { start, end } = req.query; + const filters = {}; + + if (start) filters.start = new Date(start); + if (end) filters.end = new Date(end); + + const incomes = await incomeService.getIncomes(req.user.user_id, filters); + res.json(incomes); +}); + +export const getIncome = asyncHandler(async (req, res) => { + const { id } = req.params; + const income = await incomeService.getIncomeById(id, req.user.user_id); + + if (!income) { + return res.status(404).json({ error: "Income not found" }); + } + + res.json(income); +}); + +export const updateIncome = asyncHandler(async (req, res) => { + const { id } = req.params; + const updates = req.body; + + if (updates.date) updates.date = new Date(updates.date); + if (updates.amount) updates.amount = parseFloat(updates.amount); + if (updates.category_id) { + updates.category = { + connect: { category_id: parseInt(updates.category_id) }, + }; + delete updates.category_id; + } + + try { + const income = await incomeService.updateIncome( + id, + req.user.user_id, + updates + ); + res.json(income); + } catch (error) { + res.status(404).json({ error: error.message }); + } +}); + +export const deleteIncome = asyncHandler(async (req, res) => { + const { id } = req.params; + + try { + await incomeService.deleteIncome(id, req.user.user_id); + res.status(204).send(); + } catch (error) { + res.status(404).json({ error: error.message }); + } +}); + +//---------------INCOME CATEGORIES---------------// + +export const getIncomeCategories = asyncHandler(async (req, res) => { + const categories = await incomeService.getIncomeCategories(req.user.user_id); + res.json(categories); +}); + +export const getIncomeCategoriesByUser = asyncHandler(async (req, res) => { + const categories = await incomeService.getIncomeCategoriesByUser( + req.user.user_id + ); + res.json(categories); +}); + +export const createIncomeCategory = asyncHandler(async (req, res) => { + const { category_name, icon_url, icon_emoji } = req.body; + + if (!category_name) { + return res.status(400).json({ error: "Category name is required" }); + } + + try { + const category = await incomeService.createIncomeCategory( + req.user.user_id, + { + category_name, + icon_url, + icon_emoji, + } + ); + res.status(201).json(category); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +export const updateIncomeCategory = asyncHandler(async (req, res) => { + const { id } = req.params; + const { category_name, icon_url, icon_emoji } = req.body; + + if (!category_name) { + return res.status(400).json({ error: "Category name is required" }); + } + + try { + const category = await incomeService.updateIncomeCategory( + id, + req.user.user_id, + { category_name, icon_url, icon_emoji } + ); + res.json(category); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +export const deleteIncomeCategory = asyncHandler(async (req, res) => { + const { id } = req.params; + + try { + await incomeService.deleteIncomeCategory(id, req.user.user_id); + res.status(204).send(); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); diff --git a/server/src/lib/prisma.js b/server/db/prisma.js similarity index 100% rename from server/src/lib/prisma.js rename to server/db/prisma.js diff --git a/server/package.json b/server/package.json index 8fe6e2f..d79bff0 100644 --- a/server/package.json +++ b/server/package.json @@ -27,8 +27,7 @@ "express": "^4.18.2", "helmet": "^7.1.0", "morgan": "^1.10.0", - "pg": "^8.11.3", - "@prisma/client": "^5.18.0" + "pg": "^8.11.3" }, "devDependencies": { "nodemon": "^3.0.2", diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 1c871fb..7f9dd94 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -6,7 +6,6 @@ generator client { provider = "prisma-client-js" - output = "../generated/prisma" } datasource db { @@ -49,7 +48,6 @@ model IncomeCategory { category_id Int @id @default(autoincrement()) category_name String @db.VarChar(50) icon_url String? @db.VarChar(500) - icon_emoji String? @db.VarChar(10) is_custom Boolean @default(false) user_id Int? user User? @relation(fields: [user_id], references: [user_id], onDelete: Cascade) diff --git a/server/routes/income.route.js b/server/routes/income.route.js new file mode 100644 index 0000000..bf73523 --- /dev/null +++ b/server/routes/income.route.js @@ -0,0 +1,22 @@ +import { Router } from 'express'; +import { createIncome, getIncomes, getIncome, updateIncome, deleteIncome, getIncomeCategories, getIncomeCategoriesByUser,createIncomeCategory, updateIncomeCategory, deleteIncomeCategory } from '../controllers/income.controller.js'; + +const router = Router(); + +//INCOME CATEGORY +router.get('/categories', getIncomeCategories); +router.get('/custom-categories', getIncomeCategoriesByUser) +router.post('/categories', createIncomeCategory); +router.put('/categories/:id', updateIncomeCategory); +router.delete('/categories/:id', deleteIncomeCategory); + +//INCOME +router.get('/', getIncomes); +router.get('/:id', getIncome); +router.post('/', createIncome); +router.put('/:id', updateIncome); +router.delete('/:id', deleteIncome); + + + +export default router; \ No newline at end of file diff --git a/server/server.js b/server/server.js index dd6bced..476fb04 100644 --- a/server/server.js +++ b/server/server.js @@ -2,6 +2,7 @@ import express from 'express'; import cors from 'cors'; import dotenv from 'dotenv'; import { PrismaClient } from '@prisma/client'; +import incomeRoutes from './routes/income.route.js'; dotenv.config(); @@ -11,6 +12,15 @@ const PORT = process.env.PORT || 8080; app.use(cors()); app.use(express.json()); +//temporary middleware for testing +app.use((req, res, next) => { + req.user = { user_id: 4 }; // fake user, but you have to create a fake user with id 4 as well in your local database + next(); +}); + +//Routes +app.use('/api/incomes', incomeRoutes); + // Initialize a single Prisma client instance const prisma = new PrismaClient(); diff --git a/server/services/income.service.js b/server/services/income.service.js new file mode 100644 index 0000000..10c959e --- /dev/null +++ b/server/services/income.service.js @@ -0,0 +1,181 @@ +import { prisma } from "../db/prisma.js"; + +//---------------INCOME---------------// + +//POST +const createIncome = async (userId, data) => { + return await prisma.income.create({ + data: { + ...data, + user: { connect: { user_id: userId } }, + }, + include: { category: true }, + }); +}; + +//GET ALL (with start and end date query params) +const getIncomes = async (userId, filters = {}) => { + const where = { user_id: userId }; + + if (filters.start || filters.end) { + where.date = {}; + if (filters.start) where.date.gte = new Date(filters.start); + if (filters.end) where.date.lte = new Date(filters.end); + } + + return await prisma.income.findMany({ + where, + include: { category: true }, + orderBy: { date: "desc" }, + }); +}; + +//GET BY ID +const getIncomeById = async (id, userId) => { + return await prisma.income.findFirst({ + where: { income_id: parseInt(id), user_id: userId }, + include: { category: true }, + }); +}; + +//UPDATE +const updateIncome = async (id, userId, data) => { + const income = await prisma.income.findFirst({ + where: { income_id: parseInt(id), user_id: userId }, + }); + + if (!income) { + throw new Error("Income not found or not authorized"); + } + + return await prisma.income.update({ + where: { income_id: parseInt(id) }, + data, + include: { category: true }, + }); +}; + +//DELETE +const deleteIncome = async (id, userId) => { + const income = await prisma.income.findFirst({ + where: { income_id: parseInt(id), user_id: userId }, + }); + + if (!income) { + throw new Error("Income not found or not authorized"); + } + + await prisma.income.delete({ + where: { income_id: parseInt(id) }, + }); +}; + +//---------------INCOME CATEGORY---------------// + +//GET ALL +const getIncomeCategories = async (userId) => { + return await prisma.incomeCategory.findMany({ + where: { + OR: [{ user_id: null }, { user_id: userId }], + }, + orderBy: [{ is_custom: "asc" }, { category_name: "asc" }], + }); +}; + +//GET ALL BY USER +const getIncomeCategoriesByUser = async (userId) => { + return await prisma.incomeCategory.findMany({ + where: { + user_id: userId, + }, + orderBy: [{ is_custom: "asc" }, { category_name: "asc" }], + }); +}; + +//POST +const createIncomeCategory = async (userId, data) => { + const existingCategory = await prisma.incomeCategory.findFirst({ + where: { + category_name: data.category_name, + user_id: userId, + }, + }); + + if (existingCategory) { + throw new Error("Category already exists"); + } + + return await prisma.incomeCategory.create({ + data: { + ...data, + is_custom: true, + user: { connect: { user_id: userId } }, + }, + }); +}; + +//UPDATE +const updateIncomeCategory = async (id, userId, data) => { + const category = await prisma.incomeCategory.findFirst({ + where: { category_id: parseInt(id), user_id: userId }, + }); + + if (!category) { + throw new Error("Category not found or not authorized"); + } + + if (data.category_name) { + const duplicateCategory = await prisma.incomeCategory.findFirst({ + where: { + category_name: data.category_name, + user_id: userId, + NOT: { category_id: parseInt(id) }, + }, + }); + + if (duplicateCategory) { + throw new Error("Category name already exists"); + } + } + + return await prisma.incomeCategory.update({ + where: { category_id: parseInt(id) }, + data, + }); +}; + +//DELETE +const deleteIncomeCategory = async (id, userId) => { + const category = await prisma.incomeCategory.findFirst({ + where: { category_id: parseInt(id), user_id: userId }, + }); + + if (!category) { + throw new Error("Category not found or not authorized"); + } + + const incomesUsingCategory = await prisma.income.count({ + where: { category_id: parseInt(id) }, + }); + + if (incomesUsingCategory > 0) { + throw new Error("Cannot delete category that is being used by incomes"); + } + + await prisma.incomeCategory.delete({ + where: { category_id: parseInt(id) }, + }); +}; + +export default { + createIncome, + getIncomes, + getIncomeById, + updateIncome, + deleteIncome, + getIncomeCategories, + createIncomeCategory, + getIncomeCategoriesByUser, + updateIncomeCategory, + deleteIncomeCategory, +}; diff --git a/server/utils/asyncHandler.js b/server/utils/asyncHandler.js new file mode 100644 index 0000000..bb1b35f --- /dev/null +++ b/server/utils/asyncHandler.js @@ -0,0 +1,3 @@ +export const asyncHandler = (fn) => (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch(next); +};