Skip to content

Commit fd24120

Browse files
committed
Merge branch 'develop' into #3873-guest-home-page
2 parents 9404dc1 + 228e546 commit fd24120

27 files changed

Lines changed: 445 additions & 133 deletions

.github/workflows/system-tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ on:
55
push:
66
branches:
77
- main
8+
- multitenancy
89
- develop
910
- feature/**
1011

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "Reimbursement_Request" ALTER COLUMN "saboId" SET DATA TYPE TEXT;

src/backend/src/prisma/schema.prisma

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -720,7 +720,7 @@ model Receipt {
720720
model Reimbursement_Request {
721721
reimbursementRequestId String @id @default(uuid())
722722
identifier Int
723-
saboId Int? @unique
723+
saboId String? @unique
724724
dateCreated DateTime @default(now())
725725
dateDeleted DateTime?
726726
dateOfExpense DateTime?
@@ -805,7 +805,7 @@ model Vendor {
805805
organizationId String
806806
organization Organization @relation(fields: [organizationId], references: [organizationId])
807807
username String?
808-
password String? // password is encrypted
808+
password String? // password is encrypted
809809
discountCode String?
810810
twoFactorContacts User[] @relation(name: "twoFactorContactVendors")
811811
notes String?

src/backend/src/prisma/seed-data/reimbursement-requests.seed.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,7 +1314,7 @@ export const seedReimbursementRequests = async (
13141314
organization,
13151315
new Date('2024-07-15')
13161316
);
1317-
await ReimbursementRequestService.setSaboNumber(rr30.reimbursementRequestId, 12345, users.richieRich, organization);
1317+
await ReimbursementRequestService.setSaboNumber(rr30.reimbursementRequestId, 'abc123', users.richieRich, organization);
13181318
await ReimbursementRequestService.inputReimbursementRequestInSabo(
13191319
rr30.reimbursementRequestId,
13201320
users.richieRich,
@@ -1382,7 +1382,7 @@ export const seedReimbursementRequests = async (
13821382
organization,
13831383
new Date('2024-07-18')
13841384
);
1385-
await ReimbursementRequestService.setSaboNumber(rr31.reimbursementRequestId, 12346, users.mrKrabs, organization);
1385+
await ReimbursementRequestService.setSaboNumber(rr31.reimbursementRequestId, 'sdfkj3', users.mrKrabs, organization);
13861386
await ReimbursementRequestService.inputReimbursementRequestInSabo(
13871387
rr31.reimbursementRequestId,
13881388
users.mrKrabs,
@@ -1450,7 +1450,7 @@ export const seedReimbursementRequests = async (
14501450
organization,
14511451
new Date('2024-06-10')
14521452
);
1453-
await ReimbursementRequestService.setSaboNumber(rr32.reimbursementRequestId, 12340, users.monopolyMan, organization);
1453+
await ReimbursementRequestService.setSaboNumber(rr32.reimbursementRequestId, '324jj', users.monopolyMan, organization);
14541454
await ReimbursementRequestService.inputReimbursementRequestInSabo(
14551455
rr32.reimbursementRequestId,
14561456
users.monopolyMan,
@@ -1523,7 +1523,7 @@ export const seedReimbursementRequests = async (
15231523
organization,
15241524
new Date('2024-05-20')
15251525
);
1526-
await ReimbursementRequestService.setSaboNumber(rr33.reimbursementRequestId, 12335, users.johnBoddy, organization);
1526+
await ReimbursementRequestService.setSaboNumber(rr33.reimbursementRequestId, 'kaljf23', users.johnBoddy, organization);
15271527
await ReimbursementRequestService.inputReimbursementRequestInSabo(
15281528
rr33.reimbursementRequestId,
15291529
users.johnBoddy,
@@ -1596,7 +1596,7 @@ export const seedReimbursementRequests = async (
15961596
organization,
15971597
new Date('2024-05-08')
15981598
);
1599-
await ReimbursementRequestService.setSaboNumber(rr34.reimbursementRequestId, 12330, users.richieRich, organization);
1599+
await ReimbursementRequestService.setSaboNumber(rr34.reimbursementRequestId, 'newklajfd', users.richieRich, organization);
16001600
await ReimbursementRequestService.inputReimbursementRequestInSabo(
16011601
rr34.reimbursementRequestId,
16021602
users.richieRich,

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,14 +157,14 @@ reimbursementRequestsRouter.get('/pending-advisor/list', ReimbursementRequestCon
157157
reimbursementRequestsRouter.post(
158158
'/pending-advisor/send',
159159
body('saboNumbers').isArray(),
160-
intMinZero(body('saboNumbers.*')),
160+
nonEmptyString(body('saboNumbers.*')),
161161
validateInputs,
162162
ReimbursementRequestController.sendPendingAdvisorList
163163
);
164164

165165
reimbursementRequestsRouter.post(
166166
'/:requestId/set-sabo-number',
167-
intMinZero(body('saboNumber')),
167+
nonEmptyString(body('saboNumber')),
168168
validateInputs,
169169
ReimbursementRequestController.setSaboNumber
170170
);

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/services/reimbursement-requests.services.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -612,7 +612,7 @@ export default class ReimbursementRequestService {
612612
* @param saboNumbers the sabo numbers of the reimbursement requests to send
613613
* @param organizationId the organization the user is currently in
614614
*/
615-
static async sendPendingAdvisorList(sender: User, saboNumbers: number[], organizationId: string) {
615+
static async sendPendingAdvisorList(sender: User, saboNumbers: string[], organizationId: string) {
616616
const organization = await prisma.organization.findUnique({
617617
where: { organizationId },
618618
include: { advisor: true }
@@ -688,7 +688,7 @@ export default class ReimbursementRequestService {
688688

689689
static async setSaboNumber(
690690
reimbursementRequestId: string,
691-
saboNumber: number,
691+
saboNumber: string,
692692
submitter: User,
693693
organization: Organization
694694
) {

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/src/utils/slack.utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ export const sendPendingSaboSubmissionNotification = async (
225225
threads,
226226
`${await getUserSlackMentionOrName(financeUserId)} has added this reimbursement request to Concur. ${await getUserSlackMentionOrName(pendingSubmissionFromId)}, please check your email to approve the request in Concur and mark it as submitted on Finishline.`
227227
);
228-
const userId = await getUserSlackId(financeUserId);
228+
const userId = await getUserSlackId(pendingSubmissionFromId);
229229
if (threads && threads.length !== 0 && userId) {
230230
const msgs = threads.map((thread) =>
231231
sendEphemeralMessage(

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 () => {

0 commit comments

Comments
 (0)