Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 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
1c1de38
#4048 remove some props and imports
getheobald Mar 30, 2026
a69c2a6
#4048 get wps by project endpoint
getheobald Apr 6, 2026
06c02aa
#4048 correctly wired frontend finally hopefully, minus edit endpoint…
getheobald Apr 6, 2026
f4e60bd
#4048 remove editTaskWbsElement everywhere
getheobald Apr 6, 2026
7817252
#4048 task and wp tests
getheobald Apr 6, 2026
a1dda84
#4048 played prop whackamole until it worked
getheobald Apr 7, 2026
ad83cc4
#4048 select wp from gantt chart task add
getheobald Apr 7, 2026
c61dd31
#4048 revert stub changes since I removed tasks from wp type
getheobald Apr 7, 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
15 changes: 13 additions & 2 deletions src/backend/src/controllers/tasks.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export default class TasksController {

static async editTask(req: Request, res: Response, next: NextFunction) {
try {
const { title, notes, priority, deadline, startDate } = req.body;
const { title, notes, priority, deadline, startDate, wbsElementId } = req.body;
const { taskId } = req.params as Record<string, string>;

const updateTask = await TasksService.editTask(
Expand All @@ -40,7 +40,8 @@ export default class TasksController {
notes,
priority,
startDate ? new Date(startDate) : undefined,
deadline ? new Date(deadline) : undefined
deadline ? new Date(deadline) : undefined,
wbsElementId
);

res.status(200).json(updateTask);
Expand Down Expand Up @@ -122,4 +123,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);
}
}
}
11 changes: 11 additions & 0 deletions src/backend/src/controllers/work-packages.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ export default class WorkPackagesController {
}
}

// fetch all work packages for the given project wbs number
static async getWorkPackagesByProject(req: Request, res: Response, next: NextFunction) {
try {
const projectWbsNum: WbsNumber = validateWBS(req.params.wbsNum as string);
const workPackages = await WorkPackagesService.getWorkPackagesByProject(projectWbsNum, req.organization);
res.status(200).json(workPackages);
} catch (error: unknown) {
next(error);
}
}

// Create a work package with the given details
static async createWorkPackage(req: Request, res: Response, next: NextFunction) {
try {
Expand Down
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
8 changes: 6 additions & 2 deletions src/backend/src/routes/tasks.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,24 @@ tasksRouter.post(
isOptionalDateOnly(body('deadline')),
isOptionalDateOnly(body('startDate')),
isTaskPriority(body('priority')),
nonEmptyString(body('wbsElementId').optional()),
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/delete', validateInputs, TasksController.deleteTask);

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

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

export default tasksRouter;
3 changes: 3 additions & 0 deletions src/backend/src/routes/work-packages.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ workPackagesRouter.post(
WorkPackagesController.getManyWorkPackages
);
workPackagesRouter.get('/:wbsNum', WorkPackagesController.getSingleWorkPackage);

workPackagesRouter.get('/by-project/:wbsNum', WorkPackagesController.getWorkPackagesByProject);

workPackagesRouter.post(
'/create',
nonEmptyString(body('crId').optional()),
Expand Down
110 changes: 97 additions & 13 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 @@ -136,7 +146,9 @@ export default class TasksService {
* @param title the new title for the task
* @param notes the new notes for the task
* @param priority the new priority for the task
* @param startDate the new start date for the task
* @param deadline the new deadline for the task
* @param wbsElementId the new wbs element id for the task
* @returns the sucessfully edited task
*/
static async editTask(
Expand All @@ -147,24 +159,40 @@ export default class TasksService {
notes: string,
priority: Task_Priority,
startDate?: Date,
deadline?: Date
deadline?: Date,
wbsElementId?: string
) {
const hasPermission = await userHasPermission(user.userId, organizationId, notGuest);
if (!hasPermission) throw new AccessDeniedException('Guests cannot edit tasks');

const originalTask = await prisma.task.findUnique({ where: { taskId }, include: { wbsElement: true } });

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

if (!isUnderWordCount(title, 15)) throw new HttpException(400, 'Title must be less than 15 words');

if (!isUnderWordCount(notes, 250)) throw new HttpException(400, 'Notes must be less than 250 words');

// if wbsElementId passed, error if there's a problem with the wbs element
if (wbsElementId) {
const newWbsElement = await prisma.wBS_Element.findUnique({ where: { wbsElementId } });
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: { title, notes, priority, startDate, deadline },
data: {
title,
notes,
priority,
startDate,
deadline,
// if wbsElementId passed, update prisma relation to connect task with wbs element
...(wbsElementId && { wbsElement: { connect: { wbsElementId } } })
},
...getTaskQueryArgs(originalTask.wbsElement.organizationId)
});
return taskTransformer(updatedTask);
Expand All @@ -173,13 +201,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 +221,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 +244,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 +261,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 @@ -391,4 +419,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);
}
}
33 changes: 33 additions & 0 deletions src/backend/src/services/work-packages.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,39 @@ export default class WorkPackagesService {
return resolvedWorkPackages;
}

/**
* Retrieve all work packages for a given project
* @param projectWbsNum the wbs number of the project
* @param organization the organization that the user is currently in
* @returns the work packages for the given project
* @throws if the project does not exist or is deleted
*/
static async getWorkPackagesByProject(projectWbsNum: WbsNumber, organization: Organization): Promise<WorkPackage[]> {
const project = await prisma.project.findFirst({
where: {
wbsElement: {
carNumber: projectWbsNum.carNumber,
projectNumber: projectWbsNum.projectNumber,
workPackageNumber: 0,
organizationId: organization.organizationId,
dateDeleted: null
}
}
});

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

const workPackages = await prisma.work_Package.findMany({
where: {
projectId: project.projectId,
wbsElement: { dateDeleted: null }
},
...getWorkPackageQueryArgs(organization.organizationId)
});

return workPackages.map(workPackageTransformer);
}

/**
* Creates a Work_Package in the database
* @param user the user creating the work package
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