From 7e82e9b7cb7dced9daa990e96ba318d11db773b3 Mon Sep 17 00:00:00 2001 From: Shelley Nason Date: Tue, 28 Nov 2023 10:42:48 -0600 Subject: [PATCH] feat: add category of funding activity as search field --- .../src/components/Modals/GrantDetails.vue | 4 ++ .../src/components/Modals/SearchPanel.vue | 23 ++++++++- packages/client/src/helpers/filters.js | 3 ++ packages/client/src/store/modules/grants.js | 12 +++++ packages/server/__tests__/api/grants.test.js | 2 + packages/server/__tests__/db/db.test.js | 27 ++++++++++ .../server/__tests__/db/seeds/fixtures.js | 15 ++++++ .../__tests__/lib/grants-ingest.test.js | 13 +++++ ...4_add_funding_category_column_to_grants.js | 19 +++++++ packages/server/seeds/dev/ref/grants.js | 30 +++++++++++ packages/server/src/configure.js | 1 + packages/server/src/db/index.js | 50 +++++++++++-------- .../fieldConfigs/fundingActivityCategories.js | 38 ++++++++++++++ packages/server/src/lib/grants-ingest.js | 1 + packages/server/src/routes/grants.js | 3 ++ packages/server/src/routes/searchConfig.js | 17 +++++++ 16 files changed, 235 insertions(+), 23 deletions(-) create mode 100644 packages/server/migrations/20231117213914_add_funding_category_column_to_grants.js create mode 100644 packages/server/src/lib/fieldConfigs/fundingActivityCategories.js create mode 100644 packages/server/src/routes/searchConfig.js 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:

diff --git a/packages/client/src/components/Modals/SearchPanel.vue b/packages/client/src/components/Modals/SearchPanel.vue index d8509f79d..8e7d59de8 100644 --- a/packages/client/src/components/Modals/SearchPanel.vue +++ b/packages/client/src/components/Modals/SearchPanel.vue @@ -156,6 +156,23 @@ :show-labels="false" /> + + + i.label).join(' or '); } else if (key === 'fundingTypes') { newVal = value.map((i) => i.name).join(' or '); + } else if (key === 'fundingActivityCategories') { + newVal = value.map((i) => i.name).join(' or '); } filters.push({ diff --git a/packages/client/src/store/modules/grants.js b/packages/client/src/store/modules/grants.js index 2a49f3dd6..7bafb2e3b 100644 --- a/packages/client/src/store/modules/grants.js +++ b/packages/client/src/store/modules/grants.js @@ -13,6 +13,7 @@ function initialState() { return { grantsPaginated: {}, eligibilityCodes: [], + fundingActivityCategories: [], interestedCodes: [], grantsInterested: [], closestGrants: [], @@ -53,6 +54,7 @@ function buildGrantsNextQuery({ filters, ordering, pagination }) { postedWithin reviewStatus bill + fundingActivityCategories */ const criteria = { ...filters }; // Validate and fix the inputs into appropriate types. @@ -61,6 +63,7 @@ function buildGrantsNextQuery({ filters, ordering, pagination }) { criteria.eligibility = criteria.eligibility?.map((e) => e.code); criteria.fundingTypes = criteria.fundingTypes?.map((f) => f.code); criteria.bill = criteria.bill === 'All Bills' ? null : criteria.bill; + criteria.fundingActivityCategories = criteria.fundingActivityCategories?.map((c) => c.code); if (!criteria.opportunityStatuses || criteria.opportunityStatuses.length === 0) { // by default, only show posted opportunities @@ -99,6 +102,7 @@ export default { totalInterestedGrants: (state) => state.totalInterestedGrants, currentGrant: (state) => state.currentGrant, eligibilityCodes: (state) => state.eligibilityCodes, + fundingActivityCategories: (state) => state.fundingActivityCategories, interestedCodes: (state) => ({ rejections: state.interestedCodes.filter((c) => c.status_code === 'Rejected'), result: state.interestedCodes.filter((c) => c.status_code === 'Result'), @@ -206,6 +210,10 @@ export default { fetchApi.get('/api/organizations/:organizationId/eligibility-codes') .then((data) => commit('SET_ELIGIBILITY_CODES', data)); }, + fetchSearchConfig({ commit }) { + fetchApi.get('/api/organizations/:organizationId/search-config') + .then((data) => commit('SET_SEARCH_CONFIG', data)); + }, fetchInterestedCodes({ commit }) { fetchApi.get('/api/organizations/:organizationId/interested-codes') .then((data) => commit('SET_INTERESTED_CODES', data)); @@ -300,6 +308,10 @@ export default { SET_ELIGIBILITY_CODES(state, eligibilityCodes) { state.eligibilityCodes = eligibilityCodes; }, + SET_SEARCH_CONFIG(state, searchConfig) { + state.eligibilityCodes = searchConfig.eligibilityCodes; + state.fundingActivityCategories = searchConfig.fundingActivityCategories; + }, SET_INTERESTED_CODES(state, interestedCodes) { state.interestedCodes = interestedCodes; }, diff --git a/packages/server/__tests__/api/grants.test.js b/packages/server/__tests__/api/grants.test.js index 2e9ab3468..41ee4e3a7 100644 --- a/packages/server/__tests__/api/grants.test.js +++ b/packages/server/__tests__/api/grants.test.js @@ -491,6 +491,7 @@ describe('`/api/grants` endpoint', () => { 'Appropriations Bill', 'Agency Code', 'Eligibility', + 'Category of Funding Activity', ]; const txt = await response.text(); @@ -505,6 +506,7 @@ describe('`/api/grants` endpoint', () => { expect(valMap.get('Funding Type')).to.equal('Other'); expect(valMap.get('Agency Code')).to.equal('HHS-IHS'); expect(valMap.get('Eligibility')).to.equal('"Native American tribal organizations (other than Federally recognized tribal governments)|Others(see text field entitled ""Additional Information on Eligibility"" for clarification)|Native American tribal governments(Federally recognized)"'); + expect(valMap.get('Category of Funding Activity')).to.equal('Health|Income Security and Social Services'); }); it('produces same number of rows as grid', async () => { diff --git a/packages/server/__tests__/db/db.test.js b/packages/server/__tests__/db/db.test.js index 992e198da..ebd88e71d 100644 --- a/packages/server/__tests__/db/db.test.js +++ b/packages/server/__tests__/db/db.test.js @@ -406,12 +406,27 @@ describe('db', () => { const result = await db.getSingleGrantDetails({ grantId, tenantId: fixtures.users.staffUser.tenant_id }); expect(result.interested_agencies.length).to.equal(1); }); + it('gets the viewed by agencies', async () => { + const grantId = '335255'; + const result = await db.getSingleGrantDetails({ grantId, tenantId: fixtures.users.staffUser.tenant_id }); + expect(result.viewed_by_agencies.length).to.equal(1); + }); + it('maps funding activity category codes to funding activity category names', async () => { + const grantId = '335255'; + const result = await db.getSingleGrantDetails({ grantId, tenantId: fixtures.users.staffUser.tenant_id }); + expect(result.funding_activity_categories).to.deep.equal(['Income Security and Social Services']); + }); it('returns dates in string format without timezone', async () => { const grantId = '335255'; const result = await db.getSingleGrantDetails({ grantId, tenantId: fixtures.users.staffUser.tenant_id }); expect(result.open_date).to.equal('2021-08-11'); expect(result.close_date).to.equal('2021-11-03'); }); + it('handles grant not found', async () => { + const grantId = '435255'; + const result = await db.getSingleGrantDetails({ grantId, tenantId: fixtures.users.staffUser.tenant_id }); + expect(result).to.be.null; + }); }); context('getGrantsAssignedAgency', () => { @@ -710,6 +725,18 @@ describe('db', () => { expect(result.data[4].award_ceiling).to.be.null; expect(result.data[5].award_ceiling).to.be.null; }); + it('gets grants that have any of the funding activity category codes', async () => { + const result = await db.getGrantsNew( + { fundingActivityCategories: ['IS', 'ST'] }, + { currentPage: 1, perPage: 10, isLengthAware: true }, + { orderBy: 'open_date', orderDesc: 'true' }, + fixtures.tenants.SBA.id, + fixtures.agencies.accountancy.id, + ); + expect(result).to.have.property('data').with.lengthOf(2); + expect(result.data[0].grant_id).to.equal(fixtures.grants.redefiningPossible.grant_id); + expect(result.data[1].grant_id).to.equal(fixtures.grants.healthAide.grant_id); + }); }); context('getAgency', () => { diff --git a/packages/server/__tests__/db/seeds/fixtures.js b/packages/server/__tests__/db/seeds/fixtures.js index ee3c303a1..6f2f806ef 100644 --- a/packages/server/__tests__/db/seeds/fixtures.js +++ b/packages/server/__tests__/db/seeds/fixtures.js @@ -123,6 +123,7 @@ const grants = { raw_body: 'raw body', funding_instrument_codes: 'CA G PC', bill: 'Infrastructure Investment and Jobs Act (IIJA)', + funding_activity_category_codes: 'ISS', created_at: '2021-08-11 11:30:38.89828-07', updated_at: '2021-08-11 12:30:39.531-07', }, @@ -147,6 +148,7 @@ const grants = { raw_body: 'raw body', funding_instrument_codes: 'O', bill: '', + funding_activity_category_codes: 'IS', created_at: '2021-08-06 16:03:53.57025-07', updated_at: '2021-08-11 12:35:42.562-07', }, @@ -171,6 +173,7 @@ const grants = { raw_body: 'raw body', funding_instrument_codes: 'O PC', bill: 'Infrastructure Investment and Jobs Act (IIJA)', + funding_activity_category_codes: 'ISS ST', created_at: '2021-01-06 11:30:38.89828-07', updated_at: '2022-04-23 12:30:39.531-07', }, @@ -363,6 +366,16 @@ const grantsSavedSearches = [ }, ]; +const grantsViewed = { + entry1: { + agency_id: agencies.accountancy.id, + grant_id: grants.earFellowship.grant_id, + user_id: users.adminUser.id, + created_at: '2022-08-06 16:03:53.57025-07', + updated_at: '2021-08-11 12:35:42.562-07', + }, +}; + module.exports = { tenants, agencies, @@ -375,6 +388,7 @@ module.exports = { grants, interestedCodes, roles, + grantsViewed, }; module.exports.seed = async (knex) => { @@ -402,4 +416,5 @@ module.exports.seed = async (knex) => { await knex(TABLES.assigned_grants_agency).insert(Object.values(assignedAgencyGrants)); await knex(TABLES.grants_interested).insert(Object.values(grantsInterested)); await knex(TABLES.grants_saved_searches).insert(Object.values(grantsSavedSearches)); + await knex(TABLES.grants_viewed).insert(Object.values(grantsViewed)); }; diff --git a/packages/server/__tests__/lib/grants-ingest.test.js b/packages/server/__tests__/lib/grants-ingest.test.js index aeac9b94d..1f60a3d46 100644 --- a/packages/server/__tests__/lib/grants-ingest.test.js +++ b/packages/server/__tests__/lib/grants-ingest.test.js @@ -116,6 +116,18 @@ describe('processMessages', async () => { { code: '13' }, { code: '12' }, { code: '11' }, { code: '10' }, ], + funding_activity: { + categories: [ + { + name: 'Humanities', + code: 'HU', + }, + { + name: 'Business and Commerce', + code: 'BC', + }, + ], + }, revision: { id: 'c3' }, }), ReceiptHandle: 'receipt-handle-1', @@ -194,6 +206,7 @@ describe('processMessages', async () => { description: 'Here is a description of this superb grant', eligibility_codes: '25 20 13 12 11 10', opportunity_status: 'archived', + funding_activity_category_codes: 'HU BC', raw_body: sinon.match( jsonMatcher(JSON.stringify(JSON.parse(messages[2].Body).detail.versions.new)), ), diff --git a/packages/server/migrations/20231117213914_add_funding_category_column_to_grants.js b/packages/server/migrations/20231117213914_add_funding_category_column_to_grants.js new file mode 100644 index 000000000..e6bc77391 --- /dev/null +++ b/packages/server/migrations/20231117213914_add_funding_category_column_to_grants.js @@ -0,0 +1,19 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.table('grants', (table) => { + table.text('funding_activity_category_codes'); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.table('grants', (table) => { + table.dropColumn('funding_activity_category_codes'); + }); +}; diff --git a/packages/server/seeds/dev/ref/grants.js b/packages/server/seeds/dev/ref/grants.js index d81ba26b3..bb82c58c5 100644 --- a/packages/server/seeds/dev/ref/grants.js +++ b/packages/server/seeds/dev/ref/grants.js @@ -46,6 +46,7 @@ const grants = [ opportunity_category: 'Discretionary', 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;