diff --git a/src/controllers/user-settings/edit-exclude-categories.ts b/src/controllers/user-settings/edit-exclude-categories.ts new file mode 100644 index 0000000..1d350a4 --- /dev/null +++ b/src/controllers/user-settings/edit-exclude-categories.ts @@ -0,0 +1,36 @@ +import { API_RESPONSE_STATUS } from 'shared-types'; +import { z } from 'zod'; +import { recordId } from '@common/lib/zod/custom-types'; +import { editExcludedCategories } from '@services/user-settings/edit-excluded-categories'; +import { errorHandler } from '@controllers/helpers'; +import { CustomResponse } from '@common/types'; + +export const editExcludedCategoriesHandler = async ( + req, + res: CustomResponse +) => { + try { + const { addIds, removeIds } = req.validated.body; + const { user } = req; + + const updatedCategories = await editExcludedCategories({ + userId: user.id, + addIds, + removeIds, + }); + + res.status(200).json({ + status: API_RESPONSE_STATUS.success, + response: updatedCategories, + }); + } catch (error) { + errorHandler(res, error); + } +}; + +export const editExcludedCategoriesSchema = z.object({ + body: z.object({ + addIds: z.array(recordId()).optional().default([]), + removeIds: z.array(recordId()).optional().default([]), + }), +}); \ No newline at end of file diff --git a/src/models/UserSettings.model.ts b/src/models/UserSettings.model.ts index a322dc2..4b54752 100644 --- a/src/models/UserSettings.model.ts +++ b/src/models/UserSettings.model.ts @@ -10,6 +10,14 @@ export const ZodSettingsSchema = z.object({ }), }); +export const DEFAULT_SETTINGS: SettingsSchema = { + stats: { + expenses: { + excludedCategories: [], + }, + }, +}; + // Infer the TypeScript type from the Zod schema export type SettingsSchema = z.infer; diff --git a/src/routes/user.route.ts b/src/routes/user.route.ts index 1a41f97..1daa8ca 100644 --- a/src/routes/user.route.ts +++ b/src/routes/user.route.ts @@ -20,6 +20,7 @@ import { import { addUserCurrencies, addUserCurrenciesSchema } from '@controllers/currencies/add-user-currencies'; import { authenticateJwt } from '@middlewares/passport'; import { validateEndpoint } from '@middlewares/validations'; +import { editExcludedCategoriesHandler, editExcludedCategoriesSchema } from '@controllers/user-settings/edit-exclude-categories'; const router = Router({}); @@ -47,5 +48,6 @@ router.delete('/currency/rates', authenticateJwt, removeUserCurrencyExchangeRate router.get('/settings', authenticateJwt, getUserSettings); router.put('/settings', authenticateJwt, validateEndpoint(updateUserSettingsSchema), updateUserSettings); +router.put('/settings/edit-excluded-categories', authenticateJwt, validateEndpoint(editExcludedCategoriesSchema), editExcludedCategoriesHandler); export default router; diff --git a/src/services/user-settings/edit-excluded-categories.e2e.ts b/src/services/user-settings/edit-excluded-categories.e2e.ts new file mode 100644 index 0000000..7bc2100 --- /dev/null +++ b/src/services/user-settings/edit-excluded-categories.e2e.ts @@ -0,0 +1,126 @@ +import * as helpers from '@tests/helpers'; +import { CategoryModel } from '../../../shared-types/models'; + +describe('editExcludedCategories', () => { + let rootCategory: CategoryModel; + let subCategory1: CategoryModel; + let subCategory2: CategoryModel; + + beforeEach(async () => { + rootCategory = await helpers.addCustomCategory({ + name: 'Root Category1', + color: '#FF0000', + raw: true, + }); + subCategory1 = await helpers.addCustomCategory({ + name: 'Sub Category11', + parentId: rootCategory.id, + raw: true, + }); + subCategory2 = await helpers.addCustomCategory({ + name: 'Sub Category22', + parentId: rootCategory.id, + raw: true, + }); + }); + + it('should add new categories to excluded list', async () => { + const result = await helpers.editExcludedCategories({ + addIds: [subCategory1.id, subCategory2.id], + removeIds: [], + raw: true, + }); + + expect(result).toBeDefined(); + expect(result).toContain(subCategory1.id); + expect(result).toContain(subCategory2.id); + }); + + it('should remove categories from excluded list', async () => { + await helpers.editExcludedCategories({ + addIds: [subCategory1.id, subCategory2.id], + removeIds: [], + raw: true, + }); + + const result = await helpers.editExcludedCategories({ + addIds: [], + removeIds: [subCategory1.id], + raw: true, + }); + + expect(result).not.toContain(subCategory1.id); + expect(result).toContain(subCategory2.id); + }); + + it('should handle adding and removing categories simultaneously', async () => { + await helpers.editExcludedCategories({ + addIds: [subCategory1.id], + removeIds: [], + raw: true, + }); + + const result = await helpers.editExcludedCategories({ + addIds: [subCategory2.id], + removeIds: [subCategory1.id], + raw: true, + }); + + expect(result).not.toContain(subCategory1.id); + expect(result).toContain(subCategory2.id); + }); + + it('should ignore non-existent categories when adding', async () => { + const result = await helpers.editExcludedCategories({ + addIds: [subCategory1.id, 9999], + removeIds: [], + raw: true, + }); + + expect(result).toContain(subCategory1.id); + expect(result).not.toContain(9999); + }); + + it('should ignore non-existent categories when removing', async () => { + await helpers.editExcludedCategories({ + addIds: [subCategory1.id], + removeIds: [], + raw: true, + }); + + const result = await helpers.editExcludedCategories({ + addIds: [], + removeIds: [9999], + raw: true, + }); + + expect(result).toContain(subCategory1.id); + }); + + it('should handle duplicate categories when adding', async () => { + const result = await helpers.editExcludedCategories({ + addIds: [subCategory1.id, subCategory1.id], + removeIds: [], + raw: true, + }); + + expect(result).toContain(subCategory1.id); + expect(result.length).toBe(1); + }); + + it('should handle empty addIds and removeIds', async () => { + await helpers.editExcludedCategories({ + addIds: [subCategory1.id], + removeIds: [], + raw: true, + }); + + const result = await helpers.editExcludedCategories({ + addIds: [], + removeIds: [], + raw: true, + }); + + expect(result).toContain(subCategory1.id); + }); +}); \ No newline at end of file diff --git a/src/services/user-settings/edit-excluded-categories.ts b/src/services/user-settings/edit-excluded-categories.ts new file mode 100644 index 0000000..66ff18b --- /dev/null +++ b/src/services/user-settings/edit-excluded-categories.ts @@ -0,0 +1,43 @@ +import UserSettings, { DEFAULT_SETTINGS } from '@models/UserSettings.model'; +import { withTransaction } from '../common'; +import Categories from '@models/Categories.model'; + +export const editExcludedCategories = withTransaction( + async ({ + userId, + addIds, + removeIds, + }: { + userId: number; + addIds?: number[]; + removeIds?: number[]; + }): Promise => { + const [existingSettings] = await UserSettings.findOrCreate({ + where: { userId }, + defaults: { + settings: DEFAULT_SETTINGS, + }, + }); + + let currentExcludedCategories = + existingSettings.settings.stats.expenses.excludedCategories || []; + + const validAddIds = await Categories.findAll({ + where: { id: addIds }, + }).then((categories) => categories.map((category) => category.id)); + + currentExcludedCategories = currentExcludedCategories.filter( + (id) => !removeIds?.includes(id), + ); + + currentExcludedCategories = [ + ...new Set([...currentExcludedCategories, ...validAddIds]), + ]; + + existingSettings.settings.stats.expenses.excludedCategories = currentExcludedCategories; + existingSettings.changed('settings', true); + await existingSettings.save(); + + return currentExcludedCategories; + }, +); \ No newline at end of file diff --git a/src/tests/helpers/user-settings.ts b/src/tests/helpers/user-settings.ts index c74923d..081f88d 100644 --- a/src/tests/helpers/user-settings.ts +++ b/src/tests/helpers/user-settings.ts @@ -1,6 +1,7 @@ import { makeRequest } from './common'; import { getUserSettings as apiGetUserSettings } from '@root/services/user-settings/get-user-settings'; import { updateUserSettings as apiUpdateUserSettings } from '@root/services/user-settings/update-settings'; +import { editExcludedCategories as apiEditExcludedCategories } from '@root/services/user-settings/edit-excluded-categories'; export async function getUserSettings({ raw }: { raw?: R }) { return makeRequest>, R>({ @@ -23,3 +24,18 @@ export async function updateUserSettings({ + addIds, + removeIds, + raw, +}: Omit[0], 'userId'> & { + raw?: R; +}) { + return makeRequest>, R>({ + method: 'put', + url: '/user/settings/edit-excluded-categories', + payload: { addIds, removeIds }, + raw, + }); +} \ No newline at end of file