Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions backend/src/grant/__test__/grant.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,15 +396,15 @@ describe('deleteGrantById', () => {
promise: vi.fn().mockResolvedValue({})
});

const result = await grantService.deleteGrantById('123');
const result = await grantService.deleteGrantById(123);

expect(mockDelete).toHaveBeenCalledTimes(1); //ensures delete() was called once

//ensures delete() received an object containing the expected key and condition
expect(mockDelete).toHaveBeenCalledWith(
expect.objectContaining({
TableName: expect.any(String),
Key: {grantId: '123'},
Key: {grantId: 123},
ConditionExpression: 'attribute_exists(grantId)'
}),
);
Expand All @@ -421,7 +421,7 @@ describe('deleteGrantById', () => {
promise: vi.fn().mockRejectedValue(conditionalError)
});

await expect(grantService.deleteGrantById('999'))
await expect(grantService.deleteGrantById(999))
.rejects.toThrow(/does not exist/);
});

Expand All @@ -430,7 +430,7 @@ describe('deleteGrantById', () => {
promise: vi.fn().mockRejectedValue(new Error('Some other DynamoDB error'))
});

await expect(grantService.deleteGrantById('123'))
await expect(grantService.deleteGrantById(123))
.rejects.toThrow(/Failed to delete/);
});
});
Expand Down
2 changes: 1 addition & 1 deletion backend/src/grant/grant.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export class GrantController {
}

@Delete(':grantId')
async deleteGrant(@Param('grantId') grantId: string) {
async deleteGrant(@Param('grantId') grantId: number) {
return await this.grantService.deleteGrantById(grantId);
}
@Get(':id')
Expand Down
2 changes: 1 addition & 1 deletion backend/src/grant/grant.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ import { NotificationsModule } from '../notifications/notification.module';
@Module({
imports: [NotificationsModule],
controllers: [GrantController],
providers: [GrantService],
providers: [GrantService]
})
export class GrantModule { }
67 changes: 35 additions & 32 deletions backend/src/grant/grant.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import AWS from 'aws-sdk';
import AWS, { AWSError } from 'aws-sdk';
import { Grant } from '../../../middle-layer/types/Grant';
import { NotificationService } from '.././notifications/notifcation.service';
import { NotificationService } from '../notifications/notification.service';
import { Notification } from '../../../middle-layer/types/Notification';
import { TDateISO } from '../utils/date';
import { Status } from '../../../middle-layer/types/Status';
Expand Down Expand Up @@ -120,36 +120,39 @@ async makeGrantsInactive(grantId: number): Promise<Grant> {
* @param grantData
*/
async updateGrant(grantData: Grant): Promise<string> {
// dynamically creates the update expression/attribute names based on names of grant interface
// assumption: grant interface field names are exactly the same as db storage naming
this.logger.warn('here' + grantData.status);
const updateKeys = Object.keys(grantData).filter(
key => key != 'grantId'
);
const UpdateExpression = "SET " + updateKeys.map((key) => `#${key} = :${key}`).join(", ");
const ExpressionAttributeNames = updateKeys.reduce((acc, key) =>
({ ...acc, [`#${key}`]: key }), {});
const ExpressionAttributeValues = updateKeys.reduce((acc, key) =>
({ ...acc, [`:${key}`]: grantData[key as keyof typeof grantData] }), {});

const updateKeys = Object.keys(grantData).filter(
key => key != 'grantId'
);

this.logger.warn('Update keys: ' + JSON.stringify(updateKeys));

const UpdateExpression = "SET " + updateKeys.map((key) => `#${key} = :${key}`).join(", ");
const ExpressionAttributeNames = updateKeys.reduce((acc, key) =>
({ ...acc, [`#${key}`]: key }), {});
const ExpressionAttributeValues = updateKeys.reduce((acc, key) =>
({ ...acc, [`:${key}`]: grantData[key as keyof typeof grantData] }), {});

const params = {
TableName: process.env.DYNAMODB_GRANT_TABLE_NAME || "TABLE_FAILURE",
Key: { grantId: grantData.grantId },
UpdateExpression,
ExpressionAttributeNames,
ExpressionAttributeValues,
ReturnValues: "UPDATED_NEW",
};
const params = {
TableName: process.env.DYNAMODB_GRANT_TABLE_NAME || "TABLE_FAILURE",
Key: { grantId: grantData.grantId },
UpdateExpression,
ExpressionAttributeNames,
ExpressionAttributeValues,
ReturnValues: "UPDATED_NEW",
};

try {
const result = await this.dynamoDb.update(params).promise();
await this.updateGrantNotifications(grantData);
return JSON.stringify(result); // returns the changed attributes stored in db
} catch(err) {
console.log(err);
throw new Error(`Failed to update Grant ${grantData.grantId}`)
}
}
try {
const result = await this.dynamoDb.update(params).promise();
this.logger.warn('✅ Update successful!');
//await this.updateGrantNotifications(grantData);
return JSON.stringify(result);
} catch(err: unknown) {
this.logger.error('=== DYNAMODB ERROR ===');
this.logger.error('Unknown error type: ' + JSON.stringify(err));
throw new Error(`Failed to update Grant ${grantData.grantId}`);
}
}

// Add a new grant using the Grant interface from middleware.
async addGrant(grant: Grant): Promise<number> {
Expand Down Expand Up @@ -193,10 +196,10 @@ async makeGrantsInactive(grantId: number): Promise<Grant> {
/* Deletes a grant from database based on its grant ID number
* @param grantId
*/
async deleteGrantById(grantId: string): Promise<string> {
async deleteGrantById(grantId: number): Promise<string> {
const params = {
TableName: process.env.DYNAMODB_GRANT_TABLE_NAME || "TABLE_FAILURE",
Key: { grantId: grantId },
Key: { grantId: Number(grantId) },
ConditionExpression: "attribute_exists(grantId)", // ensures grant exists
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing';
import { Notification } from '../../../../middle-layer/types/Notification';
import { NotificationController } from '../notification.controller';
import { NotificationService } from '../notifcation.service';
import { NotificationService } from '../notification.service';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { servicesVersion } from 'typescript';
import { TDateISO } from '../../utils/date';
Expand Down
2 changes: 1 addition & 1 deletion backend/src/notifications/notification.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Controller, Post, Body, Get, Query, Param, Patch, Put, Delete } from '@nestjs/common';
import { NotificationService } from './notifcation.service';
import { NotificationService } from './notification.service';
import { Notification } from '../../../middle-layer/types/Notification';


Expand Down
2 changes: 1 addition & 1 deletion backend/src/notifications/notification.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { NotificationController } from './notification.controller';
import { NotificationService } from './notifcation.service';
import { NotificationService } from './notification.service';

@Module({
providers: [NotificationService], // providers perform business logic
Expand Down
126 changes: 126 additions & 0 deletions frontend/src/custom/ActionConfirmation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { IoIosWarning } from "react-icons/io";

{/* The popup that appears on delete
isOpen
- boolean if this should be visible or not
title
- The bold text at the very top
- Ex: "Delete Grant"
subtitle and boldSubtitle
- The text on the next line and the text that should be bolded
- Ex: "Are you sure you want to delete" and "{grantText}"
warningMessage
- The text in the orange warning box
- Ex: "By deleting this grant, they won't be available in the system anymore"
onCloseDelete function
- called when user clicks "No, cancel"
onConfirmDelete function
- called when user clicks "Yes, confirm"

For an example, see GrantItem.tsx

*/}
const ActionConfirmation = ({
isOpen,
onCloseDelete,
onConfirmDelete,
title,
subtitle = "Are you sure?",
boldSubtitle = "",
warningMessage = "This action cannot be undone."
}: {
isOpen: boolean;
onCloseDelete: () => void;
onConfirmDelete: () => void;
title: string;
subtitle: string;
boldSubtitle : string;
warningMessage: string;
}) => {
if (!isOpen) return null;

return (
<div
className=" fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 transition-opacity duration-300"
onClick={onCloseDelete}
>
<div
style={{
borderStyle: 'solid',
borderColor: 'black',
borderWidth: '2px'
}}
className=" bg-white rounded-lg shadow-2xl p-8 max-w-xl w-full mx-4 transform transition-all duration-300"
onClick={(e) => e.stopPropagation()}
>

{/* Title */}
<h3 className="text-2xl font-bold text-black text-center mb-2">
{title}
</h3>

{/* Message */}
<p className="text-gray-600 text-center mb-6 text-lg">
{subtitle + " "}
<span className="font-bold">{boldSubtitle}</span>
{"?"}
</p>

<div className="max-w-md mx-auto ">

<div className="flex mb-6">

<div className="w-3" style={{backgroundColor : "#FA703F"}}/>
<div className="p-3" style={{backgroundColor : "#FFE9D9"}}>
<div className="flex">
<IoIosWarning size={24} style={{color: "#771505"}}/>
<p className="font-bold px-1 text-lg" style={{color: "#771505"}}> Warning </p>
</div>
<p className=" text-left text-lg font-semibold" style={{color : "#FA703F"}}>
{warningMessage}
</p>

</div>

</div>


{/* Buttons */}
<div className="flex w-full justify-between ">
<button
style={{
backgroundColor: "#F7A781",
borderStyle: 'solid',
borderColor: 'black',
borderWidth: '2px'
}}
className="rounded-lg hover:bg-gray-200 transition-colors w-32 h-12"
onClick={onCloseDelete}
>
No, cancel
</button>
<button
style={{
backgroundColor: 'white',
borderStyle: 'solid',
borderColor: 'black',
borderWidth: '2px'
}}
className="rounded-lg text-black hover:bg-red-700 transition-colors w-32 h-12"
onClick={() => {
onConfirmDelete();
onCloseDelete();
}}
>
Yes, confirm
</button>
</div>

</div>

</div>
</div>
);
};

export default ActionConfirmation;
24 changes: 10 additions & 14 deletions frontend/src/main-page/grants/GrantPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,9 @@ import { useEffect, useState } from "react";
import { Grant } from "../../../../middle-layer/types/Grant.ts";
import FilterBar from "./filter-bar/FilterBar.tsx";
import { useAuthContext } from "../../context/auth/authContext";
import {
updateEndDateFilter,
updateFilter,
updateStartDateFilter,
updateYearFilter,
} from "../../external/bcanSatchel/actions.ts";
import { toJS } from "mobx";
import { UserStatus } from "../../../../middle-layer/types/UserStatus.ts";
import { Navigate } from "react-router-dom";
import { updateEndDateFilter, updateFilter, updateStartDateFilter, updateYearFilter } from "../../external/bcanSatchel/actions.ts";
import { fetchGrants } from "./filter-bar/processGrantData.ts";


interface GrantPageProps {
showOnlyMyGrants?: boolean; //if true, filters grants by user email
Expand Down Expand Up @@ -71,11 +65,13 @@ function GrantPage({ showOnlyMyGrants = false }: GrantPageProps) {
)}
</div>
</div>
) :
<Navigate to="restricted" replace />
) : (
<Navigate to="/login" replace />
<div className="hidden-features">
{showNewGrantModal && (
<NewGrantModal grantToEdit={null} onClose={async () => {setShowNewGrantModal(false); await fetchGrants();}} />
)}
</div>
</div>
);
}
}

export default GrantPage;
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { sortGrants } from "./grantSorter.ts";
import { api } from "../../../api.ts";

// fetch grants
const fetchGrants = async () => {
export const fetchGrants = async () => {
try {
const response = await api("/grant");
if (!response.ok) {
Expand Down
Loading
Loading