Skip to content

Commit 6ad74f9

Browse files
Merge pull request #193 from GSA/office_filter
Office filter
2 parents ea201e2 + 03eeb7b commit 6ad74f9

5 files changed

Lines changed: 394 additions & 68 deletions

File tree

server/config/config.js

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,123 @@ module.exports = {
9090
"NA_ACTION": "Solicitation marked not applicable",
9191
"UNDO_NA_ACTION": "Not applicable status removed"
9292
},
93+
AGENCY_HIERARCHY: {
94+
'Department of Defense': {
95+
offices: ['DEPARTMENT OF THE ARMY', 'DEPARTMENT OF THE NAVY', 'DEPARTMENT OF THE AIR FORCE', 'SPACE FORCE', 'DEFENSE LOGISTICS AGENCY'],
96+
variations: {
97+
'DEPARTMENT OF THE ARMY': {
98+
aliases: ['DEPT OF THE ARMY', 'US ARMY', 'ARMY'],
99+
email_domains: ['army.mil']
100+
},
101+
'DEPARTMENT OF THE NAVY': {
102+
aliases: ['DEPT OF THE NAVY', 'US NAVY', 'NAVY'],
103+
email_domains: ['navy.mil', 'us.navy.mil']
104+
},
105+
'DEPARTMENT OF THE AIR FORCE': {
106+
aliases: ['DEPT OF THE AIR FORCE', 'US AIR FORCE', 'AIR FORCE'],
107+
email_domains: ['af.mil', 'us.af.mil']
108+
},
109+
'SPACE FORCE': {
110+
aliases: ['US SPACE FORCE', 'USSF'],
111+
email_domains: ['spaceforce.mil']
112+
},
113+
'DEFENSE LOGISTICS AGENCY': {
114+
aliases: ['DLA'],
115+
email_domains: ['dla.mil']
116+
}
117+
}
118+
},
119+
'Department of Health and Human Services': {
120+
offices: ['NATIONAL INSTITUTES OF HEALTH', 'FOOD AND DRUG ADMINISTRATION', 'INDIAN HEALTH SERVICE', 'CENTERS FOR MEDICARE & MEDICAID SERVICES'],
121+
variations: {
122+
'NATIONAL INSTITUTES OF HEALTH': {
123+
aliases: ['NIH'],
124+
email_domains: ['nih.gov']
125+
},
126+
'FOOD AND DRUG ADMINISTRATION': {
127+
aliases: ['FDA'],
128+
email_domains: ['fda.hhs.gov']
129+
},
130+
'INDIAN HEALTH SERVICE': {
131+
aliases: ['IHS'],
132+
email_domains: ['ihs.gov']
133+
},
134+
'CENTERS FOR MEDICARE & MEDICAID SERVICES': {
135+
aliases: ['CMS'],
136+
email_domains: ['cms.hhs.gov']
137+
}
138+
}
139+
},
140+
'Department of Homeland Security': {
141+
offices: ['FEDERAL EMERGENCY MANAGEMENT AGENCY', 'US CITIZENSHIP AND IMMIGRATION SERVICES', 'US SECRET SERVICE'],
142+
variations: {
143+
'FEDERAL EMERGENCY MANAGEMENT AGENCY': {
144+
aliases: ['FEMA'],
145+
email_domains: ['fema.dhs.gov']
146+
},
147+
'US CITIZENSHIP AND IMMIGRATION SERVICES': {
148+
aliases: ['USCIS'],
149+
email_domains: ['uscis.dhs.gov']
150+
},
151+
'US SECRET SERVICE': {
152+
aliases: ['USSS', 'SECRET SERVICE'],
153+
email_domains: ['usss.dhs.gov']
154+
}
155+
}
156+
},
157+
'Department of Commerce': {
158+
offices: ['NATIONAL OCEANIC AND ATMOSPHERIC ADMINISTRATION', 'NATIONAL TELECOMMUNICATIONS AND INFORMATION ADMINISTRATION'],
159+
variations: {
160+
'NATIONAL OCEANIC AND ATMOSPHERIC ADMINISTRATION': {
161+
aliases: ['NOAA'],
162+
email_domains: ['noaa.gov']
163+
},
164+
'NATIONAL TELECOMMUNICATIONS AND INFORMATION ADMINISTRATION': {
165+
aliases: ['NTIA'],
166+
email_domains: ['ntia']
167+
}
168+
}
169+
},
170+
'Department of the Interior': {
171+
offices: ['NATIONAL PARK SERVICE', 'FISH AND WILDLIFE SERVICE', 'BUREAU OF OCEAN ENERGY MANAGEMENT'],
172+
variations: {
173+
'NATIONAL PARK SERVICE': {
174+
aliases: ['NPS'],
175+
email_domains: ['nps.gov']
176+
},
177+
'FISH AND WILDLIFE SERVICE': {
178+
aliases: ['FWS', 'FISH AND WILDLIFE'],
179+
email_domains: ['fws.gov']
180+
},
181+
'BUREAU OF OCEAN ENERGY MANAGEMENT': {
182+
aliases: ['BOEM'],
183+
email_domains: ['boem.gov']
184+
}
185+
}
186+
},
187+
'Department of the Treasury': {
188+
offices: ['INTERNAL REVENUE SERVICE', 'US MINT'],
189+
variations: {
190+
'INTERNAL REVENUE SERVICE': {
191+
aliases: ['IRS'],
192+
email_domains: ['irs.gov']
193+
},
194+
'US MINT': {
195+
aliases: ['MINT'],
196+
email_domains: ['usmint.treas.gov']
197+
}
198+
}
199+
}
200+
},
201+
UNIQUE_EMAIL_AGENCY_MAPPING: {
202+
'usss.dhs.gov': 'US SECRET SERVICE',
203+
'fema.dhs.gov': 'FEDERAL EMERGENCY MANAGEMENT AGENCY',
204+
'uscis.dhs.gov': 'US CITIZENSHIP AND IMMIGRATION SERVICES',
205+
'cms.hhs.gov': 'CENTERS FOR MEDICARE & MEDICAID SERVICES',
206+
'fda.hhs.gov': 'FOOD AND DRUG ADMINISTRATION',
207+
'us.af.mil': 'DEPT OF THE AIR FORCE',
208+
'us.navy.mil': 'DEPT OF THE NAVY'
209+
},
93210
// keys for agency look should be all lower case
94211
AGENCY_LOOKUP: {
95212
"department of test": "TEST, DEPARTMENT OF",
@@ -249,7 +366,15 @@ module.exports = {
249366
"vets": "Veterans' Employment and Training Service",
250367
"vha": "Veterans Health Administration",
251368
"voa": "Voice of America",
252-
"washington, dc": "District of Columbia"
369+
"washington, dc": "District of Columbia",
370+
"army": "DEPT OF THE ARMY",
371+
"navy": "DEPT OF THE NAVY",
372+
"af": "DEPT OF THE AIR FORCE",
373+
"spaceforce": "SPACE FORCE",
374+
"dla": "DEFENSE LOGISTICS AGENCY",
375+
"ihs": "INDIAN HEALTH SERVICE",
376+
"usss": "US SECRET SERVICE",
377+
"usmint": "US MINT"
253378
},
254379

255380
// AGENCY_LOOKUP: {

server/routes/auth.routes.js

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ const {common} = require('../config/config.js')
1818
const {getConfig} = require('../config/configuration')
1919
const jwtSecret = common.jwtSecret || undefined
2020

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

154153
function grabAgencyFromEmail(email) {
155-
let agency_abbreviance = email.split('@')[1].split('.')[0]
154+
// Extract the full domain from the email
155+
const fullDomain = email.split('@')[1];
156+
157+
// Regex to check for a pattern like "@usss.dhs.gov"
158+
// This matches domains in the format of "subdomain.agency.tld" where:
159+
// - Subdomain and agency are alphanumeric with optional dots (e.g., "usss.dhs")
160+
// - TLD is at least two characters long (e.g., "gov")
161+
const regex = /^[a-z0-9]+(?:\.[a-z0-9]+)*\.[a-z]{2,}$/;
162+
163+
if (regex.test(fullDomain)) {
164+
// Check the unique email mapping
165+
const agencyName = common.UNIQUE_EMAIL_AGENCY_MAPPING[fullDomain];
166+
if (agencyName) {
167+
logger.log("info", "Matched agency from unique email mapping", {
168+
email,
169+
domain: fullDomain,
170+
resolved: agencyName,
171+
tag: 'grabAgencyFromEmail'
172+
});
173+
return agencyName;
174+
}
175+
}
156176

157-
var agencyName = translateCASAgencyName(agency_abbreviance)
177+
// If no match in the unique mapping, fall back to original functionality
178+
let agency_abbreviance = fullDomain.split('.')[0];
179+
logger.log("info", "Extracting agency from email domain", {
180+
email,
181+
domain: agency_abbreviance,
182+
tag: 'grabAgencyFromEmail'
183+
});
184+
185+
let agencyName = translateCASAgencyName(agency_abbreviance);
186+
logger.log("info", "Resolved agency name", {
187+
abbreviation: agency_abbreviance,
188+
resolved: agencyName,
189+
tag: 'translateCASAgencyName'
190+
});
158191

159192
if (!agencyName) {
160-
logger.log("error", 'Agency name not found, update with User Admin Site', {tag:"grabAgencyFromEmail"})
161-
agencyName = "No Agency Found"; // replace with your default value
193+
logger.log("error", 'Agency name not found', { tag: "grabAgencyFromEmail" });
194+
agencyName = "No Agency Found";
162195
}
163-
196+
164197
return agencyName;
165198
}
166199

@@ -395,8 +428,8 @@ function convertCASNamesToSRT (cas_userinfo) {
395428
srt_userinfo['lastName'] = srt_userinfo['last-name']
396429
delete srt_userinfo['last-name']
397430

398-
srt_userinfo['agency'] = translateCASAgencyName(srt_userinfo['org-agency-name'])
399-
delete srt_userinfo['org-agency-name']
431+
srt_userinfo['agency'] = grabAgencyFromEmail(srt_userinfo['email'])
432+
delete srt_userinfo['email']
400433

401434
return srt_userinfo;
402435
}

server/routes/prediction.routes.js

Lines changed: 43 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -360,19 +360,17 @@ function normalizeMatchFilter(filter, field){
360360
*/
361361
/** @namespace filter.numDocs */
362362
async function getPredictions (filter, user) {
363-
364363
try {
364+
logger.debug("Starting getPredictions", { filter, userAgency: user?.agency, userRole: user?.userRole })
365+
365366
let first = filter.first || 0
366367
let max_fetch_rows = filter.rows || configuration.getConfig("defaultMaxPredictions", 1000)
367368

368-
369369
if ( user === undefined || user.agency === undefined || user.userRole === undefined ) {
370+
logger.warn("Missing user information - returning empty result")
370371
return []
371372
}
372373

373-
logger.debug("Entering getPredictions")
374-
// await updatePredictionTable()
375-
376374
let attributes = {
377375
offset: first,
378376
limit: max_fetch_rows,
@@ -382,108 +380,101 @@ async function getPredictions (filter, user) {
382380
}],
383381
}
384382

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

388386
attributes.where = {
389387
noticeType: {
390388
[Op.in]: types
391389
}
392390
}
393391

394-
// filter out rows
395392
if (filter.globalFilter) {
393+
logger.debug("Applying global filter", { searchText: filter.globalFilter.toLowerCase() })
396394
attributes.where.searchText = { [Op.like]: `%${filter.globalFilter.toLowerCase()}%` }
397395
}
396+
398397
for (let f of ['office', 'agency', 'title', 'solNum', 'reviewRec', 'id']) {
399398
normalizeMatchFilter(filter, f)
400399
}
401400

402-
403-
// process PrimeNG filters: filter.filters = { field: { value: 'x', matchMode: 'equals' } }
404401
if (filter.filters) {
402+
logger.debug("Processing PrimeNG filters", { filters: filter.filters })
405403
for (let f in filter.filters) {
406404
if (filter.filters.hasOwnProperty(f) && filter.filters[f].matchMode === 'equals') {
407405
attributes.where[f] = {[Op.eq]: filter.filters[f].value}
406+
logger.debug(`Applied filter for field ${f}`, { value: filter.filters[f].value })
408407
}
409408
}
410409
}
411410

412-
413-
try {
414-
let agency = (filter && filter.filters && filter.filters.agency && filter.filters.agency.value) || "no agency"
415-
logger.log("debug", `Getting predictions for agency ${agency}. Remaining filters in meta data`, {tag: 'getPredictions', filter: filter })
416-
} catch (e) {
417-
logger.log ("error", "error logging prediction search filter", {error: e})
418-
}
419-
420-
// process dates
421-
422-
// make sure anything we return is past the date cuttoff - unless we are asking for a specific record!
423-
if ( ! filter.ignoreDateCutoff) {
411+
if (!filter.ignoreDateCutoff) {
412+
logger.debug("Applying date cutoff filters")
424413
if ((!filter.filters) || (!filter.filters.hasOwnProperty('solNum'))) {
425414
if (configuration.getConfig("minPredictionCutoffDate")) {
426-
attributes.where.date = {[Op.gt]: configuration.getConfig("minPredictionCutoffDate")}
415+
const cutoffDate = configuration.getConfig("minPredictionCutoffDate")
416+
logger.debug("Using minPredictionCutoffDate", { cutoffDate })
417+
attributes.where.date = {[Op.gt]: cutoffDate}
427418
} else if (configuration.getConfig("predictionCutoffDays")) {
428419
const numDays = configuration.getConfig("predictionCutoffDays")
429420
const today = new Date()
430421
let cutoff = new Date()
431422
cutoff.setDate(today.getDate() - numDays)
423+
logger.debug("Using predictionCutoffDays", { numDays, cutoffDate: cutoff })
432424
attributes.where.date = {[Op.gt]: cutoff}
433425
}
434426
}
435427
}
436428

437-
438-
439429
if (filter.startDate) {
440-
// double check they aren't asking for data from before the cutoff
441430
const start = Date.parse(filter.startDate)
442431
const cutoff = Date.parse(configuration.getConfig("minPredictionCutoffDate", '1990-01-01'))
432+
logger.debug("Processing start date filter", { startDate: filter.startDate, cutoffDate: cutoff })
443433
if (start > cutoff) {
444434
attributes.where.date = { [Op.gt]: filter.startDate }
445435
}
446436
}
447437

448438
if (filter.endDate) {
439+
logger.debug("Processing end date filter", { endDate: filter.endDate })
449440
attributes.where.date = (attributes.where.date) ?
450441
Object.assign(attributes.where.date, { [Op.lt]: filter.endDate }) :
451442
{ [Op.lt]: filter.endDate }
452443
}
453444

454-
// finally, put in an agency filter if this user isn't an admin
455-
// want to do it last so it overrides any possible agency setting in the supplied filter
456-
if ( ! authRoutes.isGSAAdmin(user.agency, user.userRole)) {
457-
attributes.where.agency = {
458-
[Op.eq] : (user && user.agency) ? user.agency : ''
459-
}
445+
// Agency access control - check both agency and office fields
446+
if (!authRoutes.isGSAAdmin(user.agency, user.userRole)) {
447+
logger.debug("Restricting to user's agency and office", { agency: user.agency })
448+
attributes.where[Op.or] = [
449+
{ agency: { [Op.eq]: user.agency } },
450+
{ office: { [Op.eq]: user.agency } }
451+
]
452+
} else {
453+
logger.debug("GSA Admin detected - no agency restriction applied")
460454
}
461455

462-
// set order
456+
// Set order
463457
attributes.order = []
464458
if (filter.sortField !== 'unsorted' && filter.sortField) {
465-
let direction = 'ASC';
466-
if (filter.sortOrder && filter.sortOrder < 0) {
467-
direction = 'DESC'
468-
}
459+
let direction = filter.sortOrder && filter.sortOrder < 0 ? 'DESC' : 'ASC'
460+
logger.debug("Applying sort", { field: filter.sortField, direction })
469461
attributes.order.push([filter.sortField, direction])
470462
}
471-
472-
// always end with id sort to keep the newest first (all else being equal)
473463
attributes.order.push(['id', 'DESC'])
474464

475-
attributes.raw = true // return as plan data not Sequelize object
465+
attributes.raw = true
476466
attributes.nest = true
477-
// Debugging Queries:
478-
//attributes.logging = console.log
479467

480-
// Removing where checks if values are not provided. where column = {} leads to sequelize issues
481468
attributes.where = removeEmptyFrom(attributes.where)
482-
483-
// noinspection JSUnresolvedFunction
484-
let preds = await Solicitation.findAndCountAll(attributes)
485-
469+
logger.debug("Final query attributes", { attributes })
486470

471+
let preds = await Solicitation.findAndCountAll(attributes)
472+
logger.debug("Query complete", {
473+
rowCount: preds.count,
474+
firstRow: first,
475+
maxRows: max_fetch_rows,
476+
returnedRows: preds.rows.length
477+
})
487478

488479
return {
489480
predictions: preds.rows,
@@ -492,7 +483,12 @@ async function getPredictions (filter, user) {
492483
totalCount: preds.count
493484
}
494485
} catch (e) {
495-
logger.log("error", "Error in getPredictions", {tag: "getPredictions", error: e, "error-message": e.message, stack: e.stack})
486+
logger.error("Error in getPredictions", {
487+
error: e.message,
488+
stack: e.stack,
489+
filter,
490+
userAgency: user?.agency
491+
})
496492
return {
497493
predictions: [],
498494
first: 0,

0 commit comments

Comments
 (0)