diff --git a/client/src/api/services/DefaultService.ts b/client/src/api/services/DefaultService.ts index 22c9ff8..4ceef98 100644 --- a/client/src/api/services/DefaultService.ts +++ b/client/src/api/services/DefaultService.ts @@ -2,490 +2,464 @@ /* 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 + * @returns void + * @throws ApiError + */ + public static deleteExpenses(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; } - /** - * 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, - }, - }); + ): 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, + }, + }); + } + /** + * 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; + category_id?: number; + }): 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; } - /** - * 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 - * @returns void - * @throws ApiError - */ - public static deleteExpenses( - 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, - }, - }); - } - /** - * 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; - category_id?: number; - }, - ): 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 - * @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', - }); + ): CancelablePromise { + return __request(OpenAPI, { + method: "PUT", + url: "/incomes/{id}", + path: { + id: id, + }, + body: requestBody, + mediaType: "application/json", + }); + } + /** + * Delete an income entry + * @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/server/controllers/category.controller.js b/server/controllers/category.controller.js new file mode 100644 index 0000000..4a8ac3e --- /dev/null +++ b/server/controllers/category.controller.js @@ -0,0 +1,37 @@ +import { asyncHandler } from '../utils/asyncHandler.js'; +import { + getExpenseCategories, + getExpenseCategoriesByUser, + createExpenseCategory, + updateExpenseCategory, + deleteExpenseCategory, +} from '../services/category.service.js'; + +export const listCategories = asyncHandler(async (req, res) => { + const categories = await getExpenseCategories(req.user.user_id); + res.json(categories); +}); + +export const listUserCategories = asyncHandler(async (req, res) => { + const categories = await getExpenseCategoriesByUser(req.user.user_id); + res.json(categories); +}); + +export const createCategory = asyncHandler(async (req, res) => { + const { name, icon_url } = req.body; + const created = await createExpenseCategory(req.user.user_id, { category_name: name, icon_url }); + res.status(201).json(created); +}); + +export const updateCategory = asyncHandler(async (req, res) => { + const { id } = req.params; + const { name, icon_url } = req.body; + const updated = await updateExpenseCategory(id, req.user.user_id, { category_name: name, icon_url }); + res.json(updated); +}); + +export const removeCategory = asyncHandler(async (req, res) => { + const { id } = req.params; + await deleteExpenseCategory(id, req.user.user_id); + res.status(204).send(); +}); diff --git a/server/docs/Expense Tracker API.yaml b/server/docs/Expense Tracker API.yaml index c4dee71..5564428 100644 --- a/server/docs/Expense Tracker API.yaml +++ b/server/docs/Expense Tracker API.yaml @@ -42,6 +42,7 @@ paths: responses: "200": description: Token returned + /expenses: get: summary: List all user expenses @@ -50,12 +51,12 @@ paths: name: start schema: type: string - description: Start date + description: Start date (ISO string) - in: query name: end schema: type: string - description: End date + description: End date (ISO string) - in: query name: category schema: @@ -98,6 +99,7 @@ paths: responses: "201": description: Expense created + /expenses/{id}: parameters: - in: path @@ -239,6 +241,7 @@ paths: responses: "201": description: Income created + /incomes/{id}: parameters: - in: path @@ -275,6 +278,7 @@ paths: responses: "204": description: Income deleted + /categories: get: summary: List user categories @@ -295,6 +299,7 @@ paths: responses: "201": description: Category created + /categories/{id}: parameters: - in: path @@ -320,6 +325,7 @@ paths: responses: "204": description: Category deleted + /summary/monthly: get: summary: Get current month summary @@ -331,6 +337,7 @@ paths: responses: "200": description: Monthly summary + /summary: get: summary: Get summary for custom range @@ -346,12 +353,14 @@ paths: responses: "200": description: Summary + /summary/alerts: get: summary: Budget overrun alert responses: "200": description: Alert info + /receipts/{id}: get: summary: Download/view receipt @@ -364,6 +373,7 @@ paths: responses: "200": description: Receipt file + /user/profile: get: summary: Get user profile diff --git a/server/middleware/validate.js b/server/middleware/validate.js index f8e41fe..29fc8f3 100644 --- a/server/middleware/validate.js +++ b/server/middleware/validate.js @@ -1,6 +1,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'; export const requireFields = (...fields) => (req, _res, next) => { for (const f of fields) { @@ -47,6 +48,52 @@ export const validateTextMaxLengths = (limits) => (req, _res, next) => { next(); }; +export const validateIdParam = (paramName) => (req, _res, next) => { + const id = req.params[paramName]; + if (!id || isNaN(parseInt(id, 10))) { + return next(new BadRequestError(`Invalid ID in parameter: ${paramName}`)); + } + next(); +}; + +export const validateCategoryCreate = [ + 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 (!isURL(icon_url, { require_protocol: true })) { + return next(new BadRequestError('Invalid URL format for icon_url')); + } + } + next(); + }, +]; + +export const validateCategoryUpdate = [ + 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 (icon_url) { + 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')); + } + } + next(); + }, +]; + // Combined middlewares for cleaner routes export const validateSignup = [ requireFields('email', 'password'), diff --git a/server/routes/category.route.js b/server/routes/category.route.js new file mode 100644 index 0000000..c2c36f3 --- /dev/null +++ b/server/routes/category.route.js @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import { requireAuth } from '../middleware/auth.middleware.js'; +import { listCategories, createCategory, updateCategory, removeCategory } from '../controllers/category.controller.js'; +import { validateIdParam, validateCategoryCreate, validateCategoryUpdate } from '../middleware/validate.js'; + +const router = Router(); + +router.use(requireAuth); + +router.get('/', listCategories); +router.post('/', validateCategoryCreate, createCategory); +router.put('/:id', validateIdParam('id'), validateCategoryUpdate, updateCategory); +router.delete('/:id', validateIdParam('id'), removeCategory); + +export default router; diff --git a/server/server.js b/server/server.js index 0fa4729..67dd472 100644 --- a/server/server.js +++ b/server/server.js @@ -1,27 +1,29 @@ 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 authRoutes from './routes/auth.route.js'; +import categoryRoutes from './routes/category.route.js'; dotenv.config(); const app = express(); const PORT = process.env.PORT || 8080; -app.use(cors()); +app.use(cors({ + origin: process.env.CORS_ORIGIN || true, + credentials: true, +})); app.use(express.json()); +app.use(cookieParser()); -//temporary middleware for testing -app.use((req, res, next) => { - req.user = { user_id: 4 }; // fake user, but you have to create a fake user with id 4 as well in your local database - next(); -}); -app.use('/api/auth', authRoutes) -//Routes -app.use('/api/incomes', incomeRoutes); +app.use('/api/auth', authRoutes); +app.use('/api/incomes', requireAuth, incomeRoutes); +app.use('/api/categories', categoryRoutes); // Initialize a single Prisma client instance const prisma = new PrismaClient(); diff --git a/server/services/category.service.js b/server/services/category.service.js new file mode 100644 index 0000000..894ff80 --- /dev/null +++ b/server/services/category.service.js @@ -0,0 +1,73 @@ +import { prisma } from '../db/prisma.js'; +import { NotFoundError, ConflictError } from '../utils/errors.js'; + +// return global (user_id null) + user categories +export const getExpenseCategories = async (userId) => { + return prisma.expenseCategory.findMany({ + where: { OR: [{ user_id: null }, { user_id: userId }] }, + orderBy: [{ is_custom: 'asc' }, { category_name: 'asc' }], + }); +}; + +export const getExpenseCategoriesByUser = async (userId) => { + return prisma.expenseCategory.findMany({ + where: { user_id: userId }, + orderBy: [{ is_custom: 'asc' }, { category_name: 'asc' }], + }); +}; + +export const createExpenseCategory = async (userId, data) => { + const exists = await prisma.expenseCategory.findFirst({ + where: { + category_name: { equals: data.category_name, mode: 'insensitive' }, + user_id: userId, + }, + }); + if (exists) throw new ConflictError('Category already exists'); + + return prisma.expenseCategory.create({ + data: { + category_name: data.category_name, + icon_url: data.icon_url, + is_custom: true, + user: { connect: { user_id: userId } }, + }, + }); +}; + +export const updateExpenseCategory = async (id, userId, data) => { + const categoryId = parseInt(id); + const category = await prisma.expenseCategory.findFirst({ + where: { category_id: categoryId, user_id: userId }, + }); + if (!category) throw new NotFoundError('Category not found or not authorized'); + + if (data.category_name) { + const dup = await prisma.expenseCategory.findFirst({ + where: { + category_name: { equals: data.category_name, mode: 'insensitive' }, + user_id: userId, + NOT: { category_id: categoryId }, + }, + }); + if (dup) throw new ConflictError('Category name already exists'); + } + + return prisma.expenseCategory.update({ + where: { category_id: categoryId }, + data: { category_name: data.category_name, icon_url: data.icon_url }, + }); +}; + +export const deleteExpenseCategory = async (id, userId) => { + const categoryId = parseInt(id); + const category = await prisma.expenseCategory.findFirst({ + where: { category_id: categoryId, user_id: userId }, + }); + if (!category) throw new NotFoundError('Category not found or not authorized'); + + const used = await prisma.expense.count({ where: { category_id: categoryId } }); + if (used > 0) throw new ConflictError('Cannot delete category that is being used'); + + await prisma.expenseCategory.delete({ where: { category_id: categoryId } }); +};