Skip to content

Commit 5a64bf5

Browse files
authored
Merge pull request #476 from IABTechLab/ajy-UID2-3503-Allow-user-to-belong-to-multiple-participants
Allow user to belong to multiple participants (backend)
2 parents 163d0ac + 9bafb3c commit 5a64bf5

19 files changed

+354
-181
lines changed

src/api/controllers/userController.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,12 @@ export class UserController {
5959

6060
@httpPut('/current/acceptTerms')
6161
public async acceptTerms(@request() req: UserRequest, @response() res: Response): Promise<void> {
62-
if (!req.user?.participantId) {
63-
res.status(403).json({
64-
message: 'Unauthorized. You do not have the necessary permissions.',
65-
errorHash: req.headers.traceId,
66-
});
67-
}
68-
const participant = await Participant.query().findById(req.user!.participantId!);
62+
// TODO: This just gets the user's first participant, but it will need to get the currently selected participant as part of UID2-2822
63+
const currentParticipantId = req.user?.participants?.[0].id;
64+
const participant = currentParticipantId
65+
? await Participant.query().findById(currentParticipantId)
66+
: undefined;
67+
6968
if (!participant || participant.status !== ParticipantStatus.Approved) {
7069
res.status(403).json({
7170
message: 'Unauthorized. You do not have the necessary permissions.',
@@ -147,10 +146,7 @@ export class UserController {
147146
participantId: null,
148147
deleted: true,
149148
};
150-
await Promise.all([
151-
deleteUserByEmail(kcAdminClient, user?.email!),
152-
user!.$query().patch(data),
153-
]);
149+
await Promise.all([deleteUserByEmail(kcAdminClient, user?.email!), user!.$query().patch(data)]);
154150

155151
res.sendStatus(200);
156152
}

src/api/entities/BusinessContact.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@ export class BusinessContact extends BaseModel {
1212
static get tableName() {
1313
return 'businessContacts';
1414
}
15-
static relationMappings = {
15+
static readonly relationMappings = {
1616
participant: {
1717
relation: Model.BelongsToOneRelation,
1818
modelClass: 'Participant',
1919
join: {
20-
from: 'users.participantId',
20+
from: 'businessContacts.participantId',
2121
to: 'participants.id',
2222
},
2323
},

src/api/entities/Participant.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ApiRole, ApiRoleDTO, ApiRoleSchema } from './ApiRole';
55
import { BaseModel } from './BaseModel';
66
import { ModelObjectOpt } from './ModelObjectOpt';
77
import { ParticipantType, ParticipantTypeDTO, ParticipantTypeSchema } from './ParticipantType';
8-
import { User, UserCreationPartial, UserDTO, UserSchema } from './User';
8+
import { type User, UserCreationPartial, UserDTO, UserSchema } from './User';
99

1010
export enum ParticipantStatus {
1111
AwaitingSigning = 'awaitingSigning',
@@ -43,11 +43,15 @@ export class Participant extends BaseModel {
4343
},
4444
},
4545
users: {
46-
relation: Model.HasManyRelation,
46+
relation: Model.ManyToManyRelation,
4747
modelClass: 'User',
4848
join: {
4949
from: 'participants.id',
50-
to: 'users.participantId',
50+
through: {
51+
from: 'usersToParticipantRoles.participantId',
52+
to: 'usersToParticipantRoles.userId',
53+
},
54+
to: 'users.id',
5155
},
5256
},
5357
businessContacts: {

src/api/entities/User.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { Model } from 'objection';
1+
import Objection, { Model } from 'objection';
22
import { z } from 'zod';
33

44
import { BaseModel } from './BaseModel';
55
import { ModelObjectOpt } from './ModelObjectOpt';
6+
import type { Participant } from './Participant';
67

78
export interface IUser {}
89
export enum UserRole {
@@ -31,23 +32,38 @@ export class User extends BaseModel {
3132
}
3233

3334
static readonly relationMappings = {
34-
participant: {
35-
relation: Model.BelongsToOneRelation,
35+
participants: {
36+
relation: Model.ManyToManyRelation,
3637
modelClass: 'Participant',
3738
join: {
38-
from: 'users.participantId',
39+
from: 'users.id',
40+
through: {
41+
from: 'usersToParticipantRoles.userId',
42+
to: 'usersToParticipantRoles.participantId',
43+
},
3944
to: 'participants.id',
4045
},
4146
},
4247
};
48+
4349
declare id: number;
4450
declare email: string;
4551
declare firstName: string;
4652
declare lastName: string;
4753
declare phone?: string;
4854
declare role: UserRole;
49-
declare participantId?: number | null;
55+
declare participants?: Participant[];
5056
declare acceptedTerms: boolean;
57+
58+
static readonly modifiers = {
59+
withParticipants<TResult>(query: Objection.QueryBuilder<User, TResult>) {
60+
const myQuery = query.withGraphFetched('participants') as Objection.QueryBuilder<
61+
User,
62+
TResult & { participants: Participant[] }
63+
>;
64+
return myQuery;
65+
},
66+
};
5167
}
5268

5369
export type UserDTO = ModelObjectOpt<User>;
@@ -58,7 +74,6 @@ export const UserSchema = z.object({
5874
firstName: z.string(),
5975
lastName: z.string(),
6076
phone: z.string().optional(),
61-
participantId: z.number().optional().nullable(),
6277
role: z.nativeEnum(UserRole).optional(),
6378
acceptedTerms: z.boolean(),
6479
});

src/api/routers/participants/participantsRouter.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -241,13 +241,15 @@ export function createParticipantsRouter() {
241241
}
242242
const kcAdminClient = await getKcAdminClient();
243243
const user = await createNewUser(kcAdminClient, firstName, lastName, email);
244-
await createUserInPortal({
245-
email,
246-
role,
247-
participantId: participant!.id,
248-
firstName,
249-
lastName,
250-
});
244+
await createUserInPortal(
245+
{
246+
email,
247+
role,
248+
firstName,
249+
lastName,
250+
},
251+
participant!.id
252+
);
251253
await sendInviteEmail(kcAdminClient, user);
252254
return res.sendStatus(201);
253255
} catch (err) {

src/api/services/participantsService.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ const idParser = z.object({
292292
participantId: z.coerce.number(),
293293
});
294294

295+
// TODO: move this middleware to a separate file
295296
const hasParticipantAccess = async (req: ParticipantRequest, res: Response, next: NextFunction) => {
296297
const { participantId } = idParser.parse(req.params);
297298
const traceId = getTraceId(req);
@@ -309,6 +310,7 @@ const hasParticipantAccess = async (req: ParticipantRequest, res: Response, next
309310
return next();
310311
};
311312

313+
// TODO: move this middleware to a separate file
312314
const enrichCurrentParticipant = async (
313315
req: ParticipantRequest,
314316
res: Response,
@@ -319,19 +321,23 @@ const enrichCurrentParticipant = async (
319321
if (!user) {
320322
return res.status(404).send([{ message: 'The user cannot be found.' }]);
321323
}
322-
const participant = await Participant.query().findById(user.participantId!);
324+
// TODO: This just gets the user's first participant, but it will need to get the currently selected participant as part of UID2-2822
325+
const participant = user.participants?.[0];
326+
323327
if (!participant) {
324328
return res.status(404).send([{ message: 'The participant cannot be found.' }]);
325329
}
326330
req.participant = participant;
327331
return next();
328332
};
329333

334+
// TODO: move this middleware to a separate file
330335
export const checkParticipantId = async (
331336
req: ParticipantRequest,
332337
res: Response,
333338
next: NextFunction
334339
) => {
340+
// TODO: Remove support for 'current' in UID2-2822
335341
if (req.params.participantId === 'current') {
336342
return enrichCurrentParticipant(req, res, next);
337343
}

src/api/services/userService.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { injectable } from 'inversify';
22
import { z } from 'zod';
33

4-
import { Participant } from '../entities/Participant';
54
import { ParticipantType } from '../entities/ParticipantType';
65
import { UserRole } from '../entities/User';
76
import { mapClientTypeToParticipantType } from '../helpers/siteConvertingHelpers';
@@ -33,9 +32,8 @@ export class UserService {
3332
}
3433

3534
public async getCurrentParticipant(req: UserRequest) {
36-
const currentParticipant = await Participant.query().findOne({
37-
id: req.user!.participantId,
38-
});
35+
// TODO: This just gets the user's first participant, but it will need to get the currently selected participant as part of UID2-2822
36+
const currentParticipant = req.user?.participants?.[0];
3937
const currentSite = !currentParticipant?.siteId
4038
? undefined
4139
: await getSite(currentParticipant?.siteId);

src/api/services/usersService.ts

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { isUserAnApprover } from './approversService';
99
export type UserWithIsApprover = User & { isApprover: boolean };
1010

1111
export const findUserByEmail = async (email: string) => {
12-
return User.query().findOne('email', email).where('deleted', 0);
12+
return User.query().findOne('email', email).where('deleted', 0).modify('withParticipants');
1313
};
1414

1515
export const enrichUserWithIsApprover = async (user: User) => {
@@ -20,28 +20,40 @@ export const enrichUserWithIsApprover = async (user: User) => {
2020
};
2121
};
2222

23-
export const createUserInPortal = async (user: Omit<UserDTO, 'id' | 'acceptedTerms'>) => {
23+
// TODO: Update this method so that if an existing user is invited, it will still add the new participant + mapping.
24+
export const createUserInPortal = async (
25+
user: Omit<UserDTO, 'id' | 'acceptedTerms'>,
26+
participantId: number
27+
) => {
2428
const existingUser = await findUserByEmail(user.email);
2529
if (existingUser) return existingUser;
26-
return User.query().insert(user);
30+
const newUser = await User.query().insert(user);
31+
// Update the user <-> participant mapping
32+
await newUser.$relatedQuery('participants').relate(participantId);
2733
};
2834

35+
// TODO: move this middleware to a separate file
2936
export const isUserBelongsToParticipant = async (
3037
email: string,
3138
participantId: number,
3239
traceId: string
3340
) => {
34-
const user = await User.query()
35-
.where('email', email)
36-
.andWhere('deleted', 0)
37-
.andWhere('participantId', participantId)
38-
.first();
41+
const { errorLogger } = getLoggers();
42+
const userWithParticipants = await User.query()
43+
.findOne({ email, deleted: 0 })
44+
.modify('withParticipants');
3945

40-
if (!user) {
41-
const { errorLogger } = getLoggers();
42-
errorLogger.error(`Denied access to participant ID ${participantId} by user ${email}`, traceId);
46+
if (!userWithParticipants) {
47+
errorLogger.error(`User with email ${email} not found`, traceId);
48+
return false;
49+
}
50+
for (const participant of userWithParticipants.participants!) {
51+
if (participant.id === participantId) {
52+
return true;
53+
}
4354
}
44-
return !!user;
55+
errorLogger.error(`Denied access to participant ID ${participantId} by user ${email}`, traceId);
56+
return false;
4557
};
4658

4759
export interface UserRequest extends Request {
@@ -60,6 +72,7 @@ const userIdParser = z.object({
6072
userId: z.coerce.number(),
6173
});
6274

75+
// TODO: move this middleware to a separate file
6376
export const enrichCurrentUser = async (req: UserRequest, res: Response, next: NextFunction) => {
6477
const userEmail = req.auth?.payload?.email as string;
6578
const user = await findUserByEmail(userEmail);
@@ -70,21 +83,29 @@ export const enrichCurrentUser = async (req: UserRequest, res: Response, next: N
7083
return next();
7184
};
7285

86+
// TODO: move this middleware to a separate file
7387
export const enrichWithUserFromParams = async (
7488
req: UserRequest,
7589
res: Response,
7690
next: NextFunction
7791
) => {
7892
const { userId } = userIdParser.parse(req.params);
7993
const traceId = getTraceId(req);
80-
const user = await User.query().findById(userId);
94+
const user = await User.query().findById(userId).modify('withParticipants');
95+
8196
if (!user) {
8297
return res.status(404).send([{ message: 'The user cannot be found.' }]);
8398
}
99+
if (user.participants?.length === 0) {
100+
return res.status(404).send([{ message: 'The participant for that user cannot be found.' }]);
101+
}
102+
103+
// TODO: This just gets the user's first participant, but it will need to get the currently selected participant as part of UID2-2822
104+
const firstParticipant = user.participants?.[0] as Participant;
84105
if (
85106
!(await isUserBelongsToParticipant(
86107
req.auth?.payload?.email as string,
87-
user.participantId!,
108+
firstParticipant.id,
88109
traceId
89110
))
90111
) {
@@ -96,5 +117,5 @@ export const enrichWithUserFromParams = async (
96117
};
97118

98119
export const getAllUserFromParticipant = async (participant: Participant) => {
99-
return participant!.$relatedQuery('users').castTo<User[]>();
120+
return participant.$relatedQuery('users').where('deleted', 0).castTo<User[]>();
100121
};

src/api/tests/businessContactService.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ describe('Business Contact Service Tests', () => {
1818
});
1919

2020
afterEach(() => {
21-
jest.resetAllMocks();
21+
jest.restoreAllMocks();
2222
});
2323

2424
describe('hasBusinessContactAccess middleware', () => {

0 commit comments

Comments
 (0)