Skip to content

Commit

Permalink
Merge pull request #193 from GSA/office_filter
Browse files Browse the repository at this point in the history
Office filter
  • Loading branch information
collinschreyer-dev authored Dec 6, 2024
2 parents ea201e2 + 03eeb7b commit 6ad74f9
Show file tree
Hide file tree
Showing 5 changed files with 394 additions and 68 deletions.
127 changes: 126 additions & 1 deletion server/config/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,123 @@ module.exports = {
"NA_ACTION": "Solicitation marked not applicable",
"UNDO_NA_ACTION": "Not applicable status removed"
},
AGENCY_HIERARCHY: {
'Department of Defense': {
offices: ['DEPARTMENT OF THE ARMY', 'DEPARTMENT OF THE NAVY', 'DEPARTMENT OF THE AIR FORCE', 'SPACE FORCE', 'DEFENSE LOGISTICS AGENCY'],
variations: {
'DEPARTMENT OF THE ARMY': {
aliases: ['DEPT OF THE ARMY', 'US ARMY', 'ARMY'],
email_domains: ['army.mil']
},
'DEPARTMENT OF THE NAVY': {
aliases: ['DEPT OF THE NAVY', 'US NAVY', 'NAVY'],
email_domains: ['navy.mil', 'us.navy.mil']
},
'DEPARTMENT OF THE AIR FORCE': {
aliases: ['DEPT OF THE AIR FORCE', 'US AIR FORCE', 'AIR FORCE'],
email_domains: ['af.mil', 'us.af.mil']
},
'SPACE FORCE': {
aliases: ['US SPACE FORCE', 'USSF'],
email_domains: ['spaceforce.mil']
},
'DEFENSE LOGISTICS AGENCY': {
aliases: ['DLA'],
email_domains: ['dla.mil']
}
}
},
'Department of Health and Human Services': {
offices: ['NATIONAL INSTITUTES OF HEALTH', 'FOOD AND DRUG ADMINISTRATION', 'INDIAN HEALTH SERVICE', 'CENTERS FOR MEDICARE & MEDICAID SERVICES'],
variations: {
'NATIONAL INSTITUTES OF HEALTH': {
aliases: ['NIH'],
email_domains: ['nih.gov']
},
'FOOD AND DRUG ADMINISTRATION': {
aliases: ['FDA'],
email_domains: ['fda.hhs.gov']
},
'INDIAN HEALTH SERVICE': {
aliases: ['IHS'],
email_domains: ['ihs.gov']
},
'CENTERS FOR MEDICARE & MEDICAID SERVICES': {
aliases: ['CMS'],
email_domains: ['cms.hhs.gov']
}
}
},
'Department of Homeland Security': {
offices: ['FEDERAL EMERGENCY MANAGEMENT AGENCY', 'US CITIZENSHIP AND IMMIGRATION SERVICES', 'US SECRET SERVICE'],
variations: {
'FEDERAL EMERGENCY MANAGEMENT AGENCY': {
aliases: ['FEMA'],
email_domains: ['fema.dhs.gov']
},
'US CITIZENSHIP AND IMMIGRATION SERVICES': {
aliases: ['USCIS'],
email_domains: ['uscis.dhs.gov']
},
'US SECRET SERVICE': {
aliases: ['USSS', 'SECRET SERVICE'],
email_domains: ['usss.dhs.gov']
}
}
},
'Department of Commerce': {
offices: ['NATIONAL OCEANIC AND ATMOSPHERIC ADMINISTRATION', 'NATIONAL TELECOMMUNICATIONS AND INFORMATION ADMINISTRATION'],
variations: {
'NATIONAL OCEANIC AND ATMOSPHERIC ADMINISTRATION': {
aliases: ['NOAA'],
email_domains: ['noaa.gov']
},
'NATIONAL TELECOMMUNICATIONS AND INFORMATION ADMINISTRATION': {
aliases: ['NTIA'],
email_domains: ['ntia']
}
}
},
'Department of the Interior': {
offices: ['NATIONAL PARK SERVICE', 'FISH AND WILDLIFE SERVICE', 'BUREAU OF OCEAN ENERGY MANAGEMENT'],
variations: {
'NATIONAL PARK SERVICE': {
aliases: ['NPS'],
email_domains: ['nps.gov']
},
'FISH AND WILDLIFE SERVICE': {
aliases: ['FWS', 'FISH AND WILDLIFE'],
email_domains: ['fws.gov']
},
'BUREAU OF OCEAN ENERGY MANAGEMENT': {
aliases: ['BOEM'],
email_domains: ['boem.gov']
}
}
},
'Department of the Treasury': {
offices: ['INTERNAL REVENUE SERVICE', 'US MINT'],
variations: {
'INTERNAL REVENUE SERVICE': {
aliases: ['IRS'],
email_domains: ['irs.gov']
},
'US MINT': {
aliases: ['MINT'],
email_domains: ['usmint.treas.gov']
}
}
}
},
UNIQUE_EMAIL_AGENCY_MAPPING: {
'usss.dhs.gov': 'US SECRET SERVICE',
'fema.dhs.gov': 'FEDERAL EMERGENCY MANAGEMENT AGENCY',
'uscis.dhs.gov': 'US CITIZENSHIP AND IMMIGRATION SERVICES',
'cms.hhs.gov': 'CENTERS FOR MEDICARE & MEDICAID SERVICES',
'fda.hhs.gov': 'FOOD AND DRUG ADMINISTRATION',
'us.af.mil': 'DEPT OF THE AIR FORCE',
'us.navy.mil': 'DEPT OF THE NAVY'
},
// keys for agency look should be all lower case
AGENCY_LOOKUP: {
"department of test": "TEST, DEPARTMENT OF",
Expand Down Expand Up @@ -249,7 +366,15 @@ module.exports = {
"vets": "Veterans' Employment and Training Service",
"vha": "Veterans Health Administration",
"voa": "Voice of America",
"washington, dc": "District of Columbia"
"washington, dc": "District of Columbia",
"army": "DEPT OF THE ARMY",
"navy": "DEPT OF THE NAVY",
"af": "DEPT OF THE AIR FORCE",
"spaceforce": "SPACE FORCE",
"dla": "DEFENSE LOGISTICS AGENCY",
"ihs": "INDIAN HEALTH SERVICE",
"usss": "US SECRET SERVICE",
"usmint": "US MINT"
},

// AGENCY_LOOKUP: {
Expand Down
49 changes: 41 additions & 8 deletions server/routes/auth.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ const {common} = require('../config/config.js')
const {getConfig} = require('../config/configuration')
const jwtSecret = common.jwtSecret || undefined


const roles = [
{ name: "Administrator", casGroup:"AGY-GSA-SRT-ADMINISTRATORS.ROLEMANAGEMENT", priority: 10},
{ name: "SRT Program Manager", casGroup: "AGY-GSA-SRT-PROGRAM-MANAGERS.ROLEMANAGEMENT", priority: 20},
Expand Down Expand Up @@ -152,15 +151,49 @@ function createUser(loginGovUser) {
}

function grabAgencyFromEmail(email) {
let agency_abbreviance = email.split('@')[1].split('.')[0]
// Extract the full domain from the email
const fullDomain = email.split('@')[1];

// Regex to check for a pattern like "@usss.dhs.gov"
// This matches domains in the format of "subdomain.agency.tld" where:
// - Subdomain and agency are alphanumeric with optional dots (e.g., "usss.dhs")
// - TLD is at least two characters long (e.g., "gov")
const regex = /^[a-z0-9]+(?:\.[a-z0-9]+)*\.[a-z]{2,}$/;

if (regex.test(fullDomain)) {
// Check the unique email mapping
const agencyName = common.UNIQUE_EMAIL_AGENCY_MAPPING[fullDomain];
if (agencyName) {
logger.log("info", "Matched agency from unique email mapping", {
email,
domain: fullDomain,
resolved: agencyName,
tag: 'grabAgencyFromEmail'
});
return agencyName;
}
}

var agencyName = translateCASAgencyName(agency_abbreviance)
// If no match in the unique mapping, fall back to original functionality
let agency_abbreviance = fullDomain.split('.')[0];
logger.log("info", "Extracting agency from email domain", {
email,
domain: agency_abbreviance,
tag: 'grabAgencyFromEmail'
});

let agencyName = translateCASAgencyName(agency_abbreviance);
logger.log("info", "Resolved agency name", {
abbreviation: agency_abbreviance,
resolved: agencyName,
tag: 'translateCASAgencyName'
});

if (!agencyName) {
logger.log("error", 'Agency name not found, update with User Admin Site', {tag:"grabAgencyFromEmail"})
agencyName = "No Agency Found"; // replace with your default value
logger.log("error", 'Agency name not found', { tag: "grabAgencyFromEmail" });
agencyName = "No Agency Found";
}

return agencyName;
}

Expand Down Expand Up @@ -395,8 +428,8 @@ function convertCASNamesToSRT (cas_userinfo) {
srt_userinfo['lastName'] = srt_userinfo['last-name']
delete srt_userinfo['last-name']

srt_userinfo['agency'] = translateCASAgencyName(srt_userinfo['org-agency-name'])
delete srt_userinfo['org-agency-name']
srt_userinfo['agency'] = grabAgencyFromEmail(srt_userinfo['email'])
delete srt_userinfo['email']

return srt_userinfo;
}
Expand Down
90 changes: 43 additions & 47 deletions server/routes/prediction.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -360,19 +360,17 @@ function normalizeMatchFilter(filter, field){
*/
/** @namespace filter.numDocs */
async function getPredictions (filter, user) {

try {
logger.debug("Starting getPredictions", { filter, userAgency: user?.agency, userRole: user?.userRole })

let first = filter.first || 0
let max_fetch_rows = filter.rows || configuration.getConfig("defaultMaxPredictions", 1000)


if ( user === undefined || user.agency === undefined || user.userRole === undefined ) {
logger.warn("Missing user information - returning empty result")
return []
}

logger.debug("Entering getPredictions")
// await updatePredictionTable()

let attributes = {
offset: first,
limit: max_fetch_rows,
Expand All @@ -382,108 +380,101 @@ async function getPredictions (filter, user) {
}],
}

// filter to allowed notice types
let types = configuration.getConfig("VisibleNoticeTypes", ['Solicitation', 'Combined Synopsis/Solicitation', 'RFQ'])
logger.debug("Filtering by notice types", { types })

attributes.where = {
noticeType: {
[Op.in]: types
}
}

// filter out rows
if (filter.globalFilter) {
logger.debug("Applying global filter", { searchText: filter.globalFilter.toLowerCase() })
attributes.where.searchText = { [Op.like]: `%${filter.globalFilter.toLowerCase()}%` }
}

for (let f of ['office', 'agency', 'title', 'solNum', 'reviewRec', 'id']) {
normalizeMatchFilter(filter, f)
}


// process PrimeNG filters: filter.filters = { field: { value: 'x', matchMode: 'equals' } }
if (filter.filters) {
logger.debug("Processing PrimeNG filters", { filters: filter.filters })
for (let f in filter.filters) {
if (filter.filters.hasOwnProperty(f) && filter.filters[f].matchMode === 'equals') {
attributes.where[f] = {[Op.eq]: filter.filters[f].value}
logger.debug(`Applied filter for field ${f}`, { value: filter.filters[f].value })
}
}
}


try {
let agency = (filter && filter.filters && filter.filters.agency && filter.filters.agency.value) || "no agency"
logger.log("debug", `Getting predictions for agency ${agency}. Remaining filters in meta data`, {tag: 'getPredictions', filter: filter })
} catch (e) {
logger.log ("error", "error logging prediction search filter", {error: e})
}

// process dates

// make sure anything we return is past the date cuttoff - unless we are asking for a specific record!
if ( ! filter.ignoreDateCutoff) {
if (!filter.ignoreDateCutoff) {
logger.debug("Applying date cutoff filters")
if ((!filter.filters) || (!filter.filters.hasOwnProperty('solNum'))) {
if (configuration.getConfig("minPredictionCutoffDate")) {
attributes.where.date = {[Op.gt]: configuration.getConfig("minPredictionCutoffDate")}
const cutoffDate = configuration.getConfig("minPredictionCutoffDate")
logger.debug("Using minPredictionCutoffDate", { cutoffDate })
attributes.where.date = {[Op.gt]: cutoffDate}
} else if (configuration.getConfig("predictionCutoffDays")) {
const numDays = configuration.getConfig("predictionCutoffDays")
const today = new Date()
let cutoff = new Date()
cutoff.setDate(today.getDate() - numDays)
logger.debug("Using predictionCutoffDays", { numDays, cutoffDate: cutoff })
attributes.where.date = {[Op.gt]: cutoff}
}
}
}



if (filter.startDate) {
// double check they aren't asking for data from before the cutoff
const start = Date.parse(filter.startDate)
const cutoff = Date.parse(configuration.getConfig("minPredictionCutoffDate", '1990-01-01'))
logger.debug("Processing start date filter", { startDate: filter.startDate, cutoffDate: cutoff })
if (start > cutoff) {
attributes.where.date = { [Op.gt]: filter.startDate }
}
}

if (filter.endDate) {
logger.debug("Processing end date filter", { endDate: filter.endDate })
attributes.where.date = (attributes.where.date) ?
Object.assign(attributes.where.date, { [Op.lt]: filter.endDate }) :
{ [Op.lt]: filter.endDate }
}

// finally, put in an agency filter if this user isn't an admin
// want to do it last so it overrides any possible agency setting in the supplied filter
if ( ! authRoutes.isGSAAdmin(user.agency, user.userRole)) {
attributes.where.agency = {
[Op.eq] : (user && user.agency) ? user.agency : ''
}
// Agency access control - check both agency and office fields
if (!authRoutes.isGSAAdmin(user.agency, user.userRole)) {
logger.debug("Restricting to user's agency and office", { agency: user.agency })
attributes.where[Op.or] = [
{ agency: { [Op.eq]: user.agency } },
{ office: { [Op.eq]: user.agency } }
]
} else {
logger.debug("GSA Admin detected - no agency restriction applied")
}

// set order
// Set order
attributes.order = []
if (filter.sortField !== 'unsorted' && filter.sortField) {
let direction = 'ASC';
if (filter.sortOrder && filter.sortOrder < 0) {
direction = 'DESC'
}
let direction = filter.sortOrder && filter.sortOrder < 0 ? 'DESC' : 'ASC'
logger.debug("Applying sort", { field: filter.sortField, direction })
attributes.order.push([filter.sortField, direction])
}

// always end with id sort to keep the newest first (all else being equal)
attributes.order.push(['id', 'DESC'])

attributes.raw = true // return as plan data not Sequelize object
attributes.raw = true
attributes.nest = true
// Debugging Queries:
//attributes.logging = console.log

// Removing where checks if values are not provided. where column = {} leads to sequelize issues
attributes.where = removeEmptyFrom(attributes.where)

// noinspection JSUnresolvedFunction
let preds = await Solicitation.findAndCountAll(attributes)

logger.debug("Final query attributes", { attributes })

let preds = await Solicitation.findAndCountAll(attributes)
logger.debug("Query complete", {
rowCount: preds.count,
firstRow: first,
maxRows: max_fetch_rows,
returnedRows: preds.rows.length
})

return {
predictions: preds.rows,
Expand All @@ -492,7 +483,12 @@ async function getPredictions (filter, user) {
totalCount: preds.count
}
} catch (e) {
logger.log("error", "Error in getPredictions", {tag: "getPredictions", error: e, "error-message": e.message, stack: e.stack})
logger.error("Error in getPredictions", {
error: e.message,
stack: e.stack,
filter,
userAgency: user?.agency
})
return {
predictions: [],
first: 0,
Expand Down
Loading

0 comments on commit 6ad74f9

Please sign in to comment.