Skip to content

Commit

Permalink
#3060-merged feature-branch
Browse files Browse the repository at this point in the history
  • Loading branch information
caiodasilva2005 committed Dec 20, 2024
2 parents 06df950 + 4b28cc8 commit a1d8637
Show file tree
Hide file tree
Showing 30 changed files with 817 additions and 48 deletions.
25 changes: 25 additions & 0 deletions src/backend/src/controllers/users.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,4 +191,29 @@ export default class UsersController {
next(error);
}
}

static async getUserUnreadNotifications(req: Request, res: Response, next: NextFunction) {
try {
const { userId } = req.params;
const { organization } = req;

const unreadNotifications = await UsersService.getUserUnreadNotifications(userId, organization);
res.status(200).json(unreadNotifications);
} catch (error: unknown) {
next(error);
}
}

static async removeUserNotification(req: Request, res: Response, next: NextFunction) {
try {
const { userId } = req.params;
const { notificationId } = req.body;
const { organization } = req;

const unreadNotifications = await UsersService.removeUserNotification(userId, notificationId, organization);
res.status(200).json(unreadNotifications);
} catch (error: unknown) {
next(error);
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
-- AlterTable
ALTER TABLE "Organization" ADD COLUMN "logoImageId" TEXT;

-- AlterTable
ALTER TABLE "Project" ADD COLUMN "organizationId" TEXT;

-- CreateTable
CREATE TABLE "Announcement" (
"announcementId" TEXT NOT NULL,
Expand All @@ -13,6 +19,7 @@ CREATE TABLE "Notification" (
"notificationId" TEXT NOT NULL,
"text" TEXT NOT NULL,
"iconName" TEXT NOT NULL,
"eventLink" TEXT,

CONSTRAINT "Notification_pkey" PRIMARY KEY ("notificationId")
);
Expand Down Expand Up @@ -41,6 +48,9 @@ CREATE UNIQUE INDEX "_userNotifications_AB_unique" ON "_userNotifications"("A",
-- CreateIndex
CREATE INDEX "_userNotifications_B_index" ON "_userNotifications"("B");

-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Announcement" ADD CONSTRAINT "Announcement_userCreatedId_fkey" FOREIGN KEY ("userCreatedId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE;

Expand Down
5 changes: 3 additions & 2 deletions src/backend/src/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -941,8 +941,9 @@ model Announcement {
}

model Notification {
notificationId String @id @default(uuid())
notificationId String @id @default(uuid())
text String
iconName String
users User[] @relation("userNotifications")
users User[] @relation("userNotifications")
eventLink String?
}
1 change: 1 addition & 0 deletions src/backend/src/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { writeFileSync } from 'fs';
import WorkPackageTemplatesService from '../services/work-package-template.services';
import RecruitmentServices from '../services/recruitment.services';
import OrganizationsService from '../services/organizations.services';
import NotificationsService from '../services/notifications.services';

const prisma = new PrismaClient();

Expand Down
6 changes: 6 additions & 0 deletions src/backend/src/routes/users.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,11 @@ userRouter.post(
validateInputs,
UsersController.getManyUserTasks
);
userRouter.get('/:userId/notifications', UsersController.getUserUnreadNotifications);
userRouter.post(
'/:userId/notifications/remove',
nonEmptyString(body('notificationId')),
UsersController.removeUserNotification
);

export default userRouter;
5 changes: 5 additions & 0 deletions src/backend/src/services/change-requests.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
import { ChangeRequestQueryArgs, getChangeRequestQueryArgs } from '../prisma-query-args/change-requests.query-args';
import proposedSolutionTransformer from '../transformers/proposed-solutions.transformer';
import { getProposedSolutionQueryArgs } from '../prisma-query-args/proposed-solutions.query-args';
import { sendHomeCrRequestReviewNotification, sendHomeCrReviewedNotification } from '../utils/notifications.utils';

export default class ChangeRequestsService {
/**
Expand Down Expand Up @@ -150,6 +151,8 @@ export default class ChangeRequestsService {
// send a notification to the submitter that their change request has been reviewed
await sendCRSubmitterReviewedNotification(updated);

await sendHomeCrReviewedNotification(foundCR, updated.submitter, accepted, organization.organizationId);

// send a reply to a CR's notifications of its updated status
await sendSlackCRStatusToThread(updated.notificationSlackThreads, foundCR.crId, foundCR.identifier, accepted);

Expand Down Expand Up @@ -1078,5 +1081,7 @@ export default class ChangeRequestsService {

// send slack message to CR reviewers
await sendSlackRequestedReviewNotification(newReviewers, changeRequestTransformer(foundCR));

await sendHomeCrRequestReviewNotification(foundCR, newReviewers, organization.organizationId);
}
}
3 changes: 3 additions & 0 deletions src/backend/src/services/design-reviews.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { getWorkPackageQueryArgs } from '../prisma-query-args/work-packages.quer
import { UserWithSettings } from '../utils/auth.utils';
import { getUserScheduleSettingsQueryArgs } from '../prisma-query-args/user.query-args';
import { createCalendarEvent, deleteCalendarEvent, updateCalendarEvent } from '../utils/google-integration.utils';
import { sendHomeDrNotification } from '../utils/notifications.utils';

export default class DesignReviewsService {
/**
Expand Down Expand Up @@ -205,6 +206,8 @@ export default class DesignReviewsService {
}
}

await sendHomeDrNotification(designReview, members, submitter, wbsElement.name, organization.organizationId);

const project = wbsElement.workPackage?.project;
const teams = project?.teams;
if (teams && teams.length > 0) {
Expand Down
12 changes: 10 additions & 2 deletions src/backend/src/services/notifications.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,13 +202,21 @@ export default class NotificationsService {
* @param iconName icon that appears in the notification
* @param userIds ids of users to send the notification to
* @param organizationId
* @param eventLink link the notification will go to when clicked
* @returns the created notification
*/
static async sendNotifcationToUsers(text: string, iconName: string, userIds: string[], organizationId: string) {
static async sendNotifcationToUsers(
text: string,
iconName: string,
userIds: string[],
organizationId: string,
eventLink?: string
) {
const createdNotification = await prisma.notification.create({
data: {
text,
iconName
iconName,
eventLink
},
...getNotificationQueryArgs(organizationId)
});
Expand Down
47 changes: 47 additions & 0 deletions src/backend/src/services/users.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ import { getAuthUserQueryArgs } from '../prisma-query-args/auth-user.query-args'
import authenticatedUserTransformer from '../transformers/auth-user.transformer';
import { getTaskQueryArgs } from '../prisma-query-args/tasks.query-args';
import taskTransformer from '../transformers/tasks.transformer';
import { getNotificationQueryArgs } from '../prisma-query-args/notifications.query-args';
import notificationTransformer from '../transformers/notifications.transformer';

export default class UsersService {
/**
Expand Down Expand Up @@ -566,4 +568,49 @@ export default class UsersService {
const resolvedTasks = await Promise.all(tasksPromises);
return resolvedTasks.flat();
}

/**
* Gets all of a user's unread notifications
* @param userId id of user to get unread notifications from
* @param organization the user's orgainzation
* @returns the unread notifications of the user
*/
static async getUserUnreadNotifications(userId: string, organization: Organization) {
const requestedUser = await prisma.user.findUnique({
where: { userId },
include: { unreadNotifications: getNotificationQueryArgs(organization.organizationId) }
});
if (!requestedUser) throw new NotFoundException('User', userId);

return requestedUser.unreadNotifications.map(notificationTransformer);
}

/**
* Removes a notification from the user's unread notifications
* @param userId id of the user to remove notification from
* @param notificationId id of the notification to remove
* @param organization the user's organization
* @returns the user's updated unread notifications
*/
static async removeUserNotification(userId: string, notificationId: string, organization: Organization) {
const requestedUser = await prisma.user.findUnique({
where: { userId }
});

if (!requestedUser) throw new NotFoundException('User', userId);

const updatedUser = await prisma.user.update({
where: { userId },
data: {
unreadNotifications: {
disconnect: {
notificationId
}
}
},
include: { unreadNotifications: getNotificationQueryArgs(organization.organizationId) }
});

return updatedUser.unreadNotifications.map(notificationTransformer);
}
}
3 changes: 2 additions & 1 deletion src/backend/src/transformers/notifications.transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ const notificationTransformer = (notification: Prisma.NotificationGetPayload<Not
return {
notificationId: notification.notificationId,
text: notification.text,
iconName: notification.iconName
iconName: notification.iconName,
eventLink: notification.eventLink ?? undefined
};
};

Expand Down
79 changes: 78 additions & 1 deletion src/backend/src/utils/notifications.utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Task as Prisma_Task, WBS_Element, Design_Review } from '@prisma/client';
import { Task as Prisma_Task, WBS_Element, Design_Review, Change_Request } from '@prisma/client';
import { UserWithSettings } from './auth.utils';
import NotificationsService from '../services/notifications.services';
import { User } from '@prisma/client';

export type TaskWithAssignees = Prisma_Task & {
assignees: UserWithSettings[] | null;
Expand Down Expand Up @@ -35,3 +37,78 @@ export const endOfDayTomorrow = () => {
endOfDay.setDate(startOfDay.getDate() + 1);
return endOfDay;
};

/**
* Sends a finishline notification that a design review was scheduled
* @param designReview dr that was created
* @param members optional and required members of the dr
* @param submitter the user who created the dr
* @param workPackageName the name of the work package associated witht the dr
* @param organizationId id of the organization of the dr
*/
export const sendHomeDrNotification = async (
designReview: Design_Review,
members: User[],
submitter: User,
workPackageName: string,
organizationId: string
) => {
const designReviewLink = `/settings/preferences?drId=${designReview.designReviewId}`;

const msg = `Design Review for ${workPackageName} is being scheduled by ${submitter.firstName} ${submitter.lastName}`;
await NotificationsService.sendNotifcationToUsers(
msg,
'calendar_month',
members.map((member) => member.userId),
organizationId,
designReviewLink
);
};

/**
* Sends a finishline notification that a change request was reviewed
* @param changeRequest cr that was requested review
* @param submitter the user who submitted the cr
* @param accepted true if the cr changes were accepted, false if denied
* @param organizationId id of the organization of the cr
*/
export const sendHomeCrReviewedNotification = async (
changeRequest: Change_Request,
submitter: User,
accepted: boolean,
organizationId: string
) => {
const isProd = process.env.NODE_ENV === 'production';

const changeRequestLink = isProd
? `https://finishlinebyner.com/change-requests/${changeRequest.crId}`
: `http://localhost:3000/change-requests/${changeRequest.crId}`;
await NotificationsService.sendNotifcationToUsers(
`CR #${changeRequest.identifier} has been ${accepted ? 'approved!' : 'denied.'}`,
accepted ? 'check_circle' : 'cancel',
[submitter.userId],
organizationId,
changeRequestLink
);
};

/**
* Sends a finishline notification to all requested reviewers of a change request
* @param changeRequest cr that was requested review
* @param reviewers user's reviewing the cr
* @param organizationId id of the organization of the cr
*/
export const sendHomeCrRequestReviewNotification = async (
changeRequest: Change_Request,
reviewers: User[],
organizationId: string
) => {
const changeRequestLink = `/change-requests/${changeRequest.crId}`;
await NotificationsService.sendNotifcationToUsers(
`Your review has been requested on CR #${changeRequest.identifier}`,
'edit_note',
reviewers.map((reviewer) => reviewer.userId),
organizationId,
changeRequestLink
);
};
54 changes: 54 additions & 0 deletions src/backend/tests/unmocked/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createTestOrganization, createTestTask, createTestUser, resetUsers } fr
import { batmanAppAdmin } from '../test-data/users.test-data';
import UsersService from '../../src/services/users.services';
import { NotFoundException } from '../../src/utils/errors.utils';
import NotificationsService from '../../src/services/notifications.services';

describe('User Tests', () => {
let orgId: string;
Expand Down Expand Up @@ -48,4 +49,57 @@ describe('User Tests', () => {
expect(userTasks).toStrictEqual([batmanTask, batmanTask]);
});
});

describe('Get Notifications', () => {
it('fails on invalid user id', async () => {
await expect(async () => await UsersService.getUserUnreadNotifications('1', organization)).rejects.toThrow(
new NotFoundException('User', '1')
);
});

it('Succeeds and gets user notifications', async () => {
const testBatman = await createTestUser(batmanAppAdmin, orgId);
await NotificationsService.sendNotifcationToUsers('test1', 'test1', [testBatman.userId], orgId);
await NotificationsService.sendNotifcationToUsers('test2', 'test2', [testBatman.userId], orgId);

const notifications = await UsersService.getUserUnreadNotifications(testBatman.userId, organization);

expect(notifications).toHaveLength(2);
expect(notifications[0].text).toBe('test1');
expect(notifications[1].text).toBe('test2');
});
});

describe('Remove Notifications', () => {
it('Fails with invalid user', async () => {
const testBatman = await createTestUser(batmanAppAdmin, orgId);
await NotificationsService.sendNotifcationToUsers('test1', 'test1', [testBatman.userId], orgId);
const notifications = await UsersService.getUserUnreadNotifications(testBatman.userId, organization);

await expect(
async () => await UsersService.removeUserNotification('1', notifications[0].notificationId, organization)
).rejects.toThrow(new NotFoundException('User', '1'));
});

it('Succeeds and gets user notifications', async () => {
const testBatman = await createTestUser(batmanAppAdmin, orgId);
await NotificationsService.sendNotifcationToUsers('test1', 'test1', [testBatman.userId], orgId);
await NotificationsService.sendNotifcationToUsers('test2', 'test2', [testBatman.userId], orgId);

const notifications = await UsersService.getUserUnreadNotifications(testBatman.userId, organization);

expect(notifications).toHaveLength(2);
expect(notifications[0].text).toBe('test1');
expect(notifications[1].text).toBe('test2');

const updatedNotifications = await UsersService.removeUserNotification(
testBatman.userId,
notifications[0].notificationId,
organization
);

expect(updatedNotifications).toHaveLength(1);
expect(updatedNotifications[0].text).toBe('test2');
});
});
});
Loading

0 comments on commit a1d8637

Please sign in to comment.