diff --git a/apps/backend/db/db_setup.sql b/apps/backend/db/db_setup.sql index bd78f6c..c25cd88 100644 --- a/apps/backend/db/db_setup.sql +++ b/apps/backend/db/db_setup.sql @@ -94,9 +94,12 @@ INSERT INTO project_memberships (project_id, user_id, role, start_date, hours) V (2, 3, 'Staff', '2025-03-15', 60.00); INSERT INTO expenditures (project_id, entered_by, amount, category, description, spent_on) VALUES -(1, 1, 5000, 'Travel', 'Conference attendance', '2025-02-10'), -(2, 2, 3000, 'Equipment', 'Purchase of recording devices', '2025-04-05'), -(3, 3, 2500, 'Supplies', 'Educational materials', '2025-07-12'); +(1, 1, 5000, 'Travel', 'Domestic conference attendance', '2025-02-10'), +(1, 1, 4200, 'Travel Foreign', 'International collaborator meeting in London', '2025-03-22'), +(2, 2, 3000, 'General', 'Recording device supplies', '2025-04-05'), +(2, 2, 1500, 'Visitor / Honorarium', 'Guest lecturer honorarium', '2025-05-18'), +(3, 3, 2500, 'General', 'Educational materials', '2025-07-12'), +(3, 3, 1800, 'Travel', 'Local outreach travel', '2025-08-03'); INSERT INTO reports (project_id, object_url) VALUES (1, 'https://s3.amazonaws.com/branch-reports/clinician_communication_study_report.pdf'), diff --git a/apps/backend/docker-compose.yml b/apps/backend/docker-compose.yml index a46d91f..87e772e 100644 --- a/apps/backend/docker-compose.yml +++ b/apps/backend/docker-compose.yml @@ -74,6 +74,8 @@ services: DB_USER: ${DB_USER:-branch_dev} DB_PASSWORD: ${DB_PASSWORD:-password} DB_NAME: ${DB_NAME:-branch_db} + COGNITO_USER_POOL_ID: ${COGNITO_USER_POOL_ID} + COGNITO_APP_CLIENT_ID: ${COGNITO_CLIENT_ID} ports: - '3003:3000' depends_on: @@ -93,6 +95,8 @@ services: DB_USER: ${DB_USER:-branch_dev} DB_PASSWORD: ${DB_PASSWORD:-password} DB_NAME: ${DB_NAME:-branch_db} + COGNITO_USER_POOL_ID: ${COGNITO_USER_POOL_ID} + COGNITO_CLIENT_ID: ${COGNITO_CLIENT_ID} ports: - '3004:3000' depends_on: diff --git a/apps/backend/lambdas/donors/README.md b/apps/backend/lambdas/donors/README.md index e48be9d..a83b98a 100644 --- a/apps/backend/lambdas/donors/README.md +++ b/apps/backend/lambdas/donors/README.md @@ -10,7 +10,8 @@ Lambda for managing donors. |--------|------|-------------| | GET | /health | Health check | | GET | /donors | | -| GET | /donations | | +| POST | /donations | | +| POST | /donors | | ## Setup diff --git a/apps/backend/lambdas/donors/handler.ts b/apps/backend/lambdas/donors/handler.ts index be43cdc..f1b35d8 100644 --- a/apps/backend/lambdas/donors/handler.ts +++ b/apps/backend/lambdas/donors/handler.ts @@ -11,6 +11,11 @@ export const handler = async (event: any): Promise => { const normalizedPath = rawPath.replace(/\/$/, ''); const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); + // CORS preflight + if (method === 'OPTIONS') { + return json(200, {}); + } + // Health check if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { return json(200, { ok: true, timestamp: new Date().toISOString() }); @@ -126,6 +131,13 @@ export const handler = async (event: any): Promise => { .execute(); return json(200, { data: donations }); } + + // POST /donors + if (normalizedPath === '/donors' && method === 'POST') { + const body = event.body ? JSON.parse(event.body) as Record : {}; + // TODO: Add your business logic here + return json(201, { ok: true, route: 'POST /donors', body }); + } // <<< ROUTES-END return json(404, { message: 'Not Found', path: normalizedPath, method }); diff --git a/apps/backend/lambdas/donors/openapi.yaml b/apps/backend/lambdas/donors/openapi.yaml index a8d364e..53c52d1 100644 --- a/apps/backend/lambdas/donors/openapi.yaml +++ b/apps/backend/lambdas/donors/openapi.yaml @@ -25,3 +25,43 @@ paths: responses: '200': description: OK + + /donors: + post: + summary: POST /donors + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + organization: + type: string + contact_name: + type: string + contact_email: + type: string + responses: + '201': + description: Success + + /donations: + post: + summary: POST /donations + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + donor_id: + type: integer + project_id: + type: integer + amount: + type: number + responses: + '201': + description: Success diff --git a/apps/backend/lambdas/expenditures/handler.ts b/apps/backend/lambdas/expenditures/handler.ts index bc6338b..b7be0fd 100644 --- a/apps/backend/lambdas/expenditures/handler.ts +++ b/apps/backend/lambdas/expenditures/handler.ts @@ -12,6 +12,11 @@ export const handler = async (event: any): Promise => { const normalizedPath = rawPath.replace(/\/$/, ''); const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); + // CORS preflight + if (method === 'OPTIONS') { + return json(200, {}); + } + // Health check if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { return json(200, { ok: true, timestamp: new Date().toISOString() }); diff --git a/apps/backend/lambdas/expenditures/package-lock.json b/apps/backend/lambdas/expenditures/package-lock.json index c98be8c..fadb02a 100644 --- a/apps/backend/lambdas/expenditures/package-lock.json +++ b/apps/backend/lambdas/expenditures/package-lock.json @@ -492,7 +492,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -505,7 +505,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -1099,28 +1099,28 @@ "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@tybys/wasm-util": { @@ -1518,7 +1518,7 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1531,7 +1531,7 @@ "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -2222,7 +2222,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -2319,7 +2319,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -3948,7 +3948,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/makeerror": { @@ -5127,7 +5127,7 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "^0.8.0", @@ -5171,7 +5171,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/tslib": { @@ -5206,7 +5206,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -5336,7 +5336,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { @@ -5663,7 +5663,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" diff --git a/apps/backend/lambdas/expenditures/test/expenditures.e2e.test.ts b/apps/backend/lambdas/expenditures/test/expenditures.e2e.test.ts index 22ff2d0..cd9b031 100644 --- a/apps/backend/lambdas/expenditures/test/expenditures.e2e.test.ts +++ b/apps/backend/lambdas/expenditures/test/expenditures.e2e.test.ts @@ -224,7 +224,7 @@ describe('Expenditures integration tests', () => { expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(Array.isArray(body.data)).toBe(true); - expect(body.data.length).toBe(3); + expect(body.data.length).toBe(6); expect(body.pagination).toBeUndefined(); }); @@ -233,8 +233,9 @@ describe('Expenditures integration tests', () => { const res = await handler(getEvent('/')); const body = JSON.parse(res.body); const dates = body.data.map((e: any) => new Date(e.spent_on).getTime()); - expect(dates[0]).toBeGreaterThanOrEqual(dates[1]); - expect(dates[1]).toBeGreaterThanOrEqual(dates[2]); + for (let i = 0; i < dates.length - 1; i++) { + expect(dates[i]).toBeGreaterThanOrEqual(dates[i + 1]); + } }); test('200: paginated response with page and limit', async () => { @@ -246,8 +247,8 @@ describe('Expenditures integration tests', () => { expect(body.pagination).toBeDefined(); expect(body.pagination.page).toBe(1); expect(body.pagination.limit).toBe(1); - expect(body.pagination.totalItems).toBe(3); - expect(body.pagination.totalPages).toBe(3); + expect(body.pagination.totalItems).toBe(6); + expect(body.pagination.totalPages).toBe(6); }); test('200: page 2 returns second item', async () => { @@ -264,8 +265,8 @@ describe('Expenditures integration tests', () => { const res = await handler(getEvent('/', { page: '1', limit: '100' })); const body = JSON.parse(res.body); expect(res.statusCode).toBe(200); - expect(body.data.length).toBe(3); - expect(body.pagination.totalItems).toBe(3); + expect(body.data.length).toBe(6); + expect(body.pagination.totalItems).toBe(6); expect(body.pagination.totalPages).toBe(1); }); @@ -275,7 +276,7 @@ describe('Expenditures integration tests', () => { const body = JSON.parse(res.body); expect(res.statusCode).toBe(200); expect(body.pagination).toBeUndefined(); - expect(body.data.length).toBe(3); + expect(body.data.length).toBe(6); }); test('200: only limit provided returns all without pagination', async () => { @@ -284,7 +285,7 @@ describe('Expenditures integration tests', () => { const body = JSON.parse(res.body); expect(res.statusCode).toBe(200); expect(body.pagination).toBeUndefined(); - expect(body.data.length).toBe(3); + expect(body.data.length).toBe(6); }); test('200: filter by projectId returns only matching expenditures', async () => { @@ -300,7 +301,7 @@ describe('Expenditures integration tests', () => { const res = await handler(getEvent('/', { projectId: '1', page: '1', limit: '10' })); const body = JSON.parse(res.body); expect(res.statusCode).toBe(200); - expect(body.pagination.totalItems).toBe(1); + expect(body.pagination.totalItems).toBe(2); expect(body.data.every((e: any) => e.project_id === 1)).toBe(true); }); diff --git a/apps/backend/lambdas/projects/handler.ts b/apps/backend/lambdas/projects/handler.ts index cf4eb1a..cc2e435 100644 --- a/apps/backend/lambdas/projects/handler.ts +++ b/apps/backend/lambdas/projects/handler.ts @@ -11,6 +11,11 @@ export const handler = async (event: any): Promise => { const normalizedPath = rawPath.replace(/\/$/, ''); const method = (event.requestContext?.http?.method || event.httpMethod || 'GET').toUpperCase(); + // CORS preflight + if (method === 'OPTIONS') { + return json(200, {}); + } + // Health check if ((normalizedPath.endsWith('/health') || normalizedPath === '/health') && method === 'GET') { return json(200, { ok: true, timestamp: new Date().toISOString() }); @@ -18,24 +23,26 @@ export const handler = async (event: any): Promise => { // >>> ROUTES-START (do not remove this marker) // CLI-generated routes will be inserted here - // GET /projects/{id}/members + // GET /projects/{id}/members if (normalizedPath.startsWith('/projects/') && normalizedPath.split('/').length === 4 && normalizedPath.endsWith('/members') && method === 'GET') { const id = normalizedPath.split('/')[2]; if (!id) return json(400, { message: 'id is required' }); const users = await db - .selectFrom('branch.project_memberships as pm') - .innerJoin('branch.users as u', 'u.user_id', 'pm.user_id') - .select([ - 'u.user_id', - 'u.name', - 'u.email', - 'pm.role' - ]) - .where('pm.project_id', '=', id) - .execute(); - return json(200, { ok: true, route: 'GET /projects/{id}/members', pathParams: { id }, body: { - users - }}); + .selectFrom('branch.project_memberships as pm') + .innerJoin('branch.users as u', 'u.user_id', 'pm.user_id') + .select([ + 'u.user_id', + 'u.name', + 'u.email', + 'pm.role' + ]) + .where('pm.project_id', '=', id) + .execute(); + return json(200, { + ok: true, route: 'GET /projects/{id}/members', pathParams: { id }, body: { + users + } + }); } // GET /projects if (rawPath === '/' && method === 'GET') { @@ -44,11 +51,11 @@ export const handler = async (event: any): Promise => { } // GET /projects/{id}/donors - const parts = normalizedPath.split('/'); - if (parts.length === 3 && parts[2] === 'donors' && method === 'GET') { - const id = parts[1]; + const parts = normalizedPath.split('/'); + if (parts.length === 3 && parts[2] === 'donors' && method === 'GET') { + const id = parts[1]; + - if (!id) return json(400, { message: 'id is required' }); if (isNaN(Number(id))) { return json(400, { message: 'Project id must be a valid number' }); @@ -64,7 +71,7 @@ export const handler = async (event: any): Promise => { .where("p.project_id", "=", Number(id)) .selectAll() .executeTakeFirst(); - + if (!project) { return json(404, { message: 'Project not found' }); } @@ -78,9 +85,9 @@ export const handler = async (event: any): Promise => { "bd.donor_id", "bpd.donor_id" ).selectAll().execute(); - return json(200, { donors }); + return json(200, { donors }); } - + // GET /projects/{id} if (rawPath.startsWith('/') && rawPath.split('/').length === 2 && method === 'GET') { const id = rawPath.split('/')[1]; @@ -89,13 +96,13 @@ export const handler = async (event: any): Promise => { if (!project) return json(404, { message: `Project not found for id: ${id}` }); return json(200, project); } - - + + // PUT /projects/{id} if (rawPath.startsWith('/') && rawPath.split('/').length === 2 && method === 'PUT') { const id = rawPath.split('/')[1]; if (!id) return json(400, { message: 'id is required' }); - const body = event.body ? JSON.parse(event.body) as Record : {}; + const body = event.body ? JSON.parse(event.body) as Record : {}; const updatedProject = await db .updateTable("branch.projects") .set(body) @@ -105,7 +112,7 @@ export const handler = async (event: any): Promise => { if (!updatedProject) return json(404, { message: `Project not found for id: ${id}` }); return json(200, updatedProject); } - // <<< ROUTES-END + // <<< ROUTES-END // POST /projects if ((normalizedPath === '' || normalizedPath === '/' || normalizedPath === '/projects') && method === 'POST') { let body: Record; @@ -146,7 +153,7 @@ export const handler = async (event: any): Promise => { const inserted = await db .insertInto('branch.projects') .values(values) - .returning(['project_id','name','description','total_budget','currency','start_date','end_date','created_at']) + .returning(['project_id', 'name', 'description', 'total_budget', 'currency', 'start_date', 'end_date', 'created_at']) .executeTakeFirst(); return json(201, inserted); @@ -155,7 +162,7 @@ export const handler = async (event: any): Promise => { return json(500, { message: 'Failed to create project' }); } } - + // GET /projects/{id}/expenditures if (normalizedPath.endsWith('/expenditures') && method === 'GET') { const pathParts = normalizedPath.split('/').filter(Boolean); diff --git a/apps/backend/lambdas/projects/jest.config.js b/apps/backend/lambdas/projects/jest.config.js index 6bb00ee..69a1f4c 100644 --- a/apps/backend/lambdas/projects/jest.config.js +++ b/apps/backend/lambdas/projects/jest.config.js @@ -3,4 +3,5 @@ module.exports = { testEnvironment: 'node', testMatch: ['**/*.test.ts'], globals: { 'ts-jest': { isolatedModules: true } }, + maxWorkers: 1, }; \ No newline at end of file diff --git a/apps/backend/lambdas/projects/test/crud.test.ts b/apps/backend/lambdas/projects/test/crud.test.ts index 2c23692..961869e 100644 --- a/apps/backend/lambdas/projects/test/crud.test.ts +++ b/apps/backend/lambdas/projects/test/crud.test.ts @@ -97,38 +97,3 @@ test("project get 400 test 🌞", async () => { let body = await res.json(); expect(body.message).toBe("Project not found for id: 1000"); }); -test("update project test 🌞", async () => { - let res = await fetch("http://localhost:3000/projects/1", { - method: "PUT", - body: JSON.stringify({ name: "Project 1 Updated", total_budget: 2000 }), - }); - expect(res.status).toBe(200); - let body = await res.json(); - expect(body.project_id).toBe(1); - expect(body.name).toContain("Project 1 Updated"); - expect(Number(body.total_budget)).toBe(Number(2000.00)); - expect(body.description).toBeDefined(); - expect(body.description).not.toBeNull(); - expect(typeof body.description).toBe('string'); -}); - -test("update project with new description test 🌞", async () => { - const newDesc = "Updated project description"; - let res = await fetch("http://localhost:3000/projects/1", { - method: "PUT", - body: JSON.stringify({ name: "Project 1", description: newDesc }), - }); - expect(res.status).toBe(200); - let body = await res.json(); - expect(body.description).toBe(newDesc); -}); - -test("project put 404 test 🌞", async () => { - let res = await fetch("http://localhost:3000/projects/1000", { - method: "PUT", - body: JSON.stringify({ name: "Project 1 Updated", total_budget: 2000 }), - }); - expect(res.status).toBe(404); - let body = await res.json(); - expect(body.message).toBe("Project not found for id: 1000"); -}); diff --git a/apps/frontend/jest.setup.ts b/apps/frontend/jest.setup.ts index 8b86247..3b7bcfa 100644 --- a/apps/frontend/jest.setup.ts +++ b/apps/frontend/jest.setup.ts @@ -1,4 +1,38 @@ import '@testing-library/jest-dom'; +import React from 'react'; + +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(() => ({ + push: jest.fn(), + replace: jest.fn(), + prefetch: jest.fn(), + back: jest.fn(), + forward: jest.fn(), + refresh: jest.fn(), + })), + usePathname: jest.fn(() => '/'), + useSearchParams: jest.fn(() => new URLSearchParams()), +})); + +jest.mock('next/link', () => { + function MockLink({ + href, + children, + ...rest + }: { + href: string; + children: React.ReactNode; + [key: string]: unknown; + }) { + return React.createElement('a', { href, ...rest }, children); + } + MockLink.displayName = 'MockLink'; + return MockLink; +}); + +jest.mock('next/font/google', () => ({ + PT_Sans: () => ({ style: { fontFamily: 'PT Sans' } }), +})); // jsdom doesn't include structuredClone; polyfill it for Chakra UI global.structuredClone = global.structuredClone ?? ((val: unknown) => JSON.parse(JSON.stringify(val))); diff --git a/apps/frontend/next.config.ts b/apps/frontend/next.config.ts index e9ffa30..45d300b 100644 --- a/apps/frontend/next.config.ts +++ b/apps/frontend/next.config.ts @@ -1,7 +1,30 @@ -import type { NextConfig } from "next"; +import type { NextConfig } from 'next'; const nextConfig: NextConfig = { - /* config options here */ + async rewrites() { + return [ + { + source: '/auth/:path*', + destination: 'http://localhost:3006/auth/:path*', + }, + { + source: '/expenditures/:path*', + destination: 'http://localhost:3004/expenditures/:path*', + }, + { + source: '/expenditures', + destination: 'http://localhost:3004/expenditures', + }, + { + source: '/projects/:path*', + destination: 'http://localhost:3002/projects/:path*', + }, + { + source: '/projects', + destination: 'http://localhost:3002/projects', + }, + ]; + }, }; export default nextConfig; diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 034f4b6..4e772a6 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@chakra-ui/react": "^3.33.0", + "@emotion/react": "^11.14.0", "next": "15.5.4", "react": "19.1.0", "react-dom": "19.1.0", @@ -17,6 +18,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -883,7 +885,6 @@ "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", "integrity": "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", @@ -903,7 +904,6 @@ "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", "license": "MIT", - "peer": true, "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", @@ -938,7 +938,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -975,8 +974,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@emotion/unitless": { "version": "0.10.0", @@ -1003,8 +1001,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", @@ -2846,7 +2843,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2867,7 +2863,6 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -2985,8 +2980,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3153,8 +3147,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/react": { "version": "19.1.15", @@ -5084,7 +5077,6 @@ "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", @@ -5506,15 +5498,13 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cosmiconfig": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", "license": "MIT", - "peer": true, "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", @@ -5748,7 +5738,6 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -5801,8 +5790,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -6687,8 +6675,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/find-up": { "version": "5.0.0", @@ -7168,7 +7155,6 @@ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", "license": "BSD-3-Clause", - "peer": true, "dependencies": { "react-is": "^16.7.0" } @@ -9348,7 +9334,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -10016,7 +10001,6 @@ "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -10180,7 +10164,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -10196,7 +10179,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -10209,8 +10191,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/prop-types": { "version": "15.8.1", @@ -10808,7 +10789,6 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11204,8 +11184,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/supports-color": { "version": "7.2.0", @@ -12137,11 +12116,10 @@ } }, "node_modules/yaml": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", - "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", "license": "ISC", - "peer": true, "engines": { "node": ">= 6" } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 6a3f7a6..0c8d0fb 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -11,6 +11,7 @@ "test": "jest --passWithNoTests" }, "dependencies": { + "@emotion/react": "^11.14.0", "@chakra-ui/react": "^3.33.0", "next": "15.5.4", "react": "19.1.0", @@ -20,6 +21,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", diff --git a/apps/frontend/src/app/components/AddExpenseModal.tsx b/apps/frontend/src/app/components/AddExpenseModal.tsx new file mode 100644 index 0000000..efb9fb9 --- /dev/null +++ b/apps/frontend/src/app/components/AddExpenseModal.tsx @@ -0,0 +1,297 @@ +'use client'; + +import { useState } from 'react'; +import { Button, Dialog, Portal, CloseButton, Stack } from '@chakra-ui/react'; +import DropdownSelector from './DropdownSelector'; +import { apiFetch } from '@/lib/api'; + +interface Project { + project_id: number; + name: string; +} + +interface AddExpenseModalProps { + open: boolean; + onClose: () => void; + onSuccess: () => void; + token: string; + categories: string[]; + projects: Project[]; +} + +export default function AddExpenseModal({ + open, + onClose, + onSuccess, + token, + categories, + projects, +}: AddExpenseModalProps) { + const [newDate, setNewDate] = useState(''); + const [newType, setNewType] = useState(''); + const [newDescription, setNewDescription] = useState(''); + const [newAmount, setNewAmount] = useState(''); + const [newProject, setNewProject] = useState(''); + + const [dateError, setDateError] = useState(false); + const [typeError, setTypeError] = useState(false); + const [descError, setDescError] = useState(false); + const [amountError, setAmountError] = useState(false); + const [projectError, setProjectError] = useState(false); + const [submitError, setSubmitError] = useState(null); + + function resetForm() { + setNewDate(''); + setNewType(''); + setNewDescription(''); + setNewAmount(''); + setNewProject(''); + setDateError(false); + setTypeError(false); + setDescError(false); + setAmountError(false); + setProjectError(false); + setSubmitError(null); + } + + function handleClose() { + resetForm(); + onClose(); + } + + async function handleSubmit() { + const hasDateError = !newDate.trim(); + const hasTypeError = !newType.trim(); + const hasDescError = !newDescription.trim(); + const hasAmountError = !newAmount.trim() || isNaN(Number(newAmount)) || Number(newAmount) < 0; + const hasProjectError = !newProject.trim(); + + setDateError(hasDateError); + setTypeError(hasTypeError); + setDescError(hasDescError); + setAmountError(hasAmountError); + setProjectError(hasProjectError); + + if (hasDateError || hasTypeError || hasDescError || hasAmountError || hasProjectError) return; + + const selectedProject = projects.find((p) => p.name === newProject); + if (!selectedProject) { + setProjectError(true); + return; + } + + try { + await apiFetch('/expenditures', { + method: 'POST', + token, + body: JSON.stringify({ + projectID: selectedProject.project_id, + amount: Number(newAmount), + category: newType, + description: newDescription, + spentOn: newDate, + }), + }); + + resetForm(); + onSuccess(); + } catch (err) { + setSubmitError(err instanceof Error ? err.message : 'Failed to create expense'); + } + } + + return ( + { if (!e.open) handleClose(); }}> + + + + + + + Add New Expense + + + + + + {/* Date */} +
+ + { + setNewDate(e.target.value); + setDateError(false); + }} + style={{ + border: `1px solid ${dateError ? 'var(--color-error-red)' : '#CBD5E0'}`, + borderRadius: '6px', + padding: '8px 12px', + fontSize: '14px', + outline: 'none', + width: '100%', + fontFamily: 'inherit', + color: newDate ? 'inherit' : '#A0AEC0', + }} + /> + {dateError && ( + + Select a date + + )} +
+ + {/* Type of Expense */} +
+ + { + setNewType(val as string); + setTypeError(false); + }} + /> + {typeError && ( + + Select a type of expense + + )} +
+ + {/* Description */} +
+ +