Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions src/back-end/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import teamWithUsOpportunityResourceQuestionEvaluationResource from "back-end/li
import teamWithUsProposalResourceQuestionConsensusResource from "back-end/lib/resources/proposal/team-with-us/resource-questions/consensus";
import teamWithUsOpportunityResourceQuestionConsensusResource from "back-end/lib/resources/opportunity/team-with-us/resource-questions/consensus";
import userResource from "back-end/lib/resources/user";
import contactListResource from "back-end/lib/resources/contact-list";
import adminRouter from "back-end/lib/routers/admin";
import authRouter from "back-end/lib/routers/auth";
import frontEndRouter from "back-end/lib/routers/front-end";
Expand Down Expand Up @@ -167,6 +168,7 @@ export function createRouter(connection: Connection): AppRouter {
ownedOrganizationResource,
sessionResource,
userResource,
contactListResource,
metricsResource,
emailNotificationsResource,
sprintWithUsProposalTeamQuestionEvaluationResource,
Expand Down
65 changes: 65 additions & 0 deletions src/back-end/lib/db/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { readOneFileById } from "back-end/lib/db/file";
import { makeDomainLogger } from "back-end/lib/logger";
import { console as consoleAdapter } from "back-end/lib/logger/adapters";
import { valid } from "shared/lib/http";
import { MembershipStatus } from "shared/lib/resources/affiliation";
import {
User,
UserSlim,
Expand Down Expand Up @@ -188,6 +189,70 @@ export const readManyUsersByRole = tryDb<[UserType, boolean?], User[]>(
}
);

export const readManyUsersWithOrganizations = tryDb<
[UserType[], boolean?],
Array<{ user: User; organizationNames: string[] }>
>(async (connection, userTypes, includeInactive = true) => {
// Single query with LEFT JOIN to get users and all their organizations
const results = await connection("users")
.leftJoin("affiliations", "users.id", "=", "affiliations.user")
.leftJoin(
"organizations",
"affiliations.organization",
"=",
"organizations.id"
)
.whereIn("users.type", userTypes)
.andWhere(function () {
if (!includeInactive) {
this.where({ "users.status": UserStatus.Active });
}
})
.andWhere(function () {
// Only include active affiliations and organizations, or users without affiliations
this.whereNull("affiliations.id").orWhere(function () {
this.where({
"organizations.active": true
}).andWhereNot({
"affiliations.membershipStatus": MembershipStatus.Inactive
});
});
})
.select("users.*", "organizations.legalName as organizationName")
.orderBy("affiliations.createdAt", "desc"); // Order by newest affiliations first

// Process results to group by user and collect all organizations
const userMap = new Map<
string,
{ user: RawUser; organizationNames: Set<string> }
>();

for (const result of results) {
const userId = result.id;
if (!userMap.has(userId)) {
userMap.set(userId, {
user: result,
organizationNames: new Set<string>()
});
}

// Add organization name if it exists
if (result.organizationName) {
userMap.get(userId)!.organizationNames.add(result.organizationName);
}
}

// Convert to User objects
const processedResults = await Promise.all(
Array.from(userMap.values()).map(async ({ user, organizationNames }) => ({
user: await rawUserToUser(connection, user),
organizationNames: Array.from(organizationNames)
}))
);

return valid(processedResults);
});

const tempLogger = makeDomainLogger(consoleAdapter, "create-user-debug");
export const createUser = tryDb<[CreateUserParams], User>(
async (connection, user) => {
Expand Down
155 changes: 155 additions & 0 deletions src/back-end/lib/resources/contact-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import * as crud from "back-end/lib/crud";
import * as db from "back-end/lib/db";
import * as permissions from "back-end/lib/permissions";
import {
basicResponse,
FileResponseBody,
JsonResponseBody,
makeJsonResponseBody,
nullRequestBodyHandler
} from "back-end/lib/server";
import { Session } from "shared/lib/resources/session";
import { UserType, userTypeToTitleCase } from "shared/lib/resources/user";
import { adt } from "shared/lib/types";
import { isValid } from "shared/lib/validation";
import { getString } from "shared/lib";
import {
validateContactListExportParams,
ExportContactListValidationErrors
} from "back-end/lib/validation";

const routeNamespace = "contact-list";

const readMany: crud.ReadMany<Session, db.Connection> = (
connection: db.Connection
) => {
return nullRequestBodyHandler<
FileResponseBody | JsonResponseBody<ExportContactListValidationErrors>,
Session
>(async (request) => {
const respond = (code: number, body: ExportContactListValidationErrors) =>
basicResponse(code, request.session, makeJsonResponseBody(body));

// Check admin permissions
if (!permissions.isAdmin(request.session)) {
return respond(401, {
permissions: [permissions.ERROR_MESSAGE]
});
}

// Parse query parameters
const userTypesParam = getString(request.query, "userTypes");
const fieldsParam = getString(request.query, "fields");

const userTypes = userTypesParam
? userTypesParam.split(",").filter((type) => type.trim() !== "")
: [];
const fields = fieldsParam
? fieldsParam.split(",").filter((field) => field.trim() !== "")
: [];

// Validate input parameters using the new validation functions
const validationResult = validateContactListExportParams(userTypes, fields);

if (!isValid(validationResult)) {
return respond(400, validationResult.value);
}

const { userTypes: validatedUserTypes, fields: validatedFields } =
validationResult.value;

try {
const userTypesToFetch: UserType[] = [];
if (validatedUserTypes.includes(UserType.Government)) {
userTypesToFetch.push(UserType.Government, UserType.Admin);
}
if (validatedUserTypes.includes(UserType.Vendor)) {
userTypesToFetch.push(UserType.Vendor);
}

const usersWithOrganizations = await db.readManyUsersWithOrganizations(
connection,
userTypesToFetch,
false
);

const headerRow: string[] = [];

if (validatedFields.includes("firstName")) headerRow.push("First Name");
if (validatedFields.includes("lastName")) headerRow.push("Last Name");
if (validatedFields.includes("email")) headerRow.push("Email");

// Add User Type column if both Government and Vendor types are selected
const includeUserType =
validatedUserTypes.includes(UserType.Government) &&
validatedUserTypes.includes(UserType.Vendor);
if (includeUserType) headerRow.push("User Type");

if (validatedFields.includes("organizationName"))
headerRow.push("Organization Name");

let csvContent = headerRow.join(",") + "\n";

if (isValid(usersWithOrganizations)) {
for (const userWithOrgs of usersWithOrganizations.value) {
const user = userWithOrgs.user;
const row: string[] = [];

const nameParts = (user.name || "").split(" ");
const firstName = nameParts[0] || "";
const lastName =
nameParts.length > 1 ? nameParts.slice(1).join(" ") : "";

if (validatedFields.includes("firstName")) row.push(`"${firstName}"`);
if (validatedFields.includes("lastName")) row.push(`"${lastName}"`);
if (validatedFields.includes("email"))
row.push(`"${user.email || ""}"`);

// Add user type if both Government and Vendor types are selected
if (includeUserType) {
const userTypeLabel =
user.type === UserType.Admin
? "Admin"
: userTypeToTitleCase(user.type);
row.push(`"${userTypeLabel}"`);
}

if (validatedFields.includes("organizationName")) {
const orgNamesString =
userWithOrgs.organizationNames.length > 0
? userWithOrgs.organizationNames.join("; ")
: "";
row.push(`"${orgNamesString}"`);
}

csvContent += row.join(",") + "\n";
}
}

return basicResponse(
200,
request.session,
adt("file", {
buffer: Buffer.from(csvContent, "utf-8"),
contentType: "text/csv",
contentDisposition: "attachment; filename=dm-contacts.csv"
})
);
} catch (error) {
request.logger.error(
"Error generating contact list CSV",
error as object
);
return respond(500, {
permissions: ["An error occurred while generating the contact list"]
});
}
});
};

const resource: crud.BasicCrudResource<Session, db.Connection> = {
routeNamespace,
readMany
};

export default resource;
77 changes: 76 additions & 1 deletion src/back-end/lib/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ import {
SWUProposal
} from "shared/lib/resources/proposal/sprint-with-us";
import { AuthenticatedSession, Session } from "shared/lib/resources/session";
import { isPublicSectorEmployee, User } from "shared/lib/resources/user";
import {
isPublicSectorEmployee,
User,
UserType
} from "shared/lib/resources/user";
import { adt, Id } from "shared/lib/types";
import {
allValid,
Expand Down Expand Up @@ -1204,3 +1208,74 @@ export async function validateContentId(
return invalid(["Please select a valid content id."]);
}
}

/**
* Contact List Export Validation
*/

export interface ExportContactListValidationErrors {
userTypes?: string[];
fields?: string[];
permissions?: string[];
}

export function validateContactListUserTypes(
userTypes: string[]
): Validation<UserType[]> {
if (!userTypes.length) {
return invalid(["At least one user type must be specified"]);
}

const validUserTypes = [UserType.Government, UserType.Vendor];
const invalidUserTypes = userTypes.filter(
(type: string) => !validUserTypes.includes(type as UserType)
);

if (invalidUserTypes.length > 0) {
return invalid([`Invalid user type(s): ${invalidUserTypes.join(", ")}`]);
}

return valid(userTypes.map((type) => type as UserType));
}

export function validateContactListFields(
fields: string[]
): Validation<string[]> {
if (!fields.length) {
return invalid(["At least one field must be specified"]);
}

const validFields = ["firstName", "lastName", "email", "organizationName"];
const invalidFields = fields.filter(
(field: string) => !validFields.includes(field)
);

if (invalidFields.length > 0) {
return invalid([`Invalid field(s): ${invalidFields.join(", ")}`]);
}

return valid(fields);
}

export function validateContactListExportParams(
userTypes: string[],
fields: string[]
): Validation<
{ userTypes: UserType[]; fields: string[] },
ExportContactListValidationErrors
> {
const validatedUserTypes = validateContactListUserTypes(userTypes);
const validatedFields = validateContactListFields(fields);

if (isValid(validatedUserTypes) && isValid(validatedFields)) {
return valid({
userTypes: validatedUserTypes.value,
fields: validatedFields.value
});
} else {
return invalid({
userTypes: getInvalidValue(validatedUserTypes, undefined),
fields: getInvalidValue(validatedFields, undefined)
});
}
}
8 changes: 7 additions & 1 deletion src/front-end/typescript/lib/app/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1547,7 +1547,13 @@ const update: component.base.Update<State, Msg> = ({ state, msg }) => {
});

case "pageUserList":
return component.app.updatePage({
return component.app.updatePage<
State,
Msg,
PageUserList.State,
PageUserList.Msg,
Route
>({
...defaultPageUpdateParams,
mapPageMsg: (value) => adt("pageUserList", value),
pageStatePath: ["pages", "userList"],
Expand Down
2 changes: 1 addition & 1 deletion src/front-end/typescript/lib/pages/sign-up/step-two.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
immutable,
component as component_
} from "front-end/lib/framework";
import { userTypeToTitleCase } from "front-end/lib/pages/user/lib";
import { userTypeToTitleCase } from "shared/lib/resources/user";
import * as ProfileForm from "front-end/lib/pages/user/lib/components/profile-form";
import Link, {
iconLinkSymbol,
Expand Down
10 changes: 0 additions & 10 deletions src/front-end/typescript/lib/pages/user/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,6 @@ export function userToKeyCloakIdentityProviderTitleCase(
);
}

export function userTypeToTitleCase(v: UserType): string {
switch (v) {
case UserType.Government:
case UserType.Admin:
return "Public Sector Employee";
case UserType.Vendor:
return "Vendor";
}
}

export function userTypeToPermissions(v: UserType): string[] {
switch (v) {
case UserType.Admin:
Expand Down
Loading
Loading