diff --git a/api/db/migrations/20231217212515_relate_reporting_period_to_org/migration.sql b/api/db/migrations/20231217212515_relate_reporting_period_to_org/migration.sql new file mode 100644 index 00000000..5efde6f4 --- /dev/null +++ b/api/db/migrations/20231217212515_relate_reporting_period_to_org/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `organizationId` to the `ReportingPeriod` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "ReportingPeriod" ADD COLUMN "organizationId" INTEGER NOT NULL; + +-- AddForeignKey +ALTER TABLE "ReportingPeriod" ADD CONSTRAINT "ReportingPeriod_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/api/db/migrations/20231217214528_updated_at_defaults_to_now/migration.sql b/api/db/migrations/20231217214528_updated_at_defaults_to_now/migration.sql new file mode 100644 index 00000000..45dff935 --- /dev/null +++ b/api/db/migrations/20231217214528_updated_at_defaults_to_now/migration.sql @@ -0,0 +1,28 @@ +-- AlterTable +ALTER TABLE "ExpenditureCategory" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "InputTemplate" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "OutputTemplate" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Project" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "ReportingPeriod" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Subrecipient" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "Upload" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "UploadValidation" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP; + +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "createdAt" SET DATA TYPE TIMESTAMPTZ(6), +ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP, +ALTER COLUMN "updatedAt" SET DATA TYPE TIMESTAMPTZ(6); diff --git a/api/db/migrations/20231222193706_add_notes_on_upload/migration.sql b/api/db/migrations/20231222193706_add_notes_on_upload/migration.sql new file mode 100644 index 00000000..026069bb --- /dev/null +++ b/api/db/migrations/20231222193706_add_notes_on_upload/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Upload" ADD COLUMN "notes" TEXT; diff --git a/api/db/schema.prisma b/api/db/schema.prisma index 75dd3ac2..92c129e1 100644 --- a/api/db/schema.prisma +++ b/api/db/schema.prisma @@ -27,6 +27,7 @@ model Organization { agencies Agency[] users User[] name String + reportingPeriods ReportingPeriod[] uploads Upload[] uploadValidations UploadValidation[] subrecipients Subrecipient[] @@ -41,7 +42,7 @@ model User { organizationId Int? roleId Int? createdAt DateTime @default(now()) @db.Timestamptz(6) - updatedAt DateTime @updatedAt @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) agency Agency? @relation(fields: [agencyId], references: [id]) organization Organization? @relation(fields: [organizationId], references: [id]) role Role? @relation(fields: [roleId], references: [id]) @@ -67,7 +68,7 @@ model InputTemplate { effectiveDate DateTime @db.Date rulesGeneratedAt DateTime? @db.Timestamptz(6) createdAt DateTime @default(now()) @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) reportingPeriods ReportingPeriod[] uploadValidations UploadValidation[] } @@ -79,7 +80,7 @@ model OutputTemplate { effectiveDate DateTime @db.Date rulesGeneratedAt DateTime? @db.Timestamptz(6) createdAt DateTime @default(now()) @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) reportingPeriods ReportingPeriod[] } @@ -88,6 +89,8 @@ model ReportingPeriod { name String startDate DateTime @db.Date endDate DateTime @db.Date + organizationId Int + organization Organization @relation(fields: [organizationId], references: [id]) certifiedAt DateTime? @db.Timestamptz(6) certifiedById Int? certifiedBy User? @relation(fields: [certifiedById], references: [id], onDelete: NoAction, onUpdate: NoAction) @@ -97,7 +100,7 @@ model ReportingPeriod { outputTemplate OutputTemplate @relation(fields: [outputTemplateId], references: [id], onDelete: NoAction, onUpdate: NoAction) isCurrentPeriod Boolean @default(false) createdAt DateTime @default(now()) @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) uploads Upload[] projects Project[] } @@ -107,13 +110,14 @@ model ExpenditureCategory { name String code String createdAt DateTime @default(now()) @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) Uploads Upload[] } model Upload { id Int @id @default(autoincrement()) filename String + notes String? uploadedById Int uploadedBy User @relation(fields: [uploadedById], references: [id]) agencyId Int @@ -125,7 +129,7 @@ model Upload { expenditureCategoryId Int expenditureCategory ExpenditureCategory @relation(fields: [expenditureCategoryId], references: [id]) createdAt DateTime @default(now()) @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) validations UploadValidation[] subrecipients Subrecipient[] } @@ -149,7 +153,7 @@ model UploadValidation { invalidatedById Int? invalidatedBy User? @relation("InvalidatedUploads", fields: [invalidatedById], references: [id]) createdAt DateTime @default(now()) @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) } model Subrecipient { @@ -165,7 +169,7 @@ model Subrecipient { originationUploadId Int originationUpload Upload @relation(fields: [originationUploadId], references: [id]) createdAt DateTime @default(now()) @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) } model Project { @@ -181,5 +185,5 @@ model Project { originationPeriodId Int originationPeriod ReportingPeriod @relation(fields: [originationPeriodId], references: [id]) createdAt DateTime @default(now()) @db.Timestamptz(6) - updatedAt DateTime @db.Timestamptz(6) + updatedAt DateTime @default(now()) @db.Timestamptz(6) } \ No newline at end of file diff --git a/api/package.json b/api/package.json index 4096791f..fd5df059 100644 --- a/api/package.json +++ b/api/package.json @@ -13,6 +13,7 @@ "@prisma/instrumentation": "^5.7.0", "@redwoodjs/api": "6.4.2", "@redwoodjs/graphql-server": "6.4.2", - "dd-trace": "^4.20.0" + "dd-trace": "^4.20.0", + "exceljs": "^4.4.0" } } diff --git a/api/src/graphql/reportingPeriods.sdl.ts b/api/src/graphql/reportingPeriods.sdl.ts index 13e7f334..a0d6f23d 100644 --- a/api/src/graphql/reportingPeriods.sdl.ts +++ b/api/src/graphql/reportingPeriods.sdl.ts @@ -18,7 +18,13 @@ export const schema = gql` type Query { reportingPeriods: [ReportingPeriod!]! @requireAuth + reportingPeriodsByOrg(organizationId: Int!): [ReportingPeriod!]! + @requireAuth reportingPeriod(id: Int!): ReportingPeriod @requireAuth + previousReportingPeriods( + id: Int! + organizationId: Int! + ): [ReportingPeriod!]! @requireAuth } input CreateReportingPeriodInput { diff --git a/api/src/graphql/uploads.sdl.ts b/api/src/graphql/uploads.sdl.ts index 695b2419..6d90946d 100644 --- a/api/src/graphql/uploads.sdl.ts +++ b/api/src/graphql/uploads.sdl.ts @@ -2,6 +2,7 @@ export const schema = gql` type Upload { id: Int! filename: String! + notes: String uploadedById: Int! uploadedBy: User! agencyId: Int! @@ -15,6 +16,7 @@ export const schema = gql` createdAt: DateTime! updatedAt: DateTime! validations: [UploadValidation]! + signedUrl: String } type Query { @@ -24,6 +26,7 @@ export const schema = gql` input CreateUploadInput { filename: String! + notes: String uploadedById: Int! agencyId: Int! organizationId: Int! @@ -33,6 +36,7 @@ export const schema = gql` input UpdateUploadInput { filename: String + notes: String uploadedById: Int agencyId: Int organizationId: Int diff --git a/api/src/lib/auth.ts b/api/src/lib/auth.ts index b70912ed..84f3ec3d 100644 --- a/api/src/lib/auth.ts +++ b/api/src/lib/auth.ts @@ -34,7 +34,7 @@ export const getCurrentUser = async ( ): Promise => { console.log(decoded) return { - id: 'unique-user-id', + id: 1, email: 'email@example.com', roles: ['admin'], } diff --git a/api/src/lib/aws.ts b/api/src/lib/aws.ts index bb619a5b..95b12282 100644 --- a/api/src/lib/aws.ts +++ b/api/src/lib/aws.ts @@ -1,89 +1,162 @@ import { GetObjectCommand, + HeadObjectCommand, + HeadObjectCommandInput, PutObjectCommand, PutObjectCommandInput, S3Client, } from '@aws-sdk/client-s3' -import {ReceiveMessageCommand, SendMessageCommand, SQSClient} from '@aws-sdk/client-sqs' -import {getSignedUrl as awsGetSignedUrl} from '@aws-sdk/s3-request-presigner' +import { + ReceiveMessageCommand, + SendMessageCommand, + SQSClient, +} from '@aws-sdk/client-sqs' +import { getSignedUrl as awsGetSignedUrl } from '@aws-sdk/s3-request-presigner' +import { QueryResolvers, CreateUploadInput } from 'types/graphql' + +const CPF_REPORTER_BUCKET_NAME = 'cpf-reporter' function getS3Client() { - let s3: S3Client; + let s3: S3Client if (process.env.LOCALSTACK_HOSTNAME) { /* 1. Make sure the local environment has awslocal installed. 2. Use the commands to create a bucket to test with. - - awslocal s3api create-bucket --bucket arpa-audit-reports --region us-west-2 --create-bucket-configuration '{"LocationConstraint": "us-west-2"}' + - awslocal s3api create-bucket --bucket cpf-reporter --region us-west-2 --create-bucket-configuration '{"LocationConstraint": "us-west-2"}' 3. Access bucket resource metadata through the following URL. - awslocal s3api list-buckets - - awslocal s3api list-objects --bucket arpa-audit-reports + - awslocal s3api list-objects --bucket cpf-reporter + 4. Configure cors to allow uploads via signed URLs + + ===== cors-config.json ===== + { + "CORSRules": [ + { + "AllowedHeaders": ["*"], + "AllowedMethods": ["GET", "POST", "PUT"], + "AllowedOrigins": ["http://localhost:8910"], + "ExposeHeaders": ["ETag"] + } + ] + } + + - awslocal s3api put-bucket-cors --bucket cpf-reporter --cors-configuration file://cors-config.json */ - console.log('------------ USING LOCALSTACK ------------'); - const endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:${process.env.EDGE_PORT || 4566}`; - console.log(`endpoint: ${endpoint}`); + console.log('------------ USING LOCALSTACK ------------') + const endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:${ + process.env.EDGE_PORT || 4566 + }` + console.log(`endpoint: ${endpoint}`) s3 = new S3Client({ endpoint, forcePathStyle: true, region: process.env.AWS_DEFAULT_REGION, - }); + }) } else { - s3 = new S3Client(); + s3 = new S3Client() } - return s3; + return s3 } -async function sendPutObjectToS3Bucket(bucketName: string, key: string, body: any) { - const s3 = getS3Client(); - const uploadParams : PutObjectCommandInput = { +export function uploadWorkbook( + upload: CreateUploadInput, + uploadId: number, + body: any +) { + const folderName = `${upload.organizationId}/${upload.agencyId}/${upload.reportingPeriodId}/uploads/${upload.expenditureCategoryId}/${uploadId}/${upload.filename}` + return sendPutObjectToS3Bucket(CPF_REPORTER_BUCKET_NAME, folderName, body) +} + +async function sendPutObjectToS3Bucket( + bucketName: string, + key: string, + body: any +) { + const s3 = getS3Client() + const uploadParams: PutObjectCommandInput = { Bucket: bucketName, Key: key, Body: body, ServerSideEncryption: 'AES256', - }; - await s3.send(new PutObjectCommand(uploadParams)); + } + await s3.send(new PutObjectCommand(uploadParams)) +} + +export function getTemplateRules(inputTemplateId: number) { + return sendHeadObjectToS3Bucket( + CPF_REPORTER_BUCKET_NAME, + `templates/input_templates/${inputTemplateId}/rules/` + ) } async function sendHeadObjectToS3Bucket(bucketName: string, key: string) { - const s3 = getS3Client(); - const uploadParams : PutObjectCommandInput = { + const s3 = getS3Client() + const uploadParams: HeadObjectCommandInput = { Bucket: bucketName, Key: key, - }; - await s3.send(new PutObjectCommand(uploadParams)); + } + await s3.send(new HeadObjectCommand(uploadParams)) } +export async function s3PutSignedUrl( + upload: CreateUploadInput, + uploadId: number +): Promise { + const s3 = getS3Client() + const key = `${upload.organizationId}/${upload.agencyId}/${upload.reportingPeriodId}/uploads/${upload.expenditureCategoryId}/${uploadId}/${upload.filename}` + const baseParams: PutObjectCommandInput = { + Bucket: CPF_REPORTER_BUCKET_NAME, + Key: key, + ContentType: + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + } + const url = await awsGetSignedUrl(s3, new PutObjectCommand(baseParams), { + expiresIn: 60, + }) + return url +} /** * This function is a wrapper around the getSignedUrl function from the @aws-sdk/s3-request-presigner package. * Exists to organize the imports and to make it easier to mock in tests. */ + +// eslint-disable-next-line @typescript-eslint/no-unused-vars async function getSignedUrl(bucketName: string, key: string) { - const s3 = getS3Client(); - const baseParams = { Bucket: bucketName, Key: key }; - return awsGetSignedUrl(s3, new GetObjectCommand(baseParams), { expiresIn: 60 }); + const s3 = getS3Client() + const baseParams = { Bucket: bucketName, Key: key } + return awsGetSignedUrl(s3, new GetObjectCommand(baseParams), { + expiresIn: 60, + }) } function getSQSClient() { - let sqs: SQSClient; + let sqs: SQSClient if (process.env.LOCALSTACK_HOSTNAME) { - console.log('------------ USING LOCALSTACK FOR SQS ------------'); - const endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:${process.env.EDGE_PORT || 4566}`; - sqs = new SQSClient({ endpoint, region: process.env.AWS_DEFAULT_REGION }); + console.log('------------ USING LOCALSTACK FOR SQS ------------') + const endpoint = `http://${process.env.LOCALSTACK_HOSTNAME}:${ + process.env.EDGE_PORT || 4566 + }` + sqs = new SQSClient({ endpoint, region: process.env.AWS_DEFAULT_REGION }) } else { - sqs = new SQSClient(); + sqs = new SQSClient() } - return sqs; + return sqs } +// eslint-disable-next-line @typescript-eslint/no-unused-vars async function sendSqsMessage(queueUrl: string, messageBody: any) { - const sqs = getSQSClient(); - await sqs.send(new SendMessageCommand({ - QueueUrl: queueUrl, - MessageBody: JSON.stringify(messageBody), - })); + const sqs = getSQSClient() + await sqs.send( + new SendMessageCommand({ + QueueUrl: queueUrl, + MessageBody: JSON.stringify(messageBody), + }) + ) } +// eslint-disable-next-line @typescript-eslint/no-unused-vars async function receiveSqsMessage(queueUrl: string) { - const sqs = getSQSClient(); + const sqs = getSQSClient() // const receiveResp = await sqs.send(new ReceiveMessageCommand({ // QueueUrl: process.env.TASK_QUEUE_URL, WaitTimeSeconds: 20, MaxNumberOfMessages: 1, // })); @@ -92,9 +165,20 @@ async function receiveSqsMessage(queueUrl: string) { // QueueUrl: process.env.TASK_QUEUE_URL, WaitTimeSeconds: 20, MaxNumberOfMessages: 1, // })); - await sqs.send(new ReceiveMessageCommand({ - QueueUrl: queueUrl, WaitTimeSeconds: 20, MaxNumberOfMessages: 1, - })); + await sqs.send( + new ReceiveMessageCommand({ + QueueUrl: queueUrl, + WaitTimeSeconds: 20, + MaxNumberOfMessages: 1, + }) + ) +} + +export const s3PutObjectSignedUrl: QueryResolvers['s3PutObjectSignedUrl'] = ({ + upload, + uploadId, +}) => { + return s3PutSignedUrl(upload, uploadId) } export default { @@ -103,4 +187,4 @@ export default { getSignedUrl, sendSqsMessage, receiveSqsMessage, -}; +} diff --git a/api/src/lib/ec-codes.ts b/api/src/lib/ec-codes.ts new file mode 100644 index 00000000..907e5d82 --- /dev/null +++ b/api/src/lib/ec-codes.ts @@ -0,0 +1,86 @@ +const ecCodes = { + 1.1: 'COVID-19 Vaccination', + 1.2: 'COVID-19 Testing', + 1.3: 'COVID-19 Contact Tracing', + 1.4: 'Prevention in Congregate Settings (Nursing Homes Prisons/Jails Dense Work Sites Schools Child care facilites etc.)', + 1.5: 'Personal Protective Equipment', + 1.6: 'Medical Expenses (including Alternative Care Facilities)', + 1.7: 'Other COVID-19 Public Health Expenses (including Communications Enforcement Isolation/Quarantine)', + 1.8: 'COVID-19 Assistance to Small Businesses', + 1.9: 'COVID-19 Assistance to Non-Profits', + '1.10': 'COVID-19 Aid to Impacted Industries', + 1.11: 'Community Violence Interventions', + 1.12: 'Mental Health Services', + 1.13: 'Substance Use Services', + 1.14: 'Other Public Health Services', + 2.1: 'Household Assistance: Food Programs', + 2.2: 'Household Assistance: Rent Mortgage and Utility Aid', + 2.3: 'Household Assistance: Cash Transfers', + 2.4: 'Household Assistance: Internet Access Programs', + 2.5: 'Household Assistance: Paid Sick and Medical Leave', + 2.6: 'Household Assistance: Health Insurance', + 2.7: 'Household Assistance: Services for Un/Unbanked', + 2.8: 'Household Assistance: Survivors Benefits', + 2.9: 'Unemployment Benefits or Cash Assistance to Unemployed Workers', + '2.10': + 'Assistance to Unemployed or Underemployed Workers (e.g. job training subsidized employment employment supports or incentives)', + 2.11: 'Healthy Childhood Environments: Child Care', + 2.12: 'Healthy Childhood Environments: Home Visiting', + 2.13: 'Healthy Childhood Environments: Services to Foster Youth or Families Involved in Child Welfare System', + 2.14: 'Healthy Childhood Environments: Early Learning', + 2.15: 'Long-term Housing Security: Affordable Housing', + 2.16: 'Long-term Housing Security: Services for Unhoused Persons', + 2.17: 'Housing Support: Housing Vouchers and Relocation Assistance for Disproportionately Impacted Communities', + 2.18: 'Housing Support: Other Housing Assistance', + 2.19: 'Social Determinants of Health: Community Health Workers or Benefits Navigation', + '2.20': 'Social Determinants of Health: Lead Remediation', + 2.21: 'Medical Facilities for Disproportionately Impacted Communities', + 2.22: 'Strong Healthy Communities: Neighborhood Features that Promote Health and Safety', + 2.23: 'Strong Healthy Communities: Demolition and Rehabilitation of Properties', + 2.24: 'Addressing Educational Disparities: Aid to High-Poverty Districts', + 2.25: 'Addressing Educational Disparities: Academic Social and Emotional Services', + 2.26: 'Addressing Educational Disparities: Mental Health Services', + 2.27: 'Addressing Impacts of Lost Instructional Time', + 2.28: 'Contributions to UI Trust Funds', + 2.29: 'Loans or Grants to Mitigate Financial Hardship', + '2.30': 'Technical Assistance Counseling or Business Planning', + 2.31: 'Rehabilitation of Commercial Properties or Other Improvements', + 2.32: 'Business Incubators and Start-Up or Expansion Assistance', + 2.33: 'Enhanced Support to Microbusinesses', + 2.34: 'Assistance to Impacted Nonprofit Organizations (Impacted or Disproportionately Impacted)', + 2.35: 'Aid to Tourism Travel or Hospitality', + 2.36: 'Aid to Other Impacted Industries', + 2.37: 'Economic Impact Assistance: Other', + 3.1: 'Public Sector Workforce: Payroll and Benefits for Public Health Public Safety or Human Services Workers', + 3.2: 'Public Sector Workforce: Rehiring Public Sector Staff', + 3.3: 'Public Sector Workforce: Other', + 3.4: 'Public Sector Capacity: Effective Service Delivery', + 3.5: 'Public Sector Capacity: Administrative Needs', + 4.1: 'Public Sector Employees', + 4.2: 'Private Sector: Grants to other employers', + 5.1: 'Clean Water: Centralized wastewater treatment', + 5.2: 'Clean Water: Centralized wastewater collection and conveyance', + 5.3: 'Clean Water: Decentralized wastewater', + 5.4: 'Clean Water: Combined sewer overflows', + 5.5: 'Clean Water: Other sewer infrastructure', + 5.6: 'Clean Water: Stormwater', + 5.7: 'Clean Water: Energy conservation', + 5.8: 'Clean Water: Water conservation', + 5.9: 'Clean Water: Nonpoint source', + '5.10': 'Drinking water: Treatment', + 5.11: 'Drinking water: Transmission & distribution', + 5.12: 'Drinking water: Lead Remediation including in Schools and Daycares', + 5.13: 'Drinking water: Source', + 5.14: 'Drinking water: Storage', + 5.15: 'Drinking water: Other water infrastructure', + 5.16: 'Water and Sewer: Private Wells ', + 5.17: 'Water and Sewer: IIJA Bureau of Reclamation Match ', + 5.18: 'Water and Sewer: Other ', + 5.19: 'Broadband: Last Mile projects', + '5.20': 'Broadband: IIJA Match', + 5.21: 'Broadband: Other projects', + 7.1: 'Administrative Expenses', + 7.2: 'Transfers to Other Units of Government', +} + +export { ecCodes } diff --git a/api/src/lib/persist-upload.js b/api/src/lib/persist-upload.js new file mode 100644 index 00000000..5723a10a --- /dev/null +++ b/api/src/lib/persist-upload.js @@ -0,0 +1,299 @@ +/* eslint camelcase: 0 */ + +import fs from 'fs/promises' +import path from 'path' + +import tracer from 'dd-trace' +import _ from 'lodash' + +import { logger } from 'src/lib/logger' +import { ValidationError } from 'src/lib/validation-error' +import { agenciesByOrganization } from 'src/services/agencies' +import { reportingPeriod as getReportingPeriod } from 'src/services/reportingPeriods' +import { createUpload } from 'src/services/uploads' +import { user as getUser } from 'src/services/users' + +const ExcelJS = require('exceljs') + +/** + * Get the path to the upload file for the given upload + * + * WARNING: changes to this function must be made with care, because: + * 1. there may be existing data on disk with filenames set according to this function, which could become inaccessible + * 2. this function is duplicated in GOST's import_arpa_reporter_dump.js script + * + * @param {object} upload + * @returns {string} + */ +const uploadFSName = (upload) => { + const filename = `${upload.id}${path.extname(upload.filename)}` + return path.join(`${process.env.UPLOAD_DIR}`, filename) +} + +/** + * Get the path to the JSON file for the given upload + * @param {object} upload + * @returns {string} + */ +const jsonFSName = (upload) => { + const filename = `${upload.id}.json` + return path.join(`${process.env.TEMP_DIR}`, upload.id[0], filename) +} + +/** + * Attempt to parse the buffer as an XLSX file + * @param {Buffer} buffer + * @returns {Promise} + * @throws {ValidationError} + */ +async function validateBuffer(buffer) { + try { + await new ExcelJS.Workbook().xlsx.load(buffer) + } catch (e) { + throw new ValidationError(`Cannot parse XLSX from supplied data: ${e}`) + } +} + +/** + * Create Upload row object + * @param {object} uploadData + * @returns {object} + */ +function createUploadRow(uploadData) { + const { + filename, + reportingPeriodId, + userId, + agencyId, + organizationId, + expenditureCategoryId, + } = uploadData + + return { + filename: path.basename(filename), + reportingPeriodId: reportingPeriodId, + uploadedById: userId, + agencyId: agencyId, + organizationId: organizationId, + expenditureCategoryId: expenditureCategoryId, + } +} + +/** + * Persist the upload to the filesystem + * @param {object} upload + * @param {Buffer} buffer + * @returns {Promise} + * @throws {ValidationError} + */ +async function persistUploadToFS(upload, buffer) { + return tracer.trace('persistUploadToFS', async () => { + try { + const filename = uploadFSName(upload) + await fs.mkdir(path.dirname(filename), { recursive: true }) + await fs.writeFile(filename, buffer, { flag: 'wx' }) + } catch (e) { + throw new ValidationError( + `Cannot persist ${upload.filename} to filesystem: ${e}` + ) + } + }) +} + +/** + * Validate the agency ID + * @param {string} agencyId + * @param {string} userId + * @returns {string|null} + * @throws {ValidationError} + */ +async function ensureValidAgencyId(agencyId, userId) { + // If agencyId is null, it's ok. We derive this later from the spreadsheet + // itself in validate-upload. We leave it as null here. + if (!agencyId) { + return null + } + // Otherwise, we need to make sure the user is associated with the agency + const userRecord = await getUser(userId) + const orgAgencies = await agenciesByOrganization({ + organizationId: userRecord.organizationId, + }) + const agency = orgAgencies.find((agency) => agency.id === Number(agencyId)) + if (!agency) { + throw new ValidationError( + `Supplied agency ID ${agencyId} does not correspond to an agency in the user's organization ${userRecord.organizationId}. Please report this issue to USDR.` + ) + } + return agencyId +} + +/** + * Validate the reporting period ID + * @param {string} reportingPeriodId + * @returns {string} + * @throws {ValidationError} + */ +async function ensureValidReportingPeriodId(reportingPeriodId) { + // Get the current reporting period. Passing an undefined value + // defaults to the current period. + const reportingPeriod = await getReportingPeriod(reportingPeriodId) + + if (!reportingPeriod) { + throw new ValidationError( + `Supplied reporting period ID ${reportingPeriodId} does not correspond to any existing reporting period. Please report this issue to USDR.` + ) + } + return reportingPeriod.id +} + +/** + * Persist an upload to the filesystem + * @param {string} filename + * @param {object} user + * @param {Buffer} buffer + * @param {object} body + * @returns {object} upload + * @throws {ValidationError} + */ +async function persistUpload({ filename, user, buffer, body }) { + return tracer.trace('persistUpload', async () => { + // Fetch reportingPeriodId, agencyId, and notes from the body + // and rename with 'supplied' prefix. These may be null. + const { + reportingPeriodId: suppliedReportingPeriodId, + agencyId: suppliedAgencyId, + organizationId: organizationId, + expenditureCategoryId: expenditureCategoryId, + } = body + + // Make sure we can actually read the supplied buffer (it's a valid spreadsheet) + await validateBuffer(buffer) + + // Either use supplied reportingPeriodId, + // or fall back to the current reporting period ID if undefined + const validatedReportingPeriodId = await ensureValidReportingPeriodId( + suppliedReportingPeriodId + ) + + // Check if the user is affiliated with the given agency, + // or leave undefined (we'll derive it later from the spreadsheet) + const validatedAgencyId = await ensureValidAgencyId( + suppliedAgencyId, + user.id + ) + + // Create the upload row + const uploadData = { + filename, + reportingPeriodId: validatedReportingPeriodId, + userId: user.id, + organizationId: organizationId, + agencyId: validatedAgencyId, + expenditureCategoryId: expenditureCategoryId, + } + const uploadRow = createUploadRow(uploadData) + + // Insert the upload row into the database + const upload = await createUpload({ input: uploadRow }) + + // Persist the upload to the filesystem + await persistUploadToFS(upload, buffer) + + // Return the upload we created + return upload + }) +} + +/** + * Persist the workbook to the filesystem + * @param {object} upload + * @param {object} workbook + * @returns {Promise} + * @throws {ValidationError} + */ +async function persistJson(upload, workbook) { + return tracer.trace('persistJson', async () => { + try { + const filename = jsonFSName(upload) + await fs.mkdir(path.dirname(filename), { recursive: true }) + await fs.writeFile(filename, JSON.stringify(workbook), { flag: 'w' }) + } catch (e) { + throw new ValidationError( + `Cannot persist ${upload.filename} to filesystem: ${e}` + ) + } + }) +} + +/** + * Get the buffer for an upload + * @param {object} upload + * @returns {Promise} + */ +async function bufferForUpload(upload) { + return tracer.trace('bufferForUpload', () => + fs.readFile(uploadFSName(upload)) + ) +} + +/** + * Get JSON for an upload + * @param {object} upload + * @returns {Promise} + */ +async function jsonForUpload(upload) { + return tracer.trace('jsonForUpload', async () => { + const file = await tracer.trace('fs.readFile', async (span) => { + const f = await fs.readFile(jsonFSName(upload), { encoding: 'utf-8' }) + const { size } = await fs.stat(jsonFSName(upload)) + span.setTag('filesize-kb', Math.round(size / 2 ** 10)) + span.setTag('tenant-id', upload.tenant_id) + span.setTag('reporting-period-id', upload.reporting_period_id) + return f + }) + return tracer.trace('JSON.parse', () => JSON.parse(file)) + }) +} + +/** + * Get the workbook for an upload + * + * As of xlsx@0.18.5, the XLSX.read operation is very inefficient. + * This function abstracts XLSX.read, and incorporates a local disk cache to + * avoid running the parse operation more than once per upload. + * + * TODO: we use ExcelJS now, so investigate whether we still need this cache + * + * @param {*} upload DB upload content + * @param {XLSX.ParsingOptions} options The options object that will be passed to XLSX.read + * @return {XLSX.Workbook}s The uploaded workbook, as parsed by XLSX.read. + */ +async function workbookForUpload(upload, options) { + return tracer.trace('workbookForUpload', async () => { + logger.info(`workbookForUpload(${upload.id})`) + + let workbook + try { + // attempt to read pre-parsed JSON, if it exists + logger.info(`attempting cache lookup for parsed workbook`) + workbook = await jsonForUpload(upload) + } catch (e) { + // fall back to reading the originally-uploaded .xlsm file and parsing it + logger.info(`cache lookup failed, parsing originally uploaded .xlsm file`) + const buffer = await bufferForUpload(upload) + + // NOTE: This is the slow line! + logger.info(`ExcelJS.load(${upload.id})`) + workbook = new ExcelJS.Workbook() + workbook = tracer.trace('ExcelJS.load()', () => + workbook.xlsx.load(buffer, options) + ) + + persistJson(upload, workbook) + } + + return workbook + }) +} + +export { persistUpload, bufferForUpload, workbookForUpload, uploadFSName } diff --git a/api/src/lib/preconditions.ts b/api/src/lib/preconditions.ts new file mode 100644 index 00000000..ea67d10f --- /dev/null +++ b/api/src/lib/preconditions.ts @@ -0,0 +1,7 @@ +function requiredArgument(value, message = 'required argument missing!') { + if (value === undefined) { + throw new Error(message) + } +} + +export { requiredArgument } diff --git a/api/src/lib/records.js b/api/src/lib/records.js new file mode 100644 index 00000000..287f1f40 --- /dev/null +++ b/api/src/lib/records.js @@ -0,0 +1,301 @@ +// import { ExcelJS } from 'exceljs' +// import { merge } from 'lodash' + +import { logger } from 'src/lib/logger' +// import { workbookForUpload } from 'src/lib/persist-upload' +// import { getRules } from 'src/lib/validation-rules' + +// const CERTIFICATION_SHEET = 'Certification' +// const COVER_SHEET = 'Cover' +// const LOGIC_SHEET = 'Logic' + +const EC_SHEET_TYPES = { + 'EC 1 - Public Health': 'ec1', + 'EC 2 - Negative Economic Impact': 'ec2', + 'EC 3 - Public Sector Capacity': 'ec3', + 'EC 4 - Premium Pay': 'ec4', + 'EC 5 - Infrastructure': 'ec5', + 'EC 7 - Admin': 'ec7', +} + +const DATA_SHEET_TYPES = { + ...EC_SHEET_TYPES, + Subrecipient: 'subrecipient', + 'Awards > 50000': 'awards50k', + 'Expenditures > 50000': 'expenditures50k', + 'Aggregate Awards < 50000': 'awards', +} + +const TYPE_TO_SHEET_NAME = Object.fromEntries( + Object.entries(DATA_SHEET_TYPES).map(([sheetName, type]) => [type, sheetName]) +) + +function readVersionRecord(_workbook) { + // const range = { + // s: { r: 0, c: 1 }, + // e: { r: 0, c: 1 }, + // } + + // TODO: make this work with ExcelJS + const [row] = [1.0] // XLSX.utils.sheet_to_json(workbook.Sheets[LOGIC_SHEET], { + // header: 1, + // range, + // }) + + return { + version: row[0], + } +} + +/** + * Load an uploaded spreadsheet from disk and parse it into "records". Each + * record corresponds to one row in the upload. + * + * @param {object} upload The upload to read + * @returns {Promise} + * + * TODO: make this work with ExcelJS +async function loadRecordsForUpload(upload) { + logger.info(`loadRecordsForUpload(${upload.id})`) + + const rules = getRules() + + // NOTE: workbookForUpload relies on a disk cache for optimization. + // If you change any of the below parsing parameters, you will need to + // clear the server's TEMP_DIR folder to ensure they take effect. + const workbook = await workbookForUpload(upload, { + cellDates: true, + type: 'buffer', + cellHTML: false, + cellFormula: false, + sheets: [ + CERTIFICATION_SHEET, + COVER_SHEET, + LOGIC_SHEET, + ...Object.keys(DATA_SHEET_TYPES), + ], + }) + + // parse certification and cover as special cases + const [certification] = XLSX.utils.sheet_to_json( + workbook.Sheets[CERTIFICATION_SHEET] + ) + const [cover] = XLSX.utils.sheet_to_json(workbook.Sheets[COVER_SHEET]) + const subcategory = cover['Detailed Expenditure Category'] + + const records = [ + { type: 'certification', upload, content: certification }, + { type: 'cover', upload, content: cover }, + { type: 'logic', upload, content: readVersionRecord(workbook) }, + ] + + // parse data sheets + for (const sheetName of Object.keys(DATA_SHEET_TYPES)) { + const type = DATA_SHEET_TYPES[sheetName] + const sheet = workbook.Sheets[sheetName] + const sheetAttributes = workbook.Workbook.Sheets.find( + ({ name }) => name === sheetName + ) + + // ignore hidden sheets + if (sheetAttributes.Hidden !== 0) { + // eslint-disable-next-line no-continue + continue + } + + const rulesForCurrentType = rules[type] + + // entire sheet + const sheetRange = XLSX.utils.decode_range(sheet['!ref']) + + // range C3:3 + const headerRange = merge({}, sheetRange, { + s: { c: 2, r: 2 }, + e: { r: 2 }, + }) + + // TODO: How can we safely get the row number in which data starts + // across template versions? + // range C13: + const contentRange = merge({}, sheetRange, { s: { c: 2, r: 12 } }) + + const [header] = XLSX.utils.sheet_to_json(sheet, { + header: 1, // ask for array-of-arrays + range: XLSX.utils.encode_range(headerRange), + }) + + // actually read the rows + const rows = XLSX.utils.sheet_to_json(sheet, { + header, + range: XLSX.utils.encode_range(contentRange), + blankrows: false, + }) + + // each row in the input sheet becomes a unique record + for (const row of rows) { + // Don't include any Display_Only data in the records + delete row.Display_Only + // If the row is empty, don't include it in the records + if (Object.keys(row).length === 0) { + // eslint-disable-next-line no-continue + continue + } + const formattedRow = {} + Object.keys(row).forEach((fieldId) => { + let value = row[fieldId] + if (!rulesForCurrentType[fieldId]) { + // No known rules for this type, so we can't format it. + return + } + for (const formatter of rulesForCurrentType[fieldId] + .persistentFormatters) { + try { + value = formatter(value) + } catch (e) { + console.log( + `Persistent formatter failed to format value ${value} with error:`, + e + ) + } + } + formattedRow[fieldId] = value + }) + records.push({ + type, + subcategory, + upload, + content: formattedRow, + }) + } + } + + return records +} +*/ + +/** + * Wraps loadRecordsForUpload with per-request memoization. This ensures that + * the same upload is not read from the filesystem more than once during a + * single request. + * + * @param {object} upload The upload to fetch records for. + * @returns {Promise} A list of records corresponding to the requested upload + */ +async function recordsForUpload(upload, req = null) { + logger.info(`recordsForUpload(${upload.id})`) + + if (req === undefined) { + // Will not cache outside of a request + logger.info( + `recordsForUpload(${upload.id}) will not cache for subsequent calls` + ) + req = {} + } + + if (!req.recordsForUpload) { + logger.info( + `recordsForUpload(${upload.id}) initializing req.recordsForUpload cache` + ) + req.recordsForUpload = {} + } + + if (req.recordsForUpload[upload.id]) { + logger.info(`recordsForUpload(${upload.id}): reading from cache`) + return req.recordsForUpload[upload.id] + } + + logger.info(`recordsForUpload(${upload.id}): reading from disk`) + const recordPromise = [] // loadRecordsForUpload(upload) + + // By caching the promise, we ensure that parallel fetches won't start a new + // filesystem read, even if the first read hasn't resolved yet. + req.recordsForUpload[upload.id] = recordPromise + + return recordPromise +} + +// async function recordsForReportingPeriod(periodId, tenantId) { +// logger.info(`recordsForReportingPeriod(${periodId})`) +// requiredArgument( +// periodId, +// 'must specify periodId in recordsForReportingPeriod' +// ) + +// const uploads = await usedForTreasuryExport(periodId, tenantId) +// const groupedRecords = await Promise.all( +// uploads.map((upload) => recordsForUpload(upload)) +// ) +// return groupedRecords.flat() +// } + +/** + * Get the most recent, validated record for each unique project, as of the + * specified reporting period. + */ +// async function mostRecentProjectRecords(periodId, tenantId) { +// logger.info(`mostRecentProjectRecords(${periodId})`) +// requiredArgument( +// periodId, +// 'must specify periodId in mostRecentProjectRecords' +// ) + +// const reportingPeriods = await previousReportingPeriods( +// periodId, +// organizationId +// ) + +// const allRecords = await Promise.all( +// reportingPeriods.map(({ id }) => recordsForReportingPeriod(id, tenantId)) +// ) + +// const latestProjectRecords = allRecords +// .flat() +// // exclude non-project records +// .filter((record) => Object.values(EC_SHEET_TYPES).includes(record.type)) +// // collect the latest record for each project ID +// .reduce((accumulator, record) => { +// accumulator[record.content.Project_Identification_Number__c] = record +// return accumulator +// }, {}) + +// return Object.values(latestProjectRecords) +// } + +// async function recordsForProject(periodId, tenantId) { +// log(`recordsForProject`) +// requiredArgument( +// periodId, +// 'must specify periodId in mostRecentProjectRecords' +// ) + +// const reportingPeriods = await getPreviousReportingPeriods( +// periodId, +// undefined, +// tenantId +// ) + +// const allRecords = await Promise.all( +// reportingPeriods.map(({ id }) => recordsForReportingPeriod(id, tenantId)) +// ) + +// const projectRecords = allRecords +// .flat() +// // exclude non-project records +// .filter((record) => +// [ +// ...Object.values(EC_SHEET_TYPES), +// 'awards50k', +// 'expenditures50k', +// ].includes(record.type) +// ) + +// return Object.values(projectRecords) +// } + +export { + recordsForUpload, + EC_SHEET_TYPES, + DATA_SHEET_TYPES, + TYPE_TO_SHEET_NAME, + readVersionRecord, +} diff --git a/api/src/lib/templateRules.ts b/api/src/lib/templateRules.ts new file mode 100644 index 00000000..1959ca14 --- /dev/null +++ b/api/src/lib/templateRules.ts @@ -0,0 +1,6963 @@ +export default { + logic: { + version: { + version: 'v:20230923', + key: 'version', + index: 0, + required: false, + dataType: 'String', + maxLength: 10, + listVals: [], + columnName: 'B', + humanColName: 'Input template version', + ecCodes: false, + }, + }, + ec1: { + Name: { + key: 'Name', + index: 2, + required: true, + dataType: 'String', + maxLength: 80, + listVals: [], + columnName: 'C', + humanColName: 'Project Name', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Project_Identification_Number__c: { + key: 'Project_Identification_Number__c', + index: 3, + required: true, + dataType: 'String', + maxLength: 20, + listVals: [], + columnName: 'D', + humanColName: 'Project Identification Number\r\n(Assigned by recipient)', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Completion_Status__c: { + key: 'Completion_Status__c', + index: 4, + required: true, + dataType: 'Pick List', + maxLength: null, + listVals: [ + 'Not started', + 'Completed less than 50%', + 'Completed 50% or more', + 'Completed', + 'Cancelled', + ], + columnName: 'E', + humanColName: 'Status of Completion', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Adopted_Budget__c: { + key: 'Adopted_Budget__c', + index: 5, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'F', + humanColName: 'Adopted Budget', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Total_Obligations__c: { + key: 'Total_Obligations__c', + index: 6, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'G', + humanColName: 'Total Cumulative \r\nObligations', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Total_Expenditures__c: { + key: 'Total_Expenditures__c', + index: 7, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'H', + humanColName: 'Total Cumulative \r\nExpenditures', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Current_Period_Obligations__c: { + key: 'Current_Period_Obligations__c', + index: 8, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'I', + humanColName: 'Current Period Obligations', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Current_Period_Expenditures__c: { + key: 'Current_Period_Expenditures__c', + index: 9, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'J', + humanColName: 'Current Period Expenditures', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Project_Description__c: { + key: 'Project_Description__c', + index: 10, + required: true, + dataType: 'String', + maxLength: 1500, + listVals: [], + columnName: 'K', + humanColName: 'Project Description', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Program_Income_Earned__c: { + key: 'Program_Income_Earned__c', + index: 11, + required: false, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'L', + humanColName: 'Program Income Earned', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Program_Income_Expended__c: { + key: 'Program_Income_Expended__c', + index: 12, + required: false, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'M', + humanColName: 'Program Income Expended', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Primary_Project_Demographics__c: { + key: 'Primary_Project_Demographics__c', + index: 13, + required: 'Conditional', + dataType: 'Pick List', + maxLength: null, + listVals: [ + '1 Imp General Public', + '2 Imp Low or moderate income HHs or populations', + '3 Imp HHs that experienced unemployment', + '4 Imp HHs that experienced increased food or housing insecurity', + '5 Imp HHs that qualify for certain federal programs', + '6 Imp For services to address lost instructional time in K-12 schools', + '7 Imp Other HHs or populations that experienced a negative economic', + '8 Imp SBs that experienced a negative economic impact', + '9 Imp Classes of SBs designated as negatively economically impacted', + '10 Imp NPs that experienced a negative economic impact specify', + '11 Imp Classes of NPs designated as negatively economically impacted', + '12 Imp Travel tourism or hospitality sectors', + '13 Imp Industry outside the travel tourism or hospitality sectors specify', + '14 Dis Imp Low income HHs and populations', + '15 Dis Imp HHs and populations residing in Qualified Census Tracts', + '16 Dis Imp HHs that qualify for certain federal programs', + '17 Dis Imp HHs receiving services provided by Tribal governments', + '18 Dis Imp HHs residing in the U.S. territories or receiving services', + '19 Dis Imp For services to address educational disparities Title I eligible', + '20 Dis Imp Other HHs or populations that experienced a disproportionate', + '21 Dis Imp SBs operating in Qualified Census Tracts', + '22 Dis Imp SBs operated by Tribal governments or on Tribal lands', + '23 Dis Imp SBs operating in the U.S. territories', + '24 Dis Imp Other SBs Dis Imp by the pandemic specify', + '25 Dis Imp NPs operating in Qualified Census Tracts', + '26 Dis Imp NPs operated by Tribal governments or on Tribal lands', + '27 Dis Imp NPs operating in the U.S. territories', + '28 Dis Imp Other NPs Dis Imp by the pandemic specify', + ], + columnName: 'N', + humanColName: + 'Project Demographic Distribution - Primary Populations Served', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Primary_Project_Demographics_Explanation__c: { + key: 'Primary_Project_Demographics_Explanation__c', + index: 14, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'O', + humanColName: 'Primary Project Demographic Explanation', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Secondary_Project_Demographics__c: { + key: 'Secondary_Project_Demographics__c', + index: 15, + required: false, + dataType: 'Pick List', + maxLength: null, + listVals: [ + '1 Imp General Public', + '2 Imp Low or moderate income HHs or populations', + '3 Imp HHs that experienced unemployment', + '4 Imp HHs that experienced increased food or housing insecurity', + '5 Imp HHs that qualify for certain federal programs', + '6 Imp For services to address lost instructional time in K-12 schools', + '7 Imp Other HHs or populations that experienced a negative economic', + '8 Imp SBs that experienced a negative economic impact', + '9 Imp Classes of SBs designated as negatively economically impacted', + '10 Imp NPs that experienced a negative economic impact specify', + '11 Imp Classes of NPs designated as negatively economically impacted', + '12 Imp Travel tourism or hospitality sectors', + '13 Imp Industry outside the travel tourism or hospitality sectors specify', + '14 Dis Imp Low income HHs and populations', + '15 Dis Imp HHs and populations residing in Qualified Census Tracts', + '16 Dis Imp HHs that qualify for certain federal programs', + '17 Dis Imp HHs receiving services provided by Tribal governments', + '18 Dis Imp HHs residing in the U.S. territories or receiving services', + '19 Dis Imp For services to address educational disparities Title I eligible', + '20 Dis Imp Other HHs or populations that experienced a disproportionate', + '21 Dis Imp SBs operating in Qualified Census Tracts', + '22 Dis Imp SBs operated by Tribal governments or on Tribal lands', + '23 Dis Imp SBs operating in the U.S. territories', + '24 Dis Imp Other SBs Dis Imp by the pandemic specify', + '25 Dis Imp NPs operating in Qualified Census Tracts', + '26 Dis Imp NPs operated by Tribal governments or on Tribal lands', + '27 Dis Imp NPs operating in the U.S. territories', + '28 Dis Imp Other NPs Dis Imp by the pandemic specify', + ], + columnName: 'P', + humanColName: + 'Project Demographic Distribution - Additional Populations Served', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Secondary_Proj_Demographics_Explanation__c: { + key: 'Secondary_Proj_Demographics_Explanation__c', + index: 16, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'Q', + humanColName: 'Secondary Project Demographic Explanation', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Tertiary_Project_Demographics__c: { + key: 'Tertiary_Project_Demographics__c', + index: 17, + required: false, + dataType: 'Pick List', + maxLength: null, + listVals: [ + '1 Imp General Public', + '2 Imp Low or moderate income HHs or populations', + '3 Imp HHs that experienced unemployment', + '4 Imp HHs that experienced increased food or housing insecurity', + '5 Imp HHs that qualify for certain federal programs', + '6 Imp For services to address lost instructional time in K-12 schools', + '7 Imp Other HHs or populations that experienced a negative economic', + '8 Imp SBs that experienced a negative economic impact', + '9 Imp Classes of SBs designated as negatively economically impacted', + '10 Imp NPs that experienced a negative economic impact specify', + '11 Imp Classes of NPs designated as negatively economically impacted', + '12 Imp Travel tourism or hospitality sectors', + '13 Imp Industry outside the travel tourism or hospitality sectors specify', + '14 Dis Imp Low income HHs and populations', + '15 Dis Imp HHs and populations residing in Qualified Census Tracts', + '16 Dis Imp HHs that qualify for certain federal programs', + '17 Dis Imp HHs receiving services provided by Tribal governments', + '18 Dis Imp HHs residing in the U.S. territories or receiving services', + '19 Dis Imp For services to address educational disparities Title I eligible', + '20 Dis Imp Other HHs or populations that experienced a disproportionate', + '21 Dis Imp SBs operating in Qualified Census Tracts', + '22 Dis Imp SBs operated by Tribal governments or on Tribal lands', + '23 Dis Imp SBs operating in the U.S. territories', + '24 Dis Imp Other SBs Dis Imp by the pandemic specify', + '25 Dis Imp NPs operating in Qualified Census Tracts', + '26 Dis Imp NPs operated by Tribal governments or on Tribal lands', + '27 Dis Imp NPs operating in the U.S. territories', + '28 Dis Imp Other NPs Dis Imp by the pandemic specify', + ], + columnName: 'R', + humanColName: + 'Project Demographic Distribution - Additional Populations Served', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Tertiary_Proj_Demographics_Explanation__c: { + key: 'Tertiary_Proj_Demographics_Explanation__c', + index: 18, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'S', + humanColName: 'Tertiary Project Demographic Explanation', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Structure_Objectives_of_Asst_Programs__c: { + key: 'Structure_Objectives_of_Asst_Programs__c', + index: 19, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'T', + humanColName: 'Structure and objectives of assistance program', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Recipient_Approach_Description__c: { + key: 'Recipient_Approach_Description__c', + index: 20, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'U', + humanColName: 'Recipients approach', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Spending_Allocated_Toward_Evidence_Based_Interventions: { + key: 'Spending_Allocated_Toward_Evidence_Based_Interventions', + index: 21, + required: 'Conditional', + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'V', + humanColName: + 'Identify the dollar amount of the total project spending that is allocated towards evidence-based interventions', + ecCodes: ['1.4', '1.11', '1.12', '1.13'], + }, + Whether_program_evaluation_is_being_conducted: { + key: 'Whether_program_evaluation_is_being_conducted', + index: 22, + required: 'Conditional', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'W', + humanColName: 'Is a program evaluation of the project being conducted?', + ecCodes: ['1.4', '1.11', '1.12', '1.13'], + }, + Small_Businesses_Served__c: { + key: 'Small_Businesses_Served__c', + index: 23, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'X', + humanColName: 'Number of Small Businesses Served ', + ecCodes: ['1.8'], + }, + Number_Non_Profits_Served__c: { + key: 'Number_Non_Profits_Served__c', + index: 24, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'Y', + humanColName: 'Non-Profits Served', + ecCodes: ['1.9'], + }, + Does_Project_Include_Capital_Expenditure__c: { + key: 'Does_Project_Include_Capital_Expenditure__c', + index: 25, + required: true, + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'Z', + humanColName: 'Does this project include a capital expenditure?', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Total_Cost_Capital_Expenditure__c: { + key: 'Total_Cost_Capital_Expenditure__c', + index: 26, + required: 'Conditional', + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'AA', + humanColName: 'If yes, what is the total expected capital expenditure?', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Type_of_Capital_Expenditure__c: { + key: 'Type_of_Capital_Expenditure__c', + index: 27, + required: 'Conditional', + dataType: 'Pick List', + maxLength: null, + listVals: [ + 'Acquisition of equipment for COVID-19 prevention and treatment', + 'Adaptations to congregate living facilities', + 'Affordable housing supportive housing or recovery housing', + 'Behavioral health facilities and equipment', + 'Childcare, daycare, and early learning facilities', + 'COVID-19 testing sites and laboratories', + 'COVID-19 vaccination sites', + 'Devices and equipment that assist households in accessing the internet', + 'Emergency operations centers and acquisition of emergency response equipment', + 'Food banks and other facilities', + 'Improvements to existing facilities', + 'Installation and improvement of ventilation systems', + 'Job and workforce training centers', + 'Medical equipment and facilities', + 'Medical facilities generally dedicated to COVID-19 treatment and mitigation', + 'Mitigation measures in small businesses, nonprofits, and impacted industries', + 'Parks, green spaces, recreational facilities, sidewalks', + 'Public health data systems', + 'Rehabilitations, renovation, remediation, cleanup, or conversions', + 'Schools and other educational facilities', + 'Technology and equipment to allow law enforcement', + 'Technology and tools', + 'Technology infrastructure to adapt government operations', + 'Temporary medical facilities and other measures', + 'Transitional shelters', + 'Other (please specify)', + ], + columnName: 'AB', + humanColName: 'Capital Expenditure Type', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Type_of_Capital_Expenditure_Other__c: { + key: 'Type_of_Capital_Expenditure_Other__c', + index: 28, + required: 'Conditional', + dataType: 'String', + maxLength: 255, + listVals: [], + columnName: 'AC', + humanColName: 'Other Capital Expenditure Explanation', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Capital_Expenditure_Justification__c: { + key: 'Capital_Expenditure_Justification__c', + index: 29, + required: 'Conditional', + dataType: 'String', + maxLength: 1500, + listVals: [], + columnName: 'AD', + humanColName: 'Capital Expenditure Narrative', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + '(manual entry)1': { + key: '(manual entry)1', + index: 30, + required: 'Required if Budget > $10M', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AE', + humanColName: + 'If budget is over $10M, was a Davis Bacon Act Certification completed?', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + '(manual entry)2': { + key: '(manual entry)2', + index: 31, + required: 'Required if DB is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'AF', + humanColName: + 'If budget is over $10M and Davis Bacon Act Certification NOT completed - enter the number of employees of contractors and sub-contractors working on the project.', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + '(manual entry)3': { + key: '(manual entry)3', + index: 32, + required: 'Required if DB is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'AG', + humanColName: + 'If budget is over $10M and Davis Bacon Act Certification NOT completed - enter the number of employees on the project hired directly and hired through a third party. ', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + '(manual entry)4': { + key: '(manual entry)4', + index: 33, + required: 'Required if DB is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'AH', + humanColName: + 'If budget is over $10M and Davis Bacon Act Certification NOT completed - enter the wages and benefits of workers on the project by classification', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + '(manual entry)5': { + key: '(manual entry)5', + index: 34, + required: 'Required if DB is No', + dataType: 'TBD', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AI', + humanColName: + 'If budget is over $10M and Davis Bacon Act Certification NOT completed - are those wages are at rates less than those prevailing?', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + '(manual entry)6': { + key: '(manual entry)6', + index: 35, + required: 'Required if Budget > $10M', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AJ', + humanColName: + 'If budget is over $10M, was a Labor Agreement Certification completed?', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + '(manual entry)7': { + key: '(manual entry)7', + index: 36, + required: 'Required if PLA is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'AK', + humanColName: + 'If budget is over $10M and Labor Agreement Certification NOT completed - enter how the recipient will ensure the project has ready access to a sufficient supply of appropriately skilled and unskilled labor to ensure high-quality construction throughout the life of the project, including a description of any required professional certifications and/or in-house training. ', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + '(manual entry)8': { + key: '(manual entry)8', + index: 37, + required: 'Required if PLA is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'AL', + humanColName: + 'If budget is over $10M and Labor Agreement Certification NOT completed - enter how the recipient will minimize risks of labor disputes and disruptions that would jeopardize timeliness and cost-effectiveness of the project.', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + '(manual entry)9': { + key: '(manual entry)9', + index: 38, + required: 'Required if PLA is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'AM', + humanColName: + 'If budget is over $10M and Labor Agreement Certification NOT completed - enter how the recipient will provide a safe and healthy workplace that avoids delays and costs associated with workplace illnesses, injuries, and fatalities, including descriptions of safety training, certification, and/or licensure requirements for all relevant workers (e.g., OSHA 10, OSHA 30).', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + '(manual entry)10': { + key: '(manual entry)10', + index: 39, + required: 'Required if PLA is No', + dataType: 'TBD', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AN', + humanColName: + 'If budget is over $10M and Labor Agreement Certification NOT completed - enter whether workers on the project will receive wages and benefits that will secure an appropriately skilled workforce in the context of the local or regional labor market.', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + '(manual entry)11': { + key: '(manual entry)11', + index: 40, + required: 'Required if PLA is No', + dataType: 'TBD', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AO', + humanColName: + 'If budget is over $10M and Labor Agreement Certification NOT completed - enter whether the project has completed a project labor agreement. ', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + '(manual entry)12': { + key: '(manual entry)12', + index: 41, + required: 'Required if Budget > $10M', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AP', + humanColName: + 'If budget is over $10M, does the project prioritize local hires? ', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + '(manual entry)13': { + key: '(manual entry)13', + index: 42, + required: 'Required if Budget > $10M', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AQ', + humanColName: + 'If budget is over $10M, does the project have a Community Benefit Agreement?', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + '(manual entry)14': { + key: '(manual entry)14', + index: 43, + required: 'Required if Budget > $10M', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'AR', + humanColName: + 'If budget is over $10M and has a Community Benefit Agreement, provide a description. ', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + Number_Workers_Enrolled_Sectoral__c: { + key: 'Number_Workers_Enrolled_Sectoral__c', + index: 44, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'AS', + humanColName: + 'Number of workers enrolled in sectoral job training programs', + ecCodes: ['1.11'], + }, + Number_Workers_Competing_Sectoral__c: { + key: 'Number_Workers_Competing_Sectoral__c', + index: 45, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'AT', + humanColName: + 'Number of workers completing sectoral job training programs ', + ecCodes: ['1.11'], + }, + Number_People_Summer_Youth__c: { + key: 'Number_People_Summer_Youth__c', + index: 46, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'AU', + humanColName: + 'Number of people participating in summer youth employment programs', + ecCodes: ['1.11'], + }, + Cancellation_Reason__c: { + key: 'Cancellation_Reason__c', + index: 47, + required: 'Conditional', + dataType: 'String', + maxLength: 255, + listVals: [], + columnName: 'AV', + humanColName: 'Cancelled Status of Completion Explanation', + ecCodes: [ + '1.1', + '1.2', + '1.3', + '1.4', + '1.5', + '1.6', + '1.7', + '1.8', + '1.9', + '1.10', + '1.11', + '1.12', + '1.13', + '1.14', + ], + }, + }, + ec2: { + Name: { + key: 'Name', + index: 2, + required: true, + dataType: 'String', + maxLength: 150, + listVals: [], + columnName: 'C', + humanColName: 'Project Name', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Project_Identification_Number__c: { + key: 'Project_Identification_Number__c', + index: 3, + required: true, + dataType: 'String', + maxLength: 20, + listVals: [], + columnName: 'D', + humanColName: 'Project Identification Number\r\n(Assigned by recipient)', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Completion_Status__c: { + key: 'Completion_Status__c', + index: 4, + required: true, + dataType: 'Pick List', + maxLength: null, + listVals: [ + 'Not started', + 'Completed less than 50%', + 'Completed 50% or more', + 'Completed', + 'Cancelled', + ], + columnName: 'E', + humanColName: 'Status of Completion', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Adopted_Budget__c: { + key: 'Adopted_Budget__c', + index: 5, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'F', + humanColName: 'Adopted Budget', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Total_Obligations__c: { + key: 'Total_Obligations__c', + index: 6, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'G', + humanColName: 'Total Cumulative\r\nObligations', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Total_Expenditures__c: { + key: 'Total_Expenditures__c', + index: 7, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'H', + humanColName: 'Total Cumulative\r\nExpenditures', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Current_Period_Obligations__c: { + key: 'Current_Period_Obligations__c', + index: 8, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'I', + humanColName: 'Current Period Obligations', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Current_Period_Expenditures__c: { + key: 'Current_Period_Expenditures__c', + index: 9, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'J', + humanColName: 'Current Period Expenditures', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Project_Description__c: { + key: 'Project_Description__c', + index: 10, + required: true, + dataType: 'String', + maxLength: 1500, + listVals: [], + columnName: 'K', + humanColName: 'Project Description', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Program_Income_Earned__c: { + key: 'Program_Income_Earned__c', + index: 11, + required: false, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'L', + humanColName: 'Program Income Earned', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Program_Income_Expended__c: { + key: 'Program_Income_Expended__c', + index: 12, + required: false, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'M', + humanColName: 'Program Income Expended', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Primary_Project_Demographics__c: { + key: 'Primary_Project_Demographics__c', + index: 13, + required: 'Conditional', + dataType: 'Pick List', + maxLength: null, + listVals: [ + '1 Imp General Public', + '2 Imp Low or moderate income HHs or populations', + '3 Imp HHs that experienced unemployment', + '4 Imp HHs that experienced increased food or housing insecurity', + '5 Imp HHs that qualify for certain federal programs', + '6 Imp For services to address lost instructional time in K-12 schools', + '7 Imp Other HHs or populations that experienced a negative economic', + '8 Imp SBs that experienced a negative economic impact', + '9 Imp Classes of SBs designated as negatively economically impacted', + '10 Imp NPs that experienced a negative economic impact specify', + '11 Imp Classes of NPs designated as negatively economically impacted', + '12 Imp Travel tourism or hospitality sectors', + '13 Imp Industry outside the travel tourism or hospitality sectors specify', + '14 Dis Imp Low income HHs and populations', + '15 Dis Imp HHs and populations residing in Qualified Census Tracts', + '16 Dis Imp HHs that qualify for certain federal programs', + '17 Dis Imp HHs receiving services provided by Tribal governments', + '18 Dis Imp HHs residing in the U.S. territories or receiving services', + '19 Dis Imp For services to address educational disparities Title I eligible', + '20 Dis Imp Other HHs or populations that experienced a disproportionate', + '21 Dis Imp SBs operating in Qualified Census Tracts', + '22 Dis Imp SBs operated by Tribal governments or on Tribal lands', + '23 Dis Imp SBs operating in the U.S. territories', + '24 Dis Imp Other SBs Dis Imp by the pandemic specify', + '25 Dis Imp NPs operating in Qualified Census Tracts', + '26 Dis Imp NPs operated by Tribal governments or on Tribal lands', + '27 Dis Imp NPs operating in the U.S. territories', + '28 Dis Imp Other NPs Dis Imp by the pandemic specify', + ], + columnName: 'N', + humanColName: + 'Project Demographic Distribution - Primary Populations Served', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Primary_Project_Demographics_Explanation__c: { + key: 'Primary_Project_Demographics_Explanation__c', + index: 14, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'O', + humanColName: 'Primary Project Demographic Explanation', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Secondary_Project_Demographics__c: { + key: 'Secondary_Project_Demographics__c', + index: 15, + required: false, + dataType: 'Pick List', + maxLength: null, + listVals: [ + '1 Imp General Public', + '2 Imp Low or moderate income HHs or populations', + '3 Imp HHs that experienced unemployment', + '4 Imp HHs that experienced increased food or housing insecurity', + '5 Imp HHs that qualify for certain federal programs', + '6 Imp For services to address lost instructional time in K-12 schools', + '7 Imp Other HHs or populations that experienced a negative economic', + '8 Imp SBs that experienced a negative economic impact', + '9 Imp Classes of SBs designated as negatively economically impacted', + '10 Imp NPs that experienced a negative economic impact specify', + '11 Imp Classes of NPs designated as negatively economically impacted', + '12 Imp Travel tourism or hospitality sectors', + '13 Imp Industry outside the travel tourism or hospitality sectors specify', + '14 Dis Imp Low income HHs and populations', + '15 Dis Imp HHs and populations residing in Qualified Census Tracts', + '16 Dis Imp HHs that qualify for certain federal programs', + '17 Dis Imp HHs receiving services provided by Tribal governments', + '18 Dis Imp HHs residing in the U.S. territories or receiving services', + '19 Dis Imp For services to address educational disparities Title I eligible', + '20 Dis Imp Other HHs or populations that experienced a disproportionate', + '21 Dis Imp SBs operating in Qualified Census Tracts', + '22 Dis Imp SBs operated by Tribal governments or on Tribal lands', + '23 Dis Imp SBs operating in the U.S. territories', + '24 Dis Imp Other SBs Dis Imp by the pandemic specify', + '25 Dis Imp NPs operating in Qualified Census Tracts', + '26 Dis Imp NPs operated by Tribal governments or on Tribal lands', + '27 Dis Imp NPs operating in the U.S. territories', + '28 Dis Imp Other NPs Dis Imp by the pandemic specify', + ], + columnName: 'P', + humanColName: + 'Project Demographic Distribution - Secondary Populations Served', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Secondary_Proj_Demographics_Explanation__c: { + key: 'Secondary_Proj_Demographics_Explanation__c', + index: 16, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'Q', + humanColName: 'Secondary Project Demographic Explanation', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Tertiary_Project_Demographics__c: { + key: 'Tertiary_Project_Demographics__c', + index: 17, + required: false, + dataType: 'Pick List', + maxLength: null, + listVals: [ + '1 Imp General Public', + '2 Imp Low or moderate income HHs or populations', + '3 Imp HHs that experienced unemployment', + '4 Imp HHs that experienced increased food or housing insecurity', + '5 Imp HHs that qualify for certain federal programs', + '6 Imp For services to address lost instructional time in K-12 schools', + '7 Imp Other HHs or populations that experienced a negative economic', + '8 Imp SBs that experienced a negative economic impact', + '9 Imp Classes of SBs designated as negatively economically impacted', + '10 Imp NPs that experienced a negative economic impact specify', + '11 Imp Classes of NPs designated as negatively economically impacted', + '12 Imp Travel tourism or hospitality sectors', + '13 Imp Industry outside the travel tourism or hospitality sectors specify', + '14 Dis Imp Low income HHs and populations', + '15 Dis Imp HHs and populations residing in Qualified Census Tracts', + '16 Dis Imp HHs that qualify for certain federal programs', + '17 Dis Imp HHs receiving services provided by Tribal governments', + '18 Dis Imp HHs residing in the U.S. territories or receiving services', + '19 Dis Imp For services to address educational disparities Title I eligible', + '20 Dis Imp Other HHs or populations that experienced a disproportionate', + '21 Dis Imp SBs operating in Qualified Census Tracts', + '22 Dis Imp SBs operated by Tribal governments or on Tribal lands', + '23 Dis Imp SBs operating in the U.S. territories', + '24 Dis Imp Other SBs Dis Imp by the pandemic specify', + '25 Dis Imp NPs operating in Qualified Census Tracts', + '26 Dis Imp NPs operated by Tribal governments or on Tribal lands', + '27 Dis Imp NPs operating in the U.S. territories', + '28 Dis Imp Other NPs Dis Imp by the pandemic specify', + ], + columnName: 'R', + humanColName: + 'Project Demographic Distribution - Tertiary Populations Served', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Tertiary_Proj_Demographics_Explanation__c: { + key: 'Tertiary_Proj_Demographics_Explanation__c', + index: 18, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'S', + humanColName: 'Tertiary Project Demographic Explanation', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Structure_Objectives_of_Asst_Programs__c: { + key: 'Structure_Objectives_of_Asst_Programs__c', + index: 19, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'T', + humanColName: + 'Brief description of structure and objectives of assistance program(s), including public health or negative economic impact experienced.', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Recipient_Approach_Description__c: { + key: 'Recipient_Approach_Description__c', + index: 20, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'U', + humanColName: + 'Brief description of how a recipient’s response is related and reasonably and proportional to a public health or negative economic impact of COVID-19.', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Individuals_Served__c: { + key: 'Individuals_Served__c', + index: 21, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'V', + humanColName: 'Number of households served', + ecCodes: ['2.1', '2.2', '2.3', '2.4', '2.5', '2.6', '2.7', '2.8'], + }, + Spending_Allocated_Toward_Evidence_Based_Interventions: { + key: 'Spending_Allocated_Toward_Evidence_Based_Interventions', + index: 22, + required: 'Conditional', + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'W', + humanColName: + 'The dollar amount of the total project spending that is allocated towards evidence-based interventions', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.6', + '2.7', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.25', + '2.26', + '2.30', + '2.32', + '2.33', + '2.37', + ], + }, + Whether_program_evaluation_is_being_conducted: { + key: 'Whether_program_evaluation_is_being_conducted', + index: 23, + required: 'Conditional', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'X', + humanColName: + 'Indicate if a program evaluation of the project is being conducted ', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.6', + '2.7', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.25', + '2.26', + '2.30', + '2.32', + '2.33', + '2.37', + ], + }, + Small_Businesses_Served__c: { + key: 'Small_Businesses_Served__c', + index: 24, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'Y', + humanColName: + 'Number of small businesses served (by program if recipient establishes multiple separate \r\nsmall businesses assistance programs) ', + ecCodes: ['2.29', '2.30', '2.31', '2.32', '2.33'], + }, + Number_Non_Profits_Served__c: { + key: 'Number_Non_Profits_Served__c', + index: 25, + required: 'Conditional', + dataType: 'String', + maxLength: 10, + listVals: [], + columnName: 'Z', + humanColName: + 'Number of Non-Profits served (by program if recipient establishes multiple separate non-\r\nprofit assistance programs) ', + ecCodes: ['2.34'], + }, + School_ID_or_District_ID__c: { + key: 'School_ID_or_District_ID__c', + index: 26, + required: 'Conditional', + dataType: 'String', + maxLength: 750, + listVals: [], + columnName: 'AA', + humanColName: 'NCES School ID or NCES District ID', + ecCodes: ['2.11', '2.12', '2.13', '2.14', '2.24', '2.25', '2.26', '2.27'], + }, + Industry_Experienced_8_Percent_Loss__c: { + key: 'Industry_Experienced_8_Percent_Loss__c', + index: 27, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'AB', + humanColName: 'Industry other than tourism Narrative', + ecCodes: ['2.36'], + }, + Does_Project_Include_Capital_Expenditure__c: { + key: 'Does_Project_Include_Capital_Expenditure__c', + index: 28, + required: true, + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AC', + humanColName: 'Does this project include a capital expenditure?', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Total_Cost_Capital_Expenditure__c: { + key: 'Total_Cost_Capital_Expenditure__c', + index: 29, + required: 'Conditional', + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'AD', + humanColName: 'If yes, what is the total expected capital expenditure?', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Type_of_Capital_Expenditure__c: { + key: 'Type_of_Capital_Expenditure__c', + index: 30, + required: 'Conditional', + dataType: 'Pick List', + maxLength: null, + listVals: [ + 'Acquisition of equipment for COVID-19 prevention and treatment', + 'Adaptations to congregate living facilities', + 'Affordable housing supportive housing or recovery housing', + 'Behavioral health facilities and equipment', + 'Childcare, daycare, and early learning facilities', + 'COVID-19 testing sites and laboratories', + 'COVID-19 vaccination sites', + 'Devices and equipment that assist households in accessing the internet', + 'Emergency operations centers and acquisition of emergency response equipment', + 'Food banks and other facilities', + 'Improvements to existing facilities', + 'Installation and improvement of ventilation systems', + 'Job and workforce training centers', + 'Medical equipment and facilities', + 'Medical facilities generally dedicated to COVID-19 treatment and mitigation', + 'Mitigation measures in small businesses, nonprofits, and impacted industries', + 'Parks, green spaces, recreational facilities, sidewalks', + 'Public health data systems', + 'Rehabilitations, renovation, remediation, cleanup, or conversions', + 'Schools and other educational facilities', + 'Technology and equipment to allow law enforcement', + 'Technology and tools', + 'Technology infrastructure to adapt government operations', + 'Temporary medical facilities and other measures', + 'Transitional shelters', + 'Other (please specify)', + ], + columnName: 'AE', + humanColName: 'Capital Expenditure Type', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Type_of_Capital_Expenditure_Other__c: { + key: 'Type_of_Capital_Expenditure_Other__c', + index: 31, + required: 'Conditional', + dataType: 'String', + maxLength: 255, + listVals: [], + columnName: 'AF', + humanColName: 'Other Capital Expenditure Explanation', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Capital_Expenditure_Justification__c: { + key: 'Capital_Expenditure_Justification__c', + index: 32, + required: 'Conditional', + dataType: 'String', + maxLength: 1500, + listVals: [], + columnName: 'AG', + humanColName: 'Capital Expenditure Narrative', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + '(manual entry)1': { + key: '(manual entry)1', + index: 33, + required: 'Required if Budget > $10M', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AH', + humanColName: + 'If budget is over $10M, was a Davis Bacon Act Certification completed?', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + '(manual entry)2': { + key: '(manual entry)2', + index: 34, + required: 'Required if DB is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'AI', + humanColName: + 'If budget is over $10M and Davis Bacon Act Certification NOT completed - enter the number of employees of contractors and sub-contractors working on the project.', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + '(manual entry)3': { + key: '(manual entry)3', + index: 35, + required: 'Required if DB is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'AJ', + humanColName: + 'If budget is over $10M and Davis Bacon Act Certification NOT completed - enter the number of employees on the project hired directly and hired through a third party. ', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + '(manual entry)4': { + key: '(manual entry)4', + index: 36, + required: 'Required if DB is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'AK', + humanColName: + 'If budget is over $10M and Davis Bacon Act Certification NOT completed - enter the wages and benefits of workers on the project by classification', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + '(manual entry)5': { + key: '(manual entry)5', + index: 37, + required: 'Required if DB is No', + dataType: 'TBD', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AL', + humanColName: + 'If budget is over $10M and Davis Bacon Act Certification NOT completed - are those wages are at rates less than those prevailing?', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + '(manual entry)6': { + key: '(manual entry)6', + index: 38, + required: 'Required if Budget > $10M', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AM', + humanColName: + 'If budget is over $10M, was a Labor Agreement Certification completed?', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + '(manual entry)7': { + key: '(manual entry)7', + index: 39, + required: 'Required if PLA is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'AN', + humanColName: + 'If budget is over $10M and Labor Agreement Certification NOT completed - enter how the recipient will ensure the project has ready access to a sufficient supply of appropriately skilled and unskilled labor to ensure high-quality construction throughout the life of the project, including a description of any required professional certifications and/or in-house training. ', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + '(manual entry)8': { + key: '(manual entry)8', + index: 40, + required: 'Required if PLA is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'AO', + humanColName: + 'If budget is over $10M and Labor Agreement Certification NOT completed - enter how the recipient will minimize risks of labor disputes and disruptions that would jeopardize timeliness and cost-effectiveness of the project.', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + '(manual entry)9': { + key: '(manual entry)9', + index: 41, + required: 'Required if PLA is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'AP', + humanColName: + 'If budget is over $10M and Labor Agreement Certification NOT completed - enter how the recipient will provide a safe and healthy workplace that avoids delays and costs associated with workplace illnesses, injuries, and fatalities, including descriptions of safety training, certification, and/or licensure requirements for all relevant workers (e.g., OSHA 10, OSHA 30).', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + '(manual entry)10': { + key: '(manual entry)10', + index: 42, + required: 'Required if PLA is No', + dataType: 'TBD', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AQ', + humanColName: + 'If budget is over $10M and Labor Agreement Certification NOT completed - enter whether workers on the project will receive wages and benefits that will secure an appropriately skilled workforce in the context of the local or regional labor market.', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + '(manual entry)11': { + key: '(manual entry)11', + index: 43, + required: 'Required if PLA is No', + dataType: 'TBD', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AR', + humanColName: + 'If budget is over $10M and Labor Agreement Certification NOT completed - enter whether the project has completed a project labor agreement. ', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + '(manual entry)12': { + key: '(manual entry)12', + index: 44, + required: 'Required if Budget > $10M', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AS', + humanColName: + 'If budget is over $10M, does the project prioritize local hires? ', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + '(manual entry)13': { + key: '(manual entry)13', + index: 45, + required: 'Required if Budget > $10M', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AT', + humanColName: + 'If budget is over $10M, does the project have a Community Benefit Agreement?', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + '(manual entry)14': { + key: '(manual entry)14', + index: 46, + required: 'Required if Budget > $10M', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'AU', + humanColName: + 'If budget is over $10M and has a Community Benefit Agreement, provide a description. ', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + Number_Workers_Enrolled_Sectoral__c: { + key: 'Number_Workers_Enrolled_Sectoral__c', + index: 47, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'AV', + humanColName: + 'Number of workers enrolled in sectoral job training programs', + ecCodes: ['2.10'], + }, + Number_Workers_Competing_Sectoral__c: { + key: 'Number_Workers_Competing_Sectoral__c', + index: 48, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'AW', + humanColName: + 'Number of workers completing sectoral job training programs ', + ecCodes: ['2.10'], + }, + Number_People_Summer_Youth__c: { + key: 'Number_People_Summer_Youth__c', + index: 49, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'AX', + humanColName: + 'Number of people participating in summer youth employment programs', + ecCodes: ['2.10'], + }, + Number_Households_Eviction_Prevention__c: { + key: 'Number_Households_Eviction_Prevention__c', + index: 50, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'AY', + humanColName: + 'Number of Households receiving eviction prevention services (including legal representation)', + ecCodes: ['2.2', '2.15', '2.16', '2.17', '2.18'], + }, + Number_Affordable_Housing_Units__c: { + key: 'Number_Affordable_Housing_Units__c', + index: 51, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'AZ', + humanColName: 'Number of affordable housing units preserved or developed', + ecCodes: ['2.2', '2.15', '2.16', '2.17', '2.18'], + }, + Number_Students_Tutoring_Programs__c: { + key: 'Number_Students_Tutoring_Programs__c', + index: 52, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'BA', + humanColName: + 'Number of students participating in evidence-based tutoring programs', + ecCodes: ['2.24', '2.25', '2.26', '2.27'], + }, + Number_Children_Served_Childcare__c: { + key: 'Number_Children_Served_Childcare__c', + index: 53, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'BB', + humanColName: + 'Number of children served by childcare and early learning services (pre-school/pre-K/ages 3-5)', + ecCodes: ['2.11', '2.12', '2.13', '2.14'], + }, + Number_Families_Served_Home_Visiting__c: { + key: 'Number_Families_Served_Home_Visiting__c', + index: 54, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'BC', + humanColName: 'Number of families served by home visiting', + ecCodes: ['2.11', '2.12', '2.13', '2.14'], + }, + Cancellation_Reason__c: { + key: 'Cancellation_Reason__c', + index: 55, + required: 'Conditional', + dataType: 'String', + maxLength: 255, + listVals: [], + columnName: 'BD', + humanColName: 'Cancelled Status of Completion Explanation', + ecCodes: [ + '2.1', + '2.2', + '2.3', + '2.4', + '2.5', + '2.6', + '2.7', + '2.8', + '2.9', + '2.10', + '2.11', + '2.12', + '2.13', + '2.14', + '2.15', + '2.16', + '2.17', + '2.18', + '2.19', + '2.20', + '2.21', + '2.22', + '2.23', + '2.24', + '2.25', + '2.26', + '2.27', + '2.28', + '2.29', + '2.30', + '2.31', + '2.32', + '2.33', + '2.34', + '2.35', + '2.36', + '2.37', + ], + }, + }, + ec3: { + Name: { + key: 'Name', + index: 2, + required: true, + dataType: 'String', + maxLength: 150, + listVals: [], + columnName: 'C', + humanColName: 'Project Name', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + Project_Identification_Number__c: { + key: 'Project_Identification_Number__c', + index: 3, + required: true, + dataType: 'String', + maxLength: 20, + listVals: [], + columnName: 'D', + humanColName: 'Project Identification Number\r\n(Assigned by recipient)', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + Completion_Status__c: { + key: 'Completion_Status__c', + index: 4, + required: true, + dataType: 'Pick List', + maxLength: null, + listVals: [ + 'Not started', + 'Completed less than 50%', + 'Completed 50% or more', + 'Completed', + 'Cancelled', + ], + columnName: 'E', + humanColName: 'Status of Completion', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + Adopted_Budget__c: { + key: 'Adopted_Budget__c', + index: 5, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'F', + humanColName: 'Adopted Budget', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + Total_Obligations__c: { + key: 'Total_Obligations__c', + index: 6, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'G', + humanColName: 'Total Cumulative\r\nObligations', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + Total_Expenditures__c: { + key: 'Total_Expenditures__c', + index: 7, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'H', + humanColName: 'Total Cumulative\r\nExpenditures', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + Current_Period_Obligations__c: { + key: 'Current_Period_Obligations__c', + index: 8, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'I', + humanColName: 'Current Period Obligations', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + Current_Period_Expenditures__c: { + key: 'Current_Period_Expenditures__c', + index: 9, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'J', + humanColName: 'Current Period Expenditures', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + Project_Description__c: { + key: 'Project_Description__c', + index: 10, + required: true, + dataType: 'String', + maxLength: 1500, + listVals: [], + columnName: 'K', + humanColName: 'Project Description', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + Program_Income_Earned__c: { + key: 'Program_Income_Earned__c', + index: 11, + required: false, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'L', + humanColName: 'Program Income Earned', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + Program_Income_Expended__c: { + key: 'Program_Income_Expended__c', + index: 12, + required: false, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'M', + humanColName: 'Program Income Expended', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + Structure_Objectives_of_Asst_Programs__c: { + key: 'Structure_Objectives_of_Asst_Programs__c', + index: 13, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'N', + humanColName: + 'Brief description of structure and objectives of assistance program(s), including public health or negative economic impact experienced.', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + Recipient_Approach_Description__c: { + key: 'Recipient_Approach_Description__c', + index: 14, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'O', + humanColName: + 'Brief description of how a recipient’s response is related and reasonably and proportional to a public health or negative economic impact of COVID-19.', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + Payroll_Public_Health_Safety__c: { + key: 'Payroll_Public_Health_Safety__c', + index: 15, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'P', + humanColName: + 'Number of government FTEs responding to COVID-19 supported under this authority', + ecCodes: ['3.1'], + }, + Number_of_FTEs_Rehired__c: { + key: 'Number_of_FTEs_Rehired__c', + index: 16, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'Q', + humanColName: + 'Number of FTEs rehired by governments under this authority', + ecCodes: ['3.2'], + }, + Does_Project_Include_Capital_Expenditure__c: { + key: 'Does_Project_Include_Capital_Expenditure__c', + index: 17, + required: true, + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'R', + humanColName: 'Does this project include a capital expenditure?', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + Total_Cost_Capital_Expenditure__c: { + key: 'Total_Cost_Capital_Expenditure__c', + index: 18, + required: 'Conditional', + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'S', + humanColName: 'If yes, what is the total expected capital expenditure?', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + Type_of_Capital_Expenditure__c: { + key: 'Type_of_Capital_Expenditure__c', + index: 19, + required: 'Conditional', + dataType: 'Pick List', + maxLength: null, + listVals: [ + 'Acquisition of equipment for COVID-19 prevention and treatment', + 'Adaptations to congregate living facilities', + 'Affordable housing supportive housing or recovery housing', + 'Behavioral health facilities and equipment', + 'Childcare, daycare, and early learning facilities', + 'COVID-19 testing sites and laboratories', + 'COVID-19 vaccination sites', + 'Devices and equipment that assist households in accessing the internet', + 'Emergency operations centers and acquisition of emergency response equipment', + 'Food banks and other facilities', + 'Improvements to existing facilities', + 'Installation and improvement of ventilation systems', + 'Job and workforce training centers', + 'Medical equipment and facilities', + 'Medical facilities generally dedicated to COVID-19 treatment and mitigation', + 'Mitigation measures in small businesses, nonprofits, and impacted industries', + 'Parks, green spaces, recreational facilities, sidewalks', + 'Public health data systems', + 'Rehabilitations, renovation, remediation, cleanup, or conversions', + 'Schools and other educational facilities', + 'Technology and equipment to allow law enforcement', + 'Technology and tools', + 'Technology infrastructure to adapt government operations', + 'Temporary medical facilities and other measures', + 'Transitional shelters', + 'Other (please specify)', + ], + columnName: 'T', + humanColName: 'Capital Expenditure Type', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + Type_of_Capital_Expenditure_Other__c: { + key: 'Type_of_Capital_Expenditure_Other__c', + index: 20, + required: 'Conditional', + dataType: 'String', + maxLength: 255, + listVals: [], + columnName: 'U', + humanColName: 'Other Capital Expenditure Explanation', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + Capital_Expenditure_Justification__c: { + key: 'Capital_Expenditure_Justification__c', + index: 21, + required: 'Conditional', + dataType: 'String', + maxLength: 1500, + listVals: [], + columnName: 'V', + humanColName: 'Capital Expenditure Narrative', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + '(manual entry)1': { + key: '(manual entry)1', + index: 22, + required: 'Required if Budget > $10M', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'W', + humanColName: + 'If budget is over $10M, was a Davis Bacon Act Certification completed?', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + '(manual entry)2': { + key: '(manual entry)2', + index: 23, + required: 'Required if DB is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'X', + humanColName: + 'If budget is over $10M and Davis Bacon Act Certification NOT completed - enter the number of employees of contractors and sub-contractors working on the project.', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + '(manual entry)3': { + key: '(manual entry)3', + index: 24, + required: 'Required if DB is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'Y', + humanColName: + 'If budget is over $10M and Davis Bacon Act Certification NOT completed - enter the number of employees on the project hired directly and hired through a third party. ', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + '(manual entry)4': { + key: '(manual entry)4', + index: 25, + required: 'Required if DB is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'Z', + humanColName: + 'If budget is over $10M and Davis Bacon Act Certification NOT completed - enter the wages and benefits of workers on the project by classification', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + '(manual entry)5': { + key: '(manual entry)5', + index: 26, + required: 'Required if DB is No', + dataType: 'TBD', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AA', + humanColName: + 'If budget is over $10M and Davis Bacon Act Certification NOT completed - are those wages are at rates less than those prevailing?', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + '(manual entry)6': { + key: '(manual entry)6', + index: 27, + required: 'Required if Budget > $10M', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AB', + humanColName: + 'If budget is over $10M, was a Labor Agreement Certification completed?', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + '(manual entry)7': { + key: '(manual entry)7', + index: 28, + required: 'Required if PLA is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'AC', + humanColName: + 'If budget is over $10M and Labor Agreement Certification NOT completed - enter how the recipient will ensure the project has ready access to a sufficient supply of appropriately skilled and unskilled labor to ensure high-quality construction throughout the life of the project, including a description of any required professional certifications and/or in-house training. ', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + '(manual entry)8': { + key: '(manual entry)8', + index: 29, + required: 'Required if PLA is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'AD', + humanColName: + 'If budget is over $10M and Labor Agreement Certification NOT completed - enter how the recipient will minimize risks of labor disputes and disruptions that would jeopardize timeliness and cost-effectiveness of the project.', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + '(manual entry)9': { + key: '(manual entry)9', + index: 30, + required: 'Required if PLA is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'AE', + humanColName: + 'If budget is over $10M and Labor Agreement Certification NOT completed - enter how the recipient will provide a safe and healthy workplace that avoids delays and costs associated with workplace illnesses, injuries, and fatalities, including descriptions of safety training, certification, and/or licensure requirements for all relevant workers (e.g., OSHA 10, OSHA 30).', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + '(manual entry)10': { + key: '(manual entry)10', + index: 31, + required: 'Required if PLA is No', + dataType: 'TBD', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AF', + humanColName: + 'If budget is over $10M and Labor Agreement Certification NOT completed - enter whether workers on the project will receive wages and benefits that will secure an appropriately skilled workforce in the context of the local or regional labor market.', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + '(manual entry)11': { + key: '(manual entry)11', + index: 32, + required: 'Required if PLA is No', + dataType: 'TBD', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AG', + humanColName: + 'If budget is over $10M and Labor Agreement Certification NOT completed - enter whether the project has completed a project labor agreement. ', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + '(manual entry)12': { + key: '(manual entry)12', + index: 33, + required: 'Required if Budget > $10M', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AH', + humanColName: + 'If budget is over $10M, does the project prioritize local hires? ', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + '(manual entry)13': { + key: '(manual entry)13', + index: 34, + required: 'Required if Budget > $10M', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AI', + humanColName: + 'If budget is over $10M, does the project have a Community Benefit Agreement?', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + '(manual entry)14': { + key: '(manual entry)14', + index: 35, + required: 'Required if Budget > $10M', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'AJ', + humanColName: + 'If budget is over $10M and has a Community Benefit Agreement, provide a description. ', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + Cancellation_Reason__c: { + key: 'Cancellation_Reason__c', + index: 36, + required: 'Conditional', + dataType: 'String', + maxLength: 255, + listVals: [], + columnName: 'AK', + humanColName: 'Cancelled Status of Completion Explanation', + ecCodes: ['3.1', '3.2', '3.3', '3.4', '3.5'], + }, + }, + ec4: { + Name: { + key: 'Name', + index: 2, + required: true, + dataType: 'String', + maxLength: 80, + listVals: [], + columnName: 'C', + humanColName: 'Project Name', + ecCodes: ['4.1', '4.2'], + }, + Project_Identification_Number__c: { + key: 'Project_Identification_Number__c', + index: 3, + required: true, + dataType: 'String', + maxLength: 20, + listVals: [], + columnName: 'D', + humanColName: 'Project Identification Number\r\n(Assigned by recipient)', + ecCodes: ['4.1', '4.2'], + }, + Completion_Status__c: { + key: 'Completion_Status__c', + index: 4, + required: true, + dataType: 'Pick List', + maxLength: null, + listVals: [ + 'Not started', + 'Completed less than 50%', + 'Completed 50% or more', + 'Completed', + 'Cancelled', + ], + columnName: 'E', + humanColName: 'Status of Completion', + ecCodes: ['4.1', '4.2'], + }, + Adopted_Budget__c: { + key: 'Adopted_Budget__c', + index: 5, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'F', + humanColName: 'Adopted Budget', + ecCodes: ['4.1', '4.2'], + }, + Total_Obligations__c: { + key: 'Total_Obligations__c', + index: 6, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'G', + humanColName: 'Total Cumulative \r\nObligations', + ecCodes: ['4.1', '4.2'], + }, + Total_Expenditures__c: { + key: 'Total_Expenditures__c', + index: 7, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'H', + humanColName: 'Total Cumulative \r\nExpenditures', + ecCodes: ['4.1', '4.2'], + }, + Current_Period_Obligations__c: { + key: 'Current_Period_Obligations__c', + index: 8, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'I', + humanColName: 'Current Period Obligations', + ecCodes: ['4.1', '4.2'], + }, + Current_Period_Expenditures__c: { + key: 'Current_Period_Expenditures__c', + index: 9, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'J', + humanColName: 'Current Period Expenditures', + ecCodes: ['4.1', '4.2'], + }, + Project_Description__c: { + key: 'Project_Description__c', + index: 10, + required: true, + dataType: 'String', + maxLength: 1500, + listVals: [], + columnName: 'K', + humanColName: 'Project Description', + ecCodes: ['4.1', '4.2'], + }, + Program_Income_Earned__c: { + key: 'Program_Income_Earned__c', + index: 11, + required: false, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'L', + humanColName: 'Program Income Earned', + ecCodes: ['4.1', '4.2'], + }, + Program_Income_Expended__c: { + key: 'Program_Income_Expended__c', + index: 12, + required: false, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'M', + humanColName: 'Program Income Expended', + ecCodes: ['4.1', '4.2'], + }, + Sectors_Critical_to_Health_Well_Being__c: { + key: 'Sectors_Critical_to_Health_Well_Being__c', + index: 13, + required: 'Conditional', + dataType: 'Multi-Select', + maxLength: 1500, + listVals: [ + 'Any work performed by an employee of a State local or Tribal government', + 'Behavioral health work', + 'Biomedical research', + 'Dental care work', + 'Educational work school nutrition work and other work required to operate a school facility', + 'Elections work', + 'Emergency response', + 'Family or child care', + 'Grocery stores restaurants food production and food delivery', + 'Health care', + 'Home- and community-based health care or assistance with activities of daily living', + 'Laundry work', + 'Maintenance work', + 'Medical testing and diagnostics', + 'Pharmacy', + 'Public health work', + 'Sanitation disinfection and cleaning work', + 'Social services work', + 'Solid waste or hazardous materials management response and cleanup work', + 'Transportation and warehousing', + 'Vital services to Tribes', + 'Work at hotel and commercial lodging facilities that are used for COVID-19 mitigation and containment', + 'Work in a mortuary', + 'Work in critical clinical research development and testing necessary for COVID-19 response', + 'Work requiring physical interaction with patients', + 'Other', + ], + columnName: 'N', + humanColName: 'Sectors Designated as Essential Critical Infrastructure', + ecCodes: ['4.1', '4.2'], + }, + Workers_Served__c: { + key: 'Workers_Served__c', + index: 14, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999, + listVals: [], + columnName: 'O', + humanColName: 'Number of workers to be served', + ecCodes: ['4.1', '4.2'], + }, + Premium_Pay_Narrative__c: { + key: 'Premium_Pay_Narrative__c', + index: 15, + required: 'Conditional', + dataType: 'String', + maxLength: 3000, + listVals: [], + columnName: 'P', + humanColName: 'Premium Pay Narrative', + ecCodes: ['4.1', '4.2'], + }, + Number_of_Workers_K_12__c: { + key: 'Number_of_Workers_K_12__c', + index: 16, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'Q', + humanColName: + 'Number of workers to be served with premium pay in K-12 schools ', + ecCodes: ['4.1', '4.2'], + }, + Cancellation_Reason__c: { + key: 'Cancellation_Reason__c', + index: 17, + required: 'Conditional', + dataType: 'String', + maxLength: 255, + listVals: [], + columnName: 'R', + humanColName: 'Cancelled Status of Completion Explanation', + ecCodes: ['4.1', '4.2'], + }, + }, + ec5: { + Name: { + key: 'Name', + index: 2, + required: true, + dataType: 'String', + maxLength: 80, + listVals: [], + columnName: 'C', + humanColName: 'Project Name', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + Project_Identification_Number__c: { + key: 'Project_Identification_Number__c', + index: 3, + required: true, + dataType: 'String', + maxLength: 20, + listVals: [], + columnName: 'D', + humanColName: 'Project Identification Number\r\n(Assigned by recipient)', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + Completion_Status__c: { + key: 'Completion_Status__c', + index: 4, + required: true, + dataType: 'Pick List', + maxLength: null, + listVals: [ + 'Not started', + 'Completed less than 50%', + 'Completed 50% or more', + 'Completed', + 'Cancelled', + ], + columnName: 'E', + humanColName: 'Status of Completion', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + Adopted_Budget__c: { + key: 'Adopted_Budget__c', + index: 5, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'F', + humanColName: 'Adopted Budget', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + Total_Obligations__c: { + key: 'Total_Obligations__c', + index: 6, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'G', + humanColName: 'Total Cumulative \r\nObligations', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + Total_Expenditures__c: { + key: 'Total_Expenditures__c', + index: 7, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'H', + humanColName: 'Total Cumulative \r\nExpenditures', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + Current_Period_Obligations__c: { + key: 'Current_Period_Obligations__c', + index: 8, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'I', + humanColName: 'Current Period Obligations', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + Current_Period_Expenditures__c: { + key: 'Current_Period_Expenditures__c', + index: 9, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'J', + humanColName: 'Current Period Expenditures', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + Project_Description__c: { + key: 'Project_Description__c', + index: 10, + required: true, + dataType: 'String', + maxLength: 1500, + listVals: [], + columnName: 'K', + humanColName: 'Project Description', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + Program_Income_Earned__c: { + key: 'Program_Income_Earned__c', + index: 11, + required: false, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'L', + humanColName: 'Program Income Earned', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + Program_Income_Expended__c: { + key: 'Program_Income_Expended__c', + index: 12, + required: false, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'M', + humanColName: 'Program Income Expended', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + Proj_Actual_Construction_Start_Date__c: { + key: 'Proj_Actual_Construction_Start_Date__c', + index: 13, + required: 'Conditional', + dataType: 'Date', + maxLength: null, + listVals: [], + columnName: 'N', + humanColName: 'Projected/actual construction start date', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + Initiation_of_Operations_Date__c: { + key: 'Initiation_of_Operations_Date__c', + index: 14, + required: 'Conditional', + dataType: 'Date', + maxLength: null, + listVals: [], + columnName: 'O', + humanColName: 'Projected/actual initiation of operations date', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + Location__c: { + key: 'Location__c', + index: 15, + required: 'Conditional', + dataType: 'Pick List', + maxLength: null, + listVals: [ + 'Latitude/longitude (WGS84 or NAD83 geographic coordinate system)', + 'Address', + 'Address Range', + 'Road Segment', + ], + columnName: 'P', + humanColName: 'Location Type', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + ], + }, + Location_Detail__c: { + key: 'Location_Detail__c', + index: 16, + required: 'Conditional', + dataType: 'String', + maxLength: 255, + listVals: [], + columnName: 'Q', + humanColName: 'Location Details', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + ], + }, + '(manual entry)1': { + key: '(manual entry)1', + index: 17, + required: 'Required if Budget > $10M', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'R', + humanColName: + 'If budget is over $10M, was a Davis Bacon Act Certification completed?', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + '(manual entry)2': { + key: '(manual entry)2', + index: 18, + required: 'Required if DB is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'S', + humanColName: + 'If budget is over $10M and Davis Bacon Act Certification NOT completed - enter the number of employees of contractors and sub-contractors working on the project.', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + '(manual entry)3': { + key: '(manual entry)3', + index: 19, + required: 'Required if DB is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'T', + humanColName: + 'If budget is over $10M and Davis Bacon Act Certification NOT completed - enter the number of employees on the project hired directly and hired through a third party. ', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + '(manual entry)4': { + key: '(manual entry)4', + index: 20, + required: 'Required if DB is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'U', + humanColName: + 'If budget is over $10M and Davis Bacon Act Certification NOT completed - enter the wages and benefits of workers on the project by classification', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + '(manual entry)5': { + key: '(manual entry)5', + index: 21, + required: 'Required if DB is No', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'V', + humanColName: + 'If budget is over $10M and Davis Bacon Act Certification NOT completed - are those wages are at rates less than those prevailing?', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + '(manual entry)6': { + key: '(manual entry)6', + index: 22, + required: 'Required if Budget > $10M', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'W', + humanColName: + 'If budget is over $10M, was a Labor Agreement Certification completed?', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + '(manual entry)7': { + key: '(manual entry)7', + index: 23, + required: 'Required if PLA is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'X', + humanColName: + 'If budget is over $10M and Labor Agreement Certification NOT completed - enter how the recipient will ensure the project has ready access to a sufficient supply of appropriately skilled and unskilled labor to ensure high-quality construction throughout the life of the project, including a description of any required professional certifications and/or in-house training. ', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + '(manual entry)8': { + key: '(manual entry)8', + index: 24, + required: 'Required if PLA is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'Y', + humanColName: + 'If budget is over $10M and Labor Agreement Certification NOT completed - enter how the recipient will minimize risks of labor disputes and disruptions that would jeopardize timeliness and cost-effectiveness of the project.', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + '(manual entry)9': { + key: '(manual entry)9', + index: 25, + required: 'Required if PLA is No', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'Z', + humanColName: + 'If budget is over $10M and Labor Agreement Certification NOT completed - enter how the recipient will provide a safe and healthy workplace that avoids delays and costs associated with workplace illnesses, injuries, and fatalities, including descriptions of safety training, certification, and/or licensure requirements for all relevant workers (e.g., OSHA 10, OSHA 30).', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + '(manual entry)10': { + key: '(manual entry)10', + index: 26, + required: 'Required if PLA is No', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AA', + humanColName: + 'If budget is over $10M and Labor Agreement Certification NOT completed - enter whether workers on the project will receive wages and benefits that will secure an appropriately skilled workforce in the context of the local or regional labor market.', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + '(manual entry)11': { + key: '(manual entry)11', + index: 27, + required: 'Required if PLA is No', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AB', + humanColName: + 'If budget is over $10M and Labor Agreement Certification NOT completed - enter whether the project has completed a project labor agreement. ', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + '(manual entry)12': { + key: '(manual entry)12', + index: 28, + required: 'Required if Budget > $10M', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AC', + humanColName: + 'If budget is over $10M, does the project prioritize local hires? ', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + '(manual entry)13': { + key: '(manual entry)13', + index: 29, + required: 'Required if Budget > $10M', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AD', + humanColName: + 'If budget is over $10M, does the project have a Community Benefit Agreement?', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + '(manual entry)14': { + key: '(manual entry)14', + index: 30, + required: 'Required if Budget > $10M', + dataType: 'TBD', + maxLength: null, + listVals: [], + columnName: 'AE', + humanColName: + 'If budget is over $10M and has a Community Benefit Agreement, provide a description. ', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + National_Pollutant_Discharge_Number__c: { + key: 'National_Pollutant_Discharge_Number__c', + index: 31, + required: 'Conditional', + dataType: 'String', + maxLength: 50, + listVals: [], + columnName: 'AF', + humanColName: 'National Pollutant Discharge Elimination System (NPDES)', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + ], + }, + Public_Water_System_PWS_ID_number__c: { + key: 'Public_Water_System_PWS_ID_number__c', + index: 32, + required: 'Conditional', + dataType: 'String', + maxLength: 50, + listVals: [], + columnName: 'AG', + humanColName: 'Public Water System (PWS) ID number ', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + ], + }, + Is_project_designed_to_meet_100_mbps__c: { + key: 'Is_project_designed_to_meet_100_mbps__c', + index: 33, + required: true, + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AH', + humanColName: + 'Confirm that the project is designed to, upon completion, reliably meet or exceed symmetrical 100 Mbps download and upload speeds', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Project_not_met_100_mbps_explanation__c: { + key: 'Project_not_met_100_mbps_explanation__c', + index: 34, + required: 'Conditional', + dataType: 'String', + maxLength: 1500, + listVals: [], + columnName: 'AI', + humanColName: + 'If the project is not designed to reliably meet or exceed symmetrical 100 Mbps download and upload speeds, explain why not', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Is_project_designed_to_exceed_100_mbps__c: { + key: 'Is_project_designed_to_exceed_100_mbps__c', + index: 35, + required: 'Conditional', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'AJ', + humanColName: + 'Confirm that the project is designed to, upon completion, meet or exceed symmetrical 100 Mbps download speed and between at least 20 Mbps and 100 Mbps upload speed, and be scalable to a minimum of 100 Mbps download speed and 100 Mbps upload speed', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Median_Household_Income_Service_Area__c: { + key: 'Median_Household_Income_Service_Area__c', + index: 36, + required: 'Conditional', + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'AK', + humanColName: 'Median Household Income of service area ', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + ], + }, + Lowest_Quintile_Income__c: { + key: 'Lowest_Quintile_Income__c', + index: 37, + required: 'Conditional', + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'AL', + humanColName: 'Lowest Quintile Income of the service area ', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + ], + }, + Is_project_designed_provide_hh_service__c: { + key: 'Is_project_designed_provide_hh_service__c', + index: 38, + required: 'Conditional', + dataType: 'Pick List', + maxLength: null, + listVals: [], + columnName: 'AM', + humanColName: 'Is project designed to provide service to households?', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Technology_Type_Planned__c: { + key: 'Technology_Type_Planned__c', + index: 39, + required: true, + dataType: 'Pick List', + maxLength: null, + listVals: [ + 'Fiber', + 'Coaxial Cable', + 'Terrestrial Fixed Wireless', + 'Other', + ], + columnName: 'AN', + humanColName: 'Technology Type Planned', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Technology_Type_Planned_Other__c: { + key: 'Technology_Type_Planned_Other__c', + index: 40, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'AO', + humanColName: 'If Technology Type Planned is "Other" please specify', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Technology_Type_Actual__c: { + key: 'Technology_Type_Actual__c', + index: 41, + required: 'Conditional', + dataType: 'Pick List', + maxLength: null, + listVals: [ + 'Fiber', + 'Coaxial Cable', + 'Terrestrial Fixed Wireless', + 'Other', + ], + columnName: 'AP', + humanColName: 'Technology Type Actual', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Technology_Type_Actual_Other__c: { + key: 'Technology_Type_Actual_Other__c', + index: 42, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'AQ', + humanColName: 'If Technology Type Actual is "Other" please specify', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Total_Miles_of_Fiber_Deployed_c: { + key: 'Total_Miles_of_Fiber_Deployed_c', + index: 43, + required: true, + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'AR', + humanColName: 'Total Miles of Fiber Deployed Planned', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Total_Miles_of_Fiber_Deployed_Actual__c: { + key: 'Total_Miles_of_Fiber_Deployed_Actual__c', + index: 44, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'AS', + humanColName: 'Total Miles of Fiber Deployed Actual', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Planned_Funded_Locations_Served__c: { + key: 'Planned_Funded_Locations_Served__c', + index: 45, + required: true, + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'AT', + humanColName: 'Total Number of Funded Locations Served Planned', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Actual_Funded_Locations_Served__c: { + key: 'Actual_Funded_Locations_Served__c', + index: 46, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'AU', + humanColName: 'Total Number of Funded Locations Served Actual', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Planned_Funded_Locations_25_3_Below__c: { + key: 'Planned_Funded_Locations_25_3_Below__c', + index: 47, + required: true, + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'AV', + humanColName: + 'Planned Total Number of Funded Locations Served, broken out by speeds (Pre-SLFRF investment, Number receiving 25/3 Mbps or below)', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Planned_Funded_Locations_Between_25_100__c: { + key: 'Planned_Funded_Locations_Between_25_100__c', + index: 48, + required: true, + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'AW', + humanColName: + 'Planned Total Number of Funded Locations Served, broken out by speeds (Pre-SLFRF investment, Number receiving between 25/3 Mbps and 100/20 Mbps)', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Planned_Funded_Locations_Minimum_1_Gbps__c: { + key: 'Planned_Funded_Locations_Minimum_1_Gbps__c', + index: 49, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'AX', + humanColName: + 'Planned Total Number of Funded Locations Served, broken out by speeds (Post-SLFRF investment, Number receiving minimum 1 Gbps)', + ecCodes: [], + }, + Actual_Funded_Locations_Minimum_1_Gbps__c: { + key: 'Actual_Funded_Locations_Minimum_1_Gbps__c', + index: 50, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'AY', + humanColName: + 'Actual Total Number of Funded Locations Served, broken out by speeds (Post-SLFRF investment, Number receiving minimum 1 Gbps)', + ecCodes: [], + }, + Planned_Funded_Locations_Minimum_100_100__c: { + key: 'Planned_Funded_Locations_Minimum_100_100__c', + index: 51, + required: true, + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'AZ', + humanColName: + 'Planned Total Number of Funded Locations Served, broken out by speeds (Post-SLFRF investment, Number receiving minimum 100/100 Mbps)', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Actual_Funded_Locations_Minimum_100_100__c: { + key: 'Actual_Funded_Locations_Minimum_100_100__c', + index: 52, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'BA', + humanColName: + 'Actual Total Number of Funded Locations Served, broken out by speeds (Post-SLFRF investment, Number receiving minimum 100/100 Mbps)', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Planned_Funded_Locations_Minimum_100_20__c: { + key: 'Planned_Funded_Locations_Minimum_100_20__c', + index: 53, + required: true, + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'BB', + humanColName: + 'Planned Total Number of Funded Locations Served, broken out by speeds (Post-SLFRF investment, Number receiving minimum 100/20 Mbps and scalable to minimum 100/100 Mbps)', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Actual_Funded_Locations_Minimum_100_20__c: { + key: 'Actual_Funded_Locations_Minimum_100_20__c', + index: 54, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'BC', + humanColName: + 'Actual Total Number of Funded Locations Served, broken out by speeds (Post-SLFRF investment, Number receiving minimum 100/20 Mbps and scalable to minimum 100/100 Mbps)', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Planned_Funded_Locations_Residential__c: { + key: 'Planned_Funded_Locations_Residential__c', + index: 55, + required: true, + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'BD', + humanColName: + 'Planned Total Number of Funded Locations Served, Residential', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Actual_Funded_Locations_Residential__c: { + key: 'Actual_Funded_Locations_Residential__c', + index: 56, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'BE', + humanColName: + 'Actual Total Number of Funded Locations Served, Residential', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Planned_Funded_Locations_Total_Housing__c: { + key: 'Planned_Funded_Locations_Total_Housing__c', + index: 57, + required: true, + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'BF', + humanColName: + 'Planned Total Number of Funded Locations Served, Total Housing', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Actual_Funded_Locations_Total_Housing__c: { + key: 'Actual_Funded_Locations_Total_Housing__c', + index: 58, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'BG', + humanColName: + 'Actual Total Number of Funded Locations Served, Total Housing', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Planned_Funded_Locations_Business__c: { + key: 'Planned_Funded_Locations_Business__c', + index: 59, + required: true, + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'BH', + humanColName: 'Planned Total Number of Funded Locations Served, Business', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Actual_Funded_Locations_Business__c: { + key: 'Actual_Funded_Locations_Business__c', + index: 60, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'BI', + humanColName: 'Actual Total Number of Funded Locations Served, Business', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Planned_Funded_Locations_Community__c: { + key: 'Planned_Funded_Locations_Community__c', + index: 61, + required: true, + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'BJ', + humanColName: + 'Planned Total Number of Funded Locations Served, Community Anchor Institution', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Actual_Funded_Locations_Community__c: { + key: 'Actual_Funded_Locations_Community__c', + index: 62, + required: 'Conditional', + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'BK', + humanColName: + 'Actual Total Number of Funded Locations Served, Community Anchor Institution', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Planned_Funded_Locations_Explanation__c: { + key: 'Planned_Funded_Locations_Explanation__c', + index: 63, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'BL', + humanColName: 'Planned Funded Locations Explanation', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Actual_Funded_Locations_Explanation__c: { + key: 'Actual_Funded_Locations_Explanation__c', + index: 64, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'BM', + humanColName: 'Actual Funded Locations Explanation', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Confirm_Service_Provider__c: { + key: 'Confirm_Service_Provider__c', + index: 65, + required: 'Conditional', + dataType: 'Pick List', + maxLength: null, + listVals: [], + columnName: 'BN', + humanColName: + "Confirm that the service provider for the project has, or will upon completion of the project, either participated in the Federal Communications Commission (FCC)'s Affordable Connectivity Program (ACP) or otherwise provided access to a broad-based affordability program that proides benefits to households commensurate with those provided under the ACP to low-income consumers in the proposed service area of the broadband infrastructure (applicable only to projects that provide service to households)", + ecCodes: ['5.19', '5.20', '5.21'], + }, + Cancellation_Reason__c: { + key: 'Cancellation_Reason__c', + index: 66, + required: 'Conditional', + dataType: 'String', + maxLength: 255, + listVals: [], + columnName: 'BO', + humanColName: 'Cancelled Status of Completion Explanation', + ecCodes: [ + '5.1', + '5.2', + '5.3', + '5.4', + '5.5', + '5.6', + '5.7', + '5.8', + '5.9', + '5.10', + '5.11', + '5.12', + '5.13', + '5.14', + '5.15', + '5.16', + '5.17', + '5.18', + '5.19', + '5.20', + '5.21', + ], + }, + Planned_Sum_Speed_Types_Explanation__c: { + key: 'Planned_Sum_Speed_Types_Explanation__c', + index: 67, + required: 'Conditional', + dataType: 'String', + maxLength: 1500, + listVals: [], + columnName: 'BP', + humanColName: + 'Explanation if the sum of broadband speed types does not equal total number of funded locations served\r\nPlanned', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Actual_Sum_Speed_Types_Explanation__c: { + key: 'Actual_Sum_Speed_Types_Explanation__c', + index: 68, + required: 'Conditional', + dataType: 'String', + maxLength: 1500, + listVals: [], + columnName: 'BQ', + humanColName: + 'Explanation if the sum of broadband speed types does not equal total number of funded locations served\r\nActual', + ecCodes: ['5.19', '5.20', '5.21'], + }, + Fabric_ID__c: { + key: 'Fabric_ID__c', + index: 69, + required: false, + dataType: 'Numeric', + maxLength: 9999999999, + listVals: [], + columnName: 'BR', + humanColName: 'Fabric ID#', + ecCodes: ['5.19', '5.20', '5.21'], + }, + FCC_Provider_ID__c: { + key: 'FCC_Provider_ID__c', + index: 70, + required: false, + dataType: 'Numeric', + maxLength: 999999, + listVals: [], + columnName: 'BS', + humanColName: 'FCC Prodiver ID#', + ecCodes: ['5.19', '5.20', '5.21'], + }, + }, + ec7: { + Name: { + key: 'Name', + index: 2, + required: true, + dataType: 'String', + maxLength: 80, + listVals: [], + columnName: 'C', + humanColName: 'Project Name', + ecCodes: ['7.1', '7.2'], + }, + Project_Identification_Number__c: { + key: 'Project_Identification_Number__c', + index: 3, + required: true, + dataType: 'String', + maxLength: 20, + listVals: [], + columnName: 'D', + humanColName: 'Project Identification Number\r\n(Assigned by recipient)', + ecCodes: ['7.1', '7.2'], + }, + Completion_Status__c: { + key: 'Completion_Status__c', + index: 4, + required: true, + dataType: 'Pick List', + maxLength: null, + listVals: [ + 'Not started', + 'Completed less than 50%', + 'Completed 50% or more', + 'Completed', + 'Cancelled', + ], + columnName: 'E', + humanColName: 'Status of Completion', + ecCodes: ['7.1', '7.2'], + }, + Adopted_Budget__c: { + key: 'Adopted_Budget__c', + index: 5, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'F', + humanColName: 'Adopted Budget', + ecCodes: ['7.1', '7.2'], + }, + Total_Obligations__c: { + key: 'Total_Obligations__c', + index: 6, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'G', + humanColName: 'Total Cumulative \r\nObligations', + ecCodes: ['7.1', '7.2'], + }, + Total_Expenditures__c: { + key: 'Total_Expenditures__c', + index: 7, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'H', + humanColName: 'Total Cumulative \r\nExpenditures', + ecCodes: ['7.1', '7.2'], + }, + Current_Period_Obligations__c: { + key: 'Current_Period_Obligations__c', + index: 8, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'I', + humanColName: 'Current Period Obligations', + ecCodes: ['7.1', '7.2'], + }, + Current_Period_Expenditures__c: { + key: 'Current_Period_Expenditures__c', + index: 9, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'J', + humanColName: 'Current Period Expenditures', + ecCodes: ['7.1', '7.2'], + }, + Does_Project_Include_Capital_Expenditure__c: { + key: 'Does_Project_Include_Capital_Expenditure__c', + index: 10, + required: 'Conditional', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'K', + humanColName: 'Does this project include a capital expenditure?', + ecCodes: [], + }, + Total_Cost_Capital_Expenditure__c: { + key: 'Total_Cost_Capital_Expenditure__c', + index: 11, + required: 'Conditional', + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'L', + humanColName: 'If yes, what is the total expected capital expenditure?', + ecCodes: [], + }, + Type_of_Capital_Expenditure__c: { + key: 'Type_of_Capital_Expenditure__c', + index: 12, + required: 'Conditional', + dataType: 'Pick List', + maxLength: null, + listVals: [ + 'Acquisition of equipment for COVID-19 prevention and treatment', + 'Adaptations to congregate living facilities', + 'Affordable housing supportive housing or recovery housing', + 'Behavioral health facilities and equipment', + 'Childcare, daycare, and early learning facilities', + 'COVID-19 testing sites and laboratories', + 'COVID-19 vaccination sites', + 'Devices and equipment that assist households in accessing the internet', + 'Emergency operations centers and acquisition of emergency response equipment', + 'Food banks and other facilities', + 'Improvements to existing facilities', + 'Installation and improvement of ventilation systems', + 'Job and workforce training centers', + 'Medical equipment and facilities', + 'Medical facilities generally dedicated to COVID-19 treatment and mitigation', + 'Mitigation measures in small businesses, nonprofits, and impacted industries', + 'Parks, green spaces, recreational facilities, sidewalks', + 'Public health data systems', + 'Rehabilitations, renovation, remediation, cleanup, or conversions', + 'Schools and other educational facilities', + 'Technology and equipment to allow law enforcement', + 'Technology and tools', + 'Technology infrastructure to adapt government operations', + 'Temporary medical facilities and other measures', + 'Transitional shelters', + 'Other (please specify)', + ], + columnName: 'M', + humanColName: 'Capital Expenditure Type', + ecCodes: [], + }, + Type_of_Capital_Expenditure_Other__c: { + key: 'Type_of_Capital_Expenditure_Other__c', + index: 13, + required: 'Conditional', + dataType: 'String', + maxLength: 255, + listVals: [], + columnName: 'N', + humanColName: 'Other Capital Expenditure Explanation', + ecCodes: [], + }, + Capital_Expenditure_Justification__c: { + key: 'Capital_Expenditure_Justification__c', + index: 14, + required: 'Conditional', + dataType: 'String', + maxLength: 1500, + listVals: [], + columnName: 'O', + humanColName: 'Capital Expenditure Narrative', + ecCodes: [], + }, + Project_Description__c: { + key: 'Project_Description__c', + index: 15, + required: true, + dataType: 'String', + maxLength: 1500, + listVals: [], + columnName: 'P', + humanColName: 'Project Description', + ecCodes: ['7.1', '7.2'], + }, + Program_Income_Earned__c: { + key: 'Program_Income_Earned__c', + index: 16, + required: false, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'Q', + humanColName: 'Program Income Earned', + ecCodes: ['7.1', '7.2'], + }, + Program_Income_Expended__c: { + key: 'Program_Income_Expended__c', + index: 17, + required: false, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'R', + humanColName: 'Program Income Expended', + ecCodes: ['7.1', '7.2'], + }, + Primary_Project_Demographics__c: { + key: 'Primary_Project_Demographics__c', + index: 18, + required: 'Conditional', + dataType: 'Pick List', + maxLength: null, + listVals: [ + '1 Imp General Public', + '2 Imp Low or moderate income HHs or populations', + '3 Imp HHs that experienced unemployment', + '4 Imp HHs that experienced increased food or housing insecurity', + '5 Imp HHs that qualify for certain federal programs', + '6 Imp For services to address lost instructional time in K-12 schools', + '7 Imp Other HHs or populations that experienced a negative economic', + '8 Imp SBs that experienced a negative economic impact', + '9 Imp Classes of SBs designated as negatively economically impacted', + '10 Imp NPs that experienced a negative economic impact specify', + '11 Imp Classes of NPs designated as negatively economically impacted', + '12 Imp Travel tourism or hospitality sectors', + '13 Imp Industry outside the travel tourism or hospitality sectors specify', + '14 Dis Imp Low income HHs and populations', + '15 Dis Imp HHs and populations residing in Qualified Census Tracts', + '16 Dis Imp HHs that qualify for certain federal programs', + '17 Dis Imp HHs receiving services provided by Tribal governments', + '18 Dis Imp HHs residing in the U.S. territories or receiving services', + '19 Dis Imp For services to address educational disparities Title I eligible', + '20 Dis Imp Other HHs or populations that experienced a disproportionate', + '21 Dis Imp SBs operating in Qualified Census Tracts', + '22 Dis Imp SBs operated by Tribal governments or on Tribal lands', + '23 Dis Imp SBs operating in the U.S. territories', + '24 Dis Imp Other SBs Dis Imp by the pandemic specify', + '25 Dis Imp NPs operating in Qualified Census Tracts', + '26 Dis Imp NPs operated by Tribal governments or on Tribal lands', + '27 Dis Imp NPs operating in the U.S. territories', + '28 Dis Imp Other NPs Dis Imp by the pandemic specify', + ], + columnName: 'S', + humanColName: + 'Project Demographic Distribution - Primary Populations Served', + ecCodes: [], + }, + Primary_Project_Demographics_Explanation__c: { + key: 'Primary_Project_Demographics_Explanation__c', + index: 19, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'T', + humanColName: 'Primary Project Demographic Explanation', + ecCodes: [], + }, + Secondary_Project_Demographics__c: { + key: 'Secondary_Project_Demographics__c', + index: 20, + required: false, + dataType: 'Pick List', + maxLength: null, + listVals: [ + '1 Imp General Public', + '2 Imp Low or moderate income HHs or populations', + '3 Imp HHs that experienced unemployment', + '4 Imp HHs that experienced increased food or housing insecurity', + '5 Imp HHs that qualify for certain federal programs', + '6 Imp For services to address lost instructional time in K-12 schools', + '7 Imp Other HHs or populations that experienced a negative economic', + '8 Imp SBs that experienced a negative economic impact', + '9 Imp Classes of SBs designated as negatively economically impacted', + '10 Imp NPs that experienced a negative economic impact specify', + '11 Imp Classes of NPs designated as negatively economically impacted', + '12 Imp Travel tourism or hospitality sectors', + '13 Imp Industry outside the travel tourism or hospitality sectors specify', + '14 Dis Imp Low income HHs and populations', + '15 Dis Imp HHs and populations residing in Qualified Census Tracts', + '16 Dis Imp HHs that qualify for certain federal programs', + '17 Dis Imp HHs receiving services provided by Tribal governments', + '18 Dis Imp HHs residing in the U.S. territories or receiving services', + '19 Dis Imp For services to address educational disparities Title I eligible', + '20 Dis Imp Other HHs or populations that experienced a disproportionate', + '21 Dis Imp SBs operating in Qualified Census Tracts', + '22 Dis Imp SBs operated by Tribal governments or on Tribal lands', + '23 Dis Imp SBs operating in the U.S. territories', + '24 Dis Imp Other SBs Dis Imp by the pandemic specify', + '25 Dis Imp NPs operating in Qualified Census Tracts', + '26 Dis Imp NPs operated by Tribal governments or on Tribal lands', + '27 Dis Imp NPs operating in the U.S. territories', + '28 Dis Imp Other NPs Dis Imp by the pandemic specify', + ], + columnName: 'U', + humanColName: + 'Project Demographic Distribution - Secondary Populations Served', + ecCodes: [], + }, + Secondary_Proj_Demographics_Explanation__c: { + key: 'Secondary_Proj_Demographics_Explanation__c', + index: 21, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'V', + humanColName: 'Secondary Project Demographic Explanation', + ecCodes: [], + }, + Tertiary_Project_Demographics__c: { + key: 'Tertiary_Project_Demographics__c', + index: 22, + required: false, + dataType: 'Pick List', + maxLength: null, + listVals: [ + '1 Imp General Public', + '2 Imp Low or moderate income HHs or populations', + '3 Imp HHs that experienced unemployment', + '4 Imp HHs that experienced increased food or housing insecurity', + '5 Imp HHs that qualify for certain federal programs', + '6 Imp For services to address lost instructional time in K-12 schools', + '7 Imp Other HHs or populations that experienced a negative economic', + '8 Imp SBs that experienced a negative economic impact', + '9 Imp Classes of SBs designated as negatively economically impacted', + '10 Imp NPs that experienced a negative economic impact specify', + '11 Imp Classes of NPs designated as negatively economically impacted', + '12 Imp Travel tourism or hospitality sectors', + '13 Imp Industry outside the travel tourism or hospitality sectors specify', + '14 Dis Imp Low income HHs and populations', + '15 Dis Imp HHs and populations residing in Qualified Census Tracts', + '16 Dis Imp HHs that qualify for certain federal programs', + '17 Dis Imp HHs receiving services provided by Tribal governments', + '18 Dis Imp HHs residing in the U.S. territories or receiving services', + '19 Dis Imp For services to address educational disparities Title I eligible', + '20 Dis Imp Other HHs or populations that experienced a disproportionate', + '21 Dis Imp SBs operating in Qualified Census Tracts', + '22 Dis Imp SBs operated by Tribal governments or on Tribal lands', + '23 Dis Imp SBs operating in the U.S. territories', + '24 Dis Imp Other SBs Dis Imp by the pandemic specify', + '25 Dis Imp NPs operating in Qualified Census Tracts', + '26 Dis Imp NPs operated by Tribal governments or on Tribal lands', + '27 Dis Imp NPs operating in the U.S. territories', + '28 Dis Imp Other NPs Dis Imp by the pandemic specify', + ], + columnName: 'W', + humanColName: + 'Project Demographic Distribution - Tertiary Populations Served', + ecCodes: [], + }, + Tertiary_Proj_Demographics_Explanation__c: { + key: 'Tertiary_Proj_Demographics_Explanation__c', + index: 23, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'X', + humanColName: 'Tertiary Project Demographic Explanation', + ecCodes: [], + }, + Structure_Objectives_of_Asst_Programs__c: { + key: 'Structure_Objectives_of_Asst_Programs__c', + index: 24, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'Y', + humanColName: 'Structure and objectives of assistance program', + ecCodes: [], + }, + Recipient_Approach_Description__c: { + key: 'Recipient_Approach_Description__c', + index: 25, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'Z', + humanColName: 'Recipients approach', + ecCodes: [], + }, + Cancellation_Reason__c: { + key: 'Cancellation_Reason__c', + index: 26, + required: 'Conditional', + dataType: 'String', + maxLength: 255, + listVals: [], + columnName: 'AA', + humanColName: 'Cancelled Status of Completion Explanation', + ecCodes: ['7.1', '7.2'], + }, + }, + subrecipient: { + Unique_Entity_Identifier__c: { + key: 'Unique_Entity_Identifier__c', + index: 2, + required: 'RequiredOptional', + dataType: 'String-Fixed', + maxLength: 12, + listVals: [], + columnName: 'C', + humanColName: 'UEI', + ecCodes: false, + }, + EIN__c: { + key: 'EIN__c', + index: 3, + required: 'RequiredOptional', + dataType: 'String-Fixed', + maxLength: 9, + listVals: [], + columnName: 'D', + humanColName: 'TIN', + ecCodes: false, + }, + Name: { + key: 'Name', + index: 4, + required: true, + dataType: 'String', + maxLength: 80, + listVals: [], + columnName: 'E', + humanColName: 'Legal Name', + ecCodes: false, + }, + Entity_Type_2__c: { + key: 'Entity_Type_2__c', + index: 5, + required: true, + dataType: 'Multi-Select', + maxLength: null, + listVals: ['Subrecipient', 'Contractor', 'Beneficiary'], + columnName: 'F', + humanColName: 'Entity Type', + ecCodes: false, + }, + POC_Email_Address__c: { + key: 'POC_Email_Address__c', + index: 6, + required: false, + dataType: 'String', + maxLength: 40, + listVals: [], + columnName: 'G', + humanColName: 'Point of Contact Email Address', + ecCodes: false, + }, + Address__c: { + key: 'Address__c', + index: 7, + required: true, + dataType: 'String', + maxLength: 255, + listVals: [], + columnName: 'H', + humanColName: 'Address Line 1', + ecCodes: false, + }, + Address_2__c: { + key: 'Address_2__c', + index: 8, + required: false, + dataType: 'String', + maxLength: 255, + listVals: [], + columnName: 'I', + humanColName: 'Address Line 2', + ecCodes: false, + }, + Address_3__c: { + key: 'Address_3__c', + index: 9, + required: false, + dataType: 'String', + maxLength: 255, + listVals: [], + columnName: 'J', + humanColName: 'Address Line 3', + ecCodes: false, + }, + City__c: { + key: 'City__c', + index: 10, + required: true, + dataType: 'String', + maxLength: 100, + listVals: [], + columnName: 'K', + humanColName: 'City Name', + ecCodes: false, + }, + State_Abbreviated__c: { + key: 'State_Abbreviated__c', + index: 11, + required: true, + dataType: 'Pick List', + maxLength: null, + listVals: [ + 'AL', + 'AK', + 'AS', + 'AZ', + 'AR', + 'CA', + 'CO', + 'CT', + 'DE', + 'DC', + 'FM', + 'FL', + 'GA', + 'GU', + 'HI', + 'ID', + 'IL', + 'IN', + 'IA', + 'KS', + 'KY', + 'LA', + 'ME', + 'MH', + 'MD', + 'MA', + 'MI', + 'MN', + 'MS', + 'MO', + 'MT', + 'NE', + 'NV', + 'NH', + 'NJ', + 'NM', + 'NY', + 'NC', + 'ND', + 'MP', + 'OH', + 'OK', + 'OR', + 'PW', + 'PA', + 'PR', + 'RI', + 'SC', + 'SD', + 'TN', + 'TX', + 'UT', + 'VT', + 'VA', + 'VI', + 'WA', + 'WV', + 'WI', + 'WY', + ], + columnName: 'L', + humanColName: 'State Code', + ecCodes: false, + }, + Zip__c: { + key: 'Zip__c', + index: 12, + required: true, + dataType: 'String', + maxLength: 5, + listVals: [], + columnName: 'M', + humanColName: 'Zip', + ecCodes: false, + }, + Zip_4__c: { + key: 'Zip_4__c', + index: 13, + required: false, + dataType: 'String', + maxLength: 4, + listVals: [], + columnName: 'N', + humanColName: 'Zip4', + ecCodes: false, + }, + Country__c: { + key: 'Country__c', + index: 14, + required: false, + dataType: 'String', + maxLength: 2, + listVals: [], + columnName: 'O', + humanColName: 'Country Code', + ecCodes: false, + }, + Registered_in_Sam_gov__c: { + key: 'Registered_in_Sam_gov__c', + index: 15, + required: true, + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'P', + humanColName: 'Recipient SAM.gov Registration?', + ecCodes: false, + }, + Federal_Funds_80_or_More_of_Revenue__c: { + key: 'Federal_Funds_80_or_More_of_Revenue__c', + index: 16, + required: 'Conditional', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'Q', + humanColName: + 'In its preceding fiscal year, did recipient receive 80% or more of its annual gross revenue from federal funds?', + ecCodes: false, + }, + Derives_25_Million_or_More_from_Federal__c: { + key: 'Derives_25_Million_or_More_from_Federal__c', + index: 17, + required: 'Conditional', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'R', + humanColName: + 'In the preceding fiscal year, did recipient receive $25 million or more of its annual gross revenue from federal funds?', + ecCodes: false, + }, + Total_Compensation_for_Officers_Public__c: { + key: 'Total_Compensation_for_Officers_Public__c', + index: 18, + required: 'Conditional', + dataType: 'Pick List', + maxLength: null, + listVals: ['Yes', 'No'], + columnName: 'S', + humanColName: + 'Is the "total compensation" for the organization\'s five highest paid officers publicly listed or otherwise listed in SAM.gov?', + ecCodes: false, + }, + Officer_Name__c: { + key: 'Officer_Name__c', + index: 19, + required: 'Conditional', + dataType: 'String', + maxLength: 80, + listVals: [], + columnName: 'T', + humanColName: 'Executive Name (1)', + ecCodes: false, + }, + Officer_Total_Comp__c: { + key: 'Officer_Total_Comp__c', + index: 20, + required: 'Conditional', + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'U', + humanColName: 'Total Compensation Executive (1)', + ecCodes: false, + }, + Officer_2_Name__c: { + key: 'Officer_2_Name__c', + index: 21, + required: 'Conditional', + dataType: 'String', + maxLength: 80, + listVals: [], + columnName: 'V', + humanColName: 'Executive Name (2)', + ecCodes: false, + }, + Officer_2_Total_Comp__c: { + key: 'Officer_2_Total_Comp__c', + index: 22, + required: 'Conditional', + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'W', + humanColName: 'Total Compensation Executive (2)', + ecCodes: false, + }, + Officer_3_Name__c: { + key: 'Officer_3_Name__c', + index: 23, + required: 'Conditional', + dataType: 'String', + maxLength: 80, + listVals: [], + columnName: 'X', + humanColName: 'Executive Name (3)', + ecCodes: false, + }, + Officer_3_Total_Comp__c: { + key: 'Officer_3_Total_Comp__c', + index: 24, + required: 'Conditional', + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'Y', + humanColName: 'Total Compensation Executive (3)', + ecCodes: false, + }, + Officer_4_Name__c: { + key: 'Officer_4_Name__c', + index: 25, + required: 'Conditional', + dataType: 'String', + maxLength: 80, + listVals: [], + columnName: 'Z', + humanColName: 'Executive Name (4)', + ecCodes: false, + }, + Officer_4_Total_Comp__c: { + key: 'Officer_4_Total_Comp__c', + index: 26, + required: 'Conditional', + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'AA', + humanColName: 'Total Compensation Executive (4)', + ecCodes: false, + }, + Officer_5_Name__c: { + key: 'Officer_5_Name__c', + index: 27, + required: 'Conditional', + dataType: 'String', + maxLength: 80, + listVals: [], + columnName: 'AB', + humanColName: 'Executive Name (5)', + ecCodes: false, + }, + Officer_5_Total_Comp__c: { + key: 'Officer_5_Total_Comp__c', + index: 28, + required: 'Conditional', + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'AC', + humanColName: 'Total Compensation Executive (5)', + ecCodes: false, + }, + }, + awards50k: { + Recipient_UEI__c: { + key: 'Recipient_UEI__c', + index: 2, + required: 'RequiredOptional', + dataType: 'String-Fixed', + maxLength: 12, + listVals: [], + columnName: 'C', + humanColName: 'UEI', + ecCodes: false, + }, + Recipient_EIN__c: { + key: 'Recipient_EIN__c', + index: 3, + required: 'RequiredOptional', + dataType: 'String-Fixed', + maxLength: 9, + listVals: [], + columnName: 'D', + humanColName: 'TIN', + ecCodes: false, + }, + Display_Only: { + key: 'Display_Only', + index: 8, + required: 'Hidden', + dataType: 'Display Only', + maxLength: null, + listVals: [], + columnName: 'I', + humanColName: 'Helper Column - Hidden From User - Do Not Alter', + ecCodes: false, + }, + Entity_Type_2__c: { + key: 'Entity_Type_2__c', + index: 5, + required: true, + dataType: 'Pick List', + maxLength: null, + listVals: ['Subrecipient', 'Contractor', 'Beneficiary'], + columnName: 'F', + humanColName: 'Entity Type', + ecCodes: false, + }, + Project_Identification_Number__c: { + key: 'Project_Identification_Number__c', + index: 6, + required: true, + dataType: 'String', + maxLength: 20, + listVals: [], + columnName: 'G', + humanColName: 'Project Identification Number\r\n(Assigned by recipient)', + ecCodes: false, + }, + Award_No__c: { + key: 'Award_No__c', + index: 7, + required: true, + dataType: 'String', + maxLength: 20, + listVals: [], + columnName: 'H', + humanColName: 'SubAward Number (unique identifier)', + ecCodes: false, + }, + Award_Type__c: { + key: 'Award_Type__c', + index: 9, + required: true, + dataType: 'Pick List', + maxLength: null, + listVals: [ + 'Contract: Purchase Order', + 'Contract: Delivery Order', + 'Contract: Blanket Purchase Agreement', + 'Contract: Definitive Contract', + 'Grant: Lump Sum Payment(s)', + 'Grant: Reimbursable', + 'Direct Payment', + 'Transfer: Lump Sum Payment(s)', + 'Transfer: Reimbursable', + 'Loan - maturity prior to 12/31/26 with planned forgiveness', + 'Loan - maturity prior to 12/31/26 without planned forgiveness', + 'Loan - maturity past 12/31/26 with planned forgiveness', + 'Loan - maturity past 12/31/26 without planned forgiveness', + ], + columnName: 'J', + humanColName: 'SubAward Type', + ecCodes: false, + }, + Award_Amount__c: { + key: 'Award_Amount__c', + index: 10, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'K', + humanColName: 'SubAward Amount (Obligation)', + ecCodes: false, + }, + Award_Date__c: { + key: 'Award_Date__c', + index: 11, + required: true, + dataType: 'Date', + maxLength: null, + listVals: [], + columnName: 'L', + humanColName: 'SubAward Award Date', + ecCodes: false, + }, + Primary_Sector__c: { + key: 'Primary_Sector__c', + index: 12, + required: 'Conditional', + dataType: 'Pick List', + maxLength: null, + listVals: [ + 'Any work performed by an employee of a State local or Tribal government', + 'Behavioral health work', + 'Biomedical research', + 'Dental care work', + 'Educational work school nutrition work and other work required to operate a school facility', + 'Elections work', + 'Emergency response', + 'Family or child care', + 'Grocery stores restaurants food production and food delivery', + 'Health care', + 'Home- and community-based health care or assistance with activities of daily living', + 'Laundry work', + 'Maintenance work', + 'Medical testing and diagnostics', + 'Pharmacy', + 'Public health work', + 'Sanitation disinfection and cleaning work', + 'Social services work', + 'Solid waste or hazardous materials management response and cleanup work', + 'Transportation and warehousing', + 'Vital services to Tribes', + 'Work at hotel and commercial lodging facilities that are used for COVID-19 mitigation and containment', + 'Work in a mortuary', + 'Work in critical clinical research development and testing necessary for COVID-19 response', + 'Work requiring physical interaction with patients', + 'Other', + ], + columnName: 'M', + humanColName: 'Primary Sector', + ecCodes: false, + }, + If_Other__c: { + key: 'If_Other__c', + index: 13, + required: 'Conditional', + dataType: 'String', + maxLength: 100, + listVals: [], + columnName: 'N', + humanColName: 'If Other', + ecCodes: false, + }, + Purpose_of_Funds__c: { + key: 'Purpose_of_Funds__c', + index: 14, + required: 'Conditional', + dataType: 'String', + maxLength: 3000, + listVals: [], + columnName: 'O', + humanColName: 'Purpose of funds', + ecCodes: false, + }, + Period_of_Performance_Start__c: { + key: 'Period_of_Performance_Start__c', + index: 15, + required: true, + dataType: 'Date', + maxLength: null, + listVals: [], + columnName: 'P', + humanColName: 'Period of Performance Start Date ', + ecCodes: false, + }, + Period_of_Performance_End__c: { + key: 'Period_of_Performance_End__c', + index: 16, + required: true, + dataType: 'Date', + maxLength: null, + listVals: [], + columnName: 'Q', + humanColName: 'Period of Performance End Date ', + ecCodes: false, + }, + Place_of_Performance_Address_1__c: { + key: 'Place_of_Performance_Address_1__c', + index: 17, + required: true, + dataType: 'String', + maxLength: 120, + listVals: [], + columnName: 'R', + humanColName: 'Primary Place of Performance Address Line 1 ', + ecCodes: false, + }, + Place_of_Performance_Address_2__c: { + key: 'Place_of_Performance_Address_2__c', + index: 18, + required: false, + dataType: 'String', + maxLength: 120, + listVals: [], + columnName: 'S', + humanColName: 'Primary Place of Performance Address Line 2', + ecCodes: false, + }, + Place_of_Performance_Address_3__c: { + key: 'Place_of_Performance_Address_3__c', + index: 19, + required: false, + dataType: 'String', + maxLength: 120, + listVals: [], + columnName: 'T', + humanColName: 'Primary Place of Performance Address Line 3', + ecCodes: false, + }, + Place_of_Performance_City__c: { + key: 'Place_of_Performance_City__c', + index: 20, + required: true, + dataType: 'String', + maxLength: 40, + listVals: [], + columnName: 'U', + humanColName: 'Primary Place of Performance City Name ', + ecCodes: false, + }, + State_Abbreviated__c: { + key: 'State_Abbreviated__c', + index: 21, + required: true, + dataType: 'Pick List', + maxLength: 2, + listVals: [ + 'AL', + 'AK', + 'AS', + 'AZ', + 'AR', + 'CA', + 'CO', + 'CT', + 'DE', + 'DC', + 'FM', + 'FL', + 'GA', + 'GU', + 'HI', + 'ID', + 'IL', + 'IN', + 'IA', + 'KS', + 'KY', + 'LA', + 'ME', + 'MH', + 'MD', + 'MA', + 'MI', + 'MN', + 'MS', + 'MO', + 'MT', + 'NE', + 'NV', + 'NH', + 'NJ', + 'NM', + 'NY', + 'NC', + 'ND', + 'MP', + 'OH', + 'OK', + 'OR', + 'PW', + 'PA', + 'PR', + 'RI', + 'SC', + 'SD', + 'TN', + 'TX', + 'UT', + 'VT', + 'VA', + 'VI', + 'WA', + 'WV', + 'WI', + 'WY', + ], + columnName: 'V', + humanColName: 'Primary Place of Performance State Code ', + ecCodes: false, + }, + Place_of_Performance_Zip__c: { + key: 'Place_of_Performance_Zip__c', + index: 22, + required: true, + dataType: 'String', + maxLength: 5, + listVals: [], + columnName: 'W', + humanColName: 'Primary Place of Performance Zip', + ecCodes: false, + }, + Place_of_Performance_Zip_4__c: { + key: 'Place_of_Performance_Zip_4__c', + index: 23, + required: false, + dataType: 'String', + maxLength: 4, + listVals: [], + columnName: 'X', + humanColName: 'Place of Performance Zip4', + ecCodes: false, + }, + Description__c: { + key: 'Description__c', + index: 24, + required: true, + dataType: 'String', + maxLength: 750, + listVals: [], + columnName: 'Y', + humanColName: 'SubAward Description', + ecCodes: false, + }, + Subaward_Changed__c: { + key: 'Subaward_Changed__c', + index: 25, + required: true, + dataType: 'Pick List', + maxLength: null, + listVals: [], + columnName: 'Z', + humanColName: + 'Did the Subaward Amount (Obligation) Change From Last Reporting Period?', + ecCodes: false, + }, + Change_Reason__c: { + key: 'Change_Reason__c', + index: 26, + required: 'Conditional', + dataType: 'String', + maxLength: 250, + listVals: [], + columnName: 'AA', + humanColName: 'Reason for the Subaward Amount (Obligation) Change', + ecCodes: false, + }, + }, + expenditures50k: { + Sub_Award_Lookup__c: { + key: 'Sub_Award_Lookup__c', + index: 2, + required: true, + dataType: 'String', + maxLength: 20, + listVals: [], + columnName: 'C', + humanColName: 'SubAward Number (unique identifier)', + ecCodes: false, + }, + Display_Only: { + key: 'Display_Only', + index: 4, + required: 'Informational', + dataType: 'Display Only', + maxLength: null, + listVals: [], + columnName: 'E', + humanColName: 'Legal Name', + ecCodes: false, + }, + Expenditure_Start__c: { + key: 'Expenditure_Start__c', + index: 5, + required: true, + dataType: 'Date', + maxLength: null, + listVals: [], + columnName: 'F', + humanColName: 'Expenditure Start Date', + ecCodes: false, + }, + Expenditure_End__c: { + key: 'Expenditure_End__c', + index: 6, + required: true, + dataType: 'Date', + maxLength: null, + listVals: [], + columnName: 'G', + humanColName: 'Expenditure End Date', + ecCodes: false, + }, + Expenditure_Amount__c: { + key: 'Expenditure_Amount__c', + index: 7, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'H', + humanColName: 'Total Expenditure Amount', + ecCodes: false, + }, + }, + awards: { + Project_Identification_Number__c: { + key: 'Project_Identification_Number__c', + index: 2, + required: true, + dataType: 'String', + maxLength: 20, + listVals: [], + columnName: 'C', + humanColName: 'Project Identification Number\r\n(Assigned by recipient)', + ecCodes: false, + }, + Sub_Award_Type_Aggregates_SLFRF__c: { + key: 'Sub_Award_Type_Aggregates_SLFRF__c', + index: 3, + required: true, + dataType: 'Pick List', + maxLength: null, + listVals: [ + 'Aggregate of Contracts Awarded', + 'Aggregate of Grants Awarded', + 'Aggregate of Loans Issued', + 'Aggregate of Transfers', + 'Aggregate of Direct Payments', + 'Payments to Individuals', + ], + columnName: 'D', + humanColName: 'Aggregate Sub Award Type', + ecCodes: false, + }, + Quarterly_Obligation_Amt_Aggregates__c: { + key: 'Quarterly_Obligation_Amt_Aggregates__c', + index: 4, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'E', + humanColName: 'Current Quarter Obligation', + ecCodes: false, + }, + Quarterly_Expenditure_Amt_Aggregates__c: { + key: 'Quarterly_Expenditure_Amt_Aggregates__c', + index: 5, + required: true, + dataType: 'Currency', + maxLength: null, + listVals: [], + columnName: 'F', + humanColName: 'Current Quarter Expenditure/Payments', + ecCodes: false, + }, + }, +} diff --git a/api/src/lib/validate-upload.js b/api/src/lib/validate-upload.js new file mode 100644 index 00000000..487eed1e --- /dev/null +++ b/api/src/lib/validate-upload.js @@ -0,0 +1,793 @@ +import { ecCodes } from 'src/lib/ec-codes' +import { recordsForUpload, TYPE_TO_SHEET_NAME } from 'src/lib/records' +import { ValidationError } from 'src/lib/validation-error' +import { getRules } from 'src/lib/validation-rules' +import { + subrecipient, + createSubrecipient, + updateSubrecipient, +} from 'src/services/subrecipients' +import { + updateUpload, + markValidated, + markInvalidated, +} from 'src/services/uploads' + +// Currency strings are must be at least one digit long (\d+) +// They can optionally have a decimal point followed by 1 or 2 more digits (?: \.\d{ 1, 2 }) +const CURRENCY_REGEX_PATTERN = /^\d+(?: \.\d{ 1, 2 })?$/g + +// Copied from www.emailregex.com +const EMAIL_REGEX_PATTERN = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ + +const BETA_VALIDATION_MESSAGE = + '[BETA] This is a new validation that is running in beta mode (as a warning instead of a blocking error). If you see anything incorrect about this validation, please report it at grants-helpdesk@usdigitalresponse.org' + +const SHOULD_NOT_CONTAIN_PERIOD_REGEX_PATTERN = /^[^.]*$/ + +// This maps from field name to regular expression that must match on the field. +// Note that this only covers cases where the name of the field is what we want to match on. +const FIELD_NAME_TO_PATTERN = { + POC_Email_Address__c: { + pattern: EMAIL_REGEX_PATTERN, + explanation: 'Email must be of the form "user@email.com"', + }, + Place_of_Performance_City__c: { + pattern: SHOULD_NOT_CONTAIN_PERIOD_REGEX_PATTERN, + explanation: 'Field must not contain a period.', + }, +} + +// This is a convenience wrapper that lets us use consistent behavior for new validation errors. +// Specifically, all new validations should have a message explaining they are in beta and errors +// should be reported to us. The validation should also be a warning (not a blocking error) until +// it graduates out of beta +function betaValidationWarning(message) { + return new ValidationError(`${message} -- ${BETA_VALIDATION_MESSAGE}`, { + severity: 'warn', + }) +} + +function validateFieldPattern(fieldName, value) { + let error = null + const matchedFieldPatternInfo = FIELD_NAME_TO_PATTERN[fieldName] + if (matchedFieldPatternInfo) { + const { pattern } = matchedFieldPatternInfo + const { explanation } = matchedFieldPatternInfo + if (value && typeof value === 'string' && !value.match(pattern)) { + error = new Error(`Value entered in cell is "${value}". ${explanation}`) + } + } + return error +} + +async function validateEcCode({ upload, records }) { + // grab ec code string from cover sheet + const coverSheet = records.find((doc) => doc.type === 'cover').content + const codeString = coverSheet['Detailed Expenditure Category'] + + if (!codeString) { + return new ValidationError('EC code must be set', { + tab: 'cover', + row: 2, + col: 'D', + }) + } + + const codeParts = codeString.split('-') + const code = codeParts[0] + + if (!ecCodes[code]) { + return new ValidationError( + `Record EC code ${code} from entry ${codeString} does not match any known EC code`, + { + tab: 'cover', + row: 2, + col: 'D', + severity: 'err', + } + ) + } + + // always set EC code if possible; we omit passing the transaction in this + // case, so that the code gets set even if the upload fails to validate + if (code !== upload.expenditureCategory.code) { + await updateUpload(upload.id, code) + upload.expenditureCategory.code = code + } + + return undefined +} + +async function validateVersion({ records, rules }) { + const logicSheet = records.find((record) => record.type === 'logic').content + const { version } = logicSheet + + const versionRule = rules.logic.version + + let error = null + if (version < versionRule.version) { + error = 'older' + } else if (version > versionRule.version) { + error = 'newer' + } + + if (error) { + return new ValidationError( + `Upload template version (${version}) is ${error} than the latest input template (${versionRule.version})`, + { + tab: 'logic', + row: 1, + col: versionRule.columnName, + severity: 'warn', + } + ) + } + + return undefined +} + +/** + * Return an already existing record in the db, defined via UEI or TIN + * @param {object} recipient - the recipient record + * @param {object} trns - the transaction to use for db queries + * @returns {Promise} - the existing recipient record + */ +async function findRecipientInDatabase({ recipient }) { + // There are two types of identifiers, UEI and TIN. + // A given recipient may have either or both of these identifiers. + const byUei = recipient.Unique_Entity_Identifier__c + ? await subrecipient(recipient.Unique_Entity_Identifier__c, null) + : null + + return byUei +} + +/** + * Validate the recipient's identifier + * @param {object} recipient - the recipient record + * @returns {Array} - an array of validation errors if found + */ +function validateIdentifier(recipient, recipientExists) { + const errors = [] + + // As of Q1, 2023 we require a UEI for all entities of type subrecipient and/or contractor. + // For beneficiaries or older records, we require a UEI OR a TIN/EIN + // See https://github.com/usdigitalresponse/usdr-gost/issues/1027 + const hasUEI = Boolean(recipient.Unique_Entity_Identifier__c) + const hasTIN = Boolean(recipient.EIN__c) + const entityType = recipient.Entity_Type_2__c + const isContractorOrSubrecipient = + entityType.includes('Contractor') || entityType.includes('Subrecipient') + + if (isContractorOrSubrecipient && !recipientExists && !hasUEI) { + errors.push( + new ValidationError( + 'UEI is required for all new subrecipients and contractors', + { col: 'C', severity: 'err' } + ) + ) + } else if (!isContractorOrSubrecipient && !hasUEI && !hasTIN) { + // If this entity is not new, or is not a subrecipient or contractor, then it must have a TIN OR a UEI (same as the old logic) + errors.push( + new ValidationError( + 'At least one of UEI or TIN/EIN must be set, but both are missing', + { col: 'C, D', severity: 'err' } + ) + ) + } + + return errors +} + +/** + * Check if the recipient belongs to the given upload + * @param {object} existingRecipient - the existing recipient record + * @param {object} upload - the upload record + * @returns {boolean} - true if the recipient belongs to the upload + */ +function recipientBelongsToUpload(existingRecipient, upload) { + return ( + Boolean(existingRecipient) && + existingRecipient.upload_id === upload?.id && + !existingRecipient.updated_at + ) +} + +/** + * Update or create a recipient record + * @param {object} recipientInfo - the information about the recipient + * @param {object} trns - the transaction to use for db queries + * @param {object} upload - the upload record + * @returns + */ +async function updateOrCreateRecipient( + existingRecipient, + newRecipient, + trns, + upload +) { + // TODO: what if the same upload specifies the same recipient multiple times, + // but different? + + // If the current upload owns the recipient, we can actually update it + if (existingRecipient) { + if (recipientBelongsToUpload(existingRecipient, upload)) { + await updateSubrecipient(existingRecipient.id, newRecipient) + } + } else { + await createSubrecipient( + { + id: newRecipient.Unique_Entity_Identifier__c, + tin: newRecipient.EIN__c, + record: newRecipient, + originationUploadId: upload?.id, + }, + trns + ) + } +} + +/** + * Validates a subrecipient record by checking the unique entity identifier (UEI) or taxpayer identification number (TIN/EIN). + * If the record passes validation, updates the existing recipient in the database or creates a new one. + * + * @async + * @function + * @param {object} options - The options object. + * @param {object} upload - The upload object. + * @param {object} record - The new recipient object to be validated. + * @param {array} recordErrors - The array of errors detected for the record so far. + * @returns {Promise} - The array of errors detected during the validation process. + */ +async function validateSubrecipientRecord({ + upload, + record: recipient, + recordErrors, +}) { + const errors = [] + const existingRecipient = await findRecipientInDatabase({ recipient }) + errors.push(...validateIdentifier(recipient, existingRecipient)) + + // Either: the record has already been validated before this method was invoked, or + // we found an error above. If it's not valid, don't update or create it + if (recordErrors.length === 0 && errors.length === 0) { + updateOrCreateRecipient(existingRecipient, recipient, upload) + } + return errors +} + +async function validateRecord({ upload, record, typeRules: rules }) { + // placeholder for rule errors we're going to find + const errors = [] + + // check all the rules + for (const [key, rule] of Object.entries(rules)) { + // if the rule only applies on different EC codes, skip it + if ( + rule.ecCodes && + (!upload.ec_code || !rule.ecCodes.includes(upload.expenditureCategoryId)) + ) { + // eslint-disable-next-line no-continue + continue + } + + // if the field is unset/missing/blank, is that okay? + // we don't treat numeric `0` as unset + if ([undefined, null, ''].includes(record[key])) { + // make sure required keys are present + if (rule.required === true) { + errors.push( + new ValidationError(`Value is required for ${key}`, { + col: rule.columnName, + severity: 'err', + }) + ) + } else if (rule.required === 'Conditional') { + if (rule.isRequiredFn && rule.isRequiredFn(record)) { + errors.push( + new ValidationError( + // This message should make it clear that this field is conditionally required + `Based on other values in this row, a value is required for ${key}`, + { col: rule.columnName, severity: 'err' } + ) + ) + } + } + + // if there's something in the field, make sure it meets requirements + } else { + // how do we format the value before checking it? + let value = record[key] + let formatFailures = 0 + for (const formatter of rule.validationFormatters) { + try { + value = formatter(value) + } catch (e) { + formatFailures += 1 + } + } + if (formatFailures) { + errors.push( + new ValidationError( + `Failed to apply ${formatFailures} formatters while validating value`, + { col: rule.columnName, severity: 'warn' } + ) + ) + } + + // make sure pick value is one of pick list values + if (rule.listVals.length > 0) { + // enforce validation in lower case + const lcItems = rule.listVals.map((val) => val.toLowerCase()) + + // for pick lists, the value must be one of possible values + if (rule.dataType === 'Pick List' && !lcItems.includes(value)) { + errors.push( + new ValidationError( + `Value for ${key} ('${value}') must be one of ${lcItems.length} options in the input template`, + { col: rule.columnName, severity: 'err' } + ) + ) + } + + // for multi select, all the values must be in the list of possible values + if (rule.dataType === 'Multi-Select') { + const entries = value.split(';').map((val) => val.trim()) + for (const entry of entries) { + if (!lcItems.includes(entry)) { + errors.push( + new ValidationError( + `Entry '${entry}' of ${key} is not one of ${lcItems.length} valid options`, + { col: rule.columnName, severity: 'err' } + ) + ) + } + } + } + } + + if (rule.dataType === 'Currency') { + if ( + value && + typeof value === 'string' && + !value.match(CURRENCY_REGEX_PATTERN) + ) { + errors.push( + new ValidationError( + `Data entered in cell is "${value}", but it must be a number with at most 2 decimals`, + { severity: 'err', col: rule.columnName } + ) + ) + } + } + + if (rule.dataType === 'Date') { + if ( + value && + typeof value === 'string' && + Number.isNaN(Date.parse(value)) + ) { + errors.push( + new ValidationError( + `Data entered in cell is "${value}", which is not a valid date.`, + { severity: 'err', col: rule.columnName } + ) + ) + } + } + + if (rule.dataType === 'String') { + const patternError = validateFieldPattern(key, value) + if (patternError) { + errors.push( + new ValidationError(patternError.message, { + severity: 'err', + col: rule.columnName, + }) + ) + } + } + + if (rule.dataType === 'Numeric') { + if (typeof value === 'string' && Number.isNaN(parseFloat(value))) { + // If this value is a string that can't be interpretted as a number, then error. + // Note: This value might not be exactly what was entered in the workbook. The val + // has already been fed through formatters that may have changed the value. + errors.push( + new ValidationError( + `Expected a number, but the value was '${value}'`, + { severity: 'err', col: rule.columnName } + ) + ) + } + } + + // make sure max length is not too long + if (rule.maxLength) { + if ( + (rule.dataType === 'String' || rule.dataType === 'String-Fixed') && + String(record[key]).length > rule.maxLength + ) { + errors.push( + new ValidationError( + `Value for ${key} cannot be longer than ${ + rule.maxLength + } (currently, ${String(record[key]).length})`, + { col: rule.columnName, severity: 'err' } + ) + ) + } + + // TODO: should we validate max length on currency? or numeric fields? + } + } + } + + // return all the found errors + return errors +} + +async function validateRules({ upload, records, rules, trns }) { + const errors = [] + + // go through every rule type we have + for (const [type, typeRules] of Object.entries(rules)) { + // find records of the given rule type + const tRecords = records + .filter((rec) => rec.type === type) + .map((r) => r.content) + + // for each of those records, generate a list of rule violations + for (const [recordIdx, record] of tRecords.entries()) { + let recordErrors + try { + // TODO: Consider refactoring this to take better advantage of async parallelization + // eslint-disable-next-line no-await-in-loop + recordErrors = await validateRecord({ upload, record, typeRules }) + } catch (e) { + recordErrors = [ + new ValidationError( + `unexpected error validating record: ${e.message}` + ), + ] + } + + // special sub-recipient validation + try { + if (type === 'subrecipient') { + recordErrors = [ + ...recordErrors, + // TODO: Consider refactoring this to take better advantage of async parallelization + // eslint-disable-next-line no-await-in-loop + ...(await validateSubrecipientRecord({ + upload, + record, + typeRules, + recordErrors, + trns, + })), + ] + } + } catch (e) { + recordErrors = [ + ...recordErrors, + new ValidationError( + `unexpectedError validating subrecipient: ${e.message}` + ), + ] + } + + // each rule violation gets assigned a row in a sheet; they already set their column + recordErrors.forEach((error) => { + error.tab = type + error.row = 13 + recordIdx // TODO: how do we know the data starts at row 13? + + // save each rule violation in the overall list + errors.push(error) + }) + } + } + + return errors +} + +// Subrecipients can use either the uei, or the tin, or both as their identifier. +// This helper takes those 2 nullable fields and converts it to a reliable format +// so we can index and search by them. +function subrecipientIdString(uei, tin) { + if (!uei && !tin) { + return '' + } + return JSON.stringify({ uei, tin }) +} + +function sortRecords(records, errors) { + // These 3 types need to search-able by their unique id so we can quickly verify they exist + const projects = {} + const subrecipients = {} + const awardsGT50k = {} + + const awards = [] + const expendituresGT50k = [] + for (const record of records) { + switch (record.type) { + case 'ec1': + case 'ec2': + case 'ec3': + case 'ec4': + case 'ec5': + case 'ec7': { + const projectID = record.content.Project_Identification_Number__c + if (projectID in projects) { + errors.push( + betaValidationWarning( + `Project ids must be unique, but another row used the id ${projectID}` + ) + ) + } + projects[projectID] = record.content + break + } + case 'subrecipient': { + const subRecipId = subrecipientIdString( + record.content.Unique_Entity_Identifier__c, + record.content.EIN__c + ) + if (subRecipId && subRecipId in subrecipients) { + errors.push( + betaValidationWarning( + `Subrecipient ids must be unique, but another row used the id ${subRecipId}` + ) + ) + } + subrecipients[subRecipId] = record.content + break + } + case 'awards50k': { + const awardNumber = record.content.Award_No__c + if (awardNumber && awardNumber in awardsGT50k) { + errors.push( + betaValidationWarning( + `Award numbers must be unique, but another row used the number ${awardNumber}` + ) + ) + } + awardsGT50k[awardNumber] = record.content + break + } + case 'awards': + awards.push(record.content) + break + case 'expenditures50k': + expendituresGT50k.push(record.content) + break + case 'certification': + case 'cover': + case 'logic': + // Skip these sheets, they don't include records + // eslint-disable-next-line no-continue + continue + default: + console.error(`Unexpected record type: ${record.type}`) + } + } + + return { + projects, + subrecipients, + awardsGT50k, + awards, + expendituresGT50k, + } +} + +function validateSubawardRefs(awardsGT50k, projects, subrecipients, errors) { + // Any subawards must reference valid projects and subrecipients. + // Track the subrecipient ids that were referenced, since we'll need them later + const usedSubrecipients = new Set() + for (const [awardNumber, subaward] of Object.entries(awardsGT50k)) { + const projectRef = subaward.Project_Identification_Number__c + if (!(projectRef in projects)) { + errors.push( + betaValidationWarning( + `Subaward number ${awardNumber} referenced a non-existent projectId ${projectRef}` + ) + ) + } + const subRecipRef = subrecipientIdString( + subaward.Recipient_UEI__c, + subaward.Recipient_EIN__c + ) + if (!(subRecipRef in subrecipients)) { + errors.push( + betaValidationWarning( + `Subaward number ${awardNumber} referenced a non-existent subrecipient with id ${subRecipRef}` + ) + ) + } + usedSubrecipients.add(subRecipRef) + } + // Return this so that it can be used in the subrecipient validations + return usedSubrecipients +} + +function validateSubrecipientRefs(subrecipients, usedSubrecipients, errors) { + // Make sure that every subrecip included in this upload was referenced by at least one subaward + for (const subRecipId of Object.keys(subrecipients)) { + if (!(subRecipId && usedSubrecipients.has(subRecipId))) { + errors.push( + betaValidationWarning( + `Subrecipient with id ${subRecipId} has no related subawards and can be ommitted.` + ) + ) + } + } +} + +function validateExpenditureRefs(expendituresGT50k, awardsGT50k, errors) { + // Make sure each expenditure references a valid subward + for (const expenditure of expendituresGT50k) { + const awardRef = expenditure.Sub_Award_Lookup__c + if (!(awardRef in awardsGT50k)) { + errors.push( + betaValidationWarning( + `An expenditure referenced an unknown award number ${awardRef}` + ) + ) + } + } +} + +async function validateReferences({ records }) { + const errors = [] + + const sortedRecords = sortRecords(records, errors) + + // Must include at least 1 project in the upload + if (Object.keys(sortedRecords.projects).length === 0) { + errors.push( + new ValidationError(`Upload doesn't include any project records`, { + severity: 'err', + }) + ) + } + + const usedSubrecipients = validateSubawardRefs( + sortedRecords.awardsGT50k, + sortedRecords.projects, + sortedRecords.subrecipients, + errors + ) + validateSubrecipientRefs( + sortedRecords.subrecipients, + usedSubrecipients, + errors + ) + validateExpenditureRefs( + sortedRecords.expendituresGT50k, + sortedRecords.awardsGT50k, + errors + ) + + return errors +} + +async function validateUpload(upload, user, trns = null) { + // holder for our validation errors + const errors = [] + + // holder for post-validation functions + + // grab the records + const records = await recordsForUpload(upload) + + // grab the rules + const rules = await getRules() + + // list of all of our validations + const validations = [ + validateVersion, + validateEcCode, + validateRules, + validateReferences, + ] + + // we should do this in a transaction, unless someone is doing it for us + const ourTransaction = !trns + if (ourTransaction) { + // TODO: do this with prisma + // trns = await knex.transaction() + } + + // run validations, one by one + for (const validation of validations) { + try { + // TODO: Consider refactoring this to take better advantage of async parallelization + // eslint-disable-next-line no-await-in-loop + errors.push( + await validation({ + upload, + records, + rules, + trns, + }) + ) + } catch (e) { + errors.push( + new ValidationError(`validation ${validation.name} failed: ${e}`) + ) + } + } + + // flat list without any nulls, including errors and warnings + const flatErrors = errors.flat().filter((x) => x) + + // tab should be sheet name, not sheet type + for (const error of flatErrors) { + error.tab = TYPE_TO_SHEET_NAME[error.tab] || error.tab + } + + // fatal errors determine if the upload fails validation + const fatal = flatErrors.filter((x) => x.severity === 'err') + const validated = fatal.length === 0 + + // if we successfully validated for the first time, let's mark it! + if (validated && !upload.validated_at) { + try { + await markValidated(upload.id, user.id) + } catch (e) { + errors.push(new ValidationError(`failed to mark upload: ${e.message}`)) + } + } + + // depending on whether we validated or not, lets commit/rollback. we MUST do + // this or bad things happen. this is why there are try/catch blocks around + // every other function call above here + if (ourTransaction) { + const finishTrns = validated ? trns.commit : trns.rollback + await finishTrns() + // TODO: make this work with prisma + // trns = knex + } + + // if it was valid before but is no longer valid, clear it; this happens outside the transaction + if (!validated && upload.validated_at) { + await markInvalidated(upload.id, trns) + } + + // finally, return our errors + return flatErrors +} + +async function invalidateUpload(upload, user, trns = null) { + const errors = [] + + const ourTransaction = !trns + if (ourTransaction) { + // TODO: do this with prisma + // trns = await knex.transaction() + } + + // if we successfully validated for the first time, let's mark it! + try { + await markInvalidated(upload.id, user.id) + } catch (e) { + errors.push(new ValidationError(`failed to mark upload: ${e.message}`)) + } + + // depending on whether we validated or not, lets commit/rollback. we MUST do + // this or bad things happen. this is why there are try/catch blocks around + // every other function call above here + if (ourTransaction) { + // TODO: do this with prisma + // await trns.commit() + // trns = knex + } + + // finally, return our errors + return errors +} + +export { validateUpload, invalidateUpload } diff --git a/api/src/lib/validation-error.ts b/api/src/lib/validation-error.ts new file mode 100644 index 00000000..fed35595 --- /dev/null +++ b/api/src/lib/validation-error.ts @@ -0,0 +1,32 @@ +/** + * severity: "err" | "warn" + */ +class ValidationError extends Error { + severity: string + tab: string | null + row: number | null + col: number | null + + constructor( + message, + { severity = 'err', tab = null, row = null, col = null } = {} + ) { + super(message) + this.severity = severity + this.tab = tab + this.row = row + this.col = col + } + + toObject() { + return { + message: this.message, + severity: this.severity, + tab: this.tab, + row: this.row, + col: this.col, + } + } +} + +export { ValidationError } diff --git a/api/src/lib/validation-rules.js b/api/src/lib/validation-rules.js new file mode 100644 index 00000000..8afad93b --- /dev/null +++ b/api/src/lib/validation-rules.js @@ -0,0 +1,199 @@ +import { _ } from 'lodash' + +// FIXME: we need to bring this JSON in. +import srcRules from './templateRules' + +const recordValueFormatters = { + makeString: (val) => String(val), + trimWhitespace: (val) => (_.isString(val) ? val.trim() : val), + removeCommas: (val) => (_.isString(val) ? val.replace(/,/g, '') : val), + removeSepDashes: (val) => + _.isString(val) ? val.replace(/^-/, '').replace(/;\s*-/g, ';') : val, + toLowerCase: (val) => (_.isString(val) ? val.toLowerCase() : val), +} + +// These conditional functions should return true if the field is required. +// This fn is used mark certain fields as required, as long as the status of the project +// isn't 'Not started' +function optionalIfNotStarted(projectRow) { + if (projectRow.Completion_Status__c === 'Not started') { + return false + } + return true +} + +// This is the list of field ids that should be optional if the status is 'Not started' +// For any other status, the field should be considered required. +const optionalIfNotStartedFieldIds = [ + 'Primary_Project_Demographics__c', + 'Number_Students_Tutoring_Programs__c', + 'Does_Project_Include_Capital_Expenditure__c', + 'Structure_Objectives_of_Asst_Programs__c', + 'Recipient_Approach_Description__c', + 'Individuals_Served__c', + 'Spending_Allocated_Toward_Evidence_Based_Interventions', + 'Whether_program_evaluation_is_being_conducted', + 'Small_Businesses_Served__c', + 'Number_Non_Profits_Served__c', + 'Number_Workers_Enrolled_Sectoral__c', + 'Number_Workers_Competing_Sectoral__c', + 'Number_People_Summer_Youth__c', + 'School_ID_or_District_ID__c', + 'Industry_Experienced_8_Percent_Loss__c', + 'Number_Households_Eviction_Prevention__c', + 'Number_Affordable_Housing_Units__c', + 'Number_Children_Served_Childcare__c', + 'Number_Families_Served_Home_Visiting__c', + 'Payroll_Public_Health_Safety__c', + 'Number_of_FTEs_Rehired__c', + 'Sectors_Critical_to_Health_Well_Being__c', + 'Workers_Served__c', + 'Premium_Pay_Narrative__c', + 'Number_of_Workers_K_12__c', + 'Technology_Type_Planned__c', +] + +/* This is a list of all of the configured rules for "conditionally required" fields. Each entry + in this list should include a list of field ids that are subject to the conditional requirement, + and a function that takes in a record object and returns true if it is required. */ +const CONDITIONAL_REQS_CONFIGS = [ + { + fieldIDs: optionalIfNotStartedFieldIds, + func: optionalIfNotStarted, + }, + { + fieldIDs: ['Cancellation_Reason__c'], + func: (record) => record.Completion_Status__c === 'Cancelled', + }, +] + +/* The CONFIGS format above is convenient for when we want to write the conditional rules, but bad + when we want to lookup the rules for a particular field id. This function converts the configs + into a more efficient lookup map */ +function convertConfigsToLookupMap() { + const reqFnByFieldId = {} + for (const config of CONDITIONAL_REQS_CONFIGS) { + for (const fieldID of config.fieldIDs) { + reqFnByFieldId[fieldID] = config.func + } + } + return reqFnByFieldId +} + +const CONDITIONAL_REQUIREMENTS_BY_FIELD_ID = convertConfigsToLookupMap() + +/* +Structured data recording all the immediate corrections we want to apply to dropdowns. +There are 2 types of corrections we can apply: +1) The value in the currently committed input template is incorrect. +2) A value in the dropdown list changed in the past, and we want to continue to allow legacy vals as valid inputs. +In the first case, we will alter the validation rule to check against the new correct value, and +then treat the value currently seen in the worksheet as an allowable legacy value. +In both cases, we will foribly coerce any instances of legacy values into the correct value. +This coercion happens whenever we read the value from the upload file, so it will apply to +validations we perform, as well as values we export in reports. + */ +const dropdownCorrections = { + 'Affordable housing supportive housing or recovery housing': { + correctedValue: + 'Affordable housing, supportive housing, or recovery housing', + }, + 'Childcare, daycare, and early learning facilities': { + correctedValue: 'Childcare, daycare and early learning facilities', + }, + 'COVID-19 testing sites and laboratories': { + allowableLegacyValues: [ + 'COVID-19 testing sites and laboratories, and acquisition of related equipment', + 'COVID-19 testing sites and laboratories and acquisition of related equipment', + ], + }, + 'Mitigation measures in small businesses, nonprofits, and impacted industries': + { + correctedValue: + 'Mitigation measures in small businesses, nonprofits and impacted industries', + }, + 'Family or child care': { + allowableLegacyValues: ['Family or childcare'], + }, +} + +function generateRules() { + // FIXME: this is supposed to be the contents of templateRules.json + const rules = srcRules + + // subrecipient EIN is actually a length-10 string + rules.subrecipient.EIN__c.dataType = 'String' + rules.subrecipient.EIN__c.maxLength = 10 + + rules.awards50k.Recipient_EIN__c.dataType = 'String' + rules.awards50k.Recipient_EIN__c.maxLength = 10 + + // value formatters modify the value in the record before it's validated + // we check any rule against the formatted value + // for any values we format, we should format them the same way when we export + for (const ruleType of Object.keys(rules)) { + for (const rule of Object.values(rules[ruleType])) { + // validationFormatters are only applied when validating the records, so they + // aren't used during exports. + // persistentFormatters are always applied as soon as a value is read from a + // an upload, so they will affect both validation AND the exported value. + if (typeof rule === 'object') { + rule.validationFormatters = [] + } + rule.persistentFormatters = [] + + if (rule.dataType === 'String' || rule.dataType === 'String-Fixed') { + if (typeof rule === 'object') { + rule.validationFormatters.push(recordValueFormatters.makeString) + } + rule.persistentFormatters.push(recordValueFormatters.trimWhitespace) + } + + if (rule.dataType === 'Multi-Select') { + if (typeof rule === 'object') { + rule.validationFormatters.push(recordValueFormatters.removeCommas) + rule.validationFormatters.push(recordValueFormatters.removeSepDashes) + } + } + + if (rule.listVals && rule.listVals.length > 0) { + if (typeof rule === 'object') { + rule.validationFormatters.push(recordValueFormatters.toLowerCase) + } + + for (let i = 0; i < rule.listVals.length; i += 1) { + const worksheetValue = rule.listVals[i] + const correction = dropdownCorrections[worksheetValue] + if (correction) { + const correctValue = correction.correctedValue || worksheetValue + const valuesToCoerce = ( + correction.allowableLegacyValues || [] + ).concat(worksheetValue) + + rule.listVals[i] = correctValue + rule.persistentFormatters.push((val) => + valuesToCoerce.includes(val) ? correctValue : val + ) + } + } + } + if (rule.key in CONDITIONAL_REQUIREMENTS_BY_FIELD_ID) { + rule.isRequiredFn = CONDITIONAL_REQUIREMENTS_BY_FIELD_ID[rule.key] + } + } + } + + return rules +} + +let generatedRules + +function getRules() { + if (!generatedRules) generatedRules = generateRules() + + return generatedRules +} + +export { getRules, dropdownCorrections } + +// NOTE: This file was copied from src/server/services/validation-rules.js (git @ ada8bfdc98) in the arpa-reporter repo on 2022-09-23T20:05:47.735Z diff --git a/api/src/services/projects/projects.scenarios.ts b/api/src/services/projects/projects.scenarios.ts index 8becffd1..76e9d64b 100644 --- a/api/src/services/projects/projects.scenarios.ts +++ b/api/src/services/projects/projects.scenarios.ts @@ -35,6 +35,7 @@ export const standard = defineScenario({ updatedAt: '2023-12-09T14:50:29.322Z', }, }, + organization: { create: { name: 'String' } }, }, }, }, @@ -70,6 +71,7 @@ export const standard = defineScenario({ updatedAt: '2023-12-09T14:50:29.322Z', }, }, + organization: { create: { name: 'String' } }, }, }, }, diff --git a/api/src/services/projects/projects.test.ts b/api/src/services/projects/projects.test.ts index 4c234abf..a74310d2 100644 --- a/api/src/services/projects/projects.test.ts +++ b/api/src/services/projects/projects.test.ts @@ -38,7 +38,6 @@ describe('projects', () => { status: 'String', description: 'String', originationPeriodId: scenario.project.two.originationPeriodId, - updatedAt: '2023-12-09T14:50:29.223Z', }, }) @@ -51,7 +50,7 @@ describe('projects', () => { expect(result.originationPeriodId).toEqual( scenario.project.two.originationPeriodId ) - expect(result.updatedAt).toEqual(new Date('2023-12-09T14:50:29.223Z')) + expect(result.updatedAt).toBeDefined() }) scenario('updates a project', async (scenario: StandardScenario) => { diff --git a/api/src/services/reportingPeriods/reportingPeriods.scenarios.ts b/api/src/services/reportingPeriods/reportingPeriods.scenarios.ts index da5880c7..e87d2fc0 100644 --- a/api/src/services/reportingPeriods/reportingPeriods.scenarios.ts +++ b/api/src/services/reportingPeriods/reportingPeriods.scenarios.ts @@ -26,6 +26,7 @@ export const standard = defineScenario({ updatedAt: '2023-12-07T18:38:12.356Z', }, }, + organization: { create: { name: 'String' } }, }, }, two: { @@ -50,6 +51,7 @@ export const standard = defineScenario({ updatedAt: '2023-12-07T18:38:12.356Z', }, }, + organization: { create: { name: 'String' } }, }, }, }, diff --git a/api/src/services/reportingPeriods/reportingPeriods.test.ts b/api/src/services/reportingPeriods/reportingPeriods.test.ts index f01d5806..5b770e80 100644 --- a/api/src/services/reportingPeriods/reportingPeriods.test.ts +++ b/api/src/services/reportingPeriods/reportingPeriods.test.ts @@ -44,9 +44,10 @@ describe('reportingPeriods', () => { name: 'String', startDate: '2023-12-07T18:38:12.341Z', endDate: '2023-12-07T18:38:12.341Z', + organizationId: scenario.reportingPeriod.two.organizationId, inputTemplateId: scenario.reportingPeriod.two.inputTemplateId, outputTemplateId: scenario.reportingPeriod.two.outputTemplateId, - updatedAt: '2023-12-07T18:38:12.341Z', + isCurrentPeriod: true, }, }) @@ -59,7 +60,7 @@ describe('reportingPeriods', () => { expect(result.outputTemplateId).toEqual( scenario.reportingPeriod.two.outputTemplateId ) - expect(result.updatedAt).toEqual(new Date('2023-12-07T18:38:12.341Z')) + expect(result.updatedAt).toBeDefined() }) scenario('updates a reportingPeriod', async (scenario: StandardScenario) => { diff --git a/api/src/services/reportingPeriods/reportingPeriods.ts b/api/src/services/reportingPeriods/reportingPeriods.ts index 4156493f..becb1c5b 100644 --- a/api/src/services/reportingPeriods/reportingPeriods.ts +++ b/api/src/services/reportingPeriods/reportingPeriods.ts @@ -10,12 +10,41 @@ export const reportingPeriods: QueryResolvers['reportingPeriods'] = () => { return db.reportingPeriod.findMany() } +/** + * Get all reporting periods that either match supplied period ID or are older + * than supplied period ID. + * + * @returns The matching reporting periods, sorted from oldest to newest by date + */ +export const previousReportingPeriods: QueryResolvers['previousReportingPeriods'] = + async ({ id, organizationId }) => { + const currentPeriod = await db.reportingPeriod.findUnique({ + where: { id }, + }) + const allPeriods = await db.reportingPeriod.findMany({ + where: { organizationId }, + }) + const reportingPeriods = allPeriods.filter( + (period) => new Date(period.endDate) <= new Date(currentPeriod.endDate) + ) + reportingPeriods.sort((a, b) => a.endDate.getTime() - b.endDate.getTime()) + return reportingPeriods + } + export const reportingPeriod: QueryResolvers['reportingPeriod'] = ({ id }) => { return db.reportingPeriod.findUnique({ where: { id }, }) } +export const reportingPeriodsByOrg: QueryResolvers['reportingPeriodsByOrg'] = + async ({ organizationId }) => { + const reportingPeriods = db.reportingPeriod.findMany({ + where: { organizationId }, + }) + return reportingPeriods || [] // Return an empty array if null is received + } + export const createReportingPeriod: MutationResolvers['createReportingPeriod'] = ({ input }) => { return db.reportingPeriod.create({ diff --git a/api/src/services/subrecipients/subrecipients.scenarios.ts b/api/src/services/subrecipients/subrecipients.scenarios.ts index 5d06dd68..430addd0 100644 --- a/api/src/services/subrecipients/subrecipients.scenarios.ts +++ b/api/src/services/subrecipients/subrecipients.scenarios.ts @@ -46,6 +46,7 @@ export const standard = defineScenario({ updatedAt: '2023-12-09T14:50:18.317Z', }, }, + organization: { create: { name: 'String' } }, }, }, expenditureCategory: { @@ -101,6 +102,9 @@ export const standard = defineScenario({ updatedAt: '2023-12-09T14:50:18.317Z', }, }, + organization: { + create: { name: 'String' }, + }, }, }, expenditureCategory: { diff --git a/api/src/services/subrecipients/subrecipients.test.ts b/api/src/services/subrecipients/subrecipients.test.ts index dd43b560..9905e794 100644 --- a/api/src/services/subrecipients/subrecipients.test.ts +++ b/api/src/services/subrecipients/subrecipients.test.ts @@ -39,7 +39,6 @@ describe('subrecipients', () => { startDate: '2023-12-09T14:50:18.092Z', endDate: '2023-12-09T14:50:18.092Z', originationUploadId: scenario.subrecipient.two.originationUploadId, - updatedAt: '2023-12-09T14:50:18.092Z', }, }) @@ -52,7 +51,7 @@ describe('subrecipients', () => { expect(result.originationUploadId).toEqual( scenario.subrecipient.two.originationUploadId ) - expect(result.updatedAt).toEqual(new Date('2023-12-09T14:50:18.092Z')) + expect(result.updatedAt).toBeDefined() }) scenario('updates a subrecipient', async (scenario: StandardScenario) => { diff --git a/api/src/services/uploads/uploads.scenarios.ts b/api/src/services/uploads/uploads.scenarios.ts index f7522a89..de9f1716 100644 --- a/api/src/services/uploads/uploads.scenarios.ts +++ b/api/src/services/uploads/uploads.scenarios.ts @@ -39,6 +39,9 @@ export const standard = defineScenario({ updatedAt: '2023-12-10T04:48:04.896Z', }, }, + organization: { + create: { name: 'String' }, + }, }, }, expenditureCategory: { @@ -85,6 +88,9 @@ export const standard = defineScenario({ updatedAt: '2023-12-10T04:48:04.896Z', }, }, + organization: { + create: { name: 'String' }, + }, }, }, expenditureCategory: { diff --git a/api/src/services/uploads/uploads.test.ts b/api/src/services/uploads/uploads.test.ts index 4d97bfa1..ae8cbec9 100644 --- a/api/src/services/uploads/uploads.test.ts +++ b/api/src/services/uploads/uploads.test.ts @@ -37,7 +37,6 @@ describe('uploads', () => { organizationId: scenario.upload.two.organizationId, reportingPeriodId: scenario.upload.two.reportingPeriodId, expenditureCategoryId: scenario.upload.two.expenditureCategoryId, - updatedAt: '2023-12-10T04:48:04.888Z', }, }) @@ -51,7 +50,7 @@ describe('uploads', () => { expect(result.expenditureCategoryId).toEqual( scenario.upload.two.expenditureCategoryId ) - expect(result.updatedAt).toEqual(new Date('2023-12-10T04:48:04.888Z')) + expect(result.updatedAt).toBeDefined() }) scenario('updates a upload', async (scenario: StandardScenario) => { diff --git a/api/src/services/uploads/uploads.ts b/api/src/services/uploads/uploads.ts index 5f56d21c..d1e8d897 100644 --- a/api/src/services/uploads/uploads.ts +++ b/api/src/services/uploads/uploads.ts @@ -4,8 +4,8 @@ import type { UploadRelationResolvers, } from 'types/graphql' +import { s3PutSignedUrl } from 'src/lib/aws' import { db } from 'src/lib/db' - export const uploads: QueryResolvers['uploads'] = () => { return db.upload.findMany() } @@ -16,10 +16,16 @@ export const upload: QueryResolvers['upload'] = ({ id }) => { }) } -export const createUpload: MutationResolvers['createUpload'] = ({ input }) => { - return db.upload.create({ +export const createUpload: MutationResolvers['createUpload'] = async ({ + input, +}) => { + // validateUpload(input, context.currentUser) + const upload = await db.upload.create({ data: input, }) + + upload.signedUrl = await s3PutSignedUrl(upload, upload.id) + return upload } export const updateUpload: MutationResolvers['updateUpload'] = ({ diff --git a/api/tsconfig.json b/api/tsconfig.json index fcbbf987..2316bc9a 100644 --- a/api/tsconfig.json +++ b/api/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "noEmit": true, "allowJs": true, + "resolveJsonModule": true, "esModuleInterop": true, "target": "esnext", "module": "esnext", diff --git a/api/types/graphql.d.ts b/api/types/graphql.d.ts index 91263a27..cbcde983 100644 --- a/api/types/graphql.d.ts +++ b/api/types/graphql.d.ts @@ -114,6 +114,7 @@ export type CreateUploadInput = { agencyId: Scalars['Int']; expenditureCategoryId: Scalars['Int']; filename: Scalars['String']; + notes?: InputMaybe; organizationId: Scalars['Int']; reportingPeriodId: Scalars['Int']; uploadedById: Scalars['Int']; @@ -443,12 +444,14 @@ export type Query = { organizations: Array; outputTemplate?: Maybe; outputTemplates: Array; + previousReportingPeriods: Array; project?: Maybe; projects: Array; /** Fetches the Redwood root schema. */ redwood?: Maybe; reportingPeriod?: Maybe; reportingPeriods: Array; + reportingPeriodsByOrg: Array; role?: Maybe; roles: Array; subrecipient?: Maybe; @@ -499,6 +502,13 @@ export type QueryoutputTemplateArgs = { }; +/** About the Redwood queries. */ +export type QuerypreviousReportingPeriodsArgs = { + id: Scalars['Int']; + organizationId: Scalars['Int']; +}; + + /** About the Redwood queries. */ export type QueryprojectArgs = { id: Scalars['Int']; @@ -511,6 +521,12 @@ export type QueryreportingPeriodArgs = { }; +/** About the Redwood queries. */ +export type QueryreportingPeriodsByOrgArgs = { + organizationId: Scalars['Int']; +}; + + /** About the Redwood queries. */ export type QueryroleArgs = { id: Scalars['Int']; @@ -673,6 +689,7 @@ export type UpdateUploadInput = { agencyId?: InputMaybe; expenditureCategoryId?: InputMaybe; filename?: InputMaybe; + notes?: InputMaybe; organizationId?: InputMaybe; reportingPeriodId?: InputMaybe; uploadedById?: InputMaybe; @@ -707,10 +724,12 @@ export type Upload = { expenditureCategoryId: Scalars['Int']; filename: Scalars['String']; id: Scalars['Int']; + notes?: Maybe; organization: Organization; organizationId: Scalars['Int']; reportingPeriod: ReportingPeriod; reportingPeriodId: Scalars['Int']; + signedUrl?: Maybe; updatedAt: Scalars['DateTime']; uploadedBy: User; uploadedById: Scalars['Int']; @@ -1177,11 +1196,13 @@ export type QueryResolvers, ParentType, ContextType>; outputTemplate: Resolver, ParentType, ContextType, RequireFields>; outputTemplates: OptArgsResolverFn, ParentType, ContextType>; + previousReportingPeriods: Resolver, ParentType, ContextType, RequireFields>; project: Resolver, ParentType, ContextType, RequireFields>; projects: OptArgsResolverFn, ParentType, ContextType>; redwood: OptArgsResolverFn, ParentType, ContextType>; reportingPeriod: Resolver, ParentType, ContextType, RequireFields>; reportingPeriods: OptArgsResolverFn, ParentType, ContextType>; + reportingPeriodsByOrg: Resolver, ParentType, ContextType, RequireFields>; role: Resolver, ParentType, ContextType, RequireFields>; roles: OptArgsResolverFn, ParentType, ContextType>; subrecipient: Resolver, ParentType, ContextType, RequireFields>; @@ -1207,11 +1228,13 @@ export type QueryRelationResolvers, ParentType, ContextType>; outputTemplate?: RequiredResolverFn, ParentType, ContextType, RequireFields>; outputTemplates?: RequiredResolverFn, ParentType, ContextType>; + previousReportingPeriods?: RequiredResolverFn, ParentType, ContextType, RequireFields>; project?: RequiredResolverFn, ParentType, ContextType, RequireFields>; projects?: RequiredResolverFn, ParentType, ContextType>; redwood?: RequiredResolverFn, ParentType, ContextType>; reportingPeriod?: RequiredResolverFn, ParentType, ContextType, RequireFields>; reportingPeriods?: RequiredResolverFn, ParentType, ContextType>; + reportingPeriodsByOrg?: RequiredResolverFn, ParentType, ContextType, RequireFields>; role?: RequiredResolverFn, ParentType, ContextType, RequireFields>; roles?: RequiredResolverFn, ParentType, ContextType>; subrecipient?: RequiredResolverFn, ParentType, ContextType, RequireFields>; @@ -1339,10 +1362,12 @@ export type UploadResolvers; filename: OptArgsResolverFn; id: OptArgsResolverFn; + notes: OptArgsResolverFn, ParentType, ContextType>; organization: OptArgsResolverFn; organizationId: OptArgsResolverFn; reportingPeriod: OptArgsResolverFn; reportingPeriodId: OptArgsResolverFn; + signedUrl: OptArgsResolverFn, ParentType, ContextType>; updatedAt: OptArgsResolverFn; uploadedBy: OptArgsResolverFn; uploadedById: OptArgsResolverFn; @@ -1358,10 +1383,12 @@ export type UploadRelationResolvers; filename?: RequiredResolverFn; id?: RequiredResolverFn; + notes?: RequiredResolverFn, ParentType, ContextType>; organization?: RequiredResolverFn; organizationId?: RequiredResolverFn; reportingPeriod?: RequiredResolverFn; reportingPeriodId?: RequiredResolverFn; + signedUrl?: RequiredResolverFn, ParentType, ContextType>; updatedAt?: RequiredResolverFn; uploadedBy?: RequiredResolverFn; uploadedById?: RequiredResolverFn; diff --git a/web/src/auth.ts b/web/src/auth.ts index eff2981b..2ded870c 100644 --- a/web/src/auth.ts +++ b/web/src/auth.ts @@ -31,19 +31,19 @@ export interface ValidateResetTokenResponse { // Replace this with the auth service provider client sdk const client = { login: () => ({ - id: 'unique-user-id', + id: 1, email: 'email@example.com', roles: [], }), signup: () => ({ - id: 'unique-user-id', + id: 1, email: 'email@example.com', roles: [], }), logout: () => {}, getToken: () => 'super-secret-short-lived-token', getUserMetadata: () => ({ - id: 'unique-user-id', + id: 1, email: 'email@example.com', roles: [], }), diff --git a/web/src/components/Upload/NewUpload/NewUpload.tsx b/web/src/components/Upload/NewUpload/NewUpload.tsx index b1e30872..f3085db3 100644 --- a/web/src/components/Upload/NewUpload/NewUpload.tsx +++ b/web/src/components/Upload/NewUpload/NewUpload.tsx @@ -1,5 +1,3 @@ -import type { CreateUploadInput } from 'types/graphql' - import { navigate, routes } from '@redwoodjs/router' import { useMutation } from '@redwoodjs/web' import { toast } from '@redwoodjs/web/toast' @@ -10,35 +8,32 @@ const CREATE_UPLOAD_MUTATION = gql` mutation CreateUploadMutation($input: CreateUploadInput!) { createUpload(input: $input) { id + signedUrl } } ` const NewUpload = () => { - const [createUpload, { loading, error }] = useMutation( - CREATE_UPLOAD_MUTATION, - { - onCompleted: () => { - toast.success('Upload created') - navigate(routes.uploads()) - }, - onError: (error) => { - toast.error(error.message) - }, - } - ) - - const onSave = (input: CreateUploadInput) => { - createUpload({ variables: { input } }) - } + const [_, { loading, error }] = useMutation< + CreateUploadMutation, + CreateUploadMutationVariables + >(CREATE_UPLOAD_MUTATION, { + onCompleted: () => { + toast.success('Upload created') + navigate(routes.uploads()) + }, + onError: (error) => { + toast.error(error.message) + }, + }) return (
-

New Upload

+

Submit Workbook

- +
) diff --git a/web/src/components/Upload/UploadForm/UploadForm.tsx b/web/src/components/Upload/UploadForm/UploadForm.tsx index 3b796190..25843459 100644 --- a/web/src/components/Upload/UploadForm/UploadForm.tsx +++ b/web/src/components/Upload/UploadForm/UploadForm.tsx @@ -1,155 +1,149 @@ -import type { EditUploadById, UpdateUploadInput } from 'types/graphql' +import { Button } from 'react-bootstrap' +import { useForm } from 'react-hook-form' +import type { EditUploadById } from 'types/graphql' import { Form, + FileField, + SelectField, + // HiddenField, FormError, FieldError, Label, - TextField, - NumberField, Submit, + TextAreaField, } from '@redwoodjs/forms' import type { RWGqlError } from '@redwoodjs/forms' - -type FormUpload = NonNullable +import { navigate, routes } from '@redwoodjs/router' +import { useMutation } from '@redwoodjs/web' +import { toast } from '@redwoodjs/web/toast' + +const CREATE_UPLOAD = gql` + mutation CreateUploadMutation($input: CreateUploadInput!) { + createUpload(input: $input) { + id + signedUrl + } + } +` +// type FormUpload = NonNullable interface UploadFormProps { upload?: EditUploadById['upload'] - onSave: (data: UpdateUploadInput, id?: FormUpload['id']) => void error: RWGqlError loading: boolean } const UploadForm = (props: UploadFormProps) => { - const onSubmit = (data: FormUpload) => { - props.onSave(data, props?.upload?.id) + const formMethods = useForm() + + const [create] = useMutation< + CreateUploadMutation, + CreateUploadMutationVariables + >(CREATE_UPLOAD, { + onCompleted: () => { + toast.success('Upload created') + navigate(routes.uploads()) + }, + onError: (error) => { + toast.error(error.message) + }, + }) + + const onSubmit = async (data) => { + data.filename = data.file[0].name + data.agencyId = parseInt(data.agencyId) + data.reportingPeriodId = parseInt(data.reportingPeriodId) + + const uploadInput = { + uploadedById: 1, + agencyId: 1, + notes: data.notes, + filename: data.file[0].name, + organizationId: 1, + reportingPeriodId: data.reportingPeriodId, + expenditureCategoryId: 1, + } + const res = await create({ variables: { input: uploadInput } }) + const formData = new FormData() + formData.append('file', data.file[0]) + fetch(res.data?.createUpload?.signedUrl, { + method: 'PUT', + headers: { + 'Content-Type': data.file[0].type, + }, + body: formData, + }) + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`) + } + }) + .then((responseData) => { + console.log('File upload successful. Response:', responseData) + }) + .catch((error) => { + console.error('Error uploading file:', error) + }) } - return ( -
- onSubmit={onSubmit} error={props.error}> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + const onReset = () => { + console.log('resetting form...') + formMethods.reset() + } -
- - Save - -
- -
+ return ( +
+ + {/* 1 */} + + + + + + + + + + + + + + + + +
+ + Submit + + +
+ ) } diff --git a/web/src/pages/Upload/UploadsPage/UploadsPage.tsx b/web/src/pages/Upload/UploadsPage/UploadsPage.tsx index 284ab140..6151fc2b 100644 --- a/web/src/pages/Upload/UploadsPage/UploadsPage.tsx +++ b/web/src/pages/Upload/UploadsPage/UploadsPage.tsx @@ -15,7 +15,12 @@ const UploadsPage = () => { -