From 7e4e9e981ddb1db2ab1e6ec3383b1632ffbd43d8 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Tue, 26 Aug 2025 20:21:15 +0300 Subject: [PATCH 01/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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/41] 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; +}; From 7c272e0919ff7832d5369c46cf58cc5307099fb6 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Thu, 28 Aug 2025 11:41:51 +0300 Subject: [PATCH 27/41] feat: add user profile update and password change service functions --- server/services/auth.service.js | 36 +++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/server/services/auth.service.js b/server/services/auth.service.js index e5d40eb..0e61ff4 100644 --- a/server/services/auth.service.js +++ b/server/services/auth.service.js @@ -52,3 +52,39 @@ export const getPublicUser = async (userId) => { if (!user) throw new NotFoundError('User not found'); return user; }; + +//-------------------------------------------------------- + +//UPDATE USER, in order to allow user to change their profile informations + +export const updateUserProfile = async (userId, { firstname, lastname, username }) => { + const user = await prisma.user.update({ + where: { user_id: userId }, + data: { + firstname: firstname || undefined, + lastname: lastname || undefined, + username: username || undefined, + }, + select: publicUserSelect, + }); + return user; +}; + +//possibility to change password + +export const changeUserPassword = async (userId, { currentPassword, newPassword }) => { + const user = await prisma.user.findUnique({ where: { user_id: userId } }); + if (!user) throw new NotFoundError('User not found'); + + const ok = await bcrypt.compare(currentPassword, user.hashed_password); + if (!ok) throw new UnauthorizedError('Current password is incorrect'); + + const hashed_password = await bcrypt.hash(newPassword, 10); + + await prisma.user.update({ + where: { user_id: userId }, + data: { hashed_password }, + }); + + return { message: 'Password updated successfully' }; +}; \ No newline at end of file From d56f9cef786426d3991fd6fa1646326bbd102b9e Mon Sep 17 00:00:00 2001 From: colombefioren Date: Thu, 28 Aug 2025 11:46:00 +0300 Subject: [PATCH 28/41] feat: create user profile controller with get, update, and password change endpoints --- server/controllers/user.controller.js | 56 +++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 server/controllers/user.controller.js diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js new file mode 100644 index 0000000..b509d46 --- /dev/null +++ b/server/controllers/user.controller.js @@ -0,0 +1,56 @@ +import { + updateUserProfile, + changeUserPassword, + getPublicUser, +} from "../services/auth.service.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; +import { BadRequestError } from "../utils/errors.js"; +import isStrongPassword from "validator/lib/isStrongPassword.js"; + +export const getUserProfile = asyncHandler(async (req, res) => { + const user = await getPublicUser(req.user.user_id); + return res.json(user); +}); + +export const updateProfile = asyncHandler(async (req, res) => { + const { firstname, lastname, username } = req.body; + + if (!firstname && !lastname && !username) { + throw new BadRequestError("At least one field is required for update"); + } + + const user = await updateUserProfile(req.user.user_id, { + firstname, + lastname, + username, + }); + return res.json(user); +}); + +export const changePassword = asyncHandler(async (req, res) => { + const { currentPassword, newPassword } = req.body; + + if (!currentPassword || !newPassword) { + throw new BadRequestError("Current password and new password are required"); + } + + if ( + !isStrongPassword(String(newPassword), { + minLength: 6, + minLowercase: 0, + minUppercase: 1, + minNumbers: 1, + minSymbols: 0, + }) + ) { + throw new BadRequestError( + "New password must be at least 6 characters and include at least one uppercase letter and one number" + ); + } + + const result = await changeUserPassword(req.user.user_id, { + currentPassword, + newPassword, + }); + return res.json(result); +}); From 74ec5f53bf0712d4dfc23d268261c429d1e1e320 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Thu, 28 Aug 2025 11:57:39 +0300 Subject: [PATCH 29/41] feat: add user profile routes with consistent validation pattern --- server/controllers/user.controller.js | 24 ---- server/middleware/validate.js | 151 +++++++++++++++++++------- server/routes/user.route.js | 24 ++++ 3 files changed, 136 insertions(+), 63 deletions(-) create mode 100644 server/routes/user.route.js diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js index b509d46..efc2a5c 100644 --- a/server/controllers/user.controller.js +++ b/server/controllers/user.controller.js @@ -4,8 +4,6 @@ import { getPublicUser, } from "../services/auth.service.js"; import { asyncHandler } from "../utils/asyncHandler.js"; -import { BadRequestError } from "../utils/errors.js"; -import isStrongPassword from "validator/lib/isStrongPassword.js"; export const getUserProfile = asyncHandler(async (req, res) => { const user = await getPublicUser(req.user.user_id); @@ -15,10 +13,6 @@ export const getUserProfile = asyncHandler(async (req, res) => { export const updateProfile = asyncHandler(async (req, res) => { const { firstname, lastname, username } = req.body; - if (!firstname && !lastname && !username) { - throw new BadRequestError("At least one field is required for update"); - } - const user = await updateUserProfile(req.user.user_id, { firstname, lastname, @@ -30,24 +24,6 @@ export const updateProfile = asyncHandler(async (req, res) => { export const changePassword = asyncHandler(async (req, res) => { const { currentPassword, newPassword } = req.body; - if (!currentPassword || !newPassword) { - throw new BadRequestError("Current password and new password are required"); - } - - if ( - !isStrongPassword(String(newPassword), { - minLength: 6, - minLowercase: 0, - minUppercase: 1, - minNumbers: 1, - minSymbols: 0, - }) - ) { - throw new BadRequestError( - "New password must be at least 6 characters and include at least one uppercase letter and one number" - ); - } - const result = await changeUserPassword(req.user.user_id, { currentPassword, newPassword, diff --git a/server/middleware/validate.js b/server/middleware/validate.js index 29fc8f3..3425335 100644 --- a/server/middleware/validate.js +++ b/server/middleware/validate.js @@ -1,22 +1,24 @@ -import { BadRequestError } from '../utils/errors.js'; -import isEmail from 'validator/lib/isEmail.js'; -import normalizeEmail from 'validator/lib/normalizeEmail.js'; -import isURL from 'validator/lib/isURL.js'; - -export const requireFields = (...fields) => (req, _res, next) => { - for (const f of fields) { - const v = req.body?.[f]; - if (v == null || v === '') { - return next(new BadRequestError(`Missing field: ${f}`)); +import { BadRequestError } from "../utils/errors.js"; +import isEmail from "validator/lib/isEmail.js"; +import normalizeEmail from "validator/lib/normalizeEmail.js"; +import isURL from "validator/lib/isURL.js"; + +export const requireFields = + (...fields) => + (req, _res, next) => { + for (const f of fields) { + const v = req.body?.[f]; + if (v == null || v === "") { + return next(new BadRequestError(`Missing field: ${f}`)); + } } - } - next(); -}; + next(); + }; // Normalize and validate email. Sets req.body.email to the normalized lowercase value. export const validateEmail = () => (req, _res, next) => { - const raw = String(req.body.email || '').trim(); - if (!isEmail(raw)) return next(new BadRequestError('Invalid email format')); + const raw = String(req.body.email || "").trim(); + if (!isEmail(raw)) return next(new BadRequestError("Invalid email format")); const normalized = normalizeEmail(raw, { all_lowercase: true, gmail_remove_dots: false, @@ -29,20 +31,24 @@ export const validateEmail = () => (req, _res, next) => { next(); }; -export const sanitizeBody = (...fields) => (req, _res, next) => { - for (const f of fields) { - if (typeof req.body?.[f] === 'string') { - req.body[f] = req.body[f].trim(); +export const sanitizeBody = + (...fields) => + (req, _res, next) => { + for (const f of fields) { + if (typeof req.body?.[f] === "string") { + req.body[f] = req.body[f].trim(); + } } - } - next(); -}; + next(); + }; export const validateTextMaxLengths = (limits) => (req, _res, next) => { for (const [field, max] of Object.entries(limits || {})) { const v = req.body?.[field]; - if (typeof v === 'string' && v.length > max) { - return next(new BadRequestError(`${field} is too long (max ${max} characters)`)); + if (typeof v === "string" && v.length > max) { + return next( + new BadRequestError(`${field} is too long (max ${max} characters)`) + ); } } next(); @@ -57,17 +63,19 @@ export const validateIdParam = (paramName) => (req, _res, next) => { }; export const validateCategoryCreate = [ - requireFields('name'), - sanitizeBody('name', 'icon_url'), + requireFields("name"), + sanitizeBody("name", "icon_url"), validateTextMaxLengths({ name: 50 }), (_req, _res, next) => { const { icon_url } = _req.body; if (icon_url) { - if (typeof icon_url !== 'string' || icon_url.length > 255) { - return next(new BadRequestError('icon_url is too long (max 255 characters)')); + if (typeof icon_url !== "string" || icon_url.length > 255) { + return next( + new BadRequestError("icon_url is too long (max 255 characters)") + ); } if (!isURL(icon_url, { require_protocol: true })) { - return next(new BadRequestError('Invalid URL format for icon_url')); + return next(new BadRequestError("Invalid URL format for icon_url")); } } next(); @@ -75,19 +83,21 @@ export const validateCategoryCreate = [ ]; export const validateCategoryUpdate = [ - sanitizeBody('name', 'icon_url'), + sanitizeBody("name", "icon_url"), validateTextMaxLengths({ name: 50 }), (_req, _res, next) => { const { name, icon_url } = _req.body; - if (name != null && name === '') { - return next(new BadRequestError('name cannot be empty')); + if (name != null && name === "") { + return next(new BadRequestError("name cannot be empty")); } if (icon_url) { - if (typeof icon_url !== 'string' || icon_url.length > 255) { - return next(new BadRequestError('icon_url is too long (max 255 characters)')); + if (typeof icon_url !== "string" || icon_url.length > 255) { + return next( + new BadRequestError("icon_url is too long (max 255 characters)") + ); } if (!isURL(icon_url, { require_protocol: true })) { - return next(new BadRequestError('Invalid URL format for icon_url')); + return next(new BadRequestError("Invalid URL format for icon_url")); } } next(); @@ -96,13 +106,76 @@ export const validateCategoryUpdate = [ // Combined middlewares for cleaner routes export const validateSignup = [ - requireFields('email', 'password'), + requireFields("email", "password"), validateEmail(), - sanitizeBody('username', 'firstname', 'lastname'), - validateTextMaxLengths({ username: 50, firstname: 50}), + sanitizeBody("username", "firstname", "lastname"), + validateTextMaxLengths({ username: 50, firstname: 50 }), ]; export const validateLogin = [ - requireFields('email', 'password'), + requireFields("email", "password"), validateEmail(), ]; + +//--------------------USER PROFILE VALIDATIONS +export const validateUpdateProfile = [ + sanitizeBody("firstname", "lastname", "username"), + validateTextMaxLengths({ + firstname: 100, + lastname: 100, + username: 50, + }), + (req, _res, next) => { + const { firstname, lastname, username } = req.body; + + //check if at least one field is provided + if ( + firstname === undefined && + lastname === undefined && + username === undefined + ) { + return next( + new BadRequestError("At least one field is required for update") + ); + } + + if (firstname !== undefined && firstname === "") { + return next(new BadRequestError("First name cannot be empty")); + } + + if (lastname !== undefined && lastname === "") { + return next(new BadRequestError("Last name cannot be empty")); + } + + if (username !== undefined && username === "") { + return next(new BadRequestError("Username cannot be empty")); + } + + next(); + }, +]; + +export const validateChangePassword = [ + requireFields("currentPassword", "newPassword"), + (req, _res, next) => { + const { newPassword } = req.body; + + if ( + !isStrongPassword(String(newPassword), { + minLength: 6, + minLowercase: 0, + minUppercase: 1, + minNumbers: 1, + minSymbols: 0, + }) + ) { + return next( + new BadRequestError( + "New password must be at least 6 characters and include at least one uppercase letter and one number" + ) + ); + } + + next(); + }, +]; diff --git a/server/routes/user.route.js b/server/routes/user.route.js new file mode 100644 index 0000000..fe6c7d0 --- /dev/null +++ b/server/routes/user.route.js @@ -0,0 +1,24 @@ +import { Router } from "express"; +import { requireAuth } from "../middleware/auth.middleware.js"; +import { + getUserProfile, + updateProfile, + changePassword, +} from "../controllers/user.controller.js"; +import { + validateUpdateProfile, + validateChangePassword, +} from "../middleware/validate.js"; + +const router = Router(); + +router.get("/profile", requireAuth, getUserProfile); +router.put("/profile", requireAuth, validateUpdateProfile, updateProfile); +router.patch( + "/profile/password", + requireAuth, + validateChangePassword, + changePassword +); + +export default router; From c477ba3004bc99c1a3fe71939cde3704b311d9c5 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Thu, 28 Aug 2025 12:06:13 +0300 Subject: [PATCH 30/41] feat: user profile route in main app --- server/middleware/validate.js | 1 + server/routes/user.route.js | 12 +++--------- server/server.js | 37 +++++++++++++++++++---------------- 3 files changed, 24 insertions(+), 26 deletions(-) diff --git a/server/middleware/validate.js b/server/middleware/validate.js index 3425335..0ee7edd 100644 --- a/server/middleware/validate.js +++ b/server/middleware/validate.js @@ -2,6 +2,7 @@ import { BadRequestError } from "../utils/errors.js"; import isEmail from "validator/lib/isEmail.js"; import normalizeEmail from "validator/lib/normalizeEmail.js"; import isURL from "validator/lib/isURL.js"; +import isStrongPassword from 'validator/lib/isStrongPassword.js'; export const requireFields = (...fields) => diff --git a/server/routes/user.route.js b/server/routes/user.route.js index fe6c7d0..893e653 100644 --- a/server/routes/user.route.js +++ b/server/routes/user.route.js @@ -1,5 +1,4 @@ import { Router } from "express"; -import { requireAuth } from "../middleware/auth.middleware.js"; import { getUserProfile, updateProfile, @@ -12,13 +11,8 @@ import { const router = Router(); -router.get("/profile", requireAuth, getUserProfile); -router.put("/profile", requireAuth, validateUpdateProfile, updateProfile); -router.patch( - "/profile/password", - requireAuth, - validateChangePassword, - changePassword -); +router.get("/profile", getUserProfile); +router.put("/profile", validateUpdateProfile, updateProfile); +router.patch("/profile/password", validateChangePassword, changePassword); export default router; diff --git a/server/server.js b/server/server.js index 623554c..7f9ca72 100644 --- a/server/server.js +++ b/server/server.js @@ -1,29 +1,32 @@ -import express from 'express'; -import cors from 'cors'; -import dotenv from 'dotenv'; -import cookieParser from 'cookie-parser'; -import { requireAuth } from './middleware/auth.middleware.js'; -import { PrismaClient } from '@prisma/client'; -import incomeRoutes from './routes/income.route.js'; -import authRoutes from './routes/auth.route.js'; -import categoryRoutes from './routes/category.route.js'; +import express from "express"; +import cors from "cors"; +import dotenv from "dotenv"; +import cookieParser from "cookie-parser"; +import { requireAuth } from "./middleware/auth.middleware.js"; +import { PrismaClient } from "@prisma/client"; +import incomeRoutes from "./routes/income.route.js"; +import authRoutes from "./routes/auth.route.js"; +import categoryRoutes from "./routes/category.route.js"; +import userRoutes from "./routes/user.route.js"; dotenv.config(); const app = express(); const PORT = process.env.PORT || 8080; -app.use(cors({ - origin: process.env.CORS_ORIGIN || true, - credentials: true, -})); +app.use( + cors({ + origin: process.env.CORS_ORIGIN || true, + credentials: true, + }) +); app.use(express.json()); app.use(cookieParser()); - -app.use('/api/auth', authRoutes); -app.use('/api/incomes', requireAuth, incomeRoutes); -app.use('/api/categories', categoryRoutes); +app.use("/api/auth", authRoutes); +app.use("/api/incomes", requireAuth, incomeRoutes); +app.use("/api/categories", categoryRoutes); +app.use("/api/user", requireAuth, userRoutes); // Initialize a single Prisma client instance const prisma = new PrismaClient(); From 5ec13f6069e37093f0d92e53f35ff8a001f893ba Mon Sep 17 00:00:00 2001 From: colombefioren Date: Thu, 28 Aug 2025 12:19:28 +0300 Subject: [PATCH 31/41] feat: user service and add user profile methods to defaultservice via addition of new needed paths in the spec --- client/src/api/core/OpenAPI.ts | 2 +- client/src/api/core/request.ts | 540 ++++++++++------------ client/src/api/services/DefaultService.ts | 36 +- client/src/services/UserService.ts | 46 ++ client/src/types/UserProfile.ts | 19 + server/docs/Expense Tracker API.yaml | 24 + 6 files changed, 371 insertions(+), 296 deletions(-) create mode 100644 client/src/services/UserService.ts create mode 100644 client/src/types/UserProfile.ts diff --git a/client/src/api/core/OpenAPI.ts b/client/src/api/core/OpenAPI.ts index 03da729..77fa96a 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/client/src/api/core/request.ts b/client/src/api/core/request.ts index 6cf8280..319b00a 100644 --- a/client/src/api/core/request.ts +++ b/client/src/api/core/request.ts @@ -2,315 +2,285 @@ /* 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(); - - const process = (key: string, value: any) => { - if (isString(value) || isBlob(value)) { - formData.append(key, value); - } else { - formData.append(key, JSON.stringify(value)); - } - }; +export const getFormData = (options: ApiRequestOptions): FormData | undefined => { + if (options.formData) { + const formData = new FormData(); - 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); - } - }); + const process = (key: string, value: any) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(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 - ); + }), {} as Record); + + if (isStringWithValue(token)) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers['Authorization'] = `Basic ${credentials}`; + } - 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"; + 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; + 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; } - 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}` + ); + } }; /** @@ -321,49 +291,33 @@ export const catchErrorCodes = ( * @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); - - const axiosOptions = { - url, - method: options.method, - data: body, - headers, - params: url.includes("?") ? undefined : 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); - } - }); -}; +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); + } + }); +}; diff --git a/client/src/api/services/DefaultService.ts b/client/src/api/services/DefaultService.ts index 93bed92..de18745 100644 --- a/client/src/api/services/DefaultService.ts +++ b/client/src/api/services/DefaultService.ts @@ -42,8 +42,8 @@ export class DefaultService { } /** * List all user expenses - * @param start Start date - * @param end End date + * @param start Start date (ISO string) + * @param end End date (ISO string) * @param category * @param type * @returns any List of expenses @@ -383,4 +383,36 @@ export class DefaultService { url: "/user/profile", }); } + /** + * Update user profile + * @param requestBody + * @returns any Profile updated + * @throws ApiError + */ + public static putUserProfile( + requestBody: Record + ): CancelablePromise { + return __request(OpenAPI, { + method: "PUT", + url: "/user/profile", + body: requestBody, + mediaType: "application/json", + }); + } + /** + * Update user password + * @param requestBody + * @returns any Password updated + * @throws ApiError + */ + public static patchUserProfilePassword( + requestBody: Record + ): CancelablePromise { + return __request(OpenAPI, { + method: "PATCH", + url: "/user/profile/password", + body: requestBody, + mediaType: "application/json", + }); + } } diff --git a/client/src/services/UserService.ts b/client/src/services/UserService.ts new file mode 100644 index 0000000..4a050af --- /dev/null +++ b/client/src/services/UserService.ts @@ -0,0 +1,46 @@ +import { DefaultService } from "../api/services/DefaultService"; +import { useMascotStore } from "../stores/mascotStore"; +import type { ChangePasswordRequest, UpdateProfileRequest, UserProfile } from "../types/UserProfile"; + +export class UserService { + // GET user profile + static async getProfile() { + try { + const response = await DefaultService.getUserProfile(); + useMascotStore.getState().setExpression("success"); + return response as UserProfile; + } catch (error) { + useMascotStore.getState().setExpression("error"); + console.error("Error fetching user profile:", error); + throw new Error("Failed to fetch user profile"); + } + } + + //UPDATE user profile + static async updateProfile(profileData: UpdateProfileRequest) { + try { + const response = await DefaultService.putUserProfile(profileData); + useMascotStore.getState().setExpression("success"); + return response as UserProfile; + } catch (error) { + useMascotStore.getState().setExpression("error"); + console.error("Error updating profile:", error); + throw new Error("Failed to update profile"); + } + } + + //CHANGE password + static async changePassword(passwordData: ChangePasswordRequest) { + try { + const response = await DefaultService.patchUserProfilePassword( + passwordData + ); + useMascotStore.getState().setExpression("success"); + return response; + } catch (error) { + useMascotStore.getState().setExpression("error"); + console.error("Error changing password:", error); + throw new Error("Failed to change password"); + } + } +} diff --git a/client/src/types/UserProfile.ts b/client/src/types/UserProfile.ts new file mode 100644 index 0000000..d461e7e --- /dev/null +++ b/client/src/types/UserProfile.ts @@ -0,0 +1,19 @@ +export interface UpdateProfileRequest { + firstname?: string; + lastname?: string; + username?: string; +} + +export interface ChangePasswordRequest { + currentPassword: string; + newPassword: string; +} + +export interface UserProfile { + user_id: number; + email: string; + username: string; + firstname: string; + lastname: string; + created_at: string; +} diff --git a/server/docs/Expense Tracker API.yaml b/server/docs/Expense Tracker API.yaml index be252b6..080b378 100644 --- a/server/docs/Expense Tracker API.yaml +++ b/server/docs/Expense Tracker API.yaml @@ -338,3 +338,27 @@ paths: responses: "200": description: Profile info + put: + summary: Update user profile + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Profile updated + + /user/profile/password: + patch: + summary: Update user password + requestBody: + required: true + content: + application/json: + schema: + type: object + responses: + "200": + description: Password updated From 53df101171d5c02a01fb753907fda08ae42f7cd4 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Thu, 28 Aug 2025 12:26:03 +0300 Subject: [PATCH 32/41] feat: confirmPassword attribute in the change password request type --- client/src/types/UserProfile.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/types/UserProfile.ts b/client/src/types/UserProfile.ts index d461e7e..4d5ab06 100644 --- a/client/src/types/UserProfile.ts +++ b/client/src/types/UserProfile.ts @@ -7,6 +7,7 @@ export interface UpdateProfileRequest { export interface ChangePasswordRequest { currentPassword: string; newPassword: string; + confirmPassword?: string; } export interface UserProfile { From 07bf0b1d6712866d56abf77daa0c5eddaa49f840 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Thu, 28 Aug 2025 12:28:34 +0300 Subject: [PATCH 33/41] feat: user profile hook --- client/src/hooks/useUserProfile.ts | 68 ++++++++++++++++++++++++++++++ client/src/services/UserService.ts | 2 +- 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 client/src/hooks/useUserProfile.ts diff --git a/client/src/hooks/useUserProfile.ts b/client/src/hooks/useUserProfile.ts new file mode 100644 index 0000000..d65a98e --- /dev/null +++ b/client/src/hooks/useUserProfile.ts @@ -0,0 +1,68 @@ +import { useState, useEffect } from "react"; +import { UserService } from "../services/UserService"; +import type { + UserProfile, + UpdateProfileRequest, + ChangePasswordRequest, +} from "../types/UserProfile"; + +export const useUserProfile = () => { + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchProfile = async () => { + try { + setLoading(true); + setError(null); + const userProfile = await UserService.getProfile(); + setProfile(userProfile); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch profile"); + } finally { + setLoading(false); + } + }; + + const updateProfile = async ( + data: UpdateProfileRequest + ): Promise => { + try { + setError(null); + const updatedProfile = await UserService.updateProfile(data); + setProfile(updatedProfile); + return true; + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update profile"); + return false; + } + }; + + const changePassword = async ( + data: ChangePasswordRequest + ): Promise => { + try { + setError(null); + await UserService.changePassword(data); + return true; + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to change password" + ); + return false; + } + }; + + useEffect(() => { + fetchProfile(); + }, []); + + return { + profile, + loading, + error, + fetchProfile, + updateProfile, + changePassword, + }; +}; diff --git a/client/src/services/UserService.ts b/client/src/services/UserService.ts index 4a050af..383caf9 100644 --- a/client/src/services/UserService.ts +++ b/client/src/services/UserService.ts @@ -3,7 +3,7 @@ import { useMascotStore } from "../stores/mascotStore"; import type { ChangePasswordRequest, UpdateProfileRequest, UserProfile } from "../types/UserProfile"; export class UserService { - // GET user profile + //GET user profile static async getProfile() { try { const response = await DefaultService.getUserProfile(); From ca48f82407fcfd17917598a2bba59656bc21a8c9 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Thu, 28 Aug 2025 13:21:33 +0300 Subject: [PATCH 34/41] feat(frontend): implement user profile management --- client/src/App.tsx | 14 +- client/src/api/core/request.ts | 540 ++++++++++-------- .../components/{ => Income}/IncomeForm.tsx | 8 +- .../components/{ => Income}/IncomeList.tsx | 6 +- .../src/components/Profile/PasswordForm.tsx | 195 +++++++ client/src/components/Profile/ProfileForm.tsx | 138 +++++ client/src/components/ui/LoadingSpinner.tsx | 24 + client/src/hooks/useUserProfile.ts | 20 +- ...{CreateIncomePage.tsx => CreateIncome.tsx} | 4 +- .../{EditIncomePage.tsx => EditIncome.tsx} | 4 +- .../pages/{IncomesPage.tsx => Incomes.tsx} | 4 +- client/src/pages/Profile.tsx | 114 ++++ client/src/services/UserService.ts | 48 +- client/src/types/UserProfile.ts | 2 +- client/src/utils/formUtils.ts | 19 + server/controllers/user.controller.js | 18 +- 16 files changed, 858 insertions(+), 300 deletions(-) rename client/src/components/{ => Income}/IncomeForm.tsx (90%) rename client/src/components/{ => Income}/IncomeList.tsx (93%) create mode 100644 client/src/components/Profile/PasswordForm.tsx create mode 100644 client/src/components/Profile/ProfileForm.tsx create mode 100644 client/src/components/ui/LoadingSpinner.tsx rename client/src/pages/{CreateIncomePage.tsx => CreateIncome.tsx} (89%) rename client/src/pages/{EditIncomePage.tsx => EditIncome.tsx} (92%) rename client/src/pages/{IncomesPage.tsx => Incomes.tsx} (95%) create mode 100644 client/src/pages/Profile.tsx create mode 100644 client/src/utils/formUtils.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 49f4c99..8e1c660 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,13 +1,14 @@ import { Routes, Route, useLocation } from "react-router-dom"; import { ToastProvider } from "./ui"; -import { IncomesPage } from "./pages/IncomesPage"; +import { Incomes } from "./pages/Incomes"; import Sidebar from "./components/common/Sidebar"; import Dashboard from "./pages/Dashboard"; import BackgroundImage from "./components/common/BackgroundImage"; -import { CreateIncomePage } from "./pages/CreateIncomePage"; -import { EditIncomePage } from "./pages/EditIncomePage"; +import { CreateIncome } from "./pages/CreateIncome"; +import { EditIncome } from "./pages/EditIncome"; import Mascot from "./components/common/Mascot"; import { DashboardHeader } from "./components/common/Header"; +import { Profile } from "./pages/Profile"; function App() { /* @@ -34,9 +35,10 @@ function App() { } /> - } /> - } /> - } /> + } /> + } /> + } /> + } />

diff --git a/client/src/api/core/request.ts b/client/src/api/core/request.ts index 319b00a..6cf8280 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,49 @@ 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: url.includes("?") ? undefined : 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); + } + }); +}; diff --git a/client/src/components/IncomeForm.tsx b/client/src/components/Income/IncomeForm.tsx similarity index 90% rename from client/src/components/IncomeForm.tsx rename to client/src/components/Income/IncomeForm.tsx index 8dff09c..f0477c9 100644 --- a/client/src/components/IncomeForm.tsx +++ b/client/src/components/Income/IncomeForm.tsx @@ -1,8 +1,8 @@ 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"; +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; diff --git a/client/src/components/IncomeList.tsx b/client/src/components/Income/IncomeList.tsx similarity index 93% rename from client/src/components/IncomeList.tsx rename to client/src/components/Income/IncomeList.tsx index eae35b2..2693531 100644 --- a/client/src/components/IncomeList.tsx +++ b/client/src/components/Income/IncomeList.tsx @@ -1,7 +1,7 @@ import { forwardRef, useImperativeHandle } from "react"; -import type { Income } from "../types/Income"; -import { useIncomes } from "../hooks/useIncomes"; -import { Button } from "../ui"; +import type { Income } from "../../types/Income"; +import { useIncomes } from "../../hooks/useIncomes"; +import { Button } from "../../ui"; interface IncomeListProps { startDate?: string; diff --git a/client/src/components/Profile/PasswordForm.tsx b/client/src/components/Profile/PasswordForm.tsx new file mode 100644 index 0000000..e09c130 --- /dev/null +++ b/client/src/components/Profile/PasswordForm.tsx @@ -0,0 +1,195 @@ +import React, { useState } from "react"; +import { Button, TextField } from "../../ui"; +import type { ChangePasswordRequest } from "../../types/UserProfile"; +import { createFieldChangeHandler } from "../../utils/formUtils"; + +interface PasswordFormProps { + onChangePassword: ( + data: ChangePasswordRequest + ) => Promise<{ success: boolean; error?: string }>; + loading?: boolean; +} + +export const PasswordForm: React.FC = ({ + onChangePassword, + loading = false, +}) => { + const [formData, setFormData] = useState({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }); + const [message, setMessage] = useState(""); + const [formError, setFormError] = useState(""); + const [fieldErrors, setFieldErrors] = useState>({}); + + const validateForm = (): boolean => { + const errors: Record = {}; + + if (!formData.currentPassword) { + errors.currentPassword = "Current password is required"; + } + + if (formData.newPassword.length < 6) { + errors.newPassword = "Password must be at least 6 characters long"; + } else if (!/(?=.*[A-Z])/.test(formData.newPassword)) { + errors.newPassword = + "Password must contain at least one uppercase letter"; + } else if (!/(?=.*\d)/.test(formData.newPassword)) { + errors.newPassword = "Password must contain at least one number"; + } + + if (formData.newPassword !== formData.confirmPassword) { + errors.confirmPassword = "Passwords do not match"; + } + + setFieldErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setMessage(""); + setFormError(""); + setFieldErrors({}); + + if (!validateForm()) return; + + const result = await onChangePassword({ + currentPassword: formData.currentPassword, + newPassword: formData.newPassword, + }); + + if (result.success) { + setMessage("Password changed successfully!"); + setFormData({ + currentPassword: "", + newPassword: "", + confirmPassword: "", + }); + setTimeout(() => setMessage(""), 3000); + } else { + const error = result.error || "Failed to change password"; + setFormError(error); + + if ( + error.toLowerCase().includes("current") || + error.toLowerCase().includes("incorrect") + ) { + setFieldErrors((prev) => ({ ...prev, currentPassword: error })); + } else if ( + error.toLowerCase().includes("new") || + error.toLowerCase().includes("password") + ) { + if ( + error.toLowerCase().includes("uppercase") || + error.toLowerCase().includes("number") || + error.toLowerCase().includes("6") || + error.toLowerCase().includes("character") + ) { + setFieldErrors((prev) => ({ ...prev, newPassword: error })); + } else { + setFormError(error); + } + } else { + setFormError(error); + } + } + }; + + const handleCurrentPasswordChange = createFieldChangeHandler( + setFormData, + "currentPassword" + ); + const handleNewPasswordChange = createFieldChangeHandler( + setFormData, + "newPassword" + ); + const handleConfirmPasswordChange = createFieldChangeHandler( + setFormData, + "confirmPassword" + ); + + const clearFieldError = (fieldName: string) => { + if (fieldErrors[fieldName]) { + setFieldErrors((prev) => ({ ...prev, [fieldName]: "" })); + } + if (formError) setFormError(""); + }; + + return ( +
+

Change Password

+ +
+ clearFieldError("currentPassword")} + placeholder="Enter current password" + variant="outlined" + size="medium" + fullWidth + error={!!fieldErrors.currentPassword} + helperText={fieldErrors.currentPassword} + /> + + clearFieldError("newPassword")} + placeholder="Enter new password" + variant="outlined" + size="medium" + fullWidth + error={!!fieldErrors.newPassword} + helperText={ + fieldErrors.newPassword || + "Must be at least 6 characters with 1 uppercase letter and 1 number" + } + /> + + clearFieldError("confirmPassword")} + placeholder="Confirm new password" + variant="outlined" + size="medium" + fullWidth + error={!!fieldErrors.confirmPassword} + helperText={fieldErrors.confirmPassword} + /> + + {message && ( +
+ {message} +
+ )} + + {formError && !Object.values(fieldErrors).some((error) => error) && ( +
+ {formError} +
+ )} + + + +
+ ); +}; diff --git a/client/src/components/Profile/ProfileForm.tsx b/client/src/components/Profile/ProfileForm.tsx new file mode 100644 index 0000000..2d612de --- /dev/null +++ b/client/src/components/Profile/ProfileForm.tsx @@ -0,0 +1,138 @@ +import React, { useState } from "react"; +import type { + UserProfile, + UpdateProfileRequest, +} from "../../types/UserProfile"; +import { Button, TextField } from "../../ui"; +import { createFieldChangeHandler } from "../../utils/formUtils"; + +interface ProfileFormProps { + profile: UserProfile; + onUpdate: ( + data: UpdateProfileRequest + ) => Promise<{ success: boolean; error?: string }>; + loading?: boolean; +} + +export const ProfileForm: React.FC = ({ + profile, + onUpdate, + loading = false, +}) => { + const [formData, setFormData] = useState({ + firstname: profile.firstname || "", + lastname: profile.lastname || "", + username: profile.username || "", + }); + const [message, setMessage] = useState(""); + const [formError, setFormError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setMessage(""); + setFormError(""); + + const result = await onUpdate(formData); + if (result.success) { + setMessage("Profile updated successfully!"); + setTimeout(() => setMessage(""), 3000); + } else { + setFormError(result.error || "Failed to update profile"); + } + }; + + const handleFirstNameChange = createFieldChangeHandler( + setFormData, + "firstname" + ); + const handleLastNameChange = createFieldChangeHandler( + setFormData, + "lastname" + ); + const handleUsernameChange = createFieldChangeHandler( + setFormData, + "username" + ); + + const clearError = () => { + if (formError) setFormError(""); + }; + + return ( +
+

+ Profile Information +

+ +
+
+ + + +
+ + + +
+ +
+ {profile.email} +
+

Email cannot be changed

+
+ + {message && ( +
+ {message} +
+ )} + + {formError && ( +
+ {formError} +
+ )} + + + +
+ ); +}; diff --git a/client/src/components/ui/LoadingSpinner.tsx b/client/src/components/ui/LoadingSpinner.tsx new file mode 100644 index 0000000..858e88e --- /dev/null +++ b/client/src/components/ui/LoadingSpinner.tsx @@ -0,0 +1,24 @@ +// components/ui/LoadingSpinner.tsx +import React from "react"; + +interface LoadingSpinnerProps { + size?: "sm" | "md" | "lg"; + className?: string; +} + +export const LoadingSpinner: React.FC = ({ + size = "md", + className = "", +}) => { + const sizeClasses = { + sm: "w-4 h-4", + md: "w-8 h-8", + lg: "w-12 h-12", + }; + + return ( +
+ ); +}; diff --git a/client/src/hooks/useUserProfile.ts b/client/src/hooks/useUserProfile.ts index d65a98e..ec20bd2 100644 --- a/client/src/hooks/useUserProfile.ts +++ b/client/src/hooks/useUserProfile.ts @@ -26,30 +26,30 @@ export const useUserProfile = () => { const updateProfile = async ( data: UpdateProfileRequest - ): Promise => { + ): Promise<{ success: boolean; error?: string }> => { try { setError(null); const updatedProfile = await UserService.updateProfile(data); setProfile(updatedProfile); - return true; + return { success: true }; } catch (err) { - setError(err instanceof Error ? err.message : "Failed to update profile"); - return false; + const errorMessage = + err instanceof Error ? err.message : "Failed to update profile"; + return { success: false, error: errorMessage }; } }; const changePassword = async ( data: ChangePasswordRequest - ): Promise => { + ): Promise<{ success: boolean; error?: string }> => { try { setError(null); await UserService.changePassword(data); - return true; + return { success: true }; } catch (err) { - setError( - err instanceof Error ? err.message : "Failed to change password" - ); - return false; + const errorMessage = + err instanceof Error ? err.message : "Failed to change password"; + return { success: false, error: errorMessage }; } }; diff --git a/client/src/pages/CreateIncomePage.tsx b/client/src/pages/CreateIncome.tsx similarity index 89% rename from client/src/pages/CreateIncomePage.tsx rename to client/src/pages/CreateIncome.tsx index 301fd2c..d8fbf69 100644 --- a/client/src/pages/CreateIncomePage.tsx +++ b/client/src/pages/CreateIncome.tsx @@ -1,10 +1,10 @@ import { useNavigate } from "react-router-dom"; -import { IncomeForm } from "../components/IncomeForm"; +import { IncomeForm } from "../components/Income/IncomeForm"; import { Button, useToast } from "../ui"; import { IncomeService } from "../services/IncomeService"; import type { CreateIncomeRequest } from "../types/Income"; -export const CreateIncomePage = () => { +export const CreateIncome = () => { const navigate = useNavigate(); const toast = useToast(); diff --git a/client/src/pages/EditIncomePage.tsx b/client/src/pages/EditIncome.tsx similarity index 92% rename from client/src/pages/EditIncomePage.tsx rename to client/src/pages/EditIncome.tsx index 3b2dd01..22c6d79 100644 --- a/client/src/pages/EditIncomePage.tsx +++ b/client/src/pages/EditIncome.tsx @@ -1,11 +1,11 @@ import { useState, useEffect } from "react"; import { useNavigate, useParams } from "react-router-dom"; -import { IncomeForm } from "../components/IncomeForm"; +import { IncomeForm } from "../components/Income/IncomeForm"; import { Button, useToast, Skeleton } from "../ui"; import { IncomeService } from "../services/IncomeService"; import type { Income, UpdateIncomeRequest } from "../types/Income"; -export const EditIncomePage = () => { +export const EditIncome = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const toast = useToast(); diff --git a/client/src/pages/IncomesPage.tsx b/client/src/pages/Incomes.tsx similarity index 95% rename from client/src/pages/IncomesPage.tsx rename to client/src/pages/Incomes.tsx index c6be2b4..7733557 100644 --- a/client/src/pages/IncomesPage.tsx +++ b/client/src/pages/Incomes.tsx @@ -1,12 +1,12 @@ import { useState, useRef } from "react"; import type { Income } from "../types/Income"; -import { IncomeList } from "../components/IncomeList"; +import { IncomeList } from "../components/Income/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 = () => { +export const Incomes = () => { const [dateFilter, setDateFilter] = useState<{ start?: string; end?: string; diff --git a/client/src/pages/Profile.tsx b/client/src/pages/Profile.tsx new file mode 100644 index 0000000..2e082ac --- /dev/null +++ b/client/src/pages/Profile.tsx @@ -0,0 +1,114 @@ +import { useState } from "react"; +import { useUserProfile } from "../hooks/useUserProfile"; +import { ProfileForm } from "../components/Profile/ProfileForm"; +import { PasswordForm } from "../components/Profile/PasswordForm"; +import { Button } from "../ui"; +import type { + ChangePasswordRequest, + UpdateProfileRequest, +} from "../types/UserProfile"; + +export const Profile = () => { + const { + profile, + loading, + error, + fetchProfile, + updateProfile, + changePassword, + } = useUserProfile(); + const [updating, setUpdating] = useState(false); + const [changingPassword, setChangingPassword] = useState(false); + + const handleUpdateProfile = async (data: UpdateProfileRequest) => { + setUpdating(true); + const result = await updateProfile(data); + setUpdating(false); + return result; + }; + + const handleChangePassword = async (data: ChangePasswordRequest) => { + setChangingPassword(true); + const result = await changePassword(data); + setChangingPassword(false); + return result; + }; + + if (loading) { + return ( +
+
+
+

Loading profile...

+
+
+ ); + } + + if (error) { + return ( +
+
+

Error

+

{error}

+ +
+
+ ); + } + + if (!profile) { + return ( +
+
+

+ Profile Not Found +

+

Unable to load user profile

+
+
+ ); + } + + return ( +
+
+
+

User Profile

+

+ Manage your account settings and preferences +

+
+ +
+ + + +
+ +
+

+ Account Information +

+
+
+ Member since: + + {new Date(profile.created_at).toLocaleDateString()} + +
+
+
+
+
+ ); +}; diff --git a/client/src/services/UserService.ts b/client/src/services/UserService.ts index 383caf9..f0957a0 100644 --- a/client/src/services/UserService.ts +++ b/client/src/services/UserService.ts @@ -1,46 +1,60 @@ import { DefaultService } from "../api/services/DefaultService"; import { useMascotStore } from "../stores/mascotStore"; -import type { ChangePasswordRequest, UpdateProfileRequest, UserProfile } from "../types/UserProfile"; +import type { + UserProfile, + UpdateProfileRequest, + ChangePasswordRequest, +} from "../types/UserProfile"; + +interface ApiError { + body?: { message?: string }; +} export class UserService { //GET user profile - static async getProfile() { + static async getProfile(): Promise { try { const response = await DefaultService.getUserProfile(); useMascotStore.getState().setExpression("success"); return response as UserProfile; - } catch (error) { + } catch (error: unknown) { useMascotStore.getState().setExpression("error"); - console.error("Error fetching user profile:", error); - throw new Error("Failed to fetch user profile"); + const err = error as ApiError; + const errorMessage = err.body?.message || "Failed to fetch user profile"; + throw new Error(errorMessage); } } //UPDATE user profile - static async updateProfile(profileData: UpdateProfileRequest) { + static async updateProfile( + profileData: UpdateProfileRequest + ): Promise { try { const response = await DefaultService.putUserProfile(profileData); useMascotStore.getState().setExpression("success"); return response as UserProfile; - } catch (error) { + } catch (error: unknown) { useMascotStore.getState().setExpression("error"); - console.error("Error updating profile:", error); - throw new Error("Failed to update profile"); + const err = error as ApiError; + const errorMessage = err.body?.message || "Failed to update profile"; + throw new Error(errorMessage); } } //CHANGE password - static async changePassword(passwordData: ChangePasswordRequest) { + static async changePassword( + passwordData: ChangePasswordRequest + ): Promise<{ message: string }> { try { - const response = await DefaultService.patchUserProfilePassword( - passwordData - ); + const { ...apiData } = passwordData; // removes confirmPassword if exists + const response = await DefaultService.patchUserProfilePassword(apiData); useMascotStore.getState().setExpression("success"); - return response; - } catch (error) { + return response as { message: string }; + } catch (error: unknown) { useMascotStore.getState().setExpression("error"); - console.error("Error changing password:", error); - throw new Error("Failed to change password"); + const err = error as ApiError; + const errorMessage = err.body?.message || "Failed to change password"; + throw new Error(errorMessage); } } } diff --git a/client/src/types/UserProfile.ts b/client/src/types/UserProfile.ts index 4d5ab06..6d8e02a 100644 --- a/client/src/types/UserProfile.ts +++ b/client/src/types/UserProfile.ts @@ -17,4 +17,4 @@ export interface UserProfile { firstname: string; lastname: string; created_at: string; -} +} \ No newline at end of file diff --git a/client/src/utils/formUtils.ts b/client/src/utils/formUtils.ts new file mode 100644 index 0000000..7cebb88 --- /dev/null +++ b/client/src/utils/formUtils.ts @@ -0,0 +1,19 @@ +import type { ChangeEvent } from "react"; + +export const createChangeHandler = (setter: (value: string) => void) => { + return (e: ChangeEvent) => { + setter(e.target.value); + }; +}; + +export const createFieldChangeHandler = >( + setFormData: React.Dispatch>, + field: keyof T +) => { + return (e: ChangeEvent) => { + setFormData((prev) => ({ + ...prev, + [field]: e.target.value, + })); + }; +}; diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js index efc2a5c..d44ce46 100644 --- a/server/controllers/user.controller.js +++ b/server/controllers/user.controller.js @@ -23,10 +23,16 @@ export const updateProfile = asyncHandler(async (req, res) => { export const changePassword = asyncHandler(async (req, res) => { const { currentPassword, newPassword } = req.body; - - const result = await changeUserPassword(req.user.user_id, { - currentPassword, - newPassword, - }); - return res.json(result); + try { + const result = await changeUserPassword(req.user.user_id, { + currentPassword, + newPassword, + }); + return res.json(result); + } catch (error) { + if (error instanceof UnauthorizedError) { + throw new BadRequestError("Current password is incorrect"); + } + throw error; + } }); From ee23cf91a5627e25178957110840ab79724b38af Mon Sep 17 00:00:00 2001 From: colombefioren Date: Thu, 28 Aug 2025 13:22:36 +0300 Subject: [PATCH 35/41] remove: unnecessary ui component spinner --- client/src/components/ui/LoadingSpinner.tsx | 24 --------------------- 1 file changed, 24 deletions(-) delete mode 100644 client/src/components/ui/LoadingSpinner.tsx diff --git a/client/src/components/ui/LoadingSpinner.tsx b/client/src/components/ui/LoadingSpinner.tsx deleted file mode 100644 index 858e88e..0000000 --- a/client/src/components/ui/LoadingSpinner.tsx +++ /dev/null @@ -1,24 +0,0 @@ -// components/ui/LoadingSpinner.tsx -import React from "react"; - -interface LoadingSpinnerProps { - size?: "sm" | "md" | "lg"; - className?: string; -} - -export const LoadingSpinner: React.FC = ({ - size = "md", - className = "", -}) => { - const sizeClasses = { - sm: "w-4 h-4", - md: "w-8 h-8", - lg: "w-12 h-12", - }; - - return ( -
- ); -}; From 6cf7ddd5ff60d80c627b8bcc865f497d803b922e Mon Sep 17 00:00:00 2001 From: colombefioren Date: Thu, 28 Aug 2025 13:33:34 +0300 Subject: [PATCH 36/41] feat: user sotre in zustand --- client/src/stores/userStore.ts | 70 ++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 client/src/stores/userStore.ts diff --git a/client/src/stores/userStore.ts b/client/src/stores/userStore.ts new file mode 100644 index 0000000..90d85ce --- /dev/null +++ b/client/src/stores/userStore.ts @@ -0,0 +1,70 @@ +import { create } from "zustand"; +import { UserService } from "../services/UserService"; +import type { + UserProfile, + UpdateProfileRequest, + ChangePasswordRequest, +} from "../types/UserProfile"; + +interface UserState { + user: UserProfile | null; + loading: boolean; + error: string | null; + fetchProfile: () => Promise; + updateProfile: ( + data: UpdateProfileRequest + ) => Promise<{ success: boolean; error?: string }>; + changePassword: ( + data: ChangePasswordRequest + ) => Promise<{ success: boolean; error?: string }>; + clearUser: () => void; +} + +export const useUserStore = create((set) => ({ + user: null, + loading: false, + error: null, + + fetchProfile: async () => { + set({ loading: true, error: null }); + try { + const userProfile = await UserService.getProfile(); + set({ user: userProfile, loading: false }); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Failed to fetch profile"; + set({ error: errorMessage, loading: false, user: null }); + } + }, + + updateProfile: async (data: UpdateProfileRequest) => { + try { + set({ error: null }); + const updatedProfile = await UserService.updateProfile(data); + set({ user: updatedProfile }); + return { success: true }; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Failed to update profile"; + set({ error: errorMessage }); + return { success: false, error: errorMessage }; + } + }, + + changePassword: async (data: ChangePasswordRequest) => { + try { + set({ error: null }); + await UserService.changePassword(data); + return { success: true }; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Failed to change password"; + set({ error: errorMessage }); + return { success: false, error: errorMessage }; + } + }, + + clearUser: () => { + set({ user: null, error: null }); + }, +})); From 2fe6be3208bc5d1f77d06dd2d96716332b8e6096 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Thu, 28 Aug 2025 13:34:58 +0300 Subject: [PATCH 37/41] refactor: update dashboard header to use user store --- .../src/components/common/Header/Header.tsx | 60 ++++++++++++++++--- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/client/src/components/common/Header/Header.tsx b/client/src/components/common/Header/Header.tsx index 85f3521..b5be797 100644 --- a/client/src/components/common/Header/Header.tsx +++ b/client/src/components/common/Header/Header.tsx @@ -4,9 +4,15 @@ import ThemeToggle from "../ThemeToggle"; import { ChevronDown } from "lucide-react"; import { DateDropdown, NotificationBell, SearchInput } from "./components"; import { useState, useEffect } from "react"; +import { useUserStore } from "../../../stores/userStore"; const DashboardHeader = () => { const [isScrolled, setIsScrolled] = useState(false); + const { user, loading, error, fetchProfile } = useUserStore(); + + useEffect(() => { + fetchProfile(); + }, [fetchProfile]); useEffect(() => { const handleScroll = () => { @@ -17,6 +23,32 @@ const DashboardHeader = () => { return () => window.removeEventListener("scroll", handleScroll); }, []); + const getUserDisplayName = () => { + if (loading) return "Loading..."; + if (error || !user) return "GUEST"; + + if (user.firstname && user.lastname) { + return `${user.firstname} ${user.lastname}`; + } + if (user.firstname) return user.firstname; + if (user.lastname) return user.lastname; + if (user.username) return user.username; + + return "User"; + }; + + const getUserRole = () => { + if (loading) return "Loading..."; + if (error || !user) return "Guest Mode"; + return "Client"; + }; + + const getWelcomeMessage = () => { + if (loading) return "Welcome back!"; + if (error || !user) return "Welcome, Guest!"; + return `Hi, `; + }; + return (
{ >

- Hi,{" "} - John + {getWelcomeMessage()} + {user && ( + + {getUserDisplayName().split(" ")[0]} + + )}

Track all your transactions with PennyPal @@ -45,22 +81,32 @@ const DashboardHeader = () => {

- +
Profile -
+ {user && ( +
+ )} + {!user && ( +
+ )}
- John Doe + {getUserDisplayName()} - Client + {getUserRole()}
From bb771ade0c10d330ab18e6082c9ce827e54bd90e Mon Sep 17 00:00:00 2001 From: colombefioren Date: Thu, 28 Aug 2025 13:42:29 +0300 Subject: [PATCH 38/41] refactor: update profile page to use zustand user store --- .../src/components/common/Header/Header.tsx | 8 ++-- client/src/pages/Profile.tsx | 40 ++++++++++--------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/client/src/components/common/Header/Header.tsx b/client/src/components/common/Header/Header.tsx index b5be797..66d3be7 100644 --- a/client/src/components/common/Header/Header.tsx +++ b/client/src/components/common/Header/Header.tsx @@ -37,10 +37,10 @@ const DashboardHeader = () => { return "User"; }; - const getUserRole = () => { + const getUsername = () => { if (loading) return "Loading..."; - if (error || !user) return "Guest Mode"; - return "Client"; + if (error || !user) return "guest"; + return user.username; }; const getWelcomeMessage = () => { @@ -106,7 +106,7 @@ const DashboardHeader = () => { {getUserDisplayName()} - {getUserRole()} + @{getUsername()}
diff --git a/client/src/pages/Profile.tsx b/client/src/pages/Profile.tsx index 2e082ac..eed4f11 100644 --- a/client/src/pages/Profile.tsx +++ b/client/src/pages/Profile.tsx @@ -1,22 +1,16 @@ -import { useState } from "react"; -import { useUserProfile } from "../hooks/useUserProfile"; +import React, { useState } from "react"; import { ProfileForm } from "../components/Profile/ProfileForm"; import { PasswordForm } from "../components/Profile/PasswordForm"; import { Button } from "../ui"; +import { useUserStore } from "../stores/userStore"; import type { ChangePasswordRequest, UpdateProfileRequest, } from "../types/UserProfile"; -export const Profile = () => { - const { - profile, - loading, - error, - fetchProfile, - updateProfile, - changePassword, - } = useUserProfile(); +export const Profile: React.FC = () => { + const { user, loading, error, fetchProfile, updateProfile, changePassword } = + useUserStore(); const [updating, setUpdating] = useState(false); const [changingPassword, setChangingPassword] = useState(false); @@ -36,7 +30,7 @@ export const Profile = () => { if (loading) { return ( -
+

Loading profile...

@@ -47,7 +41,7 @@ export const Profile = () => { if (error) { return ( -
+

Error

{error}

@@ -59,14 +53,22 @@ export const Profile = () => { ); } - if (!profile) { + if (!user) { return ( -
+

- Profile Not Found + Please Log In

-

Unable to load user profile

+

+ You need to be logged in to view your profile +

+
); @@ -84,7 +86,7 @@ export const Profile = () => {
@@ -103,7 +105,7 @@ export const Profile = () => {
Member since: - {new Date(profile.created_at).toLocaleDateString()} + {new Date(user.created_at).toLocaleDateString()}
From 21e9252bd482bbbc793a05921de350af4c1a2b05 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Thu, 28 Aug 2025 14:01:39 +0300 Subject: [PATCH 39/41] style: fix profile page padding --- client/src/App.tsx | 2 +- client/src/pages/Profile.tsx | 18 ++++++------------ 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/client/src/App.tsx b/client/src/App.tsx index 8e1c660..67b9704 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -23,7 +23,7 @@ function App() { pauseOnHover={true} anchorOrigin={{ vertical: "top", horizontal: "right" }} > -
+
{location.pathname.includes("/login") || location.pathname.includes("/register") ? null : ( <> diff --git a/client/src/pages/Profile.tsx b/client/src/pages/Profile.tsx index eed4f11..83296ff 100644 --- a/client/src/pages/Profile.tsx +++ b/client/src/pages/Profile.tsx @@ -30,7 +30,7 @@ export const Profile: React.FC = () => { if (loading) { return ( -
+

Loading profile...

@@ -41,7 +41,7 @@ export const Profile: React.FC = () => { if (error) { return ( -
+

Error

{error}

@@ -55,7 +55,7 @@ export const Profile: React.FC = () => { if (!user) { return ( -
+

Please Log In @@ -75,16 +75,10 @@ export const Profile: React.FC = () => { } return ( -
+
-
-

User Profile

-

- Manage your account settings and preferences -

-
- -
+ +
Date: Thu, 28 Aug 2025 18:00:31 +0300 Subject: [PATCH 40/41] refactor: better error display when users arent logged in --- client/src/pages/Profile.tsx | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/client/src/pages/Profile.tsx b/client/src/pages/Profile.tsx index 83296ff..dd5e41b 100644 --- a/client/src/pages/Profile.tsx +++ b/client/src/pages/Profile.tsx @@ -30,7 +30,7 @@ export const Profile: React.FC = () => { if (loading) { return ( -
+

Loading profile...

@@ -39,23 +39,9 @@ export const Profile: React.FC = () => { ); } - if (error) { - return ( -
-
-

Error

-

{error}

- -
-
- ); - } - if (!user) { return ( -
+

Please Log In @@ -74,11 +60,24 @@ export const Profile: React.FC = () => { ); } + if (error) { + return ( +
+
+

Error

+

{error}

+ +
+
+ ); + } + return (
- -
+
Date: Thu, 28 Aug 2025 18:06:45 +0300 Subject: [PATCH 41/41] refactor: allow users to provide empty first and last name --- server/middleware/validate.js | 8 -------- 1 file changed, 8 deletions(-) diff --git a/server/middleware/validate.js b/server/middleware/validate.js index 0ee7edd..ef141d0 100644 --- a/server/middleware/validate.js +++ b/server/middleware/validate.js @@ -140,14 +140,6 @@ export const validateUpdateProfile = [ ); } - if (firstname !== undefined && firstname === "") { - return next(new BadRequestError("First name cannot be empty")); - } - - if (lastname !== undefined && lastname === "") { - return next(new BadRequestError("Last name cannot be empty")); - } - if (username !== undefined && username === "") { return next(new BadRequestError("Username cannot be empty")); }