Skip to content

Commit eac6969

Browse files
authored
Merge pull request #503 from bcgov/feat/admin-self-serve-contact-list-export
Feat/admin self serve contact list export
2 parents 0387af3 + 7ee4ff6 commit eac6969

File tree

10 files changed

+636
-24
lines changed

10 files changed

+636
-24
lines changed

src/back-end/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import teamWithUsOpportunityResourceQuestionEvaluationResource from "back-end/li
5050
import teamWithUsProposalResourceQuestionConsensusResource from "back-end/lib/resources/proposal/team-with-us/resource-questions/consensus";
5151
import teamWithUsOpportunityResourceQuestionConsensusResource from "back-end/lib/resources/opportunity/team-with-us/resource-questions/consensus";
5252
import userResource from "back-end/lib/resources/user";
53+
import contactListResource from "back-end/lib/resources/contact-list";
5354
import adminRouter from "back-end/lib/routers/admin";
5455
import authRouter from "back-end/lib/routers/auth";
5556
import frontEndRouter from "back-end/lib/routers/front-end";
@@ -167,6 +168,7 @@ export function createRouter(connection: Connection): AppRouter {
167168
ownedOrganizationResource,
168169
sessionResource,
169170
userResource,
171+
contactListResource,
170172
metricsResource,
171173
emailNotificationsResource,
172174
sprintWithUsProposalTeamQuestionEvaluationResource,

src/back-end/lib/db/user.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { readOneFileById } from "back-end/lib/db/file";
44
import { makeDomainLogger } from "back-end/lib/logger";
55
import { console as consoleAdapter } from "back-end/lib/logger/adapters";
66
import { valid } from "shared/lib/http";
7+
import { MembershipStatus } from "shared/lib/resources/affiliation";
78
import {
89
User,
910
UserSlim,
@@ -188,6 +189,70 @@ export const readManyUsersByRole = tryDb<[UserType, boolean?], User[]>(
188189
}
189190
);
190191

192+
export const readManyUsersWithOrganizations = tryDb<
193+
[UserType[], boolean?],
194+
Array<{ user: User; organizationNames: string[] }>
195+
>(async (connection, userTypes, includeInactive = true) => {
196+
// Single query with LEFT JOIN to get users and all their organizations
197+
const results = await connection("users")
198+
.leftJoin("affiliations", "users.id", "=", "affiliations.user")
199+
.leftJoin(
200+
"organizations",
201+
"affiliations.organization",
202+
"=",
203+
"organizations.id"
204+
)
205+
.whereIn("users.type", userTypes)
206+
.andWhere(function () {
207+
if (!includeInactive) {
208+
this.where({ "users.status": UserStatus.Active });
209+
}
210+
})
211+
.andWhere(function () {
212+
// Only include active affiliations and organizations, or users without affiliations
213+
this.whereNull("affiliations.id").orWhere(function () {
214+
this.where({
215+
"organizations.active": true
216+
}).andWhereNot({
217+
"affiliations.membershipStatus": MembershipStatus.Inactive
218+
});
219+
});
220+
})
221+
.select("users.*", "organizations.legalName as organizationName")
222+
.orderBy("affiliations.createdAt", "desc"); // Order by newest affiliations first
223+
224+
// Process results to group by user and collect all organizations
225+
const userMap = new Map<
226+
string,
227+
{ user: RawUser; organizationNames: Set<string> }
228+
>();
229+
230+
for (const result of results) {
231+
const userId = result.id;
232+
if (!userMap.has(userId)) {
233+
userMap.set(userId, {
234+
user: result,
235+
organizationNames: new Set<string>()
236+
});
237+
}
238+
239+
// Add organization name if it exists
240+
if (result.organizationName) {
241+
userMap.get(userId)!.organizationNames.add(result.organizationName);
242+
}
243+
}
244+
245+
// Convert to User objects
246+
const processedResults = await Promise.all(
247+
Array.from(userMap.values()).map(async ({ user, organizationNames }) => ({
248+
user: await rawUserToUser(connection, user),
249+
organizationNames: Array.from(organizationNames)
250+
}))
251+
);
252+
253+
return valid(processedResults);
254+
});
255+
191256
const tempLogger = makeDomainLogger(consoleAdapter, "create-user-debug");
192257
export const createUser = tryDb<[CreateUserParams], User>(
193258
async (connection, user) => {
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import * as crud from "back-end/lib/crud";
2+
import * as db from "back-end/lib/db";
3+
import * as permissions from "back-end/lib/permissions";
4+
import {
5+
basicResponse,
6+
FileResponseBody,
7+
JsonResponseBody,
8+
makeJsonResponseBody,
9+
nullRequestBodyHandler
10+
} from "back-end/lib/server";
11+
import { Session } from "shared/lib/resources/session";
12+
import { UserType, userTypeToTitleCase } from "shared/lib/resources/user";
13+
import { adt } from "shared/lib/types";
14+
import { isValid } from "shared/lib/validation";
15+
import { getString } from "shared/lib";
16+
import {
17+
validateContactListExportParams,
18+
ExportContactListValidationErrors
19+
} from "back-end/lib/validation";
20+
21+
const routeNamespace = "contact-list";
22+
23+
const readMany: crud.ReadMany<Session, db.Connection> = (
24+
connection: db.Connection
25+
) => {
26+
return nullRequestBodyHandler<
27+
FileResponseBody | JsonResponseBody<ExportContactListValidationErrors>,
28+
Session
29+
>(async (request) => {
30+
const respond = (code: number, body: ExportContactListValidationErrors) =>
31+
basicResponse(code, request.session, makeJsonResponseBody(body));
32+
33+
// Check admin permissions
34+
if (!permissions.isAdmin(request.session)) {
35+
return respond(401, {
36+
permissions: [permissions.ERROR_MESSAGE]
37+
});
38+
}
39+
40+
// Parse query parameters
41+
const userTypesParam = getString(request.query, "userTypes");
42+
const fieldsParam = getString(request.query, "fields");
43+
44+
const userTypes = userTypesParam
45+
? userTypesParam.split(",").filter((type) => type.trim() !== "")
46+
: [];
47+
const fields = fieldsParam
48+
? fieldsParam.split(",").filter((field) => field.trim() !== "")
49+
: [];
50+
51+
// Validate input parameters using the new validation functions
52+
const validationResult = validateContactListExportParams(userTypes, fields);
53+
54+
if (!isValid(validationResult)) {
55+
return respond(400, validationResult.value);
56+
}
57+
58+
const { userTypes: validatedUserTypes, fields: validatedFields } =
59+
validationResult.value;
60+
61+
try {
62+
const userTypesToFetch: UserType[] = [];
63+
if (validatedUserTypes.includes(UserType.Government)) {
64+
userTypesToFetch.push(UserType.Government, UserType.Admin);
65+
}
66+
if (validatedUserTypes.includes(UserType.Vendor)) {
67+
userTypesToFetch.push(UserType.Vendor);
68+
}
69+
70+
const usersWithOrganizations = await db.readManyUsersWithOrganizations(
71+
connection,
72+
userTypesToFetch,
73+
false
74+
);
75+
76+
const headerRow: string[] = [];
77+
78+
if (validatedFields.includes("firstName")) headerRow.push("First Name");
79+
if (validatedFields.includes("lastName")) headerRow.push("Last Name");
80+
if (validatedFields.includes("email")) headerRow.push("Email");
81+
82+
// Add User Type column if both Government and Vendor types are selected
83+
const includeUserType =
84+
validatedUserTypes.includes(UserType.Government) &&
85+
validatedUserTypes.includes(UserType.Vendor);
86+
if (includeUserType) headerRow.push("User Type");
87+
88+
if (validatedFields.includes("organizationName"))
89+
headerRow.push("Organization Name");
90+
91+
let csvContent = headerRow.join(",") + "\n";
92+
93+
if (isValid(usersWithOrganizations)) {
94+
for (const userWithOrgs of usersWithOrganizations.value) {
95+
const user = userWithOrgs.user;
96+
const row: string[] = [];
97+
98+
const nameParts = (user.name || "").split(" ");
99+
const firstName = nameParts[0] || "";
100+
const lastName =
101+
nameParts.length > 1 ? nameParts.slice(1).join(" ") : "";
102+
103+
if (validatedFields.includes("firstName")) row.push(`"${firstName}"`);
104+
if (validatedFields.includes("lastName")) row.push(`"${lastName}"`);
105+
if (validatedFields.includes("email"))
106+
row.push(`"${user.email || ""}"`);
107+
108+
// Add user type if both Government and Vendor types are selected
109+
if (includeUserType) {
110+
const userTypeLabel =
111+
user.type === UserType.Admin
112+
? "Admin"
113+
: userTypeToTitleCase(user.type);
114+
row.push(`"${userTypeLabel}"`);
115+
}
116+
117+
if (validatedFields.includes("organizationName")) {
118+
const orgNamesString =
119+
userWithOrgs.organizationNames.length > 0
120+
? userWithOrgs.organizationNames.join("; ")
121+
: "";
122+
row.push(`"${orgNamesString}"`);
123+
}
124+
125+
csvContent += row.join(",") + "\n";
126+
}
127+
}
128+
129+
return basicResponse(
130+
200,
131+
request.session,
132+
adt("file", {
133+
buffer: Buffer.from(csvContent, "utf-8"),
134+
contentType: "text/csv",
135+
contentDisposition: "attachment; filename=dm-contacts.csv"
136+
})
137+
);
138+
} catch (error) {
139+
request.logger.error(
140+
"Error generating contact list CSV",
141+
error as object
142+
);
143+
return respond(500, {
144+
permissions: ["An error occurred while generating the contact list"]
145+
});
146+
}
147+
});
148+
};
149+
150+
const resource: crud.BasicCrudResource<Session, db.Connection> = {
151+
routeNamespace,
152+
readMany
153+
};
154+
155+
export default resource;

src/back-end/lib/validation.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ import {
2828
SWUProposal
2929
} from "shared/lib/resources/proposal/sprint-with-us";
3030
import { AuthenticatedSession, Session } from "shared/lib/resources/session";
31-
import { isPublicSectorEmployee, User } from "shared/lib/resources/user";
31+
import {
32+
isPublicSectorEmployee,
33+
User,
34+
UserType
35+
} from "shared/lib/resources/user";
3236
import { adt, Id } from "shared/lib/types";
3337
import {
3438
allValid,
@@ -1204,3 +1208,74 @@ export async function validateContentId(
12041208
return invalid(["Please select a valid content id."]);
12051209
}
12061210
}
1211+
1212+
/**
1213+
* Contact List Export Validation
1214+
*/
1215+
1216+
export interface ExportContactListValidationErrors {
1217+
userTypes?: string[];
1218+
fields?: string[];
1219+
permissions?: string[];
1220+
}
1221+
1222+
export function validateContactListUserTypes(
1223+
userTypes: string[]
1224+
): Validation<UserType[]> {
1225+
if (!userTypes.length) {
1226+
return invalid(["At least one user type must be specified"]);
1227+
}
1228+
1229+
const validUserTypes = [UserType.Government, UserType.Vendor];
1230+
const invalidUserTypes = userTypes.filter(
1231+
(type: string) => !validUserTypes.includes(type as UserType)
1232+
);
1233+
1234+
if (invalidUserTypes.length > 0) {
1235+
return invalid([`Invalid user type(s): ${invalidUserTypes.join(", ")}`]);
1236+
}
1237+
1238+
return valid(userTypes.map((type) => type as UserType));
1239+
}
1240+
1241+
export function validateContactListFields(
1242+
fields: string[]
1243+
): Validation<string[]> {
1244+
if (!fields.length) {
1245+
return invalid(["At least one field must be specified"]);
1246+
}
1247+
1248+
const validFields = ["firstName", "lastName", "email", "organizationName"];
1249+
const invalidFields = fields.filter(
1250+
(field: string) => !validFields.includes(field)
1251+
);
1252+
1253+
if (invalidFields.length > 0) {
1254+
return invalid([`Invalid field(s): ${invalidFields.join(", ")}`]);
1255+
}
1256+
1257+
return valid(fields);
1258+
}
1259+
1260+
export function validateContactListExportParams(
1261+
userTypes: string[],
1262+
fields: string[]
1263+
): Validation<
1264+
{ userTypes: UserType[]; fields: string[] },
1265+
ExportContactListValidationErrors
1266+
> {
1267+
const validatedUserTypes = validateContactListUserTypes(userTypes);
1268+
const validatedFields = validateContactListFields(fields);
1269+
1270+
if (isValid(validatedUserTypes) && isValid(validatedFields)) {
1271+
return valid({
1272+
userTypes: validatedUserTypes.value,
1273+
fields: validatedFields.value
1274+
});
1275+
} else {
1276+
return invalid({
1277+
userTypes: getInvalidValue(validatedUserTypes, undefined),
1278+
fields: getInvalidValue(validatedFields, undefined)
1279+
});
1280+
}
1281+
}

src/front-end/typescript/lib/app/update.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1547,7 +1547,13 @@ const update: component.base.Update<State, Msg> = ({ state, msg }) => {
15471547
});
15481548

15491549
case "pageUserList":
1550-
return component.app.updatePage({
1550+
return component.app.updatePage<
1551+
State,
1552+
Msg,
1553+
PageUserList.State,
1554+
PageUserList.Msg,
1555+
Route
1556+
>({
15511557
...defaultPageUpdateParams,
15521558
mapPageMsg: (value) => adt("pageUserList", value),
15531559
pageStatePath: ["pages", "userList"],

src/front-end/typescript/lib/pages/sign-up/step-two.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
immutable,
1717
component as component_
1818
} from "front-end/lib/framework";
19-
import { userTypeToTitleCase } from "front-end/lib/pages/user/lib";
19+
import { userTypeToTitleCase } from "shared/lib/resources/user";
2020
import * as ProfileForm from "front-end/lib/pages/user/lib/components/profile-form";
2121
import Link, {
2222
iconLinkSymbol,

src/front-end/typescript/lib/pages/user/lib/index.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,16 +41,6 @@ export function userToKeyCloakIdentityProviderTitleCase(
4141
);
4242
}
4343

44-
export function userTypeToTitleCase(v: UserType): string {
45-
switch (v) {
46-
case UserType.Government:
47-
case UserType.Admin:
48-
return "Public Sector Employee";
49-
case UserType.Vendor:
50-
return "Vendor";
51-
}
52-
}
53-
5444
export function userTypeToPermissions(v: UserType): string[] {
5545
switch (v) {
5646
case UserType.Admin:

0 commit comments

Comments
 (0)