Skip to content

Commit fd378f7

Browse files
authored
Merge pull request #4018 from Northeastern-Electric-Racing/prospective-sponsors
prospective sponsor access control changes
2 parents 0364f3c + c3dab06 commit fd378f7

File tree

7 files changed

+202
-48
lines changed

7 files changed

+202
-48
lines changed

src/backend/src/services/prospective-sponsor.services.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
11
import {
22
CreateSponsorTask,
33
FirstContactMethod,
4-
isHead,
54
ProspectiveSponsor,
65
ProspectiveSponsorStatus,
76
SponsorTask,
87
User
98
} from 'shared';
109
import { Organization, Prospective_Sponsor_Status, Sponsor_Value_Type } from '@prisma/client';
11-
import { userHasPermission } from '../utils/users.utils.js';
1210
import { getProspectiveSponsorQueryArgs } from '../prisma-query-args/prospective-sponsor.query-args.js';
1311
import { getSponsorTaskQueryArgs } from '../prisma-query-args/sponsor.query.args.js';
1412
import {
@@ -22,7 +20,7 @@ import prisma from '../prisma/prisma.js';
2220
import { prospectiveSponsorTransformer } from '../transformers/prospective-sponsor.transformer.js';
2321
import { sponsorTaskTransformer } from '../transformers/sponsor-task.transformer.js';
2422
import { notifySponsorTaskAssignee } from '../utils/slack.utils.js';
25-
import { isUserFinanceTeamOrHead } from '../utils/reimbursement-requests.utils.js';
23+
import { isUserFinanceLeadOrHead, isUserFinanceTeamOrHead } from '../utils/reimbursement-requests.utils.js';
2624

2725
export default class ProspectiveSponsorServices {
2826
/**
@@ -293,8 +291,8 @@ export default class ProspectiveSponsorServices {
293291
deleter: User,
294292
organization: Organization
295293
): Promise<ProspectiveSponsor> {
296-
if (!(await userHasPermission(deleter.userId, organization.organizationId, isHead))) {
297-
throw new AccessDeniedException('Only heads can delete prospective sponsors');
294+
if (!(await isUserFinanceLeadOrHead(deleter, organization.organizationId))) {
295+
throw new AccessDeniedException('Only finance leads or heads can delete prospective sponsors');
298296
}
299297

300298
const prospectiveSponsor = await prisma.prospective_Sponsor.findUnique({
@@ -416,9 +414,11 @@ export default class ProspectiveSponsorServices {
416414
if (prospectiveSponsor.dateDeleted) throw new DeletedException('ProspectiveSponsor', prospectiveSponsorId);
417415

418416
const isContactor = prospectiveSponsor.contactorUserId === submitter.userId;
419-
const isUserHead = await userHasPermission(submitter.userId, organization.organizationId, isHead);
420-
if (!isUserHead && !isContactor) {
421-
throw new AccessDeniedException('Only heads or the assigned contactor can accept prospective sponsors');
417+
const canAccept = await isUserFinanceLeadOrHead(submitter, organization.organizationId);
418+
if (!canAccept && !isContactor) {
419+
throw new AccessDeniedException(
420+
'Only finance leads, heads, or the assigned contactor can accept prospective sponsors'
421+
);
422422
}
423423
if (prospectiveSponsor.status === Prospective_Sponsor_Status.ACCEPTED) {
424424
throw new HttpException(400, 'This prospective sponsor has already been accepted');

src/backend/src/utils/reimbursement-requests.utils.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,25 @@ export const isUserLeadOrHeadOfFinanceTeam = async (user: User, organizationId:
502502
return user.userId === financeTeam.headId || financeTeam.leads.map((u) => u.userId).includes(user.userId);
503503
};
504504

505+
/**
506+
* Checks if a user is a lead/head of the finance team or has a system role of Head or higher.
507+
* Checks isHead first since it doesn't require the finance team to exist.
508+
*
509+
* @param user the user to check
510+
* @param organizationId the organization id
511+
* @returns whether the user is a finance lead/head or has Head+ system role
512+
*/
513+
export const isUserFinanceLeadOrHead = async (user: User, organizationId: string): Promise<boolean> => {
514+
if (await userHasPermission(user.userId, organizationId, isHead)) {
515+
return true;
516+
}
517+
try {
518+
return await isUserLeadOrHeadOfFinanceTeam(user, organizationId);
519+
} catch {
520+
return false;
521+
}
522+
};
523+
505524
export const isCurrentUserOnFinance = (user: Prisma.UserGetPayload<AuthUserQueryArgs>) => {
506525
return (
507526
user.teamsAsHead.some((team) => team.financeTeam) ||

src/backend/tests/unit/prospective-sponsor.test.ts

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import ProspectiveSponsorServices from '../../src/services/prospective-sponsor.s
33
import FinanceServices from '../../src/services/finance.services.js';
44
import { AccessDeniedException, DeletedException, HttpException, NotFoundException } from '../../src/utils/errors.utils.js';
55
import { batmanAppAdmin, wonderwomanGuest, supermanAdmin } from '../test-data/users.test-data.js';
6-
import { createTestOrganization, createTestUser, resetUsers } from '../test-utils.js';
6+
import { createFinanceTeamAndLead, createTestOrganization, createTestUser, resetUsers } from '../test-utils.js';
77
import prisma from '../../src/prisma/prisma.js';
88
import { FirstContactMethod, ProspectiveSponsorStatus } from 'shared';
99

@@ -627,7 +627,7 @@ describe('Prospective Sponsor Tests', () => {
627627
});
628628

629629
describe('Delete Prospective Sponsor', () => {
630-
it('Fails if user is not a head', async () => {
630+
it('Fails if user is not a finance lead or head', async () => {
631631
const head = await createTestUser(batmanAppAdmin, orgId);
632632
const guest = await createTestUser(wonderwomanGuest, orgId);
633633

@@ -646,7 +646,49 @@ describe('Prospective Sponsor Tests', () => {
646646

647647
await expect(
648648
ProspectiveSponsorServices.deleteProspectiveSponsor(ps.prospectiveSponsorId, guest, organization)
649-
).rejects.toThrow(new AccessDeniedException('Only heads can delete prospective sponsors'));
649+
).rejects.toThrow(new AccessDeniedException('Only finance leads or heads can delete prospective sponsors'));
650+
});
651+
652+
it('Fails if user is a finance team member (not lead)', async () => {
653+
await createFinanceTeamAndLead(organization);
654+
const financeHead = await prisma.user.findUniqueOrThrow({ where: { googleAuthId: 'financeHead' } });
655+
const financeMember = await prisma.user.findUniqueOrThrow({ where: { googleAuthId: 'financeMember' } });
656+
657+
const ps = await ProspectiveSponsorServices.createProspectiveSponsor(
658+
financeHead,
659+
organization,
660+
'Acme Corp',
661+
ProspectiveSponsorStatus.NOT_IN_CONTACT
662+
);
663+
664+
await expect(
665+
ProspectiveSponsorServices.deleteProspectiveSponsor(ps.prospectiveSponsorId, financeMember, organization)
666+
).rejects.toThrow(new AccessDeniedException('Only finance leads or heads can delete prospective sponsors'));
667+
});
668+
669+
it('Succeeds if user is a finance team lead', async () => {
670+
await createFinanceTeamAndLead(organization);
671+
const financeHead = await prisma.user.findUniqueOrThrow({ where: { googleAuthId: 'financeHead' } });
672+
const financeLead = await prisma.user.findUniqueOrThrow({ where: { googleAuthId: 'financeLead' } });
673+
674+
const ps = await ProspectiveSponsorServices.createProspectiveSponsor(
675+
financeHead,
676+
organization,
677+
'Acme Corp',
678+
ProspectiveSponsorStatus.NOT_IN_CONTACT
679+
);
680+
681+
const result = await ProspectiveSponsorServices.deleteProspectiveSponsor(
682+
ps.prospectiveSponsorId,
683+
financeLead,
684+
organization
685+
);
686+
687+
expect(result.prospectiveSponsorId).toBe(ps.prospectiveSponsorId);
688+
const deletedPs = await prisma.prospective_Sponsor.findUnique({
689+
where: { prospectiveSponsorId: ps.prospectiveSponsorId }
690+
});
691+
expect(deletedPs?.dateDeleted).not.toBeNull();
650692
});
651693

652694
it('Fails if prospective sponsor does not exist', async () => {
@@ -891,7 +933,7 @@ describe('Prospective Sponsor Tests', () => {
891933
});
892934

893935
describe('Accept Prospective Sponsor', () => {
894-
it('Fails if user is not a head or contactor', async () => {
936+
it('Fails if user is not a finance lead, head, or contactor', async () => {
895937
const head = await createTestUser(batmanAppAdmin, orgId);
896938
const guest = await createTestUser(wonderwomanGuest, orgId);
897939

@@ -920,7 +962,69 @@ describe('Prospective Sponsor Tests', () => {
920962
false,
921963
5000
922964
)
923-
).rejects.toThrow(new AccessDeniedException('Only heads or the assigned contactor can accept prospective sponsors'));
965+
).rejects.toThrow(
966+
new AccessDeniedException('Only finance leads, heads, or the assigned contactor can accept prospective sponsors')
967+
);
968+
});
969+
970+
it('Fails if user is a finance team member (not lead or contactor)', async () => {
971+
await createFinanceTeamAndLead(organization);
972+
const financeHead = await prisma.user.findUniqueOrThrow({ where: { googleAuthId: 'financeHead' } });
973+
const financeMember = await prisma.user.findUniqueOrThrow({ where: { googleAuthId: 'financeMember' } });
974+
975+
const ps = await ProspectiveSponsorServices.createProspectiveSponsor(
976+
financeHead,
977+
organization,
978+
'Acme Corp',
979+
ProspectiveSponsorStatus.NOT_IN_CONTACT
980+
);
981+
982+
await expect(
983+
ProspectiveSponsorServices.acceptProspectiveSponsor(
984+
financeMember,
985+
organization,
986+
ps.prospectiveSponsorId,
987+
undefined,
988+
['MONETARY'],
989+
new Date(),
990+
[2024],
991+
false
992+
)
993+
).rejects.toThrow(
994+
new AccessDeniedException('Only finance leads, heads, or the assigned contactor can accept prospective sponsors')
995+
);
996+
});
997+
998+
it('Succeeds if user is a finance team lead', async () => {
999+
await createFinanceTeamAndLead(organization);
1000+
const financeHead = await prisma.user.findUniqueOrThrow({ where: { googleAuthId: 'financeHead' } });
1001+
const financeLead = await prisma.user.findUniqueOrThrow({ where: { googleAuthId: 'financeLead' } });
1002+
const joinDate = new Date(2024, 6, 1);
1003+
1004+
const ps = await ProspectiveSponsorServices.createProspectiveSponsor(
1005+
financeHead,
1006+
organization,
1007+
'Finance Lead Accept Corp',
1008+
ProspectiveSponsorStatus.NOT_IN_CONTACT
1009+
);
1010+
1011+
const result = await ProspectiveSponsorServices.acceptProspectiveSponsor(
1012+
financeLead,
1013+
organization,
1014+
ps.prospectiveSponsorId,
1015+
undefined,
1016+
['MONETARY'],
1017+
joinDate,
1018+
[2024],
1019+
false,
1020+
2500
1021+
);
1022+
1023+
expect(result.status).toBe(ProspectiveSponsorStatus.ACCEPTED);
1024+
const sponsors = await FinanceServices.getAllSponsors(organization);
1025+
const createdSponsor = sponsors.find((s) => s.name === 'Finance Lead Accept Corp');
1026+
expect(createdSponsor).toBeDefined();
1027+
expect(createdSponsor!.sponsorValue).toBe(2500);
9241028
});
9251029

9261030
it('Succeeds when the contactor accepts', async () => {

src/frontend/src/pages/FinancePage/FinanceComponents/ProspectiveSponsorTasksModal.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,18 @@ import SponsorTasksModal from './SponsorTasksModal';
1111
interface ProspectiveSponsorTasksModalProps {
1212
onClose: () => void;
1313
prospectiveSponsor: ProspectiveSponsor;
14+
canEdit?: boolean;
1415
}
1516

16-
const ProspectiveSponsorTasksModal: React.FC<ProspectiveSponsorTasksModalProps> = ({ onClose, prospectiveSponsor }) => {
17+
const ProspectiveSponsorTasksModal: React.FC<ProspectiveSponsorTasksModalProps> = ({
18+
onClose,
19+
prospectiveSponsor,
20+
canEdit = true
21+
}) => {
1722
const { data: tasks } = useProspectiveSponsorTasks(prospectiveSponsor.prospectiveSponsorId);
1823
const { mutate: createTask } = useCreateProspectiveSponsorTask(prospectiveSponsor.prospectiveSponsorId);
1924

20-
return <SponsorTasksModal onClose={onClose} tasks={tasks} createTask={createTask} />;
25+
return <SponsorTasksModal onClose={onClose} tasks={tasks} createTask={createTask} canEdit={canEdit} />;
2126
};
2227

2328
export default ProspectiveSponsorTasksModal;

src/frontend/src/pages/FinancePage/FinanceComponents/SponsorTaskCard.tsx

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface SponsorTaskCardProps {
2020
showDoneCheckbox?: boolean;
2121
isExistingTask?: boolean;
2222
defaultAssigneeName?: string;
23+
canEdit?: boolean;
2324
}
2425

2526
const SponsorTaskCard: React.FC<SponsorTaskCardProps> = ({
@@ -30,7 +31,8 @@ const SponsorTaskCard: React.FC<SponsorTaskCardProps> = ({
3031
onRemove,
3132
showDoneCheckbox = false,
3233
isExistingTask = false,
33-
defaultAssigneeName
34+
defaultAssigneeName,
35+
canEdit = true
3436
}) => {
3537
const doneValue = useWatch({ control, name: `${fieldPrefix}.done` });
3638
const isDone = !!doneValue;
@@ -81,16 +83,20 @@ const SponsorTaskCard: React.FC<SponsorTaskCardProps> = ({
8183
/>
8284
)}
8385
/>
84-
<IconButton size="small" onClick={onRemove}>
85-
<RemoveCircle sx={{ fontSize: 20 }} />
86-
</IconButton>
86+
{canEdit && (
87+
<IconButton size="small" onClick={onRemove}>
88+
<RemoveCircle sx={{ fontSize: 20 }} />
89+
</IconButton>
90+
)}
8791
</Box>
8892
) : (
89-
<Box sx={{ position: 'absolute', top: 4, right: 4 }}>
90-
<IconButton size="small" onClick={onRemove}>
91-
<RemoveCircleOutlineIcon sx={{ color: 'white', fontSize: 20 }} />
92-
</IconButton>
93-
</Box>
93+
canEdit && (
94+
<Box sx={{ position: 'absolute', top: 4, right: 4 }}>
95+
<IconButton size="small" onClick={onRemove}>
96+
<RemoveCircleOutlineIcon sx={{ color: 'white', fontSize: 20 }} />
97+
</IconButton>
98+
</Box>
99+
)
94100
)}
95101
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', mb: 1.5 }}>
96102
<Box sx={{ flex: '1 1 180px', minWidth: 0 }}>

src/frontend/src/pages/FinancePage/FinanceComponents/SponsorTasksModal.tsx

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ interface SponsorTasksModalProps {
1616
onClose: () => void;
1717
tasks: SponsorTask[] | undefined;
1818
createTask: (payload: { dueDate: Date; notifyDate?: Date; assigneeUserId?: string; notes: string }) => void;
19+
canEdit?: boolean;
1920
}
2021

2122
const taskSchema = yup.object().shape({
@@ -31,7 +32,12 @@ const schema = yup.object().shape({
3132
tasks: yup.array().of(taskSchema)
3233
});
3334

34-
const SponsorTasksModal: React.FC<SponsorTasksModalProps> = ({ onClose, tasks: sponsorTasks, createTask }) => {
35+
const SponsorTasksModal: React.FC<SponsorTasksModalProps> = ({
36+
onClose,
37+
tasks: sponsorTasks,
38+
createTask,
39+
canEdit = true
40+
}) => {
3541
const toast = useToast();
3642
const { data: users, isLoading: usersIsLoading, isError: usersIsError, error: usersError } = useAllMembers();
3743
const { mutate: editTask } = useEditSponsorTask();
@@ -119,6 +125,7 @@ const SponsorTasksModal: React.FC<SponsorTasksModalProps> = ({ onClose, tasks: s
119125
members={users}
120126
showDoneCheckbox
121127
isExistingTask={!!item.sponsorTaskId}
128+
canEdit={canEdit}
122129
onRemove={() => {
123130
if (item.sponsorTaskId) {
124131
deletedTaskIds.current.push(item.sponsorTaskId);
@@ -128,29 +135,33 @@ const SponsorTasksModal: React.FC<SponsorTasksModalProps> = ({ onClose, tasks: s
128135
/>
129136
</Box>
130137
))}
131-
<Button
132-
startIcon={<AddCircle />}
133-
onClick={() =>
134-
append({
135-
dueDate: new Date(),
136-
notifyDate: undefined,
137-
assigneeUserId: '',
138-
notes: '',
139-
sponsorTaskId: undefined,
140-
done: false
141-
})
142-
}
143-
sx={{ mb: 2 }}
144-
>
145-
Add Task
146-
</Button>
138+
{canEdit && (
139+
<Button
140+
startIcon={<AddCircle />}
141+
onClick={() =>
142+
append({
143+
dueDate: new Date(),
144+
notifyDate: undefined,
145+
assigneeUserId: '',
146+
notes: '',
147+
sponsorTaskId: undefined,
148+
done: false
149+
})
150+
}
151+
sx={{ mb: 2 }}
152+
>
153+
Add Task
154+
</Button>
155+
)}
147156
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
148157
<Button onClick={onClose} sx={{ mr: 2, color: 'white', border: '1px solid white', borderRadius: 1, px: 2 }}>
149-
Cancel
150-
</Button>
151-
<Button onClick={handleSave} sx={{ backgroundColor: '#EF4345', color: 'white', borderRadius: 1, px: 2 }}>
152-
Save
158+
{canEdit ? 'Cancel' : 'Close'}
153159
</Button>
160+
{canEdit && (
161+
<Button onClick={handleSave} sx={{ backgroundColor: '#EF4345', color: 'white', borderRadius: 1, px: 2 }}>
162+
Save
163+
</Button>
164+
)}
154165
</Box>
155166
</Box>
156167
);

0 commit comments

Comments
 (0)