From 1b6988db6f70a5a200c9e5fd29c481c8a32cbb0f Mon Sep 17 00:00:00 2001 From: "Yumiko (Yumi) Chow" <75456756+yumi520@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:16:04 -0400 Subject: [PATCH 01/19] added revenue endpoints, seeded dynamo table, added swagger doc --- backend/scripts/seed-revenue-types.js | 55 ++++ backend/src/app.module.ts | 3 +- .../revenue/__test__/revenue.service.spec.ts | 150 +++++++++++ backend/src/revenue/revenue.controller.ts | 101 ++++++++ backend/src/revenue/revenue.module.ts | 10 + backend/src/revenue/revenue.service.ts | 244 ++++++++++++++++++ backend/src/revenue/types/revenue.types.ts | 80 ++++++ 7 files changed, 642 insertions(+), 1 deletion(-) create mode 100644 backend/scripts/seed-revenue-types.js create mode 100644 backend/src/revenue/__test__/revenue.service.spec.ts create mode 100644 backend/src/revenue/revenue.controller.ts create mode 100644 backend/src/revenue/revenue.module.ts create mode 100644 backend/src/revenue/revenue.service.ts create mode 100644 backend/src/revenue/types/revenue.types.ts diff --git a/backend/scripts/seed-revenue-types.js b/backend/scripts/seed-revenue-types.js new file mode 100644 index 00000000..6cde5313 --- /dev/null +++ b/backend/scripts/seed-revenue-types.js @@ -0,0 +1,55 @@ +const AWS = require('aws-sdk'); + +const tableName = + process.env.DYNAMODB_REVENUE_TYPE_TABLE_NAME || + process.env.DYNAMODB_REVENUE_TABLE_NAME || + process.env.DYNAMODB_GRANT_TABLE_NAME; + +if (!tableName) { + console.error('Missing table'); + process.exit(1); +} + +AWS.config.update({ + region: process.env.AWS_REGION, + accessKeyId: process.env.OPEN_HATCH, + secretAccessKey: process.env.CLOSED_HATCH, +}); + +const docClient = new AWS.DynamoDB.DocumentClient(); + +const seedRows = [ + { revenueTypeId: 1000, name: 'Grants' }, + { revenueTypeId: 1001, name: 'Individual Donations' }, + { revenueTypeId: 1002, name: 'Corporate Sponsorships' }, + { revenueTypeId: 1003, name: 'Fundraising Events' }, + { revenueTypeId: 1004, name: 'Other Revenue' }, +]; + +async function run() { + const now = new Date().toISOString(); + + for (const row of seedRows) { + const params = { + TableName: tableName, + Item: { + revenueTypeId: row.revenueTypeId, + name: row.name, + description: `Seeded revenue type: ${row.name}`, + isActive: true, + createdAt: now, + updatedAt: now, + }, + }; + + await docClient.put(params).promise(); + console.log(`Seeded ${row.revenueTypeId} - ${row.name}`); + } + + console.log('Revenue type seed done'); +} + +run().catch((error) => { + console.error('Revenue seed failed:', error); + process.exit(1); +}); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 25449e13..19d6b880 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -4,8 +4,9 @@ import { UserModule } from './user/user.module'; import { GrantModule } from './grant/grant.module'; import { NotificationsModule } from './notifications/notification.module'; import { CashflowModule } from './cashflow/cashflow.module'; +import { RevenueModule } from './revenue/revenue.module'; @Module({ - imports: [AuthModule, UserModule, GrantModule, NotificationsModule,CashflowModule], + imports: [AuthModule, UserModule, GrantModule, NotificationsModule, CashflowModule, RevenueModule], }) export class AppModule {} \ No newline at end of file diff --git a/backend/src/revenue/__test__/revenue.service.spec.ts b/backend/src/revenue/__test__/revenue.service.spec.ts new file mode 100644 index 00000000..7f62c888 --- /dev/null +++ b/backend/src/revenue/__test__/revenue.service.spec.ts @@ -0,0 +1,150 @@ +import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { RevenueTypeValue } from '../types/revenue.types'; +import { RevenueService } from '../revenue.service'; + +const mockPromise = vi.fn(); +const mockScan = vi.fn(() => ({ promise: mockPromise })); +const mockGet = vi.fn(() => ({ promise: mockPromise })); +const mockDelete = vi.fn(() => ({ promise: mockPromise })); +const mockUpdate = vi.fn(() => ({ promise: mockPromise })); +const mockPut = vi.fn(() => ({ promise: mockPromise })); + +const mockDocumentClient = { + scan: mockScan, + get: mockGet, + delete: mockDelete, + update: mockUpdate, + put: mockPut, +}; + +vi.mock('aws-sdk', () => ({ + DynamoDB: { + DocumentClient: vi.fn(function () { + return mockDocumentClient; + }), + }, +})); + +describe('RevenueService', () => { + let service: RevenueService; + + beforeEach(async () => { + vi.clearAllMocks(); + process.env.DYNAMODB_REVENUE_TYPE_TABLE_NAME = 'RevenueTypes'; + + const module: TestingModule = await Test.createTestingModule({ + providers: [RevenueService], + }).compile(); + + service = module.get(RevenueService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('creates a revenue type', async () => { + mockPromise.mockResolvedValueOnce({}); + + const result = await service.createRevenueType({ + name: RevenueTypeValue.Grants, + description: 'Test description', + }); + + expect(result.name).toBe(RevenueTypeValue.Grants); + expect(result.isActive).toBe(true); + expect(mockPut).toHaveBeenCalledWith( + expect.objectContaining({ + TableName: 'RevenueTypes', + }), + ); + }); + + it('gets all revenue types', async () => { + mockPromise.mockResolvedValueOnce({ + Items: [ + { + revenueTypeId: 1, + name: RevenueTypeValue.Donation, + isActive: true, + createdAt: '2026-03-19T00:00:00.000Z', + updatedAt: '2026-03-19T00:00:00.000Z', + }, + ], + }); + + const result = await service.getAllRevenueTypes(); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe(RevenueTypeValue.Donation); + }); + + it('gets one revenue type by id', async () => { + mockPromise.mockResolvedValueOnce({ + Item: { + revenueTypeId: 10, + name: RevenueTypeValue.Fundraising, + isActive: true, + createdAt: '2026-03-19T00:00:00.000Z', + updatedAt: '2026-03-19T00:00:00.000Z', + }, + }); + + const result = await service.getRevenueTypeById(10); + + expect(result.revenueTypeId).toBe(10); + expect(result.name).toBe(RevenueTypeValue.Fundraising); + }); + + it('throws not found when revenue type is missing', async () => { + mockPromise.mockResolvedValueOnce({ Item: undefined }); + + await expect(service.getRevenueTypeById(404)).rejects.toThrow(NotFoundException); + }); + + it('throws bad request for invalid id input', async () => { + await expect(service.getRevenueTypeById(0)).rejects.toThrow(BadRequestException); + }); + + it('updates revenue type', async () => { + mockPromise.mockResolvedValueOnce({ + Attributes: { + revenueTypeId: 1, + name: RevenueTypeValue.Sponsorship, + isActive: false, + createdAt: '2026-03-19T00:00:00.000Z', + updatedAt: '2026-03-19T01:00:00.000Z', + }, + }); + + const result = await service.updateRevenueType(1, { isActive: false }); + + expect(result.isActive).toBe(false); + expect(mockUpdate).toHaveBeenCalled(); + }); + + it('deletes revenue type', async () => { + mockPromise.mockResolvedValueOnce({}); + + const result = await service.deleteRevenueTypeById(5); + + expect(result.message).toContain('deleted successfully'); + expect(mockDelete).toHaveBeenCalled(); + }); + + it('maps aws validation exception to bad request', async () => { + const awsError = new Error('bad params'); + (awsError as any).code = 'ValidationException'; + mockPromise.mockRejectedValueOnce(awsError); + + await expect(service.getAllRevenueTypes()).rejects.toThrow(BadRequestException); + }); + + it('throws internal server error for unexpected errors', async () => { + mockPromise.mockRejectedValueOnce(new Error('unexpected')); + + await expect(service.getAllRevenueTypes()).rejects.toThrow(InternalServerErrorException); + }); +}); diff --git a/backend/src/revenue/revenue.controller.ts b/backend/src/revenue/revenue.controller.ts new file mode 100644 index 00000000..ab936f46 --- /dev/null +++ b/backend/src/revenue/revenue.controller.ts @@ -0,0 +1,101 @@ +import { Body, Controller, Delete, Get, Logger, Param, ParseIntPipe, Post, Put, UseGuards, ValidationPipe } from '@nestjs/common'; +import { RevenueService } from './revenue.service'; +import { VerifyUserGuard } from '../guards/auth.guard'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiParam, ApiBody, ApiTags } from '@nestjs/swagger'; +import { CreateRevenueTypeBody, RevenueTypeResponseDto, UpdateRevenueTypeBody } from './types/revenue.types'; + +@ApiTags('revenue-types') +@Controller('revenue-types') +export class RevenueController { + private readonly logger = new Logger(RevenueController.name); + + constructor(private readonly revenueService: RevenueService) {} + + @Post() + @UseGuards(VerifyUserGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Create revenue type', description: 'Creates a new revenue type record.' }) + @ApiBody({ type: CreateRevenueTypeBody }) + @ApiResponse({ status: 201, description: 'Revenue type created successfully', type: RevenueTypeResponseDto }) + @ApiResponse({ status: 400, description: 'Bad Request - Invalid request payload' }) + @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing authentication token' }) + @ApiResponse({ status: 403, description: 'Forbidden - User does not have access to this resource' }) + @ApiResponse({ status: 500, description: 'Internal Server Error - AWS or server error' }) + async createRevenueType( + @Body(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })) + body: CreateRevenueTypeBody, + ): Promise { + this.logger.log(`POST /revenue-types - Creating revenue type: ${body.name}`); + return this.revenueService.createRevenueType(body); + } + + @Get() + @UseGuards(VerifyUserGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get all revenue types', description: 'Retrieves all revenue type records.' }) + @ApiResponse({ status: 200, description: 'Revenue types retrieved successfully', type: [RevenueTypeResponseDto] }) + @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing authentication token' }) + @ApiResponse({ status: 403, description: 'Forbidden - User does not have access to this resource' }) + @ApiResponse({ status: 500, description: 'Internal Server Error - AWS or server error' }) + async getAllRevenueTypes(): Promise { + this.logger.log('GET /revenue-types - Retrieving all revenue types'); + return this.revenueService.getAllRevenueTypes(); + } + + @Get(':revenueTypeId') + @UseGuards(VerifyUserGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get revenue type by ID', description: 'Retrieves a revenue type record by ID.' }) + @ApiParam({ name: 'revenueTypeId', type: Number, description: 'Revenue type ID' }) + @ApiResponse({ status: 200, description: 'Revenue type retrieved successfully', type: RevenueTypeResponseDto }) + @ApiResponse({ status: 400, description: 'Bad Request - Invalid revenue type ID' }) + @ApiResponse({ status: 404, description: 'Not Found - Revenue type does not exist' }) + @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing authentication token' }) + @ApiResponse({ status: 403, description: 'Forbidden - User does not have access to this resource' }) + @ApiResponse({ status: 500, description: 'Internal Server Error - AWS or server error' }) + async getRevenueTypeById( + @Param('revenueTypeId', ParseIntPipe) revenueTypeId: number, + ): Promise { + this.logger.log(`GET /revenue-types/${revenueTypeId} - Retrieving revenue type`); + return this.revenueService.getRevenueTypeById(revenueTypeId); + } + + @Put(':revenueTypeId') + @UseGuards(VerifyUserGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Update revenue type', description: 'Updates a revenue type record by ID.' }) + @ApiParam({ name: 'revenueTypeId', type: Number, description: 'Revenue type ID' }) + @ApiBody({ type: UpdateRevenueTypeBody }) + @ApiResponse({ status: 200, description: 'Revenue type updated successfully', type: RevenueTypeResponseDto }) + @ApiResponse({ status: 400, description: 'Bad Request - Invalid payload or revenue type ID' }) + @ApiResponse({ status: 404, description: 'Not Found - Revenue type does not exist' }) + @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing authentication token' }) + @ApiResponse({ status: 403, description: 'Forbidden - User does not have access to this resource' }) + @ApiResponse({ status: 500, description: 'Internal Server Error - AWS or server error' }) + async updateRevenueType( + @Param('revenueTypeId', ParseIntPipe) revenueTypeId: number, + @Body(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })) + body: UpdateRevenueTypeBody, + ): Promise { + this.logger.log(`PUT /revenue-types/${revenueTypeId} - Updating revenue type`); + return this.revenueService.updateRevenueType(revenueTypeId, body); + } + + @Delete(':revenueTypeId') + @UseGuards(VerifyUserGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Delete revenue type', description: 'Deletes a revenue type record by ID.' }) + @ApiParam({ name: 'revenueTypeId', type: Number, description: 'Revenue type ID' }) + @ApiResponse({ status: 200, description: 'Revenue type deleted successfully' }) + @ApiResponse({ status: 400, description: 'Bad Request - Invalid revenue type ID' }) + @ApiResponse({ status: 404, description: 'Not Found - Revenue type does not exist' }) + @ApiResponse({ status: 401, description: 'Unauthorized - Invalid or missing authentication token' }) + @ApiResponse({ status: 403, description: 'Forbidden - User does not have access to this resource' }) + @ApiResponse({ status: 500, description: 'Internal Server Error - AWS or server error' }) + async deleteRevenueTypeById( + @Param('revenueTypeId', ParseIntPipe) revenueTypeId: number, + ): Promise<{ message: string }> { + this.logger.log(`DELETE /revenue-types/${revenueTypeId} - Deleting revenue type`); + return this.revenueService.deleteRevenueTypeById(revenueTypeId); + } +} \ No newline at end of file diff --git a/backend/src/revenue/revenue.module.ts b/backend/src/revenue/revenue.module.ts new file mode 100644 index 00000000..9c129d8f --- /dev/null +++ b/backend/src/revenue/revenue.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { RevenueService } from './revenue.service'; +import { RevenueController } from './revenue.controller'; +import { NotificationsModule } from '../notifications/notification.module'; +@Module({ + imports: [NotificationsModule], + controllers: [RevenueController], + providers: [RevenueService], +}) +export class RevenueModule { } \ No newline at end of file diff --git a/backend/src/revenue/revenue.service.ts b/backend/src/revenue/revenue.service.ts new file mode 100644 index 00000000..2a8a418d --- /dev/null +++ b/backend/src/revenue/revenue.service.ts @@ -0,0 +1,244 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, + NotFoundException, +} from '@nestjs/common'; +import * as AWS from 'aws-sdk'; +import { CreateRevenueTypeBody, RevenueTypeValue, UpdateRevenueTypeBody } from './types/revenue.types'; + +interface RevenueTypeRecord { + revenueTypeId: number; + name: RevenueTypeValue; + description?: string; + isActive: boolean; + createdAt: string; + updatedAt: string; +} + +interface AWSError extends Error { + code?: string; + statusCode?: number; + requestId?: string; + retryable?: boolean; +} + +@Injectable() +export class RevenueService { + private readonly logger = new Logger(RevenueService.name); + private readonly dynamoDb = new AWS.DynamoDB.DocumentClient(); + + private get tableName(): string { + return ( + process.env.DYNAMODB_REVENUE_TYPE_TABLE_NAME || + process.env.DYNAMODB_REVENUE_TABLE_NAME || + process.env.DYNAMODB_GRANT_TABLE_NAME || + 'TABLE_FAILURE' + ); + } + + private assertTableConfigured(): void { + if (this.tableName === 'TABLE_FAILURE') { + this.logger.error('Revenue type table environment variable is not set'); + throw new InternalServerErrorException( + 'Server configuration error: Revenue type DynamoDB table name not configured', + ); + } + } + + private isAWSError(error: unknown): error is AWSError { + return ( + typeof error === 'object' && + error !== null && + ('code' in error || 'statusCode' in error || 'requestId' in error) + ); + } + + private handleAWSError(error: AWSError, operation: string): never { + const message = error.message || 'Unknown AWS error'; + this.logger.error(`AWS error during ${operation}: ${message}`); + + switch (error.code) { + case 'ResourceNotFoundException': + throw new BadRequestException(`AWS DynamoDB Error: Table or resource not found. ${message}`); + case 'ValidationException': + throw new BadRequestException(`AWS DynamoDB Validation Error: Invalid request parameters. ${message}`); + case 'ConditionalCheckFailedException': + throw new BadRequestException(`AWS DynamoDB Error: Conditional check failed. ${message}`); + case 'ProvisionedThroughputExceededException': + case 'ThrottlingException': + throw new InternalServerErrorException(`AWS DynamoDB Error: Request throttled, please retry. ${message}`); + default: + throw new InternalServerErrorException(`AWS DynamoDB Error during ${operation}: ${message}`); + } + } + + private validateRevenueTypeId(revenueTypeId: number): void { + if (!Number.isInteger(revenueTypeId) || revenueTypeId <= 0) { + throw new BadRequestException( + `Invalid revenue type ID: ${revenueTypeId}. ID must be a positive integer.`, + ); + } + } + + async createRevenueType(body: CreateRevenueTypeBody): Promise { + this.assertTableConfigured(); + + const now = new Date().toISOString(); + const revenueTypeId = Date.now(); + const item: RevenueTypeRecord = { + revenueTypeId, + name: body.name, + description: body.description, + isActive: true, + createdAt: now, + updatedAt: now, + }; + + const params: AWS.DynamoDB.DocumentClient.PutItemInput = { + TableName: this.tableName, + Item: item, + ConditionExpression: 'attribute_not_exists(revenueTypeId)', + }; + + try { + await this.dynamoDb.put(params).promise(); + return item; + } catch (error) { + if (this.isAWSError(error)) { + this.handleAWSError(error, 'createRevenueType'); + } + throw new InternalServerErrorException('Failed to create revenue type.'); + } + } + + async getAllRevenueTypes(): Promise { + this.assertTableConfigured(); + + try { + const data = await this.dynamoDb.scan({ TableName: this.tableName }).promise(); + return (data.Items as RevenueTypeRecord[]) || []; + } catch (error) { + if (this.isAWSError(error)) { + this.handleAWSError(error, 'getAllRevenueTypes'); + } + throw new InternalServerErrorException('Failed to retrieve revenue types.'); + } + } + + async getRevenueTypeById(revenueTypeId: number): Promise { + this.assertTableConfigured(); + this.validateRevenueTypeId(revenueTypeId); + + const params: AWS.DynamoDB.DocumentClient.GetItemInput = { + TableName: this.tableName, + Key: { revenueTypeId }, + }; + + try { + const data = await this.dynamoDb.get(params).promise(); + if (!data.Item) { + throw new NotFoundException(`Revenue type ${revenueTypeId} not found.`); + } + return data.Item as RevenueTypeRecord; + } catch (error) { + if (error instanceof NotFoundException || error instanceof BadRequestException) { + throw error; + } + if (this.isAWSError(error)) { + this.handleAWSError(error, 'getRevenueTypeById'); + } + throw new InternalServerErrorException(`Failed to retrieve revenue type ${revenueTypeId}.`); + } + } + + async updateRevenueType( + revenueTypeId: number, + body: UpdateRevenueTypeBody, + ): Promise { + this.assertTableConfigured(); + this.validateRevenueTypeId(revenueTypeId); + + const entries = Object.entries(body).filter(([, value]) => value !== undefined); + if (entries.length === 0) { + throw new BadRequestException('No fields provided to update.'); + } + + const UpdateExpression = + 'SET ' + + entries + .map(([_, __], idx) => `#k${idx} = :v${idx}`) + .concat('#updatedAt = :updatedAt') + .join(', '); + + const ExpressionAttributeNames = entries.reduce>( + (acc, [key], idx) => { + acc[`#k${idx}`] = key; + return acc; + }, + { '#updatedAt': 'updatedAt' }, + ); + + const ExpressionAttributeValues = entries.reduce>( + (acc, [_, value], idx) => { + acc[`:v${idx}`] = value; + return acc; + }, + { ':updatedAt': new Date().toISOString() }, + ); + + const params: AWS.DynamoDB.DocumentClient.UpdateItemInput = { + TableName: this.tableName, + Key: { revenueTypeId }, + ConditionExpression: 'attribute_exists(revenueTypeId)', + UpdateExpression, + ExpressionAttributeNames, + ExpressionAttributeValues, + ReturnValues: 'ALL_NEW', + }; + + try { + const data = await this.dynamoDb.update(params).promise(); + if (!data.Attributes) { + throw new NotFoundException(`Revenue type ${revenueTypeId} not found.`); + } + return data.Attributes as RevenueTypeRecord; + } catch (error) { + if (error instanceof NotFoundException || error instanceof BadRequestException) { + throw error; + } + if (this.isAWSError(error) && error.code === 'ConditionalCheckFailedException') { + throw new NotFoundException(`Revenue type ${revenueTypeId} not found.`); + } + if (this.isAWSError(error)) { + this.handleAWSError(error, 'updateRevenueType'); + } + throw new InternalServerErrorException(`Failed to update revenue type ${revenueTypeId}.`); + } + } + + async deleteRevenueTypeById(revenueTypeId: number): Promise<{ message: string }> { + this.assertTableConfigured(); + this.validateRevenueTypeId(revenueTypeId); + + const params: AWS.DynamoDB.DocumentClient.DeleteItemInput = { + TableName: this.tableName, + Key: { revenueTypeId }, + ConditionExpression: 'attribute_exists(revenueTypeId)', + }; + + try { + await this.dynamoDb.delete(params).promise(); + return { message: `Revenue type ${revenueTypeId} deleted successfully` }; + } catch (error) { + if (this.isAWSError(error) && error.code === 'ConditionalCheckFailedException') { + throw new NotFoundException(`Revenue type ${revenueTypeId} not found.`); + } + if (this.isAWSError(error)) { + this.handleAWSError(error, 'deleteRevenueTypeById'); + } + throw new InternalServerErrorException(`Failed to delete revenue type ${revenueTypeId}.`); + } + } +} \ No newline at end of file diff --git a/backend/src/revenue/types/revenue.types.ts b/backend/src/revenue/types/revenue.types.ts new file mode 100644 index 00000000..723615fc --- /dev/null +++ b/backend/src/revenue/types/revenue.types.ts @@ -0,0 +1,80 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsBoolean, IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; + +export enum RevenueTypeValue { + Grants = 'Grants', + Donation = 'Individual Donations', + Sponsorship = 'Corporate Sponsorships', + Fundraising = 'Fundraising Events', + Other = 'Other Revenue', +} + +export class RevenueTypeResponseDto { + @ApiProperty({ description: 'Unique ID for the revenue type', example: 1234567890 }) + revenueTypeId!: number; + + @ApiProperty({ + description: 'Revenue type category', + enum: RevenueTypeValue, + example: RevenueTypeValue.Grants, + }) + name!: RevenueTypeValue; + + @ApiProperty({ + description: 'Additional details about this revenue type', + required: false, + example: 'Used for recurring and one-time funding streams.', + }) + description?: string; + + @ApiProperty({ description: 'Whether the revenue type is active', example: true }) + isActive!: boolean; + + @ApiProperty({ description: 'ISO timestamp for when the revenue type was created', example: '2026-03-19T00:00:00.000Z' }) + createdAt!: string; + + @ApiProperty({ description: 'ISO timestamp for when the revenue type was last updated', example: '2026-03-19T00:00:00.000Z' }) + updatedAt!: string; +} + +export class CreateRevenueTypeBody { + @ApiProperty({ + description: 'Revenue type category', + enum: RevenueTypeValue, + example: RevenueTypeValue.Fundraising, + }) + @IsEnum(RevenueTypeValue) + name!: RevenueTypeValue; + + @ApiProperty({ + description: 'Optional context for this revenue type', + required: false, + example: 'Annual events and community campaigns.', + }) + @IsOptional() + @IsString() + @MaxLength(300) + description?: string; +} + +export class UpdateRevenueTypeBody { + @ApiProperty({ + description: 'Revenue type category', + enum: RevenueTypeValue, + required: false, + }) + @IsOptional() + @IsEnum(RevenueTypeValue) + name?: RevenueTypeValue; + + @ApiProperty({ description: 'Optional context for this revenue type', required: false }) + @IsOptional() + @IsString() + @MaxLength(300) + description?: string; + + @ApiProperty({ description: 'Whether the revenue type is active', required: false }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} \ No newline at end of file From 823c93795e14f12ce31b127137eb148430ceaf03 Mon Sep 17 00:00:00 2001 From: prooflesben Date: Mon, 23 Mar 2026 02:41:34 -0400 Subject: [PATCH 02/19] Upload and get works correctly --- backend/src/app.module.ts | 4 +- backend/src/cashflow/cashflow.controller.ts | 16 -- backend/src/cashflow/cashflow.module.ts | 10 - backend/src/cashflow/cashflow.service.ts | 9 - backend/src/grant/grant.service.ts | 2 +- .../revenue/cashflow-revenue.controller.ts | 42 +++ .../src/revenue/cashflow-revenue.module.ts | 10 + .../src/revenue/cashflow-revenue.service.ts | 251 ++++++++++++++++++ backend/src/revenue/types/revenue.types.ts | 23 ++ 9 files changed, 329 insertions(+), 38 deletions(-) delete mode 100644 backend/src/cashflow/cashflow.controller.ts delete mode 100644 backend/src/cashflow/cashflow.module.ts delete mode 100644 backend/src/cashflow/cashflow.service.ts create mode 100644 backend/src/revenue/cashflow-revenue.controller.ts create mode 100644 backend/src/revenue/cashflow-revenue.module.ts create mode 100644 backend/src/revenue/cashflow-revenue.service.ts create mode 100644 backend/src/revenue/types/revenue.types.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 1c241e77..ed4c948c 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -3,11 +3,11 @@ import { AuthModule } from './auth/auth.module'; import { UserModule } from './user/user.module'; import { GrantModule } from './grant/grant.module'; import { NotificationsModule } from './notifications/notification.module'; -import { CashflowModule } from './cashflow/cashflow.module'; +import { RevenueModule } from './revenue/cashflow-revenue.module'; import { CostModule } from './cost/cashflow-cost.module'; import { DefaultValuesModule } from './default-values/default-values.module'; @Module({ - imports: [AuthModule, UserModule, GrantModule, NotificationsModule, CashflowModule, CostModule, DefaultValuesModule], + imports: [AuthModule, UserModule, GrantModule, NotificationsModule, RevenueModule, CostModule, DefaultValuesModule], }) export class AppModule {} \ No newline at end of file diff --git a/backend/src/cashflow/cashflow.controller.ts b/backend/src/cashflow/cashflow.controller.ts deleted file mode 100644 index f62af84c..00000000 --- a/backend/src/cashflow/cashflow.controller.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Controller, Get, Logger } from "@nestjs/common"; -import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger"; - - - -@ApiTags('cashflow') -@Controller('cashflow') -export class CashflowController { - private readonly logger = new Logger(CashflowController.name); - @Get('hello') - @ApiOperation({ summary: 'Hello endpoint', description: 'Returns a simple hello message' }) - @ApiResponse({ status: 200, description: 'Returns hello message' }) - getHello(): string { - return 'hello'; - } -} \ No newline at end of file diff --git a/backend/src/cashflow/cashflow.module.ts b/backend/src/cashflow/cashflow.module.ts deleted file mode 100644 index 1c310533..00000000 --- a/backend/src/cashflow/cashflow.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { CashflowService } from './cashflow.service'; -import { CashflowController } from './cashflow.controller'; - - -@Module({ - controllers: [CashflowController], - providers: [CashflowService], -}) -export class CashflowModule {} \ No newline at end of file diff --git a/backend/src/cashflow/cashflow.service.ts b/backend/src/cashflow/cashflow.service.ts deleted file mode 100644 index 19202c89..00000000 --- a/backend/src/cashflow/cashflow.service.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Injectable, Logger } from "@nestjs/common"; -import * as AWS from 'aws-sdk'; - -@Injectable() -export class CashflowService { - private readonly logger = new Logger(CashflowService.name); - private dynamoDb = new AWS.DynamoDB.DocumentClient(); - -} \ No newline at end of file diff --git a/backend/src/grant/grant.service.ts b/backend/src/grant/grant.service.ts index 89fdad2e..9d75a119 100644 --- a/backend/src/grant/grant.service.ts +++ b/backend/src/grant/grant.service.ts @@ -410,7 +410,7 @@ export class GrantService { const userId = grant.bcan_poc.POC_email; this.logger.debug(`Preparing to create notifications for user: ${userId}`); - //await this.createGrantNotifications({ ...grant, grantId: newGrantId }, userId); + await this.createGrantNotifications({ ...grant, grantId: newGrantId }, userId); this.logger.log(`Successfully created grant ${newGrantId} with all associated data`); } catch (error: unknown) { diff --git a/backend/src/revenue/cashflow-revenue.controller.ts b/backend/src/revenue/cashflow-revenue.controller.ts new file mode 100644 index 00000000..c22958aa --- /dev/null +++ b/backend/src/revenue/cashflow-revenue.controller.ts @@ -0,0 +1,42 @@ +import { Body, Controller, Get, Logger, Post, UseGuards } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from "@nestjs/swagger"; +import { VerifyAdminRoleGuard } from "../guards/auth.guard"; +import { CashflowRevenue } from "../types/CashflowRevenue"; +import { RevenueService } from "./cashflow-revenue.service"; +import { CashflowRevenueDTO } from "./types/revenue.types"; + +@ApiTags('cashflow-revenue') +@Controller('cashflow-revenue') +export class RevenueController { + + private readonly logger = new Logger(RevenueController.name); + + constructor(private readonly revService: RevenueService) { } + + @Get() + @UseGuards(VerifyAdminRoleGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get all revenue', description: 'Retrieves all cashflow revenue items' }) + @ApiResponse({ status: 200, description: 'Successfully retrieved all revenue items', type: [CashflowRevenueDTO] }) + @ApiResponse({ status: 401, description: 'Unauthorized - invalid or missing token' }) + @ApiResponse({ status: 500, description: 'Internal server error' }) + async getAllRevenue(): Promise { + return this.revService.getAllRevenue(); + } + + @Post() + @UseGuards(VerifyAdminRoleGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Create a revenue item', description: 'Creates a new cashflow revenue item' }) + @ApiBody({ type: CashflowRevenueDTO, description: 'The revenue item to create' }) + @ApiResponse({ status: 201, description: 'Successfully created revenue item', type: CashflowRevenueDTO }) + @ApiResponse({ status: 400, description: 'Bad request - invalid body or missing fields' }) + @ApiResponse({ status: 401, description: 'Unauthorized - invalid or missing token' }) + @ApiResponse({ status: 409, description: 'Conflict - revenue item with this name already exists' }) + @ApiResponse({ status: 500, description: 'Internal server error' }) + async createRevenue( + @Body() body: CashflowRevenue + ): Promise { + return await this.revService.createRevenue(body); + } +} \ No newline at end of file diff --git a/backend/src/revenue/cashflow-revenue.module.ts b/backend/src/revenue/cashflow-revenue.module.ts new file mode 100644 index 00000000..d8e22b53 --- /dev/null +++ b/backend/src/revenue/cashflow-revenue.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { RevenueService } from './cashflow-revenue.service'; +import { RevenueController } from './cashflow-revenue.controller'; + + +@Module({ + controllers: [RevenueController], + providers: [RevenueService], +}) +export class RevenueModule {} \ No newline at end of file diff --git a/backend/src/revenue/cashflow-revenue.service.ts b/backend/src/revenue/cashflow-revenue.service.ts new file mode 100644 index 00000000..267edee8 --- /dev/null +++ b/backend/src/revenue/cashflow-revenue.service.ts @@ -0,0 +1,251 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, +} from "@nestjs/common"; +import * as AWS from "aws-sdk"; +import { CashflowRevenue } from "../types/CashflowRevenue"; +import { AWSError } from "aws-sdk"; +import { RevenueType } from "../../../middle-layer/types/RevenueType"; +import { Installment } from "../../../middle-layer/types/Installment"; + +@Injectable() +export class RevenueService { + private readonly logger = new Logger(RevenueService.name); + private dynamoDb = new AWS.DynamoDB.DocumentClient(); + private revenueTableName : string = process.env.CASHFLOW_REVENUE_TABLE_NAME || "" + /** + * Helper method to check if an error is an AWS error and extract relevant information + */ + private isAWSError(error: unknown): error is AWSError { + return ( + typeof error === "object" && + error !== null && + ("code" in error || "statusCode" in error || "requestId" in error) + ); + } + + /** + * Helper method to handle AWS errors and throw appropriate NestJS exceptions + */ + private handleAWSError( + error: AWSError, + operation: string, + context?: string, + ): never { + const errorContext = context ? ` (${context})` : ""; + const errorDetails = { + code: error.code, + message: error.message, + requestId: error.requestId, + retryable: error.retryable, + }; + + this.logger.error(`AWS Error during ${operation}${errorContext}:`, { + ...errorDetails, + stack: error.stack, + }); + + // Handle specific AWS error codes + switch (error.code) { + case "ResourceNotFoundException": + throw new BadRequestException( + `AWS DynamoDB Error: Table or resource not found. ${error.message}`, + ); + case "ValidationException": + throw new BadRequestException( + `AWS DynamoDB Validation Error: Invalid request parameters. ${error.message}`, + ); + case "ProvisionedThroughputExceededException": + throw new InternalServerErrorException( + `AWS DynamoDB Error: Request rate too high. Please retry later. ${error.message}`, + ); + case "ThrottlingException": + throw new InternalServerErrorException( + `AWS DynamoDB Error: Request throttled. Please retry later. ${error.message}`, + ); + case "ConditionalCheckFailedException": + throw new BadRequestException( + `AWS DynamoDB Error: Conditional check failed. ${error.message}`, + ); + case "ItemCollectionSizeLimitExceededException": + throw new BadRequestException( + `AWS DynamoDB Error: Item collection size limit exceeded. ${error.message}`, + ); + default: + throw new InternalServerErrorException( + `AWS DynamoDB Error during ${operation}: ${error.message || "Unknown AWS error"}`, + ); + } + } + private validateAmount(amount: number): void { + if (amount === undefined || amount === null) { + this.logger.error('Validation failed: amount is required'); + throw new BadRequestException('amount is required'); + } + if (!Number.isFinite(amount) || amount <= 0) { + this.logger.error(`Validation failed: invalid amount value: ${amount}`); + throw new BadRequestException('amount must be a finite positive number'); + } +} + +private validateType(type: RevenueType): void { + if (type === undefined || type === null) { + this.logger.error('Validation failed: type is required'); + throw new BadRequestException('type is required'); + } + if (!Object.values(RevenueType).includes(type)) { + this.logger.error(`Validation failed: invalid type value: ${type}`); + throw new BadRequestException( + `type must be one of: ${Object.values(RevenueType).join(', ')}`, + ); + } +} + +private validateName(name: string): void { + if (name === undefined || name === null) { + this.logger.error('Validation failed: name is required'); + throw new BadRequestException('name is required'); + } + if (name.trim().length === 0) { + this.logger.error('Validation failed: name is empty or whitespace'); + throw new BadRequestException('name must be a non-empty string'); + } +} + +private validateInstallments(installments: Installment[]): void { + if (installments === undefined || installments === null) { + this.logger.error('Validation failed: installments is required'); + throw new BadRequestException('installments is required'); + } + if (!Array.isArray(installments)) { + this.logger.error(`Validation failed: installments is not an array, received: ${typeof installments}`); + throw new BadRequestException('installments must be an array'); + } + installments.forEach((installment, index) => { + if (!Number.isFinite(installment.amount) || installment.amount <= 0) { + this.logger.error( + `Validation failed: installments[${index}].amount is invalid: ${installment.amount}`, + ); + throw new BadRequestException( + `installments[${index}].amount must be a finite positive number`, + ); + } + if (!installment.date) { + this.logger.error(`Validation failed: installments[${index}].date is required`); + throw new BadRequestException( + `installments[${index}].date is required`, + ); + } + }); +} + +private validateRevenueObject(revenue: CashflowRevenue): void { + if (!revenue) { + this.logger.error('Validation failed: revenue body is required'); + throw new BadRequestException('revenue body is required'); + } + this.validateAmount(revenue.amount); + this.validateType(revenue.type); + this.validateName(revenue.name); + this.validateInstallments(revenue.installments); +} + +private validateTableName(tableName : string){ + if ( + tableName === "" || + tableName === null || + tableName === undefined + ) { + this.logger.error("Revenue table env variable is not filled in"); + throw new InternalServerErrorException("Server Config Error"); + } +} + + // Method to retrieve all of the revenue data + async getAllRevenue(): Promise { + this.logger.log("Retreiving all the cashflow revenue data"); + + + this.validateTableName(this.revenueTableName) + + const params = { TableName: this.revenueTableName }; + + try { + this.logger.debug(`Scanning Revenue DynamoDB table: ${params.TableName}`); + const data = await this.dynamoDb.scan(params).promise(); + if (!data || !data.Items) { + this.logger.error("There has been an error"); + throw new InternalServerErrorException("Internal Server Error"); + } + + if (data.Items?.length === 0) { + this.logger.warn("There are zero revenue items in the database"); + return [] as CashflowRevenue[]; + } + const revenue = (data.Items as CashflowRevenue[]) || []; + this.logger.log( + `Retrived ${revenue.length} revenue items from the backend`, + ); + return revenue; + } catch (error) { + if (error instanceof InternalServerErrorException) { + throw error; + } + if(this.isAWSError(error)){ + try{ + this.handleAWSError(error, "getAllRevenue", `table ${params.TableName}`) + } catch(error){ + throw new InternalServerErrorException("Internal Server Error") + } + } + + this.logger.error("Uncaught Error fetching all Revenue data: ", error) + throw new InternalServerErrorException("Internal Server Error") + } + // Call to the database + // Handle errors based off errors + } + + async createRevenue(revenue: CashflowRevenue): Promise { + this.validateRevenueObject(revenue); + this.validateTableName(this.revenueTableName); + + const normalizedRevenue = { + ...revenue, + name: revenue.name.trim(), + }; + + const params = { + TableName: this.revenueTableName, + Item: normalizedRevenue, + ConditionExpression: 'attribute_not_exists(#name)', + ExpressionAttributeNames: { + '#name': 'name', + }, + }; + + try { + this.logger.log(`Creating revenue item with name: ${normalizedRevenue.name}`); + await this.dynamoDb.put(params).promise(); + this.logger.log(`Successfully created revenue item with name: ${normalizedRevenue.name}`); + return normalizedRevenue; + } catch (error) { + if (error instanceof BadRequestException || error instanceof InternalServerErrorException) { + throw error; + } + + if (this.isAWSError(error)) { + try { + this.handleAWSError(error, 'createRevenue', `table ${params.TableName}`); + } catch (handledError) { + throw new InternalServerErrorException("Internal Service Error") + } + } + + this.logger.error('Uncaught error creating revenue item: ', error); + throw new InternalServerErrorException('Internal Server Error'); + } +} +} diff --git a/backend/src/revenue/types/revenue.types.ts b/backend/src/revenue/types/revenue.types.ts new file mode 100644 index 00000000..f7623ee0 --- /dev/null +++ b/backend/src/revenue/types/revenue.types.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { CashflowRevenue } from "../../../../middle-layer/types/CashflowRevenue"; +import { Installment } from "../../../../middle-layer/types/Installment"; +import { RevenueType } from "../../../../middle-layer/types/RevenueType"; +export class CashflowRevenueDTO implements CashflowRevenue { + @ApiProperty({ description: "The revenue amount", example: 1000 }) + amount!: number; + + @ApiProperty({ description: "The type of revenue", enum: RevenueType }) + type!: RevenueType; + + @ApiProperty({ + description: "The name of the revenue item", + example: "Q1 Sales", + }) + name!: string; + + @ApiProperty({ + description: "List of installments", + example: [{ amount: 30000, date: "2026-03-14T00:00:00.000Z" }], + }) + installments!: Installment[]; +} From bea56c5a824d9f240175eebabfc0bc6888b1f877 Mon Sep 17 00:00:00 2001 From: Jane Kamata Date: Mon, 23 Mar 2026 23:31:27 -0400 Subject: [PATCH 03/19] Connecting cost to backend --- .../src/main-page/cash-flow/CashFlowPage.tsx | 1 - .../cash-flow/components/CashAddCosts.tsx | 8 +- .../cash-flow/components/CashAddRevenue.tsx | 8 +- .../components/CashCategoryDropdown.tsx | 13 ++- .../cash-flow/components/CashEditCost.tsx | 84 +++++++++++++++++++ .../cash-flow/components/CashEditLineItem.tsx | 35 ++++++-- .../cash-flow/components/CashSourceList.tsx | 65 +++++++++++--- .../cash-flow/processCashflowData.ts | 2 +- .../cash-flow/processCashflowDataEditSave.ts | 14 ++-- 9 files changed, 193 insertions(+), 37 deletions(-) create mode 100644 frontend/src/main-page/cash-flow/components/CashEditCost.tsx diff --git a/frontend/src/main-page/cash-flow/CashFlowPage.tsx b/frontend/src/main-page/cash-flow/CashFlowPage.tsx index 4cd441d6..cd8b90ac 100644 --- a/frontend/src/main-page/cash-flow/CashFlowPage.tsx +++ b/frontend/src/main-page/cash-flow/CashFlowPage.tsx @@ -14,7 +14,6 @@ import { ProcessCashflowData } from "./processCashflowData"; import CashCreateLineItem from "./components/CashCreateLineItem"; const CashFlowPage = observer(() => { - const { costs, revenues } = ProcessCashflowData(); return ( diff --git a/frontend/src/main-page/cash-flow/components/CashAddCosts.tsx b/frontend/src/main-page/cash-flow/components/CashAddCosts.tsx index 90a155b6..413ee303 100644 --- a/frontend/src/main-page/cash-flow/components/CashAddCosts.tsx +++ b/frontend/src/main-page/cash-flow/components/CashAddCosts.tsx @@ -9,9 +9,13 @@ export default function CashAddCosts() {
{"Add Cost Source"}
-
+
- {}} /> + alert(value)} + value={CostType.Benefits} + />
{"Add Revenue Source"}
-
+
- {}} /> + alert(value)} + value={RevenueType.Fundraising} + /> void; + onChange: (e: React.ChangeEvent) => void; + value: RevenueType | CostType; }; export default function CashCategoryDropdown({ type, onChange, + value, }: CashCategoryDropdown) { return (
-