Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3ff416c
#4048 wp query args and project transformer edits to start fetching w…
getheobald Mar 23, 2026
a230f8e
#4048 finish backend transformer and query arg updates
getheobald Mar 23, 2026
48f6399
#4048 task card, column, and taskformmodal updates - still getting wb…
getheobald Mar 24, 2026
300fb60
#4048 fixed create task wbs check, chip now showing, updated task mod…
getheobald Mar 25, 2026
eec2ab2
Merge remote-tracking branch 'origin/develop' into #4048-tasks-for-wo…
getheobald Mar 25, 2026
1c111ac
#4048 revert overview and preview task include
getheobald Mar 25, 2026
35ea413
#4048 added editTaskWbsElement across stack to handle edits and now i…
getheobald Mar 26, 2026
99f3d44
#4048 wp tasks page with one-line filter, pre-refactor
getheobald Mar 26, 2026
b8e54af
#4048 refactor task frontend to accept piecemeal props instead of pro…
getheobald Mar 26, 2026
524b3a0
#4048 revert filter change and call TLC directly from WPVC
getheobald Mar 26, 2026
5047191
#4048 conditionally render wp dropdown, truncate chip, omit chip on w…
getheobald Mar 26, 2026
5cc4512
#4048 remove dead teams code from task frontend
getheobald Mar 26, 2026
5ab8d0f
#4048 clickable chip
getheobald Mar 26, 2026
a324ab9
#4048 extraneous imports
getheobald Mar 26, 2026
b4832e7
#4048 unit tests
getheobald Mar 26, 2026
359e27d
#4048 lint
getheobald Mar 27, 2026
4f6f675
#4048 tsc check and prettier
getheobald Mar 27, 2026
723e5f1
#4048 freaking commas
getheobald Mar 27, 2026
f4232a0
#4048 omg tsc check everything needs a wbsName
getheobald Mar 27, 2026
094ef6b
#4048 remove wp-task association in types, transformers, and query args
getheobald Mar 28, 2026
af1cf4d
#4048 full getTasksByWbsElement endpoint
getheobald Mar 28, 2026
4ccc6ea
#4048 revert tasklist props
getheobald Mar 28, 2026
9a64299
#4048 changed tasklistcontent state to flat array but sadly need to r…
getheobald Mar 28, 2026
a76d9bb
4048 fixed tasklistcontent to use hook but maintain custom ordering
getheobald Mar 28, 2026
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
23 changes: 23 additions & 0 deletions src/backend/src/controllers/tasks.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@ export default class TasksController {
}
}

static async editTaskWbsElement(req: Request, res: Response, next: NextFunction) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless we have different permissions (which I don't think we do) we should just add a wbselement param to the edit and then use that route instead of this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah makes sense. I separated it cause I saw that assignees and status were separated but I guess those have valid reasons and this doesn't, so I'll throw it in with editTask

try {
const { wbsElementId } = req.body;
const { taskId } = req.params as Record<string, string>;

const updatedTask = await TasksService.editTaskWbsElement(req.currentUser, taskId, wbsElementId, req.organization);

res.status(200).json(updatedTask);
} catch (error: unknown) {
next(error);
}
}

static async deleteTask(req: Request, res: Response, next: NextFunction) {
try {
const { taskId } = req.params as Record<string, string>;
Expand Down Expand Up @@ -122,4 +135,14 @@ export default class TasksController {
next(error);
}
}

static async getTasksByWbsNum(req: Request, res: Response, next: NextFunction) {
try {
const wbsNum: WbsNumber = validateWBS(req.params.wbsNum as string);
const tasks = await TasksService.getTasksByWbsNum(wbsNum, req.organization);
res.status(200).json(tasks);
} catch (error: unknown) {
next(error);
}
}
}
2 changes: 1 addition & 1 deletion src/backend/src/prisma-query-args/projects.query-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getDescriptionBulletQueryArgs } from './description-bullets.query-args.
import { getTeamPreviewQueryArgs } from './teams.query-args.js';
import { getTaskQueryArgs } from './tasks.query-args.js';
import { getLinkQueryArgs } from './links.query-args.js';
import { getWorkPackagePreviewQueryArgs, getWorkPackageQueryArgs } from './work-packages.query-args.js';
import { getWorkPackageQueryArgs, getWorkPackagePreviewQueryArgs } from './work-packages.query-args.js';

export type ProjectQueryArgs = ReturnType<typeof getProjectQueryArgs>;

Expand Down
3 changes: 2 additions & 1 deletion src/backend/src/prisma-query-args/tasks.query-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ export const getCalendarTaskQueryArgs = (organizationId: string) =>
organizationId: true,
dateDeleted: true,
leadId: true,
managerId: true
managerId: true,
name: true
}
},
createdBy: getUserQueryArgs(organizationId),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const getWorkPackageQueryArgs = (organizationId: string) =>
orderBy: { dateImplemented: 'asc' }
},
blocking: { where: { wbsElement: { dateDeleted: null } }, include: { wbsElement: true } },
descriptionBullets: { where: { dateDeleted: null }, ...getDescriptionBulletQueryArgs(organizationId) }
descriptionBullets: { where: { dateDeleted: null }, ...getDescriptionBulletQueryArgs(organizationId) },
}
},
blockedBy: { where: { dateDeleted: null } },
Expand Down
2 changes: 1 addition & 1 deletion src/backend/src/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1677,7 +1677,7 @@ const performSeed: () => Promise<void> = async () => {
"of the wheel and put pedal to the metal. Accelerating down straightaways and taking corners with finesse, it's " +
'easy to forget McCauley, in his blue racing jacket and jet black helmet, is racing laps around the roof of ' +
"Columbus Parking Garage on Northeastern's Boston campus. But that's the reality of Northeastern Electric " +
'Racing, a student club that has made due and found massive success in the world of electric racing despite its ' +
'Racing, a student club that has made do and found massive success in the world of electric racing despite its ' +
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😭

"relative rookie status. McCauley, NER's chief electrical engineer, has seen the club's car, Cinnamon, go from " +
'a 5-foot drive test to hitting 60 miles per hour in competitions. "It\'s a go-kart that has 110 kilowatts of ' +
'power, 109 kilowatts of power," says McCauley, a fourth-year electrical and computer engineering student. ' +
Expand Down
14 changes: 12 additions & 2 deletions src/backend/src/routes/tasks.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,27 @@ tasksRouter.post(
TasksController.editTask
);

tasksRouter.post('/:taskId/edit-status', isTaskStatus(body('status')), TasksController.editTaskStatus);
tasksRouter.post('/:taskId/edit-status', isTaskStatus(body('status')), validateInputs, TasksController.editTaskStatus);

tasksRouter.post(
'/:taskId/edit-assignees',
body('assignees').isArray(),
nonEmptyString(body('assignees.*')),
validateInputs,
TasksController.editTaskAssignees
);

tasksRouter.post('/:taskId/delete', TasksController.deleteTask);
tasksRouter.post(
'/:taskId/edit-wbs-element',
nonEmptyString(body('wbsElementId')),
validateInputs,
TasksController.editTaskWbsElement
);

tasksRouter.post('/:taskId/delete', validateInputs, TasksController.deleteTask);

tasksRouter.get('/overdue-by-team-member/:userId', TasksController.getOverdueTasksByTeamLeadership);

tasksRouter.get('/by-wbs/:wbsNum', TasksController.getTasksByWbsNum);

export default tasksRouter;
133 changes: 123 additions & 10 deletions src/backend/src/services/tasks.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,25 @@ export default class TasksService {
wbsElement: true,
workPackages: { include: { wbsElement: true } }
}
},
workPackage: {
include: {
project: {
include: {
teams: getTeamQueryArgs(organization.organizationId)
}
}
}
}
}
});
if (!requestedWbsElement) throw new NotFoundException('WBS Element', wbsPipe(wbsNum));
if (requestedWbsElement.dateDeleted) throw new DeletedException('WBS Element', wbsPipe(wbsNum));
const { project } = requestedWbsElement;
if (!project) throw new HttpException(400, "This task's wbs element is not linked to a project!");

const { teams } = project;
if (!requestedWbsElement.project && !requestedWbsElement.workPackage)
throw new HttpException(400, "This task's wbs element is not linked to a project or work package!");

const teams = requestedWbsElement.project?.teams ?? requestedWbsElement.workPackage?.project?.teams;
if (!teams || teams.length === 0)
throw new HttpException(400, 'This project needs to be assigned to a team to create a task!');

Expand Down Expand Up @@ -173,13 +183,16 @@ export default class TasksService {
/**
* Edits the status of a task in the database
* @param user the user editing the task
* @param organizationId the organizqtion Id
* @param organizationId the organization Id
* @param taskId the id of the task
* @param status the new status
* @returns the updated task
* @throws if the task does not exist, the task is already deleted, or if the user does not have permissions
*/
static async editTaskStatus(user: User, organizationId: string, taskId: string, status: Task_Status) {
const hasPermission = await userHasPermission(user.userId, organizationId, notGuest);
if (!hasPermission) throw new AccessDeniedException('Guests cannot edit tasks');

// Get the original task and check if it exists
const originalTask = await prisma.task.findUnique({ where: { taskId }, include: { assignees: true, wbsElement: true } });
if (!originalTask) throw new NotFoundException('Task', taskId);
Expand All @@ -190,9 +203,6 @@ export default class TasksService {
throw new HttpException(400, 'A task in progress must have a deadline and assignees!');
}

const hasPermission = await userHasPermission(user.userId, organizationId, notGuest);
if (!hasPermission) throw new AccessDeniedException('Guests cannot edit tasks');

const updatedTask = await prisma.task.update({
where: { taskId },
data: { status },
Expand All @@ -216,6 +226,9 @@ export default class TasksService {
assignees: string[],
organization: Organization
): Promise<Task> {
const hasPermission = await userHasPermission(user.userId, organization.organizationId, notGuest);
if (!hasPermission) throw new AccessDeniedException('Guests cannot edit tasks');

// Get the original task and check if it exists
const originalTask = await prisma.task.findUnique({
where: { taskId },
Expand All @@ -230,9 +243,6 @@ export default class TasksService {
const originalAssigneeIds = originalTask.assignees.map((assignee) => assignee.userId);
const newAssigneeIds = assignees.filter((userId) => !originalAssigneeIds.includes(userId));

const hasPermission = await userHasPermission(user.userId, organization.organizationId, notGuest);
if (!hasPermission) throw new AccessDeniedException('Guests cannot edit tasks');

// this throws if any of the users aren't found
const assigneeUsers = await getUsers(assignees);

Expand Down Expand Up @@ -261,6 +271,53 @@ export default class TasksService {
return updatedTask;
}

/**
* Edits the wbs element of a task in the database
* @param user the user editing the task
* @param taskId the id of the task
* @param wbsElementId the id of the new wbs element
* @param organization the organization that the user is currently in
* @returns the updated task
* @throws if the task does not exist, the task is already deleted, the wbs element doesn't exist, or if the user does not have permissions
*/
static async editTaskWbsElement(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above, lets just group this in with edit task

user: User,
taskId: string,
wbsElementId: string,
organization: Organization
): Promise<Task> {
const hasPermission = await userHasPermission(user.userId, organization.organizationId, notGuest);
if (!hasPermission) throw new AccessDeniedException('Guests cannot edit tasks');

// get the original task and check if it exists
const originalTask = await prisma.task.findUnique({
where: { taskId },
include: { wbsElement: true }
});

// throw error if there are issues with the task
if (!originalTask) throw new NotFoundException('Task', taskId);
if (originalTask.dateDeleted) throw new DeletedException('Task', taskId);
if (originalTask.wbsElement.organizationId !== organization.organizationId)
throw new InvalidOrganizationException('Task');

const newWbsElement = await prisma.wBS_Element.findUnique({
where: { wbsElementId }
});

// throw error if there are issues with the wbs element
if (!newWbsElement) throw new NotFoundException('WBS Element', wbsElementId);
if (newWbsElement.dateDeleted) throw new DeletedException('WBS Element', wbsElementId);

const updatedTask = await prisma.task.update({
where: { taskId },
data: { wbsElement: { connect: { wbsElementId } } },
...getTaskQueryArgs(organization.organizationId)
});

return taskTransformer(updatedTask);
}

/**
* Delete task in the database
* @param taskId the id number of the given task
Expand Down Expand Up @@ -391,4 +448,60 @@ export default class TasksService {

return tasks.map(taskCardPreviewTransformer);
}

/**
* Gets all tasks associated with a wbs element
* If the wbs number is a project (workPackageNumber === 0), returns the project's
* own tasks merged with all of its work packages' tasks
* If the wbs number is a work package, returns just that WP's tasks
* @param wbsNum the wbs number to fetch tasks for
* @param organization the organization that the user is currently in
* @returns array of tasks
*/
static async getTasksByWbsNum(wbsNum: WbsNumber, organization: Organization): Promise<Task[]> {
const wbsElement = await prisma.wBS_Element.findUnique({
where: {
wbsNumber: {
...wbsNum,
organizationId: organization.organizationId
}
}
});

if (!wbsElement) throw new NotFoundException('WBS Element', wbsPipe(wbsNum));
if (wbsElement.dateDeleted) throw new DeletedException('WBS Element', wbsPipe(wbsNum));

// project case, so return project's own tasks and all its wp's tasks
if (wbsNum.workPackageNumber === 0) {
const project = await prisma.project.findUnique({
where: { wbsElementId: wbsElement.wbsElementId },
include: { workPackages: { include: { wbsElement: true } } }
});

if (!project) throw new NotFoundException('Project', wbsPipe(wbsNum));

const wpWbsElementIds = project.workPackages.map((wp) => wp.wbsElementId);

const tasks = await prisma.task.findMany({
where: {
dateDeleted: null,
wbsElementId: { in: [wbsElement.wbsElementId, ...wpWbsElementIds] }
},
...getTaskQueryArgs(organization.organizationId)
});

return tasks.map(taskTransformer);
}

// work package case, so return just that wp's tasks
const tasks = await prisma.task.findMany({
where: {
dateDeleted: null,
wbsElementId: wbsElement.wbsElementId
},
...getTaskQueryArgs(organization.organizationId)
});

return tasks.map(taskTransformer);
}
}
10 changes: 8 additions & 2 deletions src/backend/src/transformers/projects.transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ const projectTransformer = (project: Prisma.ProjectGetPayload<ProjectQueryArgs>)
startDate: calculateProjectStartDate(project.workPackages),
endDate: calculateProjectEndDate(project.workPackages),
descriptionBullets: wbsElement.descriptionBullets.map(descBulletConverter),
tasks: wbsElement.tasks.map(taskTransformer),
tasks: [
...wbsElement.tasks.map(taskTransformer), // this project's tasks
...project.workPackages.flatMap((wp) => wp.wbsElement.tasks.map(taskTransformer)) // all of this project's work packages' tasks, flattened to one array
],
workPackages: project.workPackages.map(workPackageTransformer),
abbreviation: project.abbreviation ?? undefined
};
Expand Down Expand Up @@ -91,7 +94,10 @@ export const projectGanttTransformer = (project: Prisma.ProjectGetPayload<Projec
})),
duration: calculateDuration(project.workPackages),
startDate: calculateProjectStartDate(project.workPackages),
tasks: project.wbsElement.tasks.map(taskTransformer),
tasks: [
...wbsElement.tasks.map(taskTransformer), // this project's tasks
...project.workPackages.flatMap((wp) => wp.wbsElement.tasks.map(taskTransformer)) // all of this project's work packages' tasks, flattened to one array
],
workPackages: project.workPackages.map(workPackageTransformer),
abbreviation: project.abbreviation ?? undefined
};
Expand Down
4 changes: 3 additions & 1 deletion src/backend/src/transformers/tasks.transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import { convertTaskPriority, convertTaskStatus } from '../utils/tasks.utils.js'
import { userTransformer } from './user.transformer.js';
import { CalendarTaskQueryArgs, TaskQueryArgs, TaskPreviewQueryArgs } from '../prisma-query-args/tasks.query-args.js';

const taskTransformer = (task: Prisma.TaskGetPayload<TaskQueryArgs>): Task => {
export const taskTransformer = (task: Prisma.TaskGetPayload<TaskQueryArgs>): Task => {
const wbsNum = wbsNumOf(task.wbsElement);
return {
taskId: task.taskId,
wbsNum,
wbsName: task.wbsElement.name,
title: task.title,
notes: task.notes,
deadline: task.deadline ?? undefined,
Expand Down Expand Up @@ -45,6 +46,7 @@ export const calendarTaskTransformer = (task: Prisma.TaskGetPayload<CalendarTask
return {
taskId: task.taskId,
wbsNum,
wbsName: task.wbsElement.name,
title: task.title,
notes: task.notes,
deadline: task.deadline ?? undefined,
Expand Down
1 change: 1 addition & 0 deletions src/backend/src/utils/validation.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,7 @@ export const materialValidators = [
body('linkUrl').optional().isString(),
body('notes').isString().optional()
];

export const validateInputs = (req: Request, res: Response, next: Function): void => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
Expand Down
Loading