From 7e4e9e981ddb1db2ab1e6ec3383b1632ffbd43d8 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Tue, 26 Aug 2025 20:21:15 +0300 Subject: [PATCH 01/26] style: use datepicker instead of date textfield --- client/src/components/IncomeForm.tsx | 20 +++++++---- client/src/pages/IncomesPage.tsx | 52 ++++++++++++++++------------ 2 files changed, 42 insertions(+), 30 deletions(-) diff --git a/client/src/components/IncomeForm.tsx b/client/src/components/IncomeForm.tsx index eac4750..ccdf662 100644 --- a/client/src/components/IncomeForm.tsx +++ b/client/src/components/IncomeForm.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from "react"; import type { Income, IncomeFormData } from "../types/Income"; import { useIncomeCategories } from "../hooks/useIncomeCategories"; -import { Button, TextField, Select, Dialog, Skeleton } from "../ui"; +import { Button, TextField, Select, Dialog, Skeleton, DatePicker } from "../ui"; import { useMascot } from "../hooks/useMascot"; interface IncomeFormProps { @@ -77,6 +77,13 @@ export const IncomeForm: React.FC = ({ })); }; + const handleDateChange = (date: Date | null) => { + if (date) { + const formattedDate = date.toISOString().split("T")[0]; + handleChange("date", formattedDate); + } + }; + return ( = ({ fullWidth /> - handleChange("date", e.target.value)} - fullWidth + { navigate("/incomes/new"); }; + const handleStartDateChange = (date: Date | null) => { + setDateFilter((prev) => ({ + ...prev, + start: date ? date.toISOString().split("T")[0] : undefined, + })); + }; + + const handleEndDateChange = (date: Date | null) => { + setDateFilter((prev) => ({ + ...prev, + end: date ? date.toISOString().split("T")[0] : undefined, + })); + }; + return (
@@ -60,28 +74,20 @@ export const IncomesPage = () => {
- - setDateFilter((prev) => ({ - ...prev, - start: e.target.value || undefined, - })) - } - /> - - setDateFilter((prev) => ({ - ...prev, - end: e.target.value || undefined, - })) - } - /> + + + +
Date: Wed, 27 Aug 2025 18:57:09 +0300 Subject: [PATCH 02/26] refactor: fix prisma models to match openapi spec --- server/prisma/schema.prisma | 114 +++++++++++++++++------------------- 1 file changed, 54 insertions(+), 60 deletions(-) diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 7f9dd94..af8e58a 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -14,79 +14,73 @@ datasource db { } model User { - user_id Int @id @default(autoincrement()) - username String @db.VarChar(50) - email String @unique @db.VarChar(255) - hashed_password String @db.VarChar(255) - firstname String @db.VarChar(100) - lastname String @db.VarChar(100) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - expenses Expense[] - incomes Income[] - expense_categories ExpenseCategory[] - income_categories IncomeCategory[] + id Int @id @default(autoincrement()) + email String @unique @db.VarChar(255) + password String @db.VarChar(255) + username String? @db.VarChar(50) + firstName String? @db.VarChar(100) + lastName String? @db.VarChar(100) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + expenses Expense[] + incomes Income[] + categories Category[] @@map("user") } -model ExpenseCategory { - category_id Int @id @default(autoincrement()) - category_name String @db.VarChar(50) - icon_url String? @db.VarChar(500) - is_custom Boolean @default(false) - user_id Int? - user User? @relation(fields: [user_id], references: [user_id], onDelete: Cascade) - expenses Expense[] - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - @@map("expense_category") -} +model Category { + id Int @id @default(autoincrement()) + name String @db.VarChar(50) + iconUrl String? @db.VarChar(500) + isCustom Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + userId Int? + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) -model IncomeCategory { - category_id Int @id @default(autoincrement()) - category_name String @db.VarChar(50) - icon_url String? @db.VarChar(500) - is_custom Boolean @default(false) - user_id Int? - user User? @relation(fields: [user_id], references: [user_id], onDelete: Cascade) - incomes Income[] - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - @@map("income_category") + expenses Expense[] + + @@map("category") } model Expense { - expense_id Int @id @default(autoincrement()) - amount Float - description String? @db.VarChar(2000) - type String @default("ONE_TIME") @db.VarChar(20) - receipt_upload String? @db.VarChar(2000) - creation_date DateTime @default(now()) - expense_date DateTime? - start_date DateTime? - end_date DateTime? - user_id Int - user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) - category_id Int - category ExpenseCategory @relation(fields: [category_id], references: [category_id], onDelete: Cascade) + id Int @id @default(autoincrement()) + amount Float + description String? @db.VarChar(2000) + type ExpenseType @default(ONE_TIME) + receiptUrl String? @db.VarChar(2000) + createdAt DateTime @default(now()) + expenseDate DateTime? + startDate DateTime? + endDate DateTime? + + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + categoryId Int + category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) @@map("expense") } model Income { - income_id Int @id @default(autoincrement()) - amount Float - date DateTime @default(now()) - source String @db.VarChar(100) - description String? @db.VarChar(2000) - creation_date DateTime @default(now()) - user_id Int - user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) - category_id Int - category IncomeCategory @relation(fields: [category_id], references: [category_id], onDelete: Cascade) + id Int @id @default(autoincrement()) + amount Float + date DateTime @default(now()) + source String? @db.VarChar(100) + description String? @db.VarChar(2000) + createdAt DateTime @default(now()) + + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@map("income") } + +enum ExpenseType { + ONE_TIME + RECURRING +} From a2d4062a7362d2266adfce5e69404acfce5997c4 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Wed, 27 Aug 2025 18:58:59 +0300 Subject: [PATCH 03/26] refactor: remove unnecessary endpoints (income categories) --- server/docs/Expense Tracker API.yaml | 92 ++++++++-------------------- 1 file changed, 25 insertions(+), 67 deletions(-) diff --git a/server/docs/Expense Tracker API.yaml b/server/docs/Expense Tracker API.yaml index c4dee71..d23ab7f 100644 --- a/server/docs/Expense Tracker API.yaml +++ b/server/docs/Expense Tracker API.yaml @@ -141,67 +141,15 @@ paths: description: Expense updated delete: summary: Delete an expense + parameters: + - in: path + name: id + required: true + schema: + type: string responses: "204": description: Expense deleted - /incomes/categories: - get: - summary: Get system income categories - responses: - "200": - description: List of system income categories - /incomes/custom-categories: - get: - summary: Get user's custom income categories - responses: - "200": - description: List of user's custom income categories - post: - summary: Create a new custom income category - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [category_name] - properties: - category_name: - type: string - icon_url: - type: string - responses: - "201": - description: Income category created - /incomes/custom-categories/{id}: - parameters: - - in: path - name: id - required: true - schema: - type: string - put: - summary: Update an income category - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [category_name] - properties: - category_name: - type: string - icon_url: - type: string - responses: - "200": - description: Income category updated - delete: - summary: Delete an income category - responses: - "204": - description: Income category deleted /incomes: get: summary: List all incomes @@ -224,7 +172,7 @@ paths: application/json: schema: type: object - required: [amount] + required: [amount, date] properties: amount: type: number @@ -234,8 +182,6 @@ paths: type: string description: type: string - category_id: - type: number responses: "201": description: Income created @@ -272,6 +218,12 @@ paths: description: Income updated delete: summary: Delete an income entry + parameters: + - in: path + name: id + required: true + schema: + type: string responses: "204": description: Income deleted @@ -296,14 +248,14 @@ paths: "201": description: Category created /categories/{id}: - parameters: - - in: path - name: id - required: true - schema: - type: string put: summary: Rename a category + parameters: + - in: path + name: id + required: true + schema: + type: string requestBody: content: application/json: @@ -317,6 +269,12 @@ paths: description: Category updated delete: summary: Delete a category + parameters: + - in: path + name: id + required: true + schema: + type: string responses: "204": description: Category deleted From 93473c2e7e733dca24d6969c28c8930439ec3fd1 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Wed, 27 Aug 2025 19:03:01 +0300 Subject: [PATCH 04/26] refactor: remove income category from service layer --- client/src/api/services/DefaultService.ts | 89 ++------------- server/services/income.service.js | 125 ++-------------------- 2 files changed, 14 insertions(+), 200 deletions(-) diff --git a/client/src/api/services/DefaultService.ts b/client/src/api/services/DefaultService.ts index 22c9ff8..f3a11b9 100644 --- a/client/src/api/services/DefaultService.ts +++ b/client/src/api/services/DefaultService.ts @@ -145,98 +145,19 @@ export class DefaultService { /** * Delete an expense * @param id + * @param id * @returns void * @throws ApiError */ public static deleteExpenses( id: string, + id: string, ): CancelablePromise { return __request(OpenAPI, { method: 'DELETE', url: '/expenses/{id}', path: { 'id': id, - }, - }); - } - /** - * Get system income categories - * @returns any List of system income categories - * @throws ApiError - */ - public static getIncomesCategories(): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/incomes/categories', - }); - } - /** - * Get user's custom income categories - * @returns any List of user's custom income categories - * @throws ApiError - */ - public static getIncomesCustomCategories(): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/incomes/custom-categories', - }); - } - /** - * Create a new custom income category - * @param requestBody - * @returns any Income category created - * @throws ApiError - */ - public static postIncomesCustomCategories( - requestBody: { - category_name: string; - icon_url?: string; - }, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/incomes/custom-categories', - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Update an income category - * @param id - * @param requestBody - * @returns any Income category updated - * @throws ApiError - */ - public static putIncomesCustomCategories( - id: string, - requestBody: { - category_name: string; - icon_url?: string; - }, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'PUT', - url: '/incomes/custom-categories/{id}', - path: { - 'id': id, - }, - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Delete an income category - * @param id - * @returns void - * @throws ApiError - */ - public static deleteIncomesCustomCategories( - id: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'DELETE', - url: '/incomes/custom-categories/{id}', - path: { 'id': id, }, }); @@ -270,10 +191,9 @@ export class DefaultService { public static postIncomes( requestBody?: { amount: number; - date?: string; + date: string; source?: string; description?: string; - category_id?: number; }, ): CancelablePromise { return __request(OpenAPI, { @@ -329,17 +249,20 @@ export class DefaultService { /** * Delete an income entry * @param id + * @param id * @returns void * @throws ApiError */ public static deleteIncomes( id: string, + id: string, ): CancelablePromise { return __request(OpenAPI, { method: 'DELETE', url: '/incomes/{id}', path: { 'id': id, + 'id': id, }, }); } diff --git a/server/services/income.service.js b/server/services/income.service.js index d285487..77e21f3 100644 --- a/server/services/income.service.js +++ b/server/services/income.service.js @@ -7,15 +7,14 @@ const createIncome = async (userId, data) => { return await prisma.income.create({ data: { ...data, - user: { connect: { user_id: userId } }, + user: { connect: { id: userId } }, }, - include: { category: true }, }); }; //GET ALL (with start and end date query params) const getIncomes = async (userId, filters = {}) => { - const where = { user_id: userId }; + const where = { userId }; if (filters.start || filters.end) { where.date = {}; @@ -25,26 +24,21 @@ const getIncomes = async (userId, filters = {}) => { return await prisma.income.findMany({ where, - include: { category: true }, - orderBy: [ - { date: "desc" }, - { creation_date: "desc" }, // might change { income_id: "desc" } if that doesnt work - ], + orderBy: [{ date: "desc" }, { createdAt: "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 }, + where: { id: parseInt(id), userId }, }); }; //UPDATE const updateIncome = async (id, userId, data) => { const income = await prisma.income.findFirst({ - where: { income_id: parseInt(id), user_id: userId }, + where: { id: parseInt(id), userId }, }); if (!income) { @@ -52,16 +46,15 @@ const updateIncome = async (id, userId, data) => { } return await prisma.income.update({ - where: { income_id: parseInt(id) }, + where: { 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 }, + where: { id: parseInt(id), userId }, }); if (!income) { @@ -69,104 +62,7 @@ const deleteIncome = async (id, userId) => { } 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) }, + where: { id: parseInt(id) }, }); }; @@ -176,9 +72,4 @@ export default { getIncomeById, updateIncome, deleteIncome, - getIncomeCategories, - createIncomeCategory, - getIncomeCategoriesByUser, - updateIncomeCategory, - deleteIncomeCategory, }; From ed7ed6e2c7af6d06bf0930ebe34af78b8c08ab0a Mon Sep 17 00:00:00 2001 From: colombefioren Date: Wed, 27 Aug 2025 19:10:41 +0300 Subject: [PATCH 05/26] refactor: update to use new field names (id) --- server/controllers/income.controller.js | 172 +----------------------- server/prisma/schema.prisma | 34 ++--- server/services/income.service.js | 24 ++-- 3 files changed, 35 insertions(+), 195 deletions(-) diff --git a/server/controllers/income.controller.js b/server/controllers/income.controller.js index 2226afa..3fe6310 100644 --- a/server/controllers/income.controller.js +++ b/server/controllers/income.controller.js @@ -4,7 +4,7 @@ import { asyncHandler } from "../utils/asyncHandler.js"; //---------------INCOME---------------// export const createIncome = asyncHandler(async (req, res) => { - const { amount, date, source, description, category_id } = req.body; + const { amount, date, source, description } = req.body; if (!amount) { return res.status(400).json({ error: "Missing required fields" }); @@ -16,12 +16,6 @@ export const createIncome = asyncHandler(async (req, res) => { description: description || "", }; - if (category_id) { - incomeData.category = { connect: { category_id: parseInt(category_id) } }; - } else { - incomeData.category = { connect: { category_id: 1 } }; - } - if (date) { const parsedDate = new Date(date); if (!isNaN(parsedDate.getTime())) { @@ -34,21 +28,10 @@ export const createIncome = asyncHandler(async (req, res) => { } try { - const income = await incomeService.createIncome( - req.user.user_id, - incomeData - ); + const income = await incomeService.createIncome(req.user.id, incomeData); res.status(201).json(income); } catch (error) { console.error("Error creating income:", error); - - if ( - error.message.includes("category") || - error.message.includes("Category") - ) { - return res.status(400).json({ error: "Invalid category specified" }); - } - res.status(500).json({ error: "Failed to create income" }); } }); @@ -76,7 +59,7 @@ export const getIncomes = asyncHandler(async (req, res) => { } try { - const incomes = await incomeService.getIncomes(req.user.user_id, filters); + const incomes = await incomeService.getIncomes(req.user.id, filters); res.json(incomes); } catch (error) { console.error("Error fetching incomes:", error); @@ -92,7 +75,7 @@ export const getIncome = asyncHandler(async (req, res) => { } try { - const income = await incomeService.getIncomeById(id, req.user.user_id); + const income = await incomeService.getIncomeById(id, req.user.id); if (!income) { return res.status(404).json({ error: "Income not found" }); @@ -129,25 +112,8 @@ export const updateIncome = asyncHandler(async (req, res) => { updates.date = parsedDate; } - if (updates.category_id !== undefined) { - if (updates.category_id === null || updates.category_id === "") { - updates.category = { connect: { category_id: 1 } }; - } else { - const categoryId = parseInt(updates.category_id); - if (isNaN(categoryId)) { - return res.status(400).json({ error: "Invalid category ID" }); - } - updates.category = { connect: { category_id: categoryId } }; - } - delete updates.category_id; - } - try { - const income = await incomeService.updateIncome( - id, - req.user.user_id, - updates - ); + const income = await incomeService.updateIncome(id, req.user.id, updates); res.json(income); } catch (error) { console.error("Error updating income:", error); @@ -161,13 +127,6 @@ export const updateIncome = asyncHandler(async (req, res) => { .json({ error: "Income not found or not authorized" }); } - if ( - error.message.includes("category") || - error.message.includes("Category") - ) { - return res.status(400).json({ error: "Invalid category specified" }); - } - res.status(500).json({ error: "Failed to update income" }); } }); @@ -180,7 +139,7 @@ export const deleteIncome = asyncHandler(async (req, res) => { } try { - await incomeService.deleteIncome(id, req.user.user_id); + await incomeService.deleteIncome(id, req.user.id); res.status(204).send(); } catch (error) { console.error("Error deleting income:", error); @@ -197,122 +156,3 @@ export const deleteIncome = asyncHandler(async (req, res) => { res.status(500).json({ error: "Failed to delete income" }); } }); - -//---------------INCOME CATEGORIES---------------// - -export const getIncomeCategories = asyncHandler(async (req, res) => { - try { - const categories = await incomeService.getIncomeCategories( - req.user.user_id - ); - res.json(categories); - } catch (error) { - console.error("Error fetching income categories:", error); - res.status(500).json({ error: "Failed to fetch income categories" }); - } -}); - -export const getIncomeCategoriesByUser = asyncHandler(async (req, res) => { - try { - const categories = await incomeService.getIncomeCategoriesByUser( - req.user.user_id - ); - res.json(categories); - } catch (error) { - console.error("Error fetching user income categories:", error); - res.status(500).json({ error: "Failed to fetch user income categories" }); - } -}); - -export const createIncomeCategory = asyncHandler(async (req, res) => { - try { - const { category_name, icon_url, icon_emoji } = req.body; - - if (!category_name) { - return res.status(400).json({ error: "Category name is required" }); - } - - const category = await incomeService.createIncomeCategory( - req.user.user_id, - { - category_name, - icon_url, - icon_emoji, - } - ); - res.status(201).json(category); - } catch (error) { - console.error("Error creating income category:", error); - - if (error.message.includes("already exists")) { - return res.status(409).json({ error: error.message }); - } - - res.status(500).json({ error: "Failed to create income category" }); - } -}); - -export const updateIncomeCategory = asyncHandler(async (req, res) => { - try { - 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" }); - } - - if (!id || isNaN(parseInt(id))) { - return res.status(400).json({ error: "Invalid category ID" }); - } - - const category = await incomeService.updateIncomeCategory( - id, - req.user.user_id, - { category_name, icon_url, icon_emoji } - ); - res.json(category); - } catch (error) { - console.error("Error updating income category:", error); - - if ( - error.message.includes("not found") || - error.message.includes("not authorized") - ) { - return res.status(404).json({ error: error.message }); - } - - if (error.message.includes("already exists")) { - return res.status(409).json({ error: error.message }); - } - - res.status(500).json({ error: "Failed to update income category" }); - } -}); - -export const deleteIncomeCategory = asyncHandler(async (req, res) => { - try { - const { id } = req.params; - - if (!id || isNaN(parseInt(id))) { - return res.status(400).json({ error: "Invalid category ID" }); - } - - await incomeService.deleteIncomeCategory(id, req.user.user_id); - res.status(204).send(); - } catch (error) { - console.error("Error deleting income category:", error); - - if ( - error.message.includes("not found") || - error.message.includes("not authorized") - ) { - return res.status(404).json({ error: error.message }); - } - - if (error.message.includes("being used")) { - return res.status(409).json({ error: error.message }); - } - - res.status(500).json({ error: "Failed to delete income category" }); - } -}); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index af8e58a..6bcc51d 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -14,7 +14,7 @@ datasource db { } model User { - id Int @id @default(autoincrement()) + user_id Int @id @default(autoincrement()) @map("id") email String @unique @db.VarChar(255) password String @db.VarChar(255) username String? @db.VarChar(50) @@ -31,15 +31,15 @@ model User { } model Category { - id Int @id @default(autoincrement()) - name String @db.VarChar(50) - iconUrl String? @db.VarChar(500) - isCustom Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + category_id Int @id @default(autoincrement()) @map("id") + name String @db.VarChar(50) + iconUrl String? @db.VarChar(500) + isCustom Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - userId Int? - user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + user_id Int? + user User? @relation(fields: [user_id], references: [user_id], onDelete: Cascade) expenses Expense[] @@ -47,7 +47,7 @@ model Category { } model Expense { - id Int @id @default(autoincrement()) + expense_id Int @id @default(autoincrement()) @map("id") amount Float description String? @db.VarChar(2000) type ExpenseType @default(ONE_TIME) @@ -57,25 +57,25 @@ model Expense { startDate DateTime? endDate DateTime? - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user_id Int + user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) - categoryId Int - category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade) + category_id Int + category Category @relation(fields: [category_id], references: [category_id], onDelete: Cascade) @@map("expense") } model Income { - id Int @id @default(autoincrement()) + income_id Int @id @default(autoincrement()) @map("id") amount Float date DateTime @default(now()) source String? @db.VarChar(100) description String? @db.VarChar(2000) createdAt DateTime @default(now()) - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user_id Int + user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) @@map("income") } diff --git a/server/services/income.service.js b/server/services/income.service.js index 77e21f3..b353139 100644 --- a/server/services/income.service.js +++ b/server/services/income.service.js @@ -3,18 +3,18 @@ import { prisma } from "../db/prisma.js"; //---------------INCOME---------------// //POST -const createIncome = async (userId, data) => { +const createIncome = async (user_id, data) => { return await prisma.income.create({ data: { ...data, - user: { connect: { id: userId } }, + user: { connect: { user_id } }, }, }); }; //GET ALL (with start and end date query params) -const getIncomes = async (userId, filters = {}) => { - const where = { userId }; +const getIncomes = async (user_id, filters = {}) => { + const where = { user_id }; if (filters.start || filters.end) { where.date = {}; @@ -29,16 +29,16 @@ const getIncomes = async (userId, filters = {}) => { }; //GET BY ID -const getIncomeById = async (id, userId) => { +const getIncomeById = async (id, user_id) => { return await prisma.income.findFirst({ - where: { id: parseInt(id), userId }, + where: { income_id: parseInt(id), user_id }, }); }; //UPDATE -const updateIncome = async (id, userId, data) => { +const updateIncome = async (id, user_id, data) => { const income = await prisma.income.findFirst({ - where: { id: parseInt(id), userId }, + where: { income_id: parseInt(id), user_id }, }); if (!income) { @@ -46,15 +46,15 @@ const updateIncome = async (id, userId, data) => { } return await prisma.income.update({ - where: { id: parseInt(id) }, + where: { income_id: parseInt(id) }, data, }); }; //DELETE -const deleteIncome = async (id, userId) => { +const deleteIncome = async (id, user_id) => { const income = await prisma.income.findFirst({ - where: { id: parseInt(id), userId }, + where: { income_id: parseInt(id), user_id }, }); if (!income) { @@ -62,7 +62,7 @@ const deleteIncome = async (id, userId) => { } await prisma.income.delete({ - where: { id: parseInt(id) }, + where: { income_id: parseInt(id) }, }); }; From aeda3802d4b0d3d2ee108a19fd59c4ecc8c023c0 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Wed, 27 Aug 2025 19:12:35 +0300 Subject: [PATCH 06/26] refactor: remove category references --- server/controllers/income.controller.js | 35 ++++++++++++++----------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/server/controllers/income.controller.js b/server/controllers/income.controller.js index 3fe6310..6b98842 100644 --- a/server/controllers/income.controller.js +++ b/server/controllers/income.controller.js @@ -6,8 +6,10 @@ import { asyncHandler } from "../utils/asyncHandler.js"; export const createIncome = asyncHandler(async (req, res) => { const { amount, date, source, description } = req.body; - if (!amount) { - return res.status(400).json({ error: "Missing required fields" }); + if (!amount || !date) { + return res + .status(400) + .json({ error: "Missing required fields: amount and date are required" }); } const incomeData = { @@ -16,19 +18,18 @@ export const createIncome = asyncHandler(async (req, res) => { description: description || "", }; - if (date) { - const parsedDate = new Date(date); - if (!isNaN(parsedDate.getTime())) { - incomeData.date = parsedDate; - } else { - return res.status(400).json({ error: "Invalid date format" }); - } + const parsedDate = new Date(date); + if (!isNaN(parsedDate.getTime())) { + incomeData.date = parsedDate; } else { - incomeData.date = new Date(); + return res.status(400).json({ error: "Invalid date format" }); } try { - const income = await incomeService.createIncome(req.user.id, incomeData); + const income = await incomeService.createIncome( + req.user.user_id, + incomeData + ); res.status(201).json(income); } catch (error) { console.error("Error creating income:", error); @@ -59,7 +60,7 @@ export const getIncomes = asyncHandler(async (req, res) => { } try { - const incomes = await incomeService.getIncomes(req.user.id, filters); + const incomes = await incomeService.getIncomes(req.user.user_id, filters); res.json(incomes); } catch (error) { console.error("Error fetching incomes:", error); @@ -75,7 +76,7 @@ export const getIncome = asyncHandler(async (req, res) => { } try { - const income = await incomeService.getIncomeById(id, req.user.id); + const income = await incomeService.getIncomeById(id, req.user.user_id); if (!income) { return res.status(404).json({ error: "Income not found" }); @@ -113,7 +114,11 @@ export const updateIncome = asyncHandler(async (req, res) => { } try { - const income = await incomeService.updateIncome(id, req.user.id, updates); + const income = await incomeService.updateIncome( + id, + req.user.user_id, + updates + ); res.json(income); } catch (error) { console.error("Error updating income:", error); @@ -139,7 +144,7 @@ export const deleteIncome = asyncHandler(async (req, res) => { } try { - await incomeService.deleteIncome(id, req.user.id); + await incomeService.deleteIncome(id, req.user.user_id); res.status(204).send(); } catch (error) { console.error("Error deleting income:", error); From 93fe19df75e8e4008b17011192fd2e14a792ffc2 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Wed, 27 Aug 2025 19:13:28 +0300 Subject: [PATCH 07/26] refactor: simplify income routes by removing category endpoints --- server/routes/income.route.js | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/server/routes/income.route.js b/server/routes/income.route.js index 32e8cc8..9495e6c 100644 --- a/server/routes/income.route.js +++ b/server/routes/income.route.js @@ -1,22 +1,19 @@ -import { Router } from 'express'; -import { createIncome, getIncomes, getIncome, updateIncome, deleteIncome, getIncomeCategories, getIncomeCategoriesByUser,createIncomeCategory, updateIncomeCategory, deleteIncomeCategory } from '../controllers/income.controller.js'; +import { Router } from "express"; +import { + createIncome, + getIncomes, + getIncome, + updateIncome, + deleteIncome, +} from "../controllers/income.controller.js"; const router = Router(); -//INCOME CATEGORY -router.get('/categories', getIncomeCategories); -router.get('/custom-categories', getIncomeCategoriesByUser) -router.post('/custom-categories', createIncomeCategory); -router.put('/custom-categories/:id', updateIncomeCategory); -router.delete('/custom-categories/:id', deleteIncomeCategory); +// INCOME ROUTES +router.get("/", getIncomes); +router.get("/:id", getIncome); +router.post("/", createIncome); +router.put("/:id", updateIncome); +router.delete("/:id", deleteIncome); -//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 +export default router; From f19ddcddb9fe7719bf80b4a949e25cd441aa3396 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Wed, 27 Aug 2025 19:21:20 +0300 Subject: [PATCH 08/26] refactor: remove income category from types --- client/src/api/services/DefaultService.ts | 782 +++++++++++----------- client/src/types/Income.ts | 17 +- 2 files changed, 378 insertions(+), 421 deletions(-) diff --git a/client/src/api/services/DefaultService.ts b/client/src/api/services/DefaultService.ts index f3a11b9..93bed92 100644 --- a/client/src/api/services/DefaultService.ts +++ b/client/src/api/services/DefaultService.ts @@ -2,413 +2,385 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import type { CancelablePromise } from '../core/CancelablePromise'; -import { OpenAPI } from '../core/OpenAPI'; -import { request as __request } from '../core/request'; +import type { CancelablePromise } from "../core/CancelablePromise"; +import { OpenAPI } from "../core/OpenAPI"; +import { request as __request } from "../core/request"; export class DefaultService { - /** - * Register a new user - * @param requestBody - * @returns any User created - * @throws ApiError - */ - public static postAuthSignup( - requestBody: { - email: string; - password: string; - }, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/auth/signup', - body: requestBody, - mediaType: 'application/json', - }); + /** + * Register a new user + * @param requestBody + * @returns any User created + * @throws ApiError + */ + public static postAuthSignup(requestBody: { + email: string; + password: string; + }): CancelablePromise { + return __request(OpenAPI, { + method: "POST", + url: "/auth/signup", + body: requestBody, + mediaType: "application/json", + }); + } + /** + * Login and receive JWT token + * @param requestBody + * @returns any Token returned + * @throws ApiError + */ + public static postAuthLogin(requestBody: { + email: string; + password: string; + }): CancelablePromise { + return __request(OpenAPI, { + method: "POST", + url: "/auth/login", + body: requestBody, + mediaType: "application/json", + }); + } + /** + * List all user expenses + * @param start Start date + * @param end End date + * @param category + * @param type + * @returns any List of expenses + * @throws ApiError + */ + public static getExpenses( + start?: string, + end?: string, + category?: string, + type?: "recurring" | "one-time" + ): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/expenses", + query: { + start: start, + end: end, + category: category, + type: type, + }, + }); + } + /** + * Create a new expense + * @param formData + * @returns any Expense created + * @throws ApiError + */ + public static postExpenses(formData?: { + amount: number; + date: string; + categoryId: string; + description?: string; + type?: "one-time" | "recurring"; + startDate?: string; + endDate?: string; + receipt?: Blob; + }): CancelablePromise { + return __request(OpenAPI, { + method: "POST", + url: "/expenses", + formData: formData, + mediaType: "multipart/form-data", + }); + } + /** + * Get a single expense + * @param id + * @returns any Expense data + * @throws ApiError + */ + public static getExpenses1(id: string): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/expenses/{id}", + path: { + id: id, + }, + }); + } + /** + * Update an expense + * @param id + * @param formData + * @returns any Expense updated + * @throws ApiError + */ + public static putExpenses( + id: string, + formData?: { + amount?: number; + date?: string; + categoryId?: string; + description?: string; + type?: "one-time" | "recurring"; + startDate?: string; + endDate?: string; + receipt?: Blob; } - /** - * Login and receive JWT token - * @param requestBody - * @returns any Token returned - * @throws ApiError - */ - public static postAuthLogin( - requestBody: { - email: string; - password: string; - }, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/auth/login', - body: requestBody, - mediaType: 'application/json', - }); + ): CancelablePromise { + return __request(OpenAPI, { + method: "PUT", + url: "/expenses/{id}", + path: { + id: id, + }, + formData: formData, + mediaType: "multipart/form-data", + }); + } + /** + * Delete an expense + * @param id + * @param id + * @returns void + * @throws ApiError + */ + public static deleteExpenses(id: string): CancelablePromise { + return __request(OpenAPI, { + method: "DELETE", + url: "/expenses/{id}", + path: { + id: id, + }, + }); + } + /** + * List all incomes + * @param start + * @param end + * @returns any List of incomes + * @throws ApiError + */ + public static getIncomes( + start?: string, + end?: string + ): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/incomes", + query: { + start: start, + end: end, + }, + }); + } + /** + * Create new income + * @param requestBody + * @returns any Income created + * @throws ApiError + */ + public static postIncomes(requestBody?: { + amount: number; + date: string; + source?: string; + description?: string; + }): CancelablePromise { + return __request(OpenAPI, { + method: "POST", + url: "/incomes", + body: requestBody, + mediaType: "application/json", + }); + } + /** + * Get an income entry + * @param id + * @returns any Income data + * @throws ApiError + */ + public static getIncomes1(id: string): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/incomes/{id}", + path: { + id: id, + }, + }); + } + /** + * Update an income entry + * @param id + * @param requestBody + * @returns any Income updated + * @throws ApiError + */ + public static putIncomes( + id: string, + requestBody?: { + amount?: number; + date?: string; + source?: string; + description?: string; } - /** - * List all user expenses - * @param start Start date - * @param end End date - * @param category - * @param type - * @returns any List of expenses - * @throws ApiError - */ - public static getExpenses( - start?: string, - end?: string, - category?: string, - type?: 'recurring' | 'one-time', - ): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/expenses', - query: { - 'start': start, - 'end': end, - 'category': category, - 'type': type, - }, - }); - } - /** - * Create a new expense - * @param formData - * @returns any Expense created - * @throws ApiError - */ - public static postExpenses( - formData?: { - amount: number; - date: string; - categoryId: string; - description?: string; - type?: 'one-time' | 'recurring'; - startDate?: string; - endDate?: string; - receipt?: Blob; - }, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/expenses', - formData: formData, - mediaType: 'multipart/form-data', - }); - } - /** - * Get a single expense - * @param id - * @returns any Expense data - * @throws ApiError - */ - public static getExpenses1( - id: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/expenses/{id}', - path: { - 'id': id, - }, - }); - } - /** - * Update an expense - * @param id - * @param formData - * @returns any Expense updated - * @throws ApiError - */ - public static putExpenses( - id: string, - formData?: { - amount?: number; - date?: string; - categoryId?: string; - description?: string; - type?: 'one-time' | 'recurring'; - startDate?: string; - endDate?: string; - receipt?: Blob; - }, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'PUT', - url: '/expenses/{id}', - path: { - 'id': id, - }, - formData: formData, - mediaType: 'multipart/form-data', - }); - } - /** - * Delete an expense - * @param id - * @param id - * @returns void - * @throws ApiError - */ - public static deleteExpenses( - id: string, - id: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'DELETE', - url: '/expenses/{id}', - path: { - 'id': id, - 'id': id, - }, - }); - } - /** - * List all incomes - * @param start - * @param end - * @returns any List of incomes - * @throws ApiError - */ - public static getIncomes( - start?: string, - end?: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/incomes', - query: { - 'start': start, - 'end': end, - }, - }); - } - /** - * Create new income - * @param requestBody - * @returns any Income created - * @throws ApiError - */ - public static postIncomes( - requestBody?: { - amount: number; - date: string; - source?: string; - description?: string; - }, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/incomes', - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Get an income entry - * @param id - * @returns any Income data - * @throws ApiError - */ - public static getIncomes1( - id: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/incomes/{id}', - path: { - 'id': id, - }, - }); - } - /** - * Update an income entry - * @param id - * @param requestBody - * @returns any Income updated - * @throws ApiError - */ - public static putIncomes( - id: string, - requestBody?: { - amount?: number; - date?: string; - source?: string; - description?: string; - }, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'PUT', - url: '/incomes/{id}', - path: { - 'id': id, - }, - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Delete an income entry - * @param id - * @param id - * @returns void - * @throws ApiError - */ - public static deleteIncomes( - id: string, - id: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'DELETE', - url: '/incomes/{id}', - path: { - 'id': id, - 'id': id, - }, - }); - } - /** - * List user categories - * @returns any List of categories - * @throws ApiError - */ - public static getCategories(): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/categories', - }); - } - /** - * Create new category - * @param requestBody - * @returns any Category created - * @throws ApiError - */ - public static postCategories( - requestBody?: { - name: string; - }, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/categories', - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Rename a category - * @param id - * @param requestBody - * @returns any Category updated - * @throws ApiError - */ - public static putCategories( - id: string, - requestBody?: { - name?: string; - }, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'PUT', - url: '/categories/{id}', - path: { - 'id': id, - }, - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Delete a category - * @param id - * @returns void - * @throws ApiError - */ - public static deleteCategories( - id: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'DELETE', - url: '/categories/{id}', - path: { - 'id': id, - }, - }); - } - /** - * Get current month summary - * @param month - * @returns any Monthly summary - * @throws ApiError - */ - public static getSummaryMonthly( - month?: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/summary/monthly', - query: { - 'month': month, - }, - }); - } - /** - * Get summary for custom range - * @param start - * @param end - * @returns any Summary - * @throws ApiError - */ - public static getSummary( - start?: string, - end?: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/summary', - query: { - 'start': start, - 'end': end, - }, - }); - } - /** - * Budget overrun alert - * @returns any Alert info - * @throws ApiError - */ - public static getSummaryAlerts(): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/summary/alerts', - }); - } - /** - * Download/view receipt - * @param id - * @returns any Receipt file - * @throws ApiError - */ - public static getReceipts( - id: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/receipts/{id}', - path: { - 'id': id, - }, - }); - } - /** - * Get user profile - * @returns any Profile info - * @throws ApiError - */ - public static getUserProfile(): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/user/profile', - }); + ): CancelablePromise { + return __request(OpenAPI, { + method: "PUT", + url: "/incomes/{id}", + path: { + id: id, + }, + body: requestBody, + mediaType: "application/json", + }); + } + /** + * Delete an income entry + * @param id + * @param id + * @returns void + * @throws ApiError + */ + public static deleteIncomes(id: string): CancelablePromise { + return __request(OpenAPI, { + method: "DELETE", + url: "/incomes/{id}", + path: { + id: id, + }, + }); + } + /** + * List user categories + * @returns any List of categories + * @throws ApiError + */ + public static getCategories(): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/categories", + }); + } + /** + * Create new category + * @param requestBody + * @returns any Category created + * @throws ApiError + */ + public static postCategories(requestBody?: { + name: string; + }): CancelablePromise { + return __request(OpenAPI, { + method: "POST", + url: "/categories", + body: requestBody, + mediaType: "application/json", + }); + } + /** + * Rename a category + * @param id + * @param requestBody + * @returns any Category updated + * @throws ApiError + */ + public static putCategories( + id: string, + requestBody?: { + name?: string; } + ): CancelablePromise { + return __request(OpenAPI, { + method: "PUT", + url: "/categories/{id}", + path: { + id: id, + }, + body: requestBody, + mediaType: "application/json", + }); + } + /** + * Delete a category + * @param id + * @returns void + * @throws ApiError + */ + public static deleteCategories(id: string): CancelablePromise { + return __request(OpenAPI, { + method: "DELETE", + url: "/categories/{id}", + path: { + id: id, + }, + }); + } + /** + * Get current month summary + * @param month + * @returns any Monthly summary + * @throws ApiError + */ + public static getSummaryMonthly(month?: string): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/summary/monthly", + query: { + month: month, + }, + }); + } + /** + * Get summary for custom range + * @param start + * @param end + * @returns any Summary + * @throws ApiError + */ + public static getSummary( + start?: string, + end?: string + ): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/summary", + query: { + start: start, + end: end, + }, + }); + } + /** + * Budget overrun alert + * @returns any Alert info + * @throws ApiError + */ + public static getSummaryAlerts(): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/summary/alerts", + }); + } + /** + * Download/view receipt + * @param id + * @returns any Receipt file + * @throws ApiError + */ + public static getReceipts(id: string): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/receipts/{id}", + path: { + id: id, + }, + }); + } + /** + * Get user profile + * @returns any Profile info + * @throws ApiError + */ + public static getUserProfile(): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/user/profile", + }); + } } diff --git a/client/src/types/Income.ts b/client/src/types/Income.ts index 1a11bb1..e9ca23f 100644 --- a/client/src/types/Income.ts +++ b/client/src/types/Income.ts @@ -6,8 +6,6 @@ export interface Income { description?: string; creation_date: string; user_id: number; - category_id: number; - category?: IncomeCategory; } export interface IncomeFormData { @@ -15,25 +13,13 @@ export interface IncomeFormData { date: string; source: string; description: string; - category_id: number; -} - -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; + date: string; source: string; description?: string; - category_id: number; } export interface UpdateIncomeRequest { @@ -41,5 +27,4 @@ export interface UpdateIncomeRequest { date?: string; source?: string; description?: string; - category_id?: number; } From 4512003935ce08e7eac71a8d548e10c4592de9f4 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Wed, 27 Aug 2025 19:23:16 +0300 Subject: [PATCH 09/26] refactor: remove income category methods from services --- client/src/services/IncomeService.ts | 91 ---------------------------- 1 file changed, 91 deletions(-) diff --git a/client/src/services/IncomeService.ts b/client/src/services/IncomeService.ts index 30ed6c9..5dfc3bf 100644 --- a/client/src/services/IncomeService.ts +++ b/client/src/services/IncomeService.ts @@ -2,7 +2,6 @@ import { DefaultService } from "../api/services/DefaultService"; import { useMascotStore } from "../stores/mascotStore"; import type { Income, - IncomeCategory, CreateIncomeRequest, UpdateIncomeRequest, } from "../types/Income"; @@ -16,7 +15,6 @@ export class IncomeService { return response as Income[]; } catch (error) { useMascotStore.getState().setExpression("error"); - console.error("Error fetching incomes:", error); throw new Error("Failed to fetch incomes"); } @@ -27,11 +25,9 @@ export class IncomeService { try { const response = await DefaultService.getIncomes1(id); useMascotStore.getState().setExpression("success"); - return response as Income; } catch (error) { useMascotStore.getState().setExpression("error"); - console.error(`Error fetching income ${id}:`, error); throw new Error("Failed to fetch income"); } @@ -45,9 +41,6 @@ export class IncomeService { amount: Number(incomeData.amount), }; - if (incomeData.date) { - requestData.date = incomeData.date; - } const response = await DefaultService.postIncomes(requestData); useMascotStore.getState().setExpression("success"); return response as Income; @@ -68,11 +61,9 @@ export class IncomeService { const response = await DefaultService.putIncomes(id, requestData); useMascotStore.getState().setExpression("success"); - return response as Income; } catch (error) { useMascotStore.getState().setExpression("error"); - console.error(`Error updating income ${id}:`, error); throw new Error("Failed to update income"); } @@ -85,90 +76,8 @@ export class IncomeService { useMascotStore.getState().setExpression("success"); } catch (error) { useMascotStore.getState().setExpression("error"); - console.error(`Error deleting income ${id}:`, error); throw new Error("Failed to delete income"); } } - - //GET ALL income categories (custom and system) - static async getIncomeCategories() { - try { - const response = await DefaultService.getIncomesCategories(); - useMascotStore.getState().setExpression("success"); - - return response as IncomeCategory[]; - } catch (error) { - useMascotStore.getState().setExpression("error"); - - console.error("Error fetching income categories:", error); - throw new Error("Failed to fetch income categories"); - } - } - - //GET custom categories - static async getCustomIncomeCategories() { - try { - const response = await DefaultService.getIncomesCustomCategories(); - useMascotStore.getState().setExpression("success"); - - return response as IncomeCategory[]; - } catch (error) { - useMascotStore.getState().setExpression("error"); - - console.error("Error fetching custom income categories:", error); - throw new Error("Failed to fetch custom income categories"); - } - } - - //POST new category - static async createIncomeCategory(name: string) { - try { - const response = await DefaultService.postIncomesCustomCategories({ - category_name: name, - }); - useMascotStore.getState().setExpression("success"); - - return response as IncomeCategory; - } catch (error) { - useMascotStore.getState().setExpression("error"); - - console.error("Error creating income category:", error); - throw new Error("Failed to create income category"); - } - } - - //UPDATE a category - static async updateIncomeCategory( - id: string, - name: string - ): Promise { - try { - const response = await DefaultService.putIncomesCustomCategories(id, { - category_name: name, - }); - useMascotStore.getState().setExpression("success"); - - return response as IncomeCategory; - } catch (error) { - useMascotStore.getState().setExpression("error"); - - console.error(`Error updating income category ${id}:`, error); - throw new Error("Failed to update income category"); - } - } - - //DELETE category - static async deleteIncomeCategory(id: string) { - try { - useMascotStore.getState().setExpression("success"); - - await DefaultService.deleteIncomesCustomCategories(id); - } catch (error) { - useMascotStore.getState().setExpression("error"); - - console.error(`Error deleting income category ${id}:`, error); - throw new Error("Failed to delete income category"); - } - } } From 9434623e8b09efb959f101de421e2658b019b50a Mon Sep 17 00:00:00 2001 From: colombefioren Date: Wed, 27 Aug 2025 19:24:55 +0300 Subject: [PATCH 10/26] refactor: remove category field and set default date (because of the date required in backend) --- client/src/components/IncomeForm.tsx | 33 +++++----------------------- 1 file changed, 5 insertions(+), 28 deletions(-) diff --git a/client/src/components/IncomeForm.tsx b/client/src/components/IncomeForm.tsx index ccdf662..4eca75e 100644 --- a/client/src/components/IncomeForm.tsx +++ b/client/src/components/IncomeForm.tsx @@ -1,7 +1,6 @@ import React, { useState, useEffect } from "react"; import type { Income, IncomeFormData } from "../types/Income"; -import { useIncomeCategories } from "../hooks/useIncomeCategories"; -import { Button, TextField, Select, Dialog, Skeleton, DatePicker } from "../ui"; +import { Button, TextField, Dialog, DatePicker } from "../ui"; import { useMascot } from "../hooks/useMascot"; interface IncomeFormProps { @@ -17,7 +16,6 @@ export const IncomeForm: React.FC = ({ onCancel, open, }) => { - const { categories, loading: categoriesLoading } = useIncomeCategories(); const [saving, setSaving] = useState(false); const { showSuccess, showError } = useMascot(); @@ -28,7 +26,6 @@ export const IncomeForm: React.FC = ({ : new Date().toISOString().split("T")[0], source: income?.source || "", description: income?.description || "", - category_id: income?.category_id || 1, }); useEffect(() => { @@ -38,7 +35,6 @@ export const IncomeForm: React.FC = ({ date: new Date(income.date).toISOString().split("T")[0], source: income.source, description: income.description || "", - category_id: income.category_id, }); } else { setFormData({ @@ -46,7 +42,6 @@ export const IncomeForm: React.FC = ({ date: new Date().toISOString().split("T")[0], source: "", description: "", - category_id: 1, }); } }, [income, open]); @@ -72,8 +67,7 @@ export const IncomeForm: React.FC = ({ ) => { setFormData((prev) => ({ ...prev, - [field]: - field === "amount" || field === "category_id" ? Number(value) : value, + [field]: field === "amount" ? Number(value) : value, })); }; @@ -101,10 +95,11 @@ export const IncomeForm: React.FC = ({ /> = ({ fullWidth /> - {categoriesLoading ? ( - - ) : ( -
- - handleChange("category_id", value)} - options={categories.map((cat) => ({ - label: cat.category_name, - value: cat.category_id, - }))} - /> -
- )} - (

{income.amount.toFixed(2)} MGA

- {income.category && ( - - )}

{income.source}

{income.description && ( diff --git a/client/src/hooks/useIncomeCategories.ts b/client/src/hooks/useIncomeCategories.ts deleted file mode 100644 index 7684f7e..0000000 --- a/client/src/hooks/useIncomeCategories.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { IncomeCategory } from "../types/Income"; -import { IncomeService } from "../services/IncomeService"; -import { useState, useEffect } from "react"; - -export const useIncomeCategories = () => { - const [categories, setCategories] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchCategories = async () => { - try { - setLoading(true); - setError(null); - const data = await IncomeService.getIncomeCategories(); - setCategories(data); - } catch (err) { - setError( - err instanceof Error ? err.message : "Failed to fetch categories" - ); - } finally { - setLoading(false); - } - }; - - fetchCategories(); - }, []); - - const refetch = async () => { - try { - setLoading(true); - setError(null); - const data = await IncomeService.getIncomeCategories(); - setCategories(data); - } catch (err) { - setError( - err instanceof Error ? err.message : "Failed to refetch categories" - ); - } finally { - setLoading(false); - } - }; - - return { categories, loading, error, refetch }; -}; diff --git a/client/src/pages/IncomesPage.tsx b/client/src/pages/IncomesPage.tsx index e0d2900..94f30cc 100644 --- a/client/src/pages/IncomesPage.tsx +++ b/client/src/pages/IncomesPage.tsx @@ -33,7 +33,6 @@ export const IncomesPage = () => { toast.success("Income deleted successfully"); setDeleteConfirmOpen(false); setIncomeToDelete(null); - //refresh after every operations incomeListRef.current?.refetch(); } catch (error) { const message = @@ -74,20 +73,18 @@ export const IncomesPage = () => {
- - - - + +
{ {incomeToDelete && (

- Amount: ${incomeToDelete.amount.toFixed(2)} + Amount: {incomeToDelete.amount.toFixed(2)} MGA

- Source: {incomeToDelete.source} + Source: {incomeToDelete.source.length > 0 ? incomeToDelete.source : "-"}

Date:{" "} diff --git a/client/src/services/IncomeService.ts b/client/src/services/IncomeService.ts index 30ed6c9..5dfc3bf 100644 --- a/client/src/services/IncomeService.ts +++ b/client/src/services/IncomeService.ts @@ -2,7 +2,6 @@ import { DefaultService } from "../api/services/DefaultService"; import { useMascotStore } from "../stores/mascotStore"; import type { Income, - IncomeCategory, CreateIncomeRequest, UpdateIncomeRequest, } from "../types/Income"; @@ -16,7 +15,6 @@ export class IncomeService { return response as Income[]; } catch (error) { useMascotStore.getState().setExpression("error"); - console.error("Error fetching incomes:", error); throw new Error("Failed to fetch incomes"); } @@ -27,11 +25,9 @@ export class IncomeService { try { const response = await DefaultService.getIncomes1(id); useMascotStore.getState().setExpression("success"); - return response as Income; } catch (error) { useMascotStore.getState().setExpression("error"); - console.error(`Error fetching income ${id}:`, error); throw new Error("Failed to fetch income"); } @@ -45,9 +41,6 @@ export class IncomeService { amount: Number(incomeData.amount), }; - if (incomeData.date) { - requestData.date = incomeData.date; - } const response = await DefaultService.postIncomes(requestData); useMascotStore.getState().setExpression("success"); return response as Income; @@ -68,11 +61,9 @@ export class IncomeService { const response = await DefaultService.putIncomes(id, requestData); useMascotStore.getState().setExpression("success"); - return response as Income; } catch (error) { useMascotStore.getState().setExpression("error"); - console.error(`Error updating income ${id}:`, error); throw new Error("Failed to update income"); } @@ -85,90 +76,8 @@ export class IncomeService { useMascotStore.getState().setExpression("success"); } catch (error) { useMascotStore.getState().setExpression("error"); - console.error(`Error deleting income ${id}:`, error); throw new Error("Failed to delete income"); } } - - //GET ALL income categories (custom and system) - static async getIncomeCategories() { - try { - const response = await DefaultService.getIncomesCategories(); - useMascotStore.getState().setExpression("success"); - - return response as IncomeCategory[]; - } catch (error) { - useMascotStore.getState().setExpression("error"); - - console.error("Error fetching income categories:", error); - throw new Error("Failed to fetch income categories"); - } - } - - //GET custom categories - static async getCustomIncomeCategories() { - try { - const response = await DefaultService.getIncomesCustomCategories(); - useMascotStore.getState().setExpression("success"); - - return response as IncomeCategory[]; - } catch (error) { - useMascotStore.getState().setExpression("error"); - - console.error("Error fetching custom income categories:", error); - throw new Error("Failed to fetch custom income categories"); - } - } - - //POST new category - static async createIncomeCategory(name: string) { - try { - const response = await DefaultService.postIncomesCustomCategories({ - category_name: name, - }); - useMascotStore.getState().setExpression("success"); - - return response as IncomeCategory; - } catch (error) { - useMascotStore.getState().setExpression("error"); - - console.error("Error creating income category:", error); - throw new Error("Failed to create income category"); - } - } - - //UPDATE a category - static async updateIncomeCategory( - id: string, - name: string - ): Promise { - try { - const response = await DefaultService.putIncomesCustomCategories(id, { - category_name: name, - }); - useMascotStore.getState().setExpression("success"); - - return response as IncomeCategory; - } catch (error) { - useMascotStore.getState().setExpression("error"); - - console.error(`Error updating income category ${id}:`, error); - throw new Error("Failed to update income category"); - } - } - - //DELETE category - static async deleteIncomeCategory(id: string) { - try { - useMascotStore.getState().setExpression("success"); - - await DefaultService.deleteIncomesCustomCategories(id); - } catch (error) { - useMascotStore.getState().setExpression("error"); - - console.error(`Error deleting income category ${id}:`, error); - throw new Error("Failed to delete income category"); - } - } } diff --git a/client/src/types/Income.ts b/client/src/types/Income.ts index 1a11bb1..e9ca23f 100644 --- a/client/src/types/Income.ts +++ b/client/src/types/Income.ts @@ -6,8 +6,6 @@ export interface Income { description?: string; creation_date: string; user_id: number; - category_id: number; - category?: IncomeCategory; } export interface IncomeFormData { @@ -15,25 +13,13 @@ export interface IncomeFormData { date: string; source: string; description: string; - category_id: number; -} - -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; + date: string; source: string; description?: string; - category_id: number; } export interface UpdateIncomeRequest { @@ -41,5 +27,4 @@ export interface UpdateIncomeRequest { date?: string; source?: string; description?: string; - category_id?: number; } diff --git a/server/controllers/income.controller.js b/server/controllers/income.controller.js index 2226afa..6b98842 100644 --- a/server/controllers/income.controller.js +++ b/server/controllers/income.controller.js @@ -4,10 +4,12 @@ import { asyncHandler } from "../utils/asyncHandler.js"; //---------------INCOME---------------// export const createIncome = asyncHandler(async (req, res) => { - const { amount, date, source, description, category_id } = req.body; + const { amount, date, source, description } = req.body; - if (!amount) { - return res.status(400).json({ error: "Missing required fields" }); + if (!amount || !date) { + return res + .status(400) + .json({ error: "Missing required fields: amount and date are required" }); } const incomeData = { @@ -16,21 +18,11 @@ export const createIncome = asyncHandler(async (req, res) => { description: description || "", }; - if (category_id) { - incomeData.category = { connect: { category_id: parseInt(category_id) } }; + const parsedDate = new Date(date); + if (!isNaN(parsedDate.getTime())) { + incomeData.date = parsedDate; } else { - incomeData.category = { connect: { category_id: 1 } }; - } - - if (date) { - const parsedDate = new Date(date); - if (!isNaN(parsedDate.getTime())) { - incomeData.date = parsedDate; - } else { - return res.status(400).json({ error: "Invalid date format" }); - } - } else { - incomeData.date = new Date(); + return res.status(400).json({ error: "Invalid date format" }); } try { @@ -41,14 +33,6 @@ export const createIncome = asyncHandler(async (req, res) => { res.status(201).json(income); } catch (error) { console.error("Error creating income:", error); - - if ( - error.message.includes("category") || - error.message.includes("Category") - ) { - return res.status(400).json({ error: "Invalid category specified" }); - } - res.status(500).json({ error: "Failed to create income" }); } }); @@ -129,19 +113,6 @@ export const updateIncome = asyncHandler(async (req, res) => { updates.date = parsedDate; } - if (updates.category_id !== undefined) { - if (updates.category_id === null || updates.category_id === "") { - updates.category = { connect: { category_id: 1 } }; - } else { - const categoryId = parseInt(updates.category_id); - if (isNaN(categoryId)) { - return res.status(400).json({ error: "Invalid category ID" }); - } - updates.category = { connect: { category_id: categoryId } }; - } - delete updates.category_id; - } - try { const income = await incomeService.updateIncome( id, @@ -161,13 +132,6 @@ export const updateIncome = asyncHandler(async (req, res) => { .json({ error: "Income not found or not authorized" }); } - if ( - error.message.includes("category") || - error.message.includes("Category") - ) { - return res.status(400).json({ error: "Invalid category specified" }); - } - res.status(500).json({ error: "Failed to update income" }); } }); @@ -197,122 +161,3 @@ export const deleteIncome = asyncHandler(async (req, res) => { res.status(500).json({ error: "Failed to delete income" }); } }); - -//---------------INCOME CATEGORIES---------------// - -export const getIncomeCategories = asyncHandler(async (req, res) => { - try { - const categories = await incomeService.getIncomeCategories( - req.user.user_id - ); - res.json(categories); - } catch (error) { - console.error("Error fetching income categories:", error); - res.status(500).json({ error: "Failed to fetch income categories" }); - } -}); - -export const getIncomeCategoriesByUser = asyncHandler(async (req, res) => { - try { - const categories = await incomeService.getIncomeCategoriesByUser( - req.user.user_id - ); - res.json(categories); - } catch (error) { - console.error("Error fetching user income categories:", error); - res.status(500).json({ error: "Failed to fetch user income categories" }); - } -}); - -export const createIncomeCategory = asyncHandler(async (req, res) => { - try { - const { category_name, icon_url, icon_emoji } = req.body; - - if (!category_name) { - return res.status(400).json({ error: "Category name is required" }); - } - - const category = await incomeService.createIncomeCategory( - req.user.user_id, - { - category_name, - icon_url, - icon_emoji, - } - ); - res.status(201).json(category); - } catch (error) { - console.error("Error creating income category:", error); - - if (error.message.includes("already exists")) { - return res.status(409).json({ error: error.message }); - } - - res.status(500).json({ error: "Failed to create income category" }); - } -}); - -export const updateIncomeCategory = asyncHandler(async (req, res) => { - try { - 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" }); - } - - if (!id || isNaN(parseInt(id))) { - return res.status(400).json({ error: "Invalid category ID" }); - } - - const category = await incomeService.updateIncomeCategory( - id, - req.user.user_id, - { category_name, icon_url, icon_emoji } - ); - res.json(category); - } catch (error) { - console.error("Error updating income category:", error); - - if ( - error.message.includes("not found") || - error.message.includes("not authorized") - ) { - return res.status(404).json({ error: error.message }); - } - - if (error.message.includes("already exists")) { - return res.status(409).json({ error: error.message }); - } - - res.status(500).json({ error: "Failed to update income category" }); - } -}); - -export const deleteIncomeCategory = asyncHandler(async (req, res) => { - try { - const { id } = req.params; - - if (!id || isNaN(parseInt(id))) { - return res.status(400).json({ error: "Invalid category ID" }); - } - - await incomeService.deleteIncomeCategory(id, req.user.user_id); - res.status(204).send(); - } catch (error) { - console.error("Error deleting income category:", error); - - if ( - error.message.includes("not found") || - error.message.includes("not authorized") - ) { - return res.status(404).json({ error: error.message }); - } - - if (error.message.includes("being used")) { - return res.status(409).json({ error: error.message }); - } - - res.status(500).json({ error: "Failed to delete income category" }); - } -}); diff --git a/server/docs/Expense Tracker API.yaml b/server/docs/Expense Tracker API.yaml index c4dee71..d23ab7f 100644 --- a/server/docs/Expense Tracker API.yaml +++ b/server/docs/Expense Tracker API.yaml @@ -141,67 +141,15 @@ paths: description: Expense updated delete: summary: Delete an expense + parameters: + - in: path + name: id + required: true + schema: + type: string responses: "204": description: Expense deleted - /incomes/categories: - get: - summary: Get system income categories - responses: - "200": - description: List of system income categories - /incomes/custom-categories: - get: - summary: Get user's custom income categories - responses: - "200": - description: List of user's custom income categories - post: - summary: Create a new custom income category - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [category_name] - properties: - category_name: - type: string - icon_url: - type: string - responses: - "201": - description: Income category created - /incomes/custom-categories/{id}: - parameters: - - in: path - name: id - required: true - schema: - type: string - put: - summary: Update an income category - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [category_name] - properties: - category_name: - type: string - icon_url: - type: string - responses: - "200": - description: Income category updated - delete: - summary: Delete an income category - responses: - "204": - description: Income category deleted /incomes: get: summary: List all incomes @@ -224,7 +172,7 @@ paths: application/json: schema: type: object - required: [amount] + required: [amount, date] properties: amount: type: number @@ -234,8 +182,6 @@ paths: type: string description: type: string - category_id: - type: number responses: "201": description: Income created @@ -272,6 +218,12 @@ paths: description: Income updated delete: summary: Delete an income entry + parameters: + - in: path + name: id + required: true + schema: + type: string responses: "204": description: Income deleted @@ -296,14 +248,14 @@ paths: "201": description: Category created /categories/{id}: - parameters: - - in: path - name: id - required: true - schema: - type: string put: summary: Rename a category + parameters: + - in: path + name: id + required: true + schema: + type: string requestBody: content: application/json: @@ -317,6 +269,12 @@ paths: description: Category updated delete: summary: Delete a category + parameters: + - in: path + name: id + required: true + schema: + type: string responses: "204": description: Category deleted diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 7f9dd94..6bcc51d 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -14,79 +14,73 @@ datasource db { } model User { - user_id Int @id @default(autoincrement()) - username String @db.VarChar(50) - email String @unique @db.VarChar(255) - hashed_password String @db.VarChar(255) - firstname String @db.VarChar(100) - lastname String @db.VarChar(100) - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - expenses Expense[] - incomes Income[] - expense_categories ExpenseCategory[] - income_categories IncomeCategory[] + user_id Int @id @default(autoincrement()) @map("id") + email String @unique @db.VarChar(255) + password String @db.VarChar(255) + username String? @db.VarChar(50) + firstName String? @db.VarChar(100) + lastName String? @db.VarChar(100) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + expenses Expense[] + incomes Income[] + categories Category[] @@map("user") } -model ExpenseCategory { - category_id Int @id @default(autoincrement()) - category_name String @db.VarChar(50) - icon_url String? @db.VarChar(500) - is_custom Boolean @default(false) - user_id Int? - user User? @relation(fields: [user_id], references: [user_id], onDelete: Cascade) - expenses Expense[] - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - @@map("expense_category") -} +model Category { + category_id Int @id @default(autoincrement()) @map("id") + name String @db.VarChar(50) + iconUrl String? @db.VarChar(500) + isCustom Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user_id Int? + user User? @relation(fields: [user_id], references: [user_id], onDelete: Cascade) -model IncomeCategory { - category_id Int @id @default(autoincrement()) - category_name String @db.VarChar(50) - icon_url String? @db.VarChar(500) - is_custom Boolean @default(false) - user_id Int? - user User? @relation(fields: [user_id], references: [user_id], onDelete: Cascade) - incomes Income[] - created_at DateTime @default(now()) - updated_at DateTime @updatedAt - - @@map("income_category") + expenses Expense[] + + @@map("category") } model Expense { - expense_id Int @id @default(autoincrement()) - amount Float - description String? @db.VarChar(2000) - type String @default("ONE_TIME") @db.VarChar(20) - receipt_upload String? @db.VarChar(2000) - creation_date DateTime @default(now()) - expense_date DateTime? - start_date DateTime? - end_date DateTime? - user_id Int - user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) - category_id Int - category ExpenseCategory @relation(fields: [category_id], references: [category_id], onDelete: Cascade) + expense_id Int @id @default(autoincrement()) @map("id") + amount Float + description String? @db.VarChar(2000) + type ExpenseType @default(ONE_TIME) + receiptUrl String? @db.VarChar(2000) + createdAt DateTime @default(now()) + expenseDate DateTime? + startDate DateTime? + endDate DateTime? + + user_id Int + user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) + + category_id Int + category Category @relation(fields: [category_id], references: [category_id], onDelete: Cascade) @@map("expense") } model Income { - income_id Int @id @default(autoincrement()) - amount Float - date DateTime @default(now()) - source String @db.VarChar(100) - description String? @db.VarChar(2000) - creation_date DateTime @default(now()) - user_id Int - user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) - category_id Int - category IncomeCategory @relation(fields: [category_id], references: [category_id], onDelete: Cascade) + income_id Int @id @default(autoincrement()) @map("id") + amount Float + date DateTime @default(now()) + source String? @db.VarChar(100) + description String? @db.VarChar(2000) + createdAt DateTime @default(now()) + + user_id Int + user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) @@map("income") } + +enum ExpenseType { + ONE_TIME + RECURRING +} diff --git a/server/routes/income.route.js b/server/routes/income.route.js index 32e8cc8..9495e6c 100644 --- a/server/routes/income.route.js +++ b/server/routes/income.route.js @@ -1,22 +1,19 @@ -import { Router } from 'express'; -import { createIncome, getIncomes, getIncome, updateIncome, deleteIncome, getIncomeCategories, getIncomeCategoriesByUser,createIncomeCategory, updateIncomeCategory, deleteIncomeCategory } from '../controllers/income.controller.js'; +import { Router } from "express"; +import { + createIncome, + getIncomes, + getIncome, + updateIncome, + deleteIncome, +} from "../controllers/income.controller.js"; const router = Router(); -//INCOME CATEGORY -router.get('/categories', getIncomeCategories); -router.get('/custom-categories', getIncomeCategoriesByUser) -router.post('/custom-categories', createIncomeCategory); -router.put('/custom-categories/:id', updateIncomeCategory); -router.delete('/custom-categories/:id', deleteIncomeCategory); +// INCOME ROUTES +router.get("/", getIncomes); +router.get("/:id", getIncome); +router.post("/", createIncome); +router.put("/:id", updateIncome); +router.delete("/:id", deleteIncome); -//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 +export default router; diff --git a/server/server.js b/server/server.js index 0fa4729..03f1b8c 100644 --- a/server/server.js +++ b/server/server.js @@ -15,7 +15,7 @@ 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 + req.user = { user_id: 1 }; // fake user, but you have to create a fake user with id 4 as well in your local database next(); }); diff --git a/server/services/income.service.js b/server/services/income.service.js index d285487..b353139 100644 --- a/server/services/income.service.js +++ b/server/services/income.service.js @@ -3,19 +3,18 @@ import { prisma } from "../db/prisma.js"; //---------------INCOME---------------// //POST -const createIncome = async (userId, data) => { +const createIncome = async (user_id, data) => { return await prisma.income.create({ data: { ...data, - user: { connect: { user_id: userId } }, + user: { connect: { user_id } }, }, - include: { category: true }, }); }; //GET ALL (with start and end date query params) -const getIncomes = async (userId, filters = {}) => { - const where = { user_id: userId }; +const getIncomes = async (user_id, filters = {}) => { + const where = { user_id }; if (filters.start || filters.end) { where.date = {}; @@ -25,26 +24,21 @@ const getIncomes = async (userId, filters = {}) => { return await prisma.income.findMany({ where, - include: { category: true }, - orderBy: [ - { date: "desc" }, - { creation_date: "desc" }, // might change { income_id: "desc" } if that doesnt work - ], + orderBy: [{ date: "desc" }, { createdAt: "desc" }], }); }; //GET BY ID -const getIncomeById = async (id, userId) => { +const getIncomeById = async (id, user_id) => { return await prisma.income.findFirst({ - where: { income_id: parseInt(id), user_id: userId }, - include: { category: true }, + where: { income_id: parseInt(id), user_id }, }); }; //UPDATE -const updateIncome = async (id, userId, data) => { +const updateIncome = async (id, user_id, data) => { const income = await prisma.income.findFirst({ - where: { income_id: parseInt(id), user_id: userId }, + where: { income_id: parseInt(id), user_id }, }); if (!income) { @@ -54,14 +48,13 @@ const updateIncome = async (id, userId, data) => { return await prisma.income.update({ where: { income_id: parseInt(id) }, data, - include: { category: true }, }); }; //DELETE -const deleteIncome = async (id, userId) => { +const deleteIncome = async (id, user_id) => { const income = await prisma.income.findFirst({ - where: { income_id: parseInt(id), user_id: userId }, + where: { income_id: parseInt(id), user_id }, }); if (!income) { @@ -73,112 +66,10 @@ const deleteIncome = async (id, userId) => { }); }; -//---------------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, }; From 2b461956d8a4f0ebd163d3870646fbc613d20b41 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Wed, 27 Aug 2025 20:09:18 +0300 Subject: [PATCH 17/26] feat: auth middleware before granting the autorisation to fetch incomes --- server/routes/income.route.js | 11 ++++++----- server/server.js | 6 ------ 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/server/routes/income.route.js b/server/routes/income.route.js index 9495e6c..f782173 100644 --- a/server/routes/income.route.js +++ b/server/routes/income.route.js @@ -6,14 +6,15 @@ import { updateIncome, deleteIncome, } from "../controllers/income.controller.js"; +import { requireAuth } from "../middleware/auth.middleware.js"; const router = Router(); // INCOME ROUTES -router.get("/", getIncomes); -router.get("/:id", getIncome); -router.post("/", createIncome); -router.put("/:id", updateIncome); -router.delete("/:id", deleteIncome); +router.get("/", requireAuth, getIncomes); +router.get("/:id", requireAuth, getIncome); +router.post("/", requireAuth, createIncome); +router.put("/:id", requireAuth, updateIncome); +router.delete("/:id", requireAuth, deleteIncome); export default router; diff --git a/server/server.js b/server/server.js index 03f1b8c..6d12f49 100644 --- a/server/server.js +++ b/server/server.js @@ -13,12 +13,6 @@ 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: 1 }; // fake user, but you have to create a fake user with id 4 as well in your local database - next(); -}); - app.use('/api/auth', authRoutes) //Routes app.use('/api/incomes', incomeRoutes); From f3fb671798c0556db81b25bc2d93009072e32122 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Wed, 27 Aug 2025 20:14:48 +0300 Subject: [PATCH 18/26] fix: updated password field to be hashed_password --- client/src/api/core/OpenAPI.ts | 2 +- server/prisma/schema.prisma | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/api/core/OpenAPI.ts b/client/src/api/core/OpenAPI.ts index 77fa96a..03da729 100644 --- a/client/src/api/core/OpenAPI.ts +++ b/client/src/api/core/OpenAPI.ts @@ -29,4 +29,4 @@ export const OpenAPI: OpenAPIConfig = { PASSWORD: undefined, HEADERS: undefined, ENCODE_PATH: undefined, -}; +}; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 6bcc51d..93257bc 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -16,7 +16,7 @@ datasource db { model User { user_id Int @id @default(autoincrement()) @map("id") email String @unique @db.VarChar(255) - password String @db.VarChar(255) + hashed_password String @db.VarChar(255) username String? @db.VarChar(50) firstName String? @db.VarChar(100) lastName String? @db.VarChar(100) From 7293c21834626d50248994fa1afef481b8f98d18 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Wed, 27 Aug 2025 20:53:47 +0300 Subject: [PATCH 19/26] refactor: prisma models attributes to follow the precendent ones --- server/prisma/schema.prisma | 84 ++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 44 deletions(-) diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 93257bc..7235f00 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -14,65 +14,61 @@ datasource db { } model User { - user_id Int @id @default(autoincrement()) @map("id") - email String @unique @db.VarChar(255) - hashed_password String @db.VarChar(255) - username String? @db.VarChar(50) - firstName String? @db.VarChar(100) - lastName String? @db.VarChar(100) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + user_id Int @id @default(autoincrement()) + username String? @db.VarChar(50) + email String @unique @db.VarChar(255) + hashed_password String @db.VarChar(255) + firstname String? @db.VarChar(100) + lastname String? @db.VarChar(100) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt expenses Expense[] incomes Income[] - categories Category[] + categories ExpenseCategory[] @@map("user") } -model Category { - category_id Int @id @default(autoincrement()) @map("id") - name String @db.VarChar(50) - iconUrl String? @db.VarChar(500) - isCustom Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user_id Int? - user User? @relation(fields: [user_id], references: [user_id], onDelete: Cascade) - - expenses Expense[] - - @@map("category") +model ExpenseCategory { + category_id Int @id @default(autoincrement()) + category_name String @db.VarChar(50) + icon_url String? @db.VarChar(500) + is_custom Boolean @default(false) + user_id Int? + user User? @relation(fields: [user_id], references: [user_id], onDelete: Cascade) + expenses Expense[] + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@map("expense_category") } model Expense { - expense_id Int @id @default(autoincrement()) @map("id") - amount Float - description String? @db.VarChar(2000) - type ExpenseType @default(ONE_TIME) - receiptUrl String? @db.VarChar(2000) - createdAt DateTime @default(now()) - expenseDate DateTime? - startDate DateTime? - endDate DateTime? - - user_id Int - user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) - - category_id Int - category Category @relation(fields: [category_id], references: [category_id], onDelete: Cascade) + expense_id Int @id @default(autoincrement()) + amount Float + description String? @db.VarChar(2000) + type String @default("ONE_TIME") @db.VarChar(20) + receipt_upload String? @db.VarChar(2000) + creation_date DateTime @default(now()) + expense_date DateTime? + start_date DateTime? + end_date DateTime? + user_id Int + user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) + category_id Int + category ExpenseCategory @relation(fields: [category_id], references: [category_id], onDelete: Cascade) @@map("expense") } model Income { - income_id Int @id @default(autoincrement()) @map("id") - amount Float - date DateTime @default(now()) - source String? @db.VarChar(100) - description String? @db.VarChar(2000) - createdAt DateTime @default(now()) + income_id Int @id @default(autoincrement()) @map("id") + amount Float + date DateTime @default(now()) + source String? @db.VarChar(100) + description String? @db.VarChar(2000) + creation_date DateTime @default(now()) user_id Int user User @relation(fields: [user_id], references: [user_id], onDelete: Cascade) From 71aafeee6f6d8b3d382d92dc9cebbf9e2b4e4b9c Mon Sep 17 00:00:00 2001 From: colombefioren Date: Wed, 27 Aug 2025 21:18:22 +0300 Subject: [PATCH 20/26] fix: enable CORS with credentials for frontend and feat cookieparser for testing --- server/server.js | 49 +++++++++++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/server/server.js b/server/server.js index 6d12f49..bb7243d 100644 --- a/server/server.js +++ b/server/server.js @@ -1,43 +1,54 @@ -import express from 'express'; -import cors from 'cors'; -import dotenv from 'dotenv'; -import { PrismaClient } from '@prisma/client'; -import incomeRoutes from './routes/income.route.js'; -import authRoutes from './routes/auth.route.js' +import express from "express"; +import cors from "cors"; +import dotenv from "dotenv"; +import { PrismaClient } from "@prisma/client"; +import incomeRoutes from "./routes/income.route.js"; +import authRoutes from "./routes/auth.route.js"; +import cookieParser from "cookie-parser"; dotenv.config(); const app = express(); const PORT = process.env.PORT || 8080; -app.use(cors()); +app.use( + cors({ + origin: process.env.CORS_ORIGIN, + credentials: true, + }) +); + +app.use(cookieParser()); + app.use(express.json()); -app.use('/api/auth', authRoutes) +app.use("/api/auth", authRoutes); //Routes -app.use('/api/incomes', incomeRoutes); +app.use("/api/incomes", incomeRoutes); // Initialize a single Prisma client instance const prisma = new PrismaClient(); -app.get('/', (req, res) => { - res.json({ message: 'Expense Tracker API', status: 'running' }); +app.get("/", (req, res) => { + res.json({ message: "Expense Tracker API", status: "running" }); }); -app.get('/api/health', (req, res) => { - res.json({ status: 'OK' }); +app.get("/api/health", (req, res) => { + res.json({ status: "OK" }); }); // DB health check using Prisma raw query; does not require any models -app.get('/api/db-check', async (_req, res) => { +app.get("/api/db-check", async (_req, res) => { try { const result = await prisma.$queryRaw`SELECT NOW() as now`; // Result shape differs by driver; normalize to { now } - const now = Array.isArray(result) ? result[0]?.now ?? result[0]?.NOW ?? result[0] : result?.now ?? result; + const now = Array.isArray(result) + ? result[0]?.now ?? result[0]?.NOW ?? result[0] + : result?.now ?? result; res.json({ ok: true, now }); } catch (err) { - console.error('DB check failed:', err); - res.status(500).json({ ok: false, error: 'DB connection failed' }); + console.error("DB check failed:", err); + res.status(500).json({ ok: false, error: "DB connection failed" }); } }); @@ -45,10 +56,10 @@ app.get('/api/db-check', async (_req, res) => { // eslint-disable-next-line no-unused-vars app.use((err, _req, res, _next) => { const status = err?.status || 500; - const payload = { error: err?.message || 'Internal Server Error' }; + const payload = { error: err?.message || "Internal Server Error" }; if (err?.details) payload.details = err.details; if (status >= 500) { - console.error('Unhandled error:', err); + console.error("Unhandled error:", err); } res.status(status).json(payload); }); From d33163e7f3836154de6dc94def4f872a99d221cf Mon Sep 17 00:00:00 2001 From: colombefioren Date: Wed, 27 Aug 2025 21:28:07 +0300 Subject: [PATCH 21/26] feat: axios options with withCredentials true to allow cookie storage in front --- client/src/api/core/request.ts | 541 ++++++++++++++++++--------------- 1 file changed, 294 insertions(+), 247 deletions(-) diff --git a/client/src/api/core/request.ts b/client/src/api/core/request.ts index 319b00a..adb89ff 100644 --- a/client/src/api/core/request.ts +++ b/client/src/api/core/request.ts @@ -2,285 +2,315 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -import axios from 'axios'; -import type { AxiosError, AxiosRequestConfig, AxiosResponse, AxiosInstance } from 'axios'; -import FormData from 'form-data'; - -import { ApiError } from './ApiError'; -import type { ApiRequestOptions } from './ApiRequestOptions'; -import type { ApiResult } from './ApiResult'; -import { CancelablePromise } from './CancelablePromise'; -import type { OnCancel } from './CancelablePromise'; -import type { OpenAPIConfig } from './OpenAPI'; - -export const isDefined = (value: T | null | undefined): value is Exclude => { - return value !== undefined && value !== null; +import axios from "axios"; +import type { + AxiosError, + AxiosRequestConfig, + AxiosResponse, + AxiosInstance, +} from "axios"; +import FormData from "form-data"; + +import { ApiError } from "./ApiError"; +import type { ApiRequestOptions } from "./ApiRequestOptions"; +import type { ApiResult } from "./ApiResult"; +import { CancelablePromise } from "./CancelablePromise"; +import type { OnCancel } from "./CancelablePromise"; +import type { OpenAPIConfig } from "./OpenAPI"; + +export const isDefined = ( + value: T | null | undefined +): value is Exclude => { + return value !== undefined && value !== null; }; export const isString = (value: any): value is string => { - return typeof value === 'string'; + return typeof value === "string"; }; export const isStringWithValue = (value: any): value is string => { - return isString(value) && value !== ''; + return isString(value) && value !== ""; }; export const isBlob = (value: any): value is Blob => { - return ( - typeof value === 'object' && - typeof value.type === 'string' && - typeof value.stream === 'function' && - typeof value.arrayBuffer === 'function' && - typeof value.constructor === 'function' && - typeof value.constructor.name === 'string' && - /^(Blob|File)$/.test(value.constructor.name) && - /^(Blob|File)$/.test(value[Symbol.toStringTag]) - ); + return ( + typeof value === "object" && + typeof value.type === "string" && + typeof value.stream === "function" && + typeof value.arrayBuffer === "function" && + typeof value.constructor === "function" && + typeof value.constructor.name === "string" && + /^(Blob|File)$/.test(value.constructor.name) && + /^(Blob|File)$/.test(value[Symbol.toStringTag]) + ); }; export const isFormData = (value: any): value is FormData => { - return value instanceof FormData; + return value instanceof FormData; }; export const isSuccess = (status: number): boolean => { - return status >= 200 && status < 300; + return status >= 200 && status < 300; }; export const base64 = (str: string): string => { - try { - return btoa(str); - } catch (err) { - // @ts-ignore - return Buffer.from(str).toString('base64'); - } + try { + return btoa(str); + } catch (err) { + // @ts-ignore + return Buffer.from(str).toString("base64"); + } }; export const getQueryString = (params: Record): string => { - const qs: string[] = []; + const qs: string[] = []; - const append = (key: string, value: any) => { - qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); - }; + const append = (key: string, value: any) => { + qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; - const process = (key: string, value: any) => { - if (isDefined(value)) { - if (Array.isArray(value)) { - value.forEach(v => { - process(key, v); - }); - } else if (typeof value === 'object') { - Object.entries(value).forEach(([k, v]) => { - process(`${key}[${k}]`, v); - }); - } else { - append(key, value); - } - } - }; + const process = (key: string, value: any) => { + if (isDefined(value)) { + if (Array.isArray(value)) { + value.forEach((v) => { + process(key, v); + }); + } else if (typeof value === "object") { + Object.entries(value).forEach(([k, v]) => { + process(`${key}[${k}]`, v); + }); + } else { + append(key, value); + } + } + }; - Object.entries(params).forEach(([key, value]) => { - process(key, value); - }); + Object.entries(params).forEach(([key, value]) => { + process(key, value); + }); - if (qs.length > 0) { - return `?${qs.join('&')}`; - } + if (qs.length > 0) { + return `?${qs.join("&")}`; + } - return ''; + return ""; }; const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { - const encoder = config.ENCODE_PATH || encodeURI; - - const path = options.url - .replace('{api-version}', config.VERSION) - .replace(/{(.*?)}/g, (substring: string, group: string) => { - if (options.path?.hasOwnProperty(group)) { - return encoder(String(options.path[group])); - } - return substring; - }); + const encoder = config.ENCODE_PATH || encodeURI; + + const path = options.url + .replace("{api-version}", config.VERSION) + .replace(/{(.*?)}/g, (substring: string, group: string) => { + if (options.path?.hasOwnProperty(group)) { + return encoder(String(options.path[group])); + } + return substring; + }); - const url = `${config.BASE}${path}`; - if (options.query) { - return `${url}${getQueryString(options.query)}`; - } - return url; + const url = `${config.BASE}${path}`; + if (options.query) { + return `${url}${getQueryString(options.query)}`; + } + return url; }; -export const getFormData = (options: ApiRequestOptions): FormData | undefined => { - if (options.formData) { - const formData = new FormData(); +export const getFormData = ( + options: ApiRequestOptions +): FormData | undefined => { + if (options.formData) { + const formData = new FormData(); - const process = (key: string, value: any) => { - if (isString(value) || isBlob(value)) { - formData.append(key, value); - } else { - formData.append(key, JSON.stringify(value)); - } - }; + const process = (key: string, value: any) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + }; - Object.entries(options.formData) - .filter(([_, value]) => isDefined(value)) - .forEach(([key, value]) => { - if (Array.isArray(value)) { - value.forEach(v => process(key, v)); - } else { - process(key, value); - } - }); - - return formData; - } - return undefined; + Object.entries(options.formData) + .filter(([_, value]) => isDefined(value)) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((v) => process(key, v)); + } else { + process(key, value); + } + }); + + return formData; + } + return undefined; }; type Resolver = (options: ApiRequestOptions) => Promise; -export const resolve = async (options: ApiRequestOptions, resolver?: T | Resolver): Promise => { - if (typeof resolver === 'function') { - return (resolver as Resolver)(options); - } - return resolver; +export const resolve = async ( + options: ApiRequestOptions, + resolver?: T | Resolver +): Promise => { + if (typeof resolver === "function") { + return (resolver as Resolver)(options); + } + return resolver; }; -export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions, formData?: FormData): Promise> => { - const [token, username, password, additionalHeaders] = await Promise.all([ - resolve(options, config.TOKEN), - resolve(options, config.USERNAME), - resolve(options, config.PASSWORD), - resolve(options, config.HEADERS), - ]); - - const formHeaders = typeof formData?.getHeaders === 'function' && formData?.getHeaders() || {} - - const headers = Object.entries({ - Accept: 'application/json', - ...additionalHeaders, - ...options.headers, - ...formHeaders, - }) +export const getHeaders = async ( + config: OpenAPIConfig, + options: ApiRequestOptions, + formData?: FormData +): Promise> => { + const [token, username, password, additionalHeaders] = await Promise.all([ + resolve(options, config.TOKEN), + resolve(options, config.USERNAME), + resolve(options, config.PASSWORD), + resolve(options, config.HEADERS), + ]); + + const formHeaders = + (typeof formData?.getHeaders === "function" && formData?.getHeaders()) || + {}; + + const headers = Object.entries({ + Accept: "application/json", + ...additionalHeaders, + ...options.headers, + ...formHeaders, + }) .filter(([_, value]) => isDefined(value)) - .reduce((headers, [key, value]) => ({ + .reduce( + (headers, [key, value]) => ({ ...headers, [key]: String(value), - }), {} as Record); - - if (isStringWithValue(token)) { - headers['Authorization'] = `Bearer ${token}`; - } - - if (isStringWithValue(username) && isStringWithValue(password)) { - const credentials = base64(`${username}:${password}`); - headers['Authorization'] = `Basic ${credentials}`; - } + }), + {} as Record + ); - if (options.body !== undefined) { - if (options.mediaType) { - headers['Content-Type'] = options.mediaType; - } else if (isBlob(options.body)) { - headers['Content-Type'] = options.body.type || 'application/octet-stream'; - } else if (isString(options.body)) { - headers['Content-Type'] = 'text/plain'; - } else if (!isFormData(options.body)) { - headers['Content-Type'] = 'application/json'; - } + if (isStringWithValue(token)) { + headers["Authorization"] = `Bearer ${token}`; + } + + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers["Authorization"] = `Basic ${credentials}`; + } + + if (options.body !== undefined) { + if (options.mediaType) { + headers["Content-Type"] = options.mediaType; + } else if (isBlob(options.body)) { + headers["Content-Type"] = options.body.type || "application/octet-stream"; + } else if (isString(options.body)) { + headers["Content-Type"] = "text/plain"; + } else if (!isFormData(options.body)) { + headers["Content-Type"] = "application/json"; } + } - return headers; + return headers; }; export const getRequestBody = (options: ApiRequestOptions): any => { - if (options.body) { - return options.body; - } - return undefined; + if (options.body) { + return options.body; + } + return undefined; }; export const sendRequest = async ( - config: OpenAPIConfig, - options: ApiRequestOptions, - url: string, - body: any, - formData: FormData | undefined, - headers: Record, - onCancel: OnCancel, - axiosClient: AxiosInstance + config: OpenAPIConfig, + options: ApiRequestOptions, + url: string, + body: any, + formData: FormData | undefined, + headers: Record, + onCancel: OnCancel, + axiosClient: AxiosInstance ): Promise> => { - const source = axios.CancelToken.source(); - - const requestConfig: AxiosRequestConfig = { - url, - headers, - data: body ?? formData, - method: options.method, - withCredentials: config.WITH_CREDENTIALS, - withXSRFToken: config.CREDENTIALS === 'include' ? config.WITH_CREDENTIALS : false, - cancelToken: source.token, - }; - - onCancel(() => source.cancel('The user aborted a request.')); - - try { - return await axiosClient.request(requestConfig); - } catch (error) { - const axiosError = error as AxiosError; - if (axiosError.response) { - return axiosError.response; - } - throw error; + const source = axios.CancelToken.source(); + + const requestConfig: AxiosRequestConfig = { + url, + headers, + data: body ?? formData, + method: options.method, + withCredentials: config.WITH_CREDENTIALS, + withXSRFToken: + config.CREDENTIALS === "include" ? config.WITH_CREDENTIALS : false, + cancelToken: source.token, + }; + + onCancel(() => source.cancel("The user aborted a request.")); + + try { + return await axiosClient.request(requestConfig); + } catch (error) { + const axiosError = error as AxiosError; + if (axiosError.response) { + return axiosError.response; } + throw error; + } }; -export const getResponseHeader = (response: AxiosResponse, responseHeader?: string): string | undefined => { - if (responseHeader) { - const content = response.headers[responseHeader]; - if (isString(content)) { - return content; - } +export const getResponseHeader = ( + response: AxiosResponse, + responseHeader?: string +): string | undefined => { + if (responseHeader) { + const content = response.headers[responseHeader]; + if (isString(content)) { + return content; } - return undefined; + } + return undefined; }; export const getResponseBody = (response: AxiosResponse): any => { - if (response.status !== 204) { - return response.data; - } - return undefined; + if (response.status !== 204) { + return response.data; + } + return undefined; }; -export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { - const errors: Record = { - 400: 'Bad Request', - 401: 'Unauthorized', - 403: 'Forbidden', - 404: 'Not Found', - 500: 'Internal Server Error', - 502: 'Bad Gateway', - 503: 'Service Unavailable', - ...options.errors, - } - - const error = errors[result.status]; - if (error) { - throw new ApiError(options, result, error); - } - - if (!result.ok) { - const errorStatus = result.status ?? 'unknown'; - const errorStatusText = result.statusText ?? 'unknown'; - const errorBody = (() => { - try { - return JSON.stringify(result.body, null, 2); - } catch (e) { - return undefined; - } - })(); - - throw new ApiError(options, result, - `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` - ); - } +export const catchErrorCodes = ( + options: ApiRequestOptions, + result: ApiResult +): void => { + const errors: Record = { + 400: "Bad Request", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not Found", + 500: "Internal Server Error", + 502: "Bad Gateway", + 503: "Service Unavailable", + ...options.errors, + }; + + const error = errors[result.status]; + if (error) { + throw new ApiError(options, result, error); + } + + if (!result.ok) { + const errorStatus = result.status ?? "unknown"; + const errorStatusText = result.statusText ?? "unknown"; + const errorBody = (() => { + try { + return JSON.stringify(result.body, null, 2); + } catch (e) { + return undefined; + } + })(); + + throw new ApiError( + options, + result, + `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` + ); + } }; /** @@ -291,33 +321,50 @@ export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): * @returns CancelablePromise * @throws ApiError */ -export const request = (config: OpenAPIConfig, options: ApiRequestOptions, axiosClient: AxiosInstance = axios): CancelablePromise => { - return new CancelablePromise(async (resolve, reject, onCancel) => { - try { - const url = getUrl(config, options); - const formData = getFormData(options); - const body = getRequestBody(options); - const headers = await getHeaders(config, options, formData); - - if (!onCancel.isCancelled) { - const response = await sendRequest(config, options, url, body, formData, headers, onCancel, axiosClient); - const responseBody = getResponseBody(response); - const responseHeader = getResponseHeader(response, options.responseHeader); - - const result: ApiResult = { - url, - ok: isSuccess(response.status), - status: response.status, - statusText: response.statusText, - body: responseHeader ?? responseBody, - }; - - catchErrorCodes(options, result); - - resolve(result.body); - } - } catch (error) { - reject(error); - } - }); -}; +export const request = ( + config: OpenAPIConfig, + options: ApiRequestOptions, + axiosClient: AxiosInstance = axios +): CancelablePromise => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + try { + const url = getUrl(config, options); + const formData = getFormData(options); + const body = getRequestBody(options); + const headers = await getHeaders(config, options, formData); + + + const axiosOptions = { + url, + method: options.method, + data: body, + headers, + params: options.query, + withCredentials: true, + }; + + if (!onCancel.isCancelled) { + const response = await axiosClient.request(axiosOptions); + const responseBody = getResponseBody(response); + const responseHeader = getResponseHeader( + response, + options.responseHeader + ); + + const result: ApiResult = { + url, + ok: isSuccess(response.status), + status: response.status, + statusText: response.statusText, + body: responseHeader ?? responseBody, + }; + + catchErrorCodes(options, result); + + resolve(result.body); + } + } catch (error) { + reject(error); + } + }); +}; From 0c73431e88b0803ad0a08b9c84f93817124320a9 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Wed, 27 Aug 2025 21:28:51 +0300 Subject: [PATCH 22/26] fix: name mismatch by turning createdAt to creation_date --- server/services/income.service.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/services/income.service.js b/server/services/income.service.js index b353139..b5d9c00 100644 --- a/server/services/income.service.js +++ b/server/services/income.service.js @@ -24,7 +24,7 @@ const getIncomes = async (user_id, filters = {}) => { return await prisma.income.findMany({ where, - orderBy: [{ date: "desc" }, { createdAt: "desc" }], + orderBy: [{ date: "desc" }, { creation_date: "desc" }], }); }; From 2429863a4f52ea7a87bcd6985847c7c8fd293a81 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Wed, 27 Aug 2025 21:30:37 +0300 Subject: [PATCH 23/26] fix: made source field unrequired when users are creating an income --- client/src/components/IncomeForm.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/components/IncomeForm.tsx b/client/src/components/IncomeForm.tsx index c6a8ef0..d6e04f3 100644 --- a/client/src/components/IncomeForm.tsx +++ b/client/src/components/IncomeForm.tsx @@ -108,7 +108,6 @@ export const IncomeForm: React.FC = ({ value={formData.source ?? ""} onChange={(e) => handleChange("source", e.target.value)} fullWidth - required /> Date: Wed, 27 Aug 2025 22:47:01 +0300 Subject: [PATCH 24/26] fix(client): prevent duplicate query parameters in generated request --- client/src/api/core/request.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/src/api/core/request.ts b/client/src/api/core/request.ts index adb89ff..6cf8280 100644 --- a/client/src/api/core/request.ts +++ b/client/src/api/core/request.ts @@ -333,13 +333,12 @@ export const request = ( const body = getRequestBody(options); const headers = await getHeaders(config, options, formData); - const axiosOptions = { url, method: options.method, data: body, headers, - params: options.query, + params: url.includes("?") ? undefined : options.query, withCredentials: true, }; From faec33de2b881fe53b56238a5ebd42f2ecce3854 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Wed, 27 Aug 2025 23:35:27 +0300 Subject: [PATCH 25/26] feat: enhance input validation in server --- server/controllers/income.controller.js | 38 +++++++++++++++++- server/utils/validators.js | 51 +++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 server/utils/validators.js diff --git a/server/controllers/income.controller.js b/server/controllers/income.controller.js index 6b98842..69260ef 100644 --- a/server/controllers/income.controller.js +++ b/server/controllers/income.controller.js @@ -1,5 +1,6 @@ import incomeService from "../services/income.service.js"; import { asyncHandler } from "../utils/asyncHandler.js"; +import { validateDateRange, validateIncomeData } from "../utils/validators.js"; //---------------INCOME---------------// @@ -12,6 +13,17 @@ export const createIncome = asyncHandler(async (req, res) => { .json({ error: "Missing required fields: amount and date are required" }); } + //data validation + + const validationErrors = validateIncomeData(req.body); + + if (validationErrors.length > 0) { + return res.status(400).json({ + error: "Validation failed", + details: validationErrors, + }); + } + const incomeData = { amount: parseFloat(amount), source: source || "", @@ -41,6 +53,14 @@ export const getIncomes = asyncHandler(async (req, res) => { const { start, end } = req.query; const filters = {}; + const dateValidationErrors = validateDateRange(start, end); + if (dateValidationErrors.length > 0) { + return res.status(400).json({ + error: "Invalid date range", + details: dateValidationErrors, + }); + } + if (start) { const parsedStart = new Date(start); if (!isNaN(parsedStart.getTime())) { @@ -96,7 +116,23 @@ export const updateIncome = asyncHandler(async (req, res) => { return res.status(400).json({ error: "Invalid income ID" }); } - const updates = req.body; + //in case the request body is empty + + if (Object.keys(req.body).length === 0) { + return res.status(400).json({ + error: "No update data provided", + }); + } + + const validationErrors = validateIncomeData(req.body); + if (validationErrors.length > 0) { + return res.status(400).json({ + error: "Validation failed", + details: validationErrors, + }); + } + + const updates = {...req.body}; if (updates.amount !== undefined) { if (isNaN(parseFloat(updates.amount))) { diff --git a/server/utils/validators.js b/server/utils/validators.js new file mode 100644 index 0000000..813a5b5 --- /dev/null +++ b/server/utils/validators.js @@ -0,0 +1,51 @@ +//------------INCOME VALIDATORS----------------// + +//When a user is providing an income, amount should be greater than 1 +//the source character should not exceed 100 chara due to db restrictions, and same with description but with 500 chara + +export const validateIncomeData = (data) => { + const errors = []; + + if (data.amount !== undefined) { + const amount = parseFloat(data.amount); + if (isNaN(amount) || amount <= 0) { + errors.push("Amount must be greater than 0"); + } + } + + // if (data.date) { + // const parsedDate = new Date(data.date); + // if (isNaN(parsedDate.getTime())) { + // errors.push("Invalid date format"); + // } else if (parsedDate > new Date()) { + // errors.push("Date cannot be in the future"); + // } + // } + + if (data.source && data.source.length > 100) { + errors.push("Source cannot exceed 100 characters"); + } + + if (data.description && data.description.length > 500) { + errors.push("Description cannot exceed 500 characters"); + } + + return errors; +}; + +//A simple validation to make the date filter coherent + +export const validateDateRange = (start, end) => { + const errors = []; + + if (start && end) { + const startDate = new Date(start); + const endDate = new Date(end); + + if (startDate > endDate) { + errors.push("Start date cannot be after end date"); + } + } + + return errors; +}; From 4852c9f523203e39faa6dd2be0ecd0f24ba742c0 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Thu, 28 Aug 2025 10:23:43 +0300 Subject: [PATCH 26/26] feat: implement frontend value validation and improve error display --- client/src/components/IncomeForm.tsx | 24 ++++++++++++++++---- client/src/pages/IncomesPage.tsx | 33 ++++++++++++++++++---------- client/src/utils/validators.ts | 32 +++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 15 deletions(-) create mode 100644 client/src/utils/validators.ts diff --git a/client/src/components/IncomeForm.tsx b/client/src/components/IncomeForm.tsx index d6e04f3..8dff09c 100644 --- a/client/src/components/IncomeForm.tsx +++ b/client/src/components/IncomeForm.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react"; import type { Income, IncomeFormData } from "../types/Income"; import { Button, TextField, Dialog, DatePicker } from "../ui"; import { useMascot } from "../hooks/useMascot"; +import { validateIncomeData } from "../utils/validators"; interface IncomeFormProps { income?: Income; @@ -20,7 +21,7 @@ export const IncomeForm: React.FC = ({ const { showSuccess, showError } = useMascot(); const [formData, setFormData] = useState({ - amount: income?.amount || 1, + amount: income?.amount || 0, date: income?.date ? new Date(income.date).toISOString().split("T")[0] : new Date().toISOString().split("T")[0], @@ -28,6 +29,8 @@ export const IncomeForm: React.FC = ({ description: income?.description || "", }); + const [errors, setErrors] = useState>({}); + useEffect(() => { if (income) { setFormData({ @@ -38,18 +41,24 @@ export const IncomeForm: React.FC = ({ }); } else { setFormData({ - amount: 1, + amount: 0, date: new Date().toISOString().split("T")[0], source: "", description: "", }); } + setErrors({}); }, [income, open]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - setSaving(true); + const validationErrors = validateIncomeData(formData); + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + return; + } + setSaving(true); try { await onSave(formData); showSuccess(); @@ -69,6 +78,7 @@ export const IncomeForm: React.FC = ({ ...prev, [field]: field === "amount" ? Number(value) : value, })); + setErrors((prev) => ({ ...prev, [field]: "" })); }; const handleDateChange = (date: Date | null) => { @@ -92,7 +102,9 @@ export const IncomeForm: React.FC = ({ onChange={(e) => handleChange("amount", e.target.value)} required fullWidth - min={1} + min={0} + error={!!errors.amount} + helperText={errors.amount} /> = ({ value={formData.source ?? ""} onChange={(e) => handleChange("source", e.target.value)} fullWidth + error={!!errors.source} + helperText={errors.source} /> = ({ value={formData.description || ""} onChange={(e) => handleChange("description", e.target.value)} fullWidth + error={!!errors.description} + helperText={errors.description} />

diff --git a/client/src/pages/IncomesPage.tsx b/client/src/pages/IncomesPage.tsx index 94f30cc..c6be2b4 100644 --- a/client/src/pages/IncomesPage.tsx +++ b/client/src/pages/IncomesPage.tsx @@ -4,14 +4,17 @@ import { IncomeList } from "../components/IncomeList"; import { Button, Dialog, useToast, DatePicker } from "../ui"; import { IncomeService } from "../services/IncomeService"; import { useNavigate } from "react-router-dom"; +import { validateDateRange } from "../utils/validators"; export const IncomesPage = () => { const [dateFilter, setDateFilter] = useState<{ start?: string; end?: string; }>({}); + const [dateError, setDateError] = useState(null); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [incomeToDelete, setIncomeToDelete] = useState(null); + const toast = useToast(); const incomeListRef = useRef<{ refetch: () => void }>(null); const navigate = useNavigate(); @@ -46,17 +49,17 @@ export const IncomesPage = () => { }; const handleStartDateChange = (date: Date | null) => { - setDateFilter((prev) => ({ - ...prev, - start: date ? date.toISOString().split("T")[0] : undefined, - })); + const newStart = date ? date.toISOString().split("T")[0] : undefined; + const err = validateDateRange(newStart, dateFilter.end); + setDateError(err); + setDateFilter((prev) => ({ ...prev, start: newStart })); }; const handleEndDateChange = (date: Date | null) => { - setDateFilter((prev) => ({ - ...prev, - end: date ? date.toISOString().split("T")[0] : undefined, - })); + const newEnd = date ? date.toISOString().split("T")[0] : undefined; + const err = validateDateRange(dateFilter.start, newEnd); + setDateError(err); + setDateFilter((prev) => ({ ...prev, end: newEnd })); }; return ( @@ -71,7 +74,13 @@ export const IncomesPage = () => {
-
+
+ {dateError && ( +
+ {dateError} +
+ )} +
{ {incomeToDelete && (

- Amount: {incomeToDelete.amount.toFixed(2)} MGA + Amount: + {incomeToDelete.amount.toFixed(2)} MGA

- Source: {incomeToDelete.source.length > 0 ? incomeToDelete.source : "-"} + Source:{" "} + {incomeToDelete.source.length > 0 ? incomeToDelete.source : "-"}

Date:{" "} diff --git a/client/src/utils/validators.ts b/client/src/utils/validators.ts new file mode 100644 index 0000000..278f8be --- /dev/null +++ b/client/src/utils/validators.ts @@ -0,0 +1,32 @@ +import type { IncomeFormData } from "../types/Income"; + +export const validateIncomeData = (data: IncomeFormData) => { + const errors: Record = {}; + + const amount = data.amount; + if (isNaN(amount) || amount <= 0) { + errors.amount = "Amount must be greater than 0"; + } + + if (data.source && data.source.length > 100) { + errors.source = "Source cannot exceed 100 characters"; + } + + if (data.description && data.description.length > 500) { + errors.description = "Description cannot exceed 500 characters"; + } + + return errors; +}; + +export const validateDateRange = (start?: string, end?: string) => { + if (!start || !end) return null; + + const startDate = new Date(start); + const endDate = new Date(end); + + if (startDate > endDate) { + return "Start date cannot be after end date"; + } + return null; +};