diff --git a/packages/client/src/components/Modals/GrantDetails.vue b/packages/client/src/components/Modals/GrantDetails.vue index 935cca2ba..8322e356d 100644 --- a/packages/client/src/components/Modals/GrantDetails.vue +++ b/packages/client/src/components/Modals/GrantDetails.vue @@ -22,6 +22,10 @@
{{ titleize(field) }}: {{ selectedGrant[field] }}
+ Category of Funding Activity: + {{ selectedGrant['funding_activity_categories']?.join(', ') }} +
Description:
The Division of Earth Sciences (EAR) awards Postdoctoral Fellowships
', eligibility_codes: '25', + funding_activity_category_codes: 'ST', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -68,6 +69,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Health Aide Program for Covid
', eligibility_codes: '11 07 25', + funding_activity_category_codes: 'HL ISS', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-06 16:03:53.57025-07', @@ -90,6 +92,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666999
', eligibility_codes: '25', + funding_activity_category_codes: 'HL', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -112,6 +115,7 @@ const grants = [ opportunity_category: 'Discretionary', description: '', eligibility_codes: '', + funding_activity_category_codes: 'HL', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-06 16:03:53.57025-07', @@ -134,6 +138,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Health Aide Program for Covid
', eligibility_codes: '11 07 25', + funding_activity_category_codes: 'O', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-06 16:03:53.57025-07', @@ -156,6 +161,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666666
', eligibility_codes: '25', + funding_activity_category_codes: 'ENV', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -178,6 +184,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666656
', eligibility_codes: '25', + funding_activity_category_codes: 'ST', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -200,6 +207,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666646
', eligibility_codes: '25', + funding_activity_category_codes: 'ISS', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -222,6 +230,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666636
', eligibility_codes: '25', + funding_activity_category_codes: 'O', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -244,6 +253,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666626
', eligibility_codes: '25', + funding_activity_category_codes: 'AR', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -266,6 +276,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666616
', eligibility_codes: '25', + funding_activity_category_codes: 'AG', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -288,6 +299,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666606
', eligibility_codes: '25', + funding_activity_category_codes: 'ISS', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -310,6 +322,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666596
', eligibility_codes: '25', + funding_activity_category_codes: '', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -354,6 +367,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666576
', eligibility_codes: '25', + funding_activity_category_codes: 'NR', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -376,6 +390,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666566
', eligibility_codes: '25', + funding_activity_category_codes: 'HL', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -398,6 +413,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666556
', eligibility_codes: '25', + funding_activity_category_codes: 'O', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -420,6 +436,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666546
', eligibility_codes: '25', + funding_activity_category_codes: 'NR', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -442,6 +459,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666536
', eligibility_codes: '25', + funding_activity_category_codes: 'ED ELT EN IS ST', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -486,6 +504,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666516
', eligibility_codes: '25', + funding_activity_category_codes: 'HL', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -508,6 +527,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666506
', eligibility_codes: '25', + funding_activity_category_codes: 'HL', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -530,6 +550,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666496
', eligibility_codes: '25', + funding_activity_category_codes: 'O', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -552,6 +573,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666486
', eligibility_codes: '25', + funding_activity_category_codes: 'ENV', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -574,6 +596,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666476
', eligibility_codes: '25', + funding_activity_category_codes: 'ISS', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -596,6 +619,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666466
', eligibility_codes: '25', + funding_activity_category_codes: 'ED', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -618,6 +642,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666456
', eligibility_codes: '25', + funding_activity_category_codes: 'ED ELT', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -640,6 +665,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666446
', eligibility_codes: '25', + funding_activity_category_codes: 'ST', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -662,6 +688,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666436
', eligibility_codes: '25', + funding_activity_category_codes: 'O', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -684,6 +711,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666426
', eligibility_codes: '25', + funding_activity_category_codes: 'O', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -706,6 +734,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666427 - zero ceil
', eligibility_codes: '25', + funding_activity_category_codes: 'O', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', @@ -728,6 +757,7 @@ const grants = [ opportunity_category: 'Discretionary', description: 'Test Grant Description 666428 - null ceil
', eligibility_codes: '25', + funding_activity_category_codes: 'HL', opportunity_status: 'posted', raw_body: 'raw body', created_at: '2021-08-11 11:30:38.89828-07', diff --git a/packages/server/src/configure.js b/packages/server/src/configure.js index 3f575e873..3d8b330ee 100755 --- a/packages/server/src/configure.js +++ b/packages/server/src/configure.js @@ -20,6 +20,7 @@ function configureApiRoutes(app) { app.use('/api/organizations/:organizationId/grants-saved-search', require('./routes/grantsSavedSearch')); app.use('/api/organizations/:organizationId/dashboard', require('./routes/dashboard')); app.use('/api/organizations/:organizationId/eligibility-codes', require('./routes/eligibilityCodes')); + app.use('/api/organizations/:organizationId/search-config', require('./routes/searchConfig')); app.use('/api/organizations/:organizationId/interested-codes', require('./routes/interestedCodes')); app.use('/api/organizations/:organizationId/keywords', require('./routes/keywords')); app.use('/api/organizations/:organizationId/refresh', require('./routes/refresh')); diff --git a/packages/server/src/db/index.js b/packages/server/src/db/index.js index c47a3948e..6d7b5c251 100755 --- a/packages/server/src/db/index.js +++ b/packages/server/src/db/index.js @@ -22,6 +22,7 @@ const moment = require('moment'); const knex = require('./connection'); const { TABLES } = require('./constants'); const emailConstants = require('../lib/email/constants'); +const { fundingActivityCategoriesByCode } = require('../lib/fieldConfigs/fundingActivityCategories'); const helpers = require('./helpers'); async function getUsers(tenantId) { @@ -495,6 +496,10 @@ function buildKeywordQuery(queryBuilder, includeKeywords, excludeKeywords, order return Boolean(includeExpression); } +function matchAsWordRegex(word) { + return `\\m${word}\\M`; +} + function buildFiltersQuery(queryBuilder, filters, agencyId) { const statusMap = { Applied: 'Result', @@ -538,6 +543,10 @@ function buildFiltersQuery(queryBuilder, filters, agencyId) { const date = moment().subtract(filters.postedWithinDays, 'days').startOf('day').format('YYYY-MM-DD'); qb.where(`${TABLES.grants}.open_date`, '>=', date); } + if (filters.fundingActivityCategories?.length) { + qb.where('funding_activity_category_codes', '~*', + filters.fundingActivityCategories.map(matchAsWordRegex).join('|')); + } }, ); } @@ -584,6 +593,7 @@ function grantsQuery(queryBuilder, filters, agencyId, orderingParams, pagination } } +// Convert saved search criteria to db query filters function formatSearchCriteriaToQueryFilters(criteria) { const parsedCriteria = JSON.parse(criteria); const postedWithinOptions = { @@ -614,6 +624,10 @@ function formatSearchCriteriaToQueryFilters(criteria) { filters.eligibilityCodes = parsedCriteria.eligibility.map((e) => e.code); delete parsedCriteria.eligibility; } + if (parsedCriteria.fundingActivityCategories) { + filters.fundingActivityCategories = parsedCriteria.fundingActivityCategories.map((c) => c.code); + delete parsedCriteria.fundingActivityCategories; + } filters = { ...filters, ...parsedCriteria }; return filters; @@ -623,6 +637,7 @@ function validateSearchFilters(filters) { const filterOptionsByType = { reviewStatuses: { type: 'List', valueType: 'Enum', values: ['Applied', 'Not Applying', 'Interested'] }, eligibilityCodes: { type: 'List', valueType: 'String' }, + fundingActivityCategories: { type: 'List', valueType: 'String' }, includeKeywords: { type: 'List', valueType: 'String' }, excludeKeywords: { type: 'List', valueType: 'String' }, opportunityNumber: { type: 'String', valueType: 'Any' }, @@ -769,6 +784,7 @@ async function getGrantsNew(filters, paginationParams, orderingParams, tenantId, 'grants.description_ts', 'grants.funding_instrument_codes', 'grants.bill', + 'grants.funding_activity_category_codes', ]) .select(knex.raw(` CASE @@ -811,6 +827,7 @@ async function getGrantsNew(filters, paginationParams, orderingParams, tenantId, 'grants.description_ts', 'grants.funding_instrument_codes', 'grants.bill', + 'grants.funding_activity_category_codes', ); if (toCsv) { query.modify(addCsvData); @@ -824,12 +841,14 @@ async function getGrantsNew(filters, paginationParams, orderingParams, tenantId, lastPage: Math.ceil(parseInt(fullCount, 10) / parseInt(paginationParams.perPage, 10)), }; - const dataWithAgency = await enhanceGrantData(tenantId, data); + const enhancedData = await enhanceGrantData(tenantId, data); - return { data: dataWithAgency, pagination }; + return { data: enhancedData, pagination }; } async function enhanceGrantData(tenantId, data) { + if (!data.length) return []; + const viewedByQuery = knex(TABLES.agencies) .join(TABLES.grants_viewed, `${TABLES.agencies}.id`, '=', `${TABLES.grants_viewed}.agency_id`) .whereIn('grant_id', data.map((grant) => grant.grant_id)) @@ -843,7 +862,7 @@ async function enhanceGrantData(tenantId, data) { ); const interestedBy = await getInterestedAgencies({ grantIds: data.map((grant) => grant.grant_id), tenantId }); - const dataWithAgency = data.map((grant) => { + const enhancedData = data.map((grant) => { const viewedByAgencies = viewedBy.filter((viewed) => viewed.grant_id === grant.grant_id); const agenciesInterested = interestedBy.filter((interested) => interested.grant_id === grant.grant_id); return { @@ -851,10 +870,14 @@ async function enhanceGrantData(tenantId, data) { etitle: decodeURIComponent(escape(grant.title)), viewed_by_agencies: viewedByAgencies, interested_agencies: agenciesInterested, + funding_activity_categories: (grant.funding_activity_category_codes || '') + .split(' ') + .map((code) => fundingActivityCategoriesByCode[code]?.name) + .filter(Boolean), }; }); - return dataWithAgency; + return enhancedData; } async function getGrants({ @@ -1025,23 +1048,8 @@ async function getSingleGrantDetails({ grantId, tenantId }) { const results = await knex.table(TABLES.grants) .select('*') .where({ grant_id: grantId }); - - const viewedBy = await knex(TABLES.agencies) - .join(TABLES.grants_viewed, `${TABLES.agencies}.id`, '=', `${TABLES.grants_viewed}.agency_id`) - .whereIn('grant_id', [grantId]) - .andWhere('tenant_id', tenantId) - .select(`${TABLES.grants_viewed}.grant_id`, `${TABLES.grants_viewed}.agency_id`, `${TABLES.agencies}.name as agency_name`, `${TABLES.agencies}.abbreviation as agency_abbreviation`); - - const interestedBy = await getInterestedAgencies({ grantIds: [grantId], tenantId }); - - const viewedByAgencies = viewedBy.filter((viewed) => viewed.grant_id === grantId); - const agenciesInterested = interestedBy.filter((interested) => interested.grant_id === grantId); - - return { - ...(results[0]), - viewed_by_agencies: viewedByAgencies, - interested_agencies: agenciesInterested, - }; + const enhancedResults = await enhanceGrantData(tenantId, results); + return enhancedResults.length ? enhancedResults[0] : null; } async function getClosestGrants({ diff --git a/packages/server/src/lib/fieldConfigs/fundingActivityCategories.js b/packages/server/src/lib/fieldConfigs/fundingActivityCategories.js new file mode 100644 index 000000000..3b49ee400 --- /dev/null +++ b/packages/server/src/lib/fieldConfigs/fundingActivityCategories.js @@ -0,0 +1,38 @@ +// Corresponds to "Category" facet on grants.gov. +const fundingActivityCategories = [ + { name: 'Affordable Care Act', code: 'ACA' }, + { name: 'Agriculture', code: 'AG' }, + { name: 'Arts', code: 'AR' }, + { name: 'Business and Commerce', code: 'BC' }, + { name: 'Community Development', code: 'CD' }, + { name: 'Consumer Protection', code: 'CP' }, + { name: 'Disaster Prevention and Relief', code: 'DPR' }, + { name: 'Education', code: 'ED' }, + { name: 'Employment, Labor and Training', code: 'ELT' }, + { name: 'Energy', code: 'EN' }, + { name: 'Environment', code: 'ENV' }, + { name: 'Food and Nutrition', code: 'FN' }, + { name: 'Health', code: 'HL' }, + { name: 'Housing', code: 'HO' }, + { name: 'Humanities', code: 'HU' }, + { name: 'Income Security and Social Services', code: 'ISS' }, + { name: 'Information and Statistics', code: 'IS' }, + { name: 'Infrastructure Investment and Jobs Act', code: 'IIJ' }, + { name: 'Law, Justice and Legal Services', code: 'LJL' }, + { name: 'Natural Resources', code: 'NR' }, + { name: 'Opportunity Zone Benefits', code: 'OZ' }, + { name: 'Other', code: 'O' }, + { name: 'Recovery Act', code: 'RA' }, + { name: 'Regional Development', code: 'RD' }, + { + name: 'Science and Technology and Other Research and Development', + code: 'ST', + }, + { name: 'Transportation', code: 'T' }, +]; + +const fundingActivityCategoriesByCode = fundingActivityCategories.reduce( + (obj, item) => Object.assign(obj, { [item.code]: item }), {}, +); + +module.exports = { fundingActivityCategories, fundingActivityCategoriesByCode }; diff --git a/packages/server/src/lib/grants-ingest.js b/packages/server/src/lib/grants-ingest.js index 14ed7cabf..db326641b 100644 --- a/packages/server/src/lib/grants-ingest.js +++ b/packages/server/src/lib/grants-ingest.js @@ -39,6 +39,7 @@ function mapSourceDataToGrant(source) { opportunity_category: source.opportunity.category.name, cfda_list: (source.cfda_numbers || []).join(', '), eligibility_codes: (source.eligible_applicants || []).map((it) => it.code).join(' '), + funding_activity_category_codes: (source.funding_activity?.categories || []).map((it) => it.code).join(' '), award_ceiling: source.award && source.award.ceiling ? source.award.ceiling : undefined, award_floor: source.award && source.award.floor ? source.award.floor : undefined, raw_body: JSON.stringify(source), diff --git a/packages/server/src/routes/grants.js b/packages/server/src/routes/grants.js index 8d4a298a6..3dc958cc8 100755 --- a/packages/server/src/routes/grants.js +++ b/packages/server/src/routes/grants.js @@ -51,6 +51,7 @@ function criteriaToFiltersObj(criteria, agencyId) { return { reviewStatuses: filters.reviewStatus?.split(',').filter((r) => r !== 'Assigned').map((r) => r.trim()) || [], eligibilityCodes: filters.eligibility?.split(',') || [], + fundingActivityCategories: filters.fundingActivityCategories?.split(',') || [], includeKeywords: filters.includeKeywords?.split(',').map((k) => k.trim()) || [], excludeKeywords: filters.excludeKeywords?.split(',').map((k) => k.trim()) || [], opportunityNumber: filters.opportunityNumber || '', @@ -128,6 +129,7 @@ router.get('/exportCSVNew', requireUser, async (req, res) => { // Generate CSV const formattedData = data.map((grant) => ({ ...grant, + funding_activity_categories: grant.funding_activity_categories.join('|'), interested_agencies: grant.interested_agencies .map((v) => v.agency_abbreviation) .join(', '), @@ -170,6 +172,7 @@ router.get('/exportCSVNew', requireUser, async (req, res) => { { key: 'bill', header: 'Appropriations Bill' }, { key: 'agency_code', header: 'Agency Code' }, { key: 'eligibility', header: 'Eligibility' }, + { key: 'funding_activity_categories', header: 'Category of Funding Activity' }, ], }); diff --git a/packages/server/src/routes/searchConfig.js b/packages/server/src/routes/searchConfig.js new file mode 100644 index 000000000..127135228 --- /dev/null +++ b/packages/server/src/routes/searchConfig.js @@ -0,0 +1,17 @@ +const express = require('express'); + +const router = express.Router({ mergeParams: true }); +const { fundingActivityCategories } = require('../lib/fieldConfigs/fundingActivityCategories'); +const db = require('../db'); +const { requireUser } = require('../lib/access-helpers'); + +router.get('/', requireUser, async (req, res) => { + const eligibilityCodes = await db.getAgencyEligibilityCodes(req.session.selectedAgency); + eligibilityCodes.forEach((ec) => { + delete ec.created_at; + delete ec.updated_at; + }); + res.json({ eligibilityCodes, fundingActivityCategories }); +}); + +module.exports = router;