diff --git a/src/back-end/index.ts b/src/back-end/index.ts index be8fffe29..49cb5c969 100644 --- a/src/back-end/index.ts +++ b/src/back-end/index.ts @@ -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"; @@ -167,6 +168,7 @@ export function createRouter(connection: Connection): AppRouter { ownedOrganizationResource, sessionResource, userResource, + contactListResource, metricsResource, emailNotificationsResource, sprintWithUsProposalTeamQuestionEvaluationResource, diff --git a/src/back-end/lib/db/user.ts b/src/back-end/lib/db/user.ts index fabc6e7ca..ba2d1a7e8 100644 --- a/src/back-end/lib/db/user.ts +++ b/src/back-end/lib/db/user.ts @@ -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, @@ -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 } + >(); + + for (const result of results) { + const userId = result.id; + if (!userMap.has(userId)) { + userMap.set(userId, { + user: result, + organizationNames: new Set() + }); + } + + // 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) => { diff --git a/src/back-end/lib/resources/contact-list.ts b/src/back-end/lib/resources/contact-list.ts new file mode 100644 index 000000000..5d6fe942c --- /dev/null +++ b/src/back-end/lib/resources/contact-list.ts @@ -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 = ( + connection: db.Connection +) => { + return nullRequestBodyHandler< + FileResponseBody | JsonResponseBody, + 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 = { + routeNamespace, + readMany +}; + +export default resource; diff --git a/src/back-end/lib/validation.ts b/src/back-end/lib/validation.ts index d18aba4ba..2c18cf43e 100644 --- a/src/back-end/lib/validation.ts +++ b/src/back-end/lib/validation.ts @@ -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, @@ -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 { + 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 { + 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) + }); + } +} diff --git a/src/front-end/typescript/lib/app/update.ts b/src/front-end/typescript/lib/app/update.ts index bdd3e639c..30c6a6355 100644 --- a/src/front-end/typescript/lib/app/update.ts +++ b/src/front-end/typescript/lib/app/update.ts @@ -1547,7 +1547,13 @@ const update: component.base.Update = ({ 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"], diff --git a/src/front-end/typescript/lib/pages/sign-up/step-two.tsx b/src/front-end/typescript/lib/pages/sign-up/step-two.tsx index 7f9d4b30d..b4255cf66 100644 --- a/src/front-end/typescript/lib/pages/sign-up/step-two.tsx +++ b/src/front-end/typescript/lib/pages/sign-up/step-two.tsx @@ -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, diff --git a/src/front-end/typescript/lib/pages/user/lib/index.ts b/src/front-end/typescript/lib/pages/user/lib/index.ts index 47910b2ee..6d6db39ae 100644 --- a/src/front-end/typescript/lib/pages/user/lib/index.ts +++ b/src/front-end/typescript/lib/pages/user/lib/index.ts @@ -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: diff --git a/src/front-end/typescript/lib/pages/user/list.tsx b/src/front-end/typescript/lib/pages/user/list.tsx index 187e052c9..23e419f0c 100644 --- a/src/front-end/typescript/lib/pages/user/list.tsx +++ b/src/front-end/typescript/lib/pages/user/list.tsx @@ -12,13 +12,15 @@ import { import * as api from "front-end/lib/http/api"; import { userStatusToColor, - userStatusToTitleCase, - userTypeToTitleCase + userStatusToTitleCase } from "front-end/lib/pages/user/lib"; +import { userTypeToTitleCase } from "shared/lib/resources/user"; import Badge from "front-end/lib/views/badge"; -import Link, { routeDest } from "front-end/lib/views/link"; +import Link, { routeDest, externalDest } from "front-end/lib/views/link"; import React from "react"; -import { Col, Row } from "reactstrap"; +import { Button, Col, Row } from "reactstrap"; +import * as Checkbox from "front-end/lib/components/form-field/checkbox"; +import * as FormField from "front-end/lib/components/form-field"; import { compareStrings } from "shared/lib"; import { isAdmin, User, UserType } from "shared/lib/resources/user"; import { adt, ADT } from "shared/lib/types"; @@ -31,9 +33,30 @@ interface TableUser extends User { export interface State { table: Immutable; users: TableUser[]; + showExportModal: boolean; + userTypeCheckboxes: { + [UserType.Government]: Immutable; + [UserType.Vendor]: Immutable; + }; + fieldCheckboxes: { + firstName: Immutable; + lastName: Immutable; + email: Immutable; + organizationName: Immutable; + }; } -type InnerMsg = ADT<"onInitResponse", TableUser[]> | ADT<"table", Table.Msg>; +type InnerMsg = + | ADT<"onInitResponse", TableUser[]> + | ADT<"table", Table.Msg> + | ADT<"showExportModal"> + | ADT<"hideExportModal"> + | ADT<"userTypeCheckboxGovernment", Checkbox.Msg> + | ADT<"userTypeCheckboxVendor", Checkbox.Msg> + | ADT<"fieldCheckboxFirstName", Checkbox.Msg> + | ADT<"fieldCheckboxLastName", Checkbox.Msg> + | ADT<"fieldCheckboxEmail", Checkbox.Msg> + | ADT<"fieldCheckboxOrganizationName", Checkbox.Msg>; export type Msg = component_.page.Msg; @@ -43,14 +66,101 @@ function baseInit(): component_.base.InitReturnValue { const [tableState, tableCmds] = Table.init({ idNamespace: "user-list-table" }); + + // Initialize user type checkboxes + const [govCheckboxState, govCheckboxCmds] = Checkbox.init({ + errors: [], + child: { + value: true, + id: "export-user-type-government" + } + }); + + const [vendorCheckboxState, vendorCheckboxCmds] = Checkbox.init({ + errors: [], + child: { + value: true, + id: "export-user-type-vendor" + } + }); + + // Initialize field checkboxes + const [firstNameCheckboxState, firstNameCheckboxCmds] = Checkbox.init({ + errors: [], + child: { + value: true, + id: "export-field-first-name" + } + }); + + const [lastNameCheckboxState, lastNameCheckboxCmds] = Checkbox.init({ + errors: [], + child: { + value: true, + id: "export-field-last-name" + } + }); + + const [emailCheckboxState, emailCheckboxCmds] = Checkbox.init({ + errors: [], + child: { + value: true, + id: "export-field-email" + } + }); + + const [organizationNameCheckboxState, organizationNameCheckboxCmds] = + Checkbox.init({ + errors: [], + child: { + value: true, + id: "export-field-organization-name" + } + }); + return [ { users: [], - table: immutable(tableState) + table: immutable(tableState), + showExportModal: false, + userTypeCheckboxes: { + [UserType.Government]: immutable(govCheckboxState), + [UserType.Vendor]: immutable(vendorCheckboxState) + }, + fieldCheckboxes: { + firstName: immutable(firstNameCheckboxState), + lastName: immutable(lastNameCheckboxState), + email: immutable(emailCheckboxState), + organizationName: immutable(organizationNameCheckboxState) + } }, [ component_.cmd.dispatch(component_.page.readyMsg()), - ...component_.cmd.mapMany(tableCmds, (msg) => adt("table", msg) as Msg) + ...component_.cmd.mapMany(tableCmds, (msg) => adt("table", msg) as Msg), + ...component_.cmd.mapMany( + govCheckboxCmds, + (msg) => adt("userTypeCheckboxGovernment", msg) as Msg + ), + ...component_.cmd.mapMany( + vendorCheckboxCmds, + (msg) => adt("userTypeCheckboxVendor", msg) as Msg + ), + ...component_.cmd.mapMany( + firstNameCheckboxCmds, + (msg) => adt("fieldCheckboxFirstName", msg) as Msg + ), + ...component_.cmd.mapMany( + lastNameCheckboxCmds, + (msg) => adt("fieldCheckboxLastName", msg) as Msg + ), + ...component_.cmd.mapMany( + emailCheckboxCmds, + (msg) => adt("fieldCheckboxEmail", msg) as Msg + ), + ...component_.cmd.mapMany( + organizationNameCheckboxCmds, + (msg) => adt("fieldCheckboxOrganizationName", msg) as Msg + ) ] ]; } @@ -134,6 +244,59 @@ const update: component_.page.Update = ({ childMsg: msg.value, mapChildMsg: (value) => ({ tag: "table", value }) }); + case "showExportModal": + return [state.set("showExportModal", true), []]; + case "hideExportModal": + return [state.set("showExportModal", false), []]; + case "userTypeCheckboxGovernment": + return component_.base.updateChild({ + state, + childStatePath: ["userTypeCheckboxes", UserType.Government], + childUpdate: Checkbox.update, + childMsg: msg.value, + mapChildMsg: (value) => adt("userTypeCheckboxGovernment", value) + }); + case "userTypeCheckboxVendor": + return component_.base.updateChild({ + state, + childStatePath: ["userTypeCheckboxes", UserType.Vendor], + childUpdate: Checkbox.update, + childMsg: msg.value, + mapChildMsg: (value) => adt("userTypeCheckboxVendor", value) + }); + case "fieldCheckboxFirstName": + return component_.base.updateChild({ + state, + childStatePath: ["fieldCheckboxes", "firstName"], + childUpdate: Checkbox.update, + childMsg: msg.value, + mapChildMsg: (value) => adt("fieldCheckboxFirstName", value) + }); + case "fieldCheckboxLastName": + return component_.base.updateChild({ + state, + childStatePath: ["fieldCheckboxes", "lastName"], + childUpdate: Checkbox.update, + childMsg: msg.value, + mapChildMsg: (value) => adt("fieldCheckboxLastName", value) + }); + case "fieldCheckboxEmail": + return component_.base.updateChild({ + state, + childStatePath: ["fieldCheckboxes", "email"], + childUpdate: Checkbox.update, + childMsg: msg.value, + mapChildMsg: (value) => adt("fieldCheckboxEmail", value) + }); + case "fieldCheckboxOrganizationName": + return component_.base.updateChild({ + state, + childStatePath: ["fieldCheckboxes", "organizationName"], + childUpdate: Checkbox.update, + childMsg: msg.value, + mapChildMsg: (value) => adt("fieldCheckboxOrganizationName", value) + }); + default: return [state, []]; } @@ -197,6 +360,144 @@ function tableBodyRows(state: Immutable): Table.BodyRows { }); } +const getModal: component_.page.GetModal = (state) => { + if (!state.showExportModal) { + return component_.page.modal.hide(); + } + + // Check if at least one user type and one field is selected + const hasUserTypeSelected = Object.values(state.userTypeCheckboxes).some( + (checkboxState) => FormField.getValue(checkboxState) + ); + const hasFieldSelected = Object.values(state.fieldCheckboxes).some( + (checkboxState) => FormField.getValue(checkboxState) + ); + const canExport = hasUserTypeSelected && hasFieldSelected; + + // Build query parameters from checkbox states + const selectedUserTypes = Object.entries(state.userTypeCheckboxes) + .filter(([_, checkboxState]) => FormField.getValue(checkboxState)) + .map(([type]) => type); + + const selectedFields = Object.entries(state.fieldCheckboxes) + .filter(([_, checkboxState]) => FormField.getValue(checkboxState)) + .map(([field]) => field); + + // Use URLSearchParams for safer URL construction + const params = new URLSearchParams({ + userTypes: selectedUserTypes.join(","), + fields: selectedFields.join(",") + }); + + const csvURLWithQueryParams = `/api/contact-list?${params}`; + + return component_.page.modal.show({ + title: "Export Contact List", + onCloseMsg: adt("hideExportModal"), + actions: [], + body: (dispatch) => ( +
+
+
Select User Types
+ + adt("userTypeCheckboxGovernment" as const, msg) + )} + /> + + adt("userTypeCheckboxVendor" as const, msg) + )} + /> +
+ +
+
Select Fields to Export
+ + adt("fieldCheckboxFirstName" as const, msg) + )} + /> + + adt("fieldCheckboxLastName" as const, msg) + )} + /> + + adt("fieldCheckboxEmail" as const, msg) + )} + /> + + adt("fieldCheckboxOrganizationName" as const, msg) + )} + /> +
+ + {!canExport && ( +
+ Please select at least one user type and one field to export. +
+ )} + + {/* Action buttons styled like modal footer */} +
+
+ dispatch(adt("hideExportModal"))} + disabled={!canExport} + className="mx-0"> + Export + + dispatch(adt("hideExportModal"))} + color="secondary" + className="mr-3"> + Cancel + +
+
+
+ ) + }); +}; + const view: component_.page.View = ({ state, dispatch @@ -208,7 +509,14 @@ const view: component_.page.View = ({ return ( -

Digital Marketplace Users

+
+

Digital Marketplace Users

+ +