From b221aa8ad2a06bc6d85b33580ff6e4e2891a1815 Mon Sep 17 00:00:00 2001 From: aditya Date: Wed, 22 May 2024 14:06:50 -0400 Subject: [PATCH] Adds a script to create new organizations, agencies, and users (#279) * fix: ensures db initializes when script is run * feat: add ability to onboard new organizations * feat: add helper functions to create orgs, agencies, users * chore: add comments * feat: add test coverage to agency creation * feat: adds test coverage to get or create org * feat: adds test coverage for user get or create * fix: ensure upload form is not reliant on organization foreign key to reporting period * feat: add ability to create reporting periods and tie to orgs * fix: linting * chore: make reportingPeriod.organizationId optional in order to phase it out * feat: adds support for creating reporting period and templates * fix: linting * chore: add logging --- .../migration.sql | 8 ++ api/db/schema.prisma | 4 +- api/src/lib/constants.ts | 6 + .../services/agencies/agencies.scenarios.ts | 44 +++++++- api/src/services/agencies/agencies.test.ts | 17 ++- api/src/services/agencies/agencies.ts | 57 ++++++++++ .../expenditureCategories.test.ts | 11 ++ .../expenditureCategories.ts | 45 ++++++++ .../inputTemplates/inputTemplates.test.ts | 14 +++ .../services/inputTemplates/inputTemplates.ts | 38 +++++++ .../organizations/organizations.scenarios.ts | 46 +++++++- .../organizations/organizations.test.ts | 28 ++++- .../services/organizations/organizations.ts | 50 +++++++++ .../outputTemplates/outputTemplates.test.ts | 14 +++ .../outputTemplates/outputTemplates.ts | 38 +++++++ api/src/services/passage/passage.test.ts | 8 +- api/src/services/passage/passage.ts | 6 +- .../reportingPeriods.scenarios.ts | 8 +- .../reportingPeriods/reportingPeriods.test.ts | 32 ++++++ .../reportingPeriods/reportingPeriods.ts | 62 ++++++++++ api/src/services/users/users.test.ts | 23 ++++ api/src/services/users/users.ts | 87 ++++++++++++++ scripts/onboardOrganization.ts | 106 ++++++++++++++++++ .../OrganizationPickListsCell.tsx | 23 ++-- .../ReportingPeriodsCell.tsx | 4 +- 25 files changed, 737 insertions(+), 42 deletions(-) create mode 100644 api/db/migrations/20240522032556_make_org_id_optional/migration.sql create mode 100644 scripts/onboardOrganization.ts diff --git a/api/db/migrations/20240522032556_make_org_id_optional/migration.sql b/api/db/migrations/20240522032556_make_org_id_optional/migration.sql new file mode 100644 index 00000000..25ebbc10 --- /dev/null +++ b/api/db/migrations/20240522032556_make_org_id_optional/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "ReportingPeriod" DROP CONSTRAINT "ReportingPeriod_organizationId_fkey"; + +-- AlterTable +ALTER TABLE "ReportingPeriod" ALTER COLUMN "organizationId" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "ReportingPeriod" ADD CONSTRAINT "ReportingPeriod_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/api/db/schema.prisma b/api/db/schema.prisma index fa95d5ee..170857c7 100644 --- a/api/db/schema.prisma +++ b/api/db/schema.prisma @@ -81,8 +81,8 @@ model ReportingPeriod { name String startDate DateTime @db.Date endDate DateTime @db.Date - organizationId Int - organization Organization @relation(fields: [organizationId], references: [id]) + organizationId Int? + organization Organization? @relation(fields: [organizationId], references: [id]) inputTemplateId Int inputTemplate InputTemplate @relation(fields: [inputTemplateId], references: [id], onDelete: NoAction, onUpdate: NoAction) outputTemplateId Int diff --git a/api/src/lib/constants.ts b/api/src/lib/constants.ts index 66fc2e4d..1021134b 100644 --- a/api/src/lib/constants.ts +++ b/api/src/lib/constants.ts @@ -3,3 +3,9 @@ export const ROLES = { ORGANIZATION_STAFF: 'ORGANIZATION_STAFF', USDR_ADMIN: 'USDR_ADMIN', } + +export const EXPENDITURE_CATEGORIES = { + '1A': 'Broadband Infrastructure', + '1B': 'Digital Connectivity Technology', + '1C': 'Multi-Purpose Community Facility', +} diff --git a/api/src/services/agencies/agencies.scenarios.ts b/api/src/services/agencies/agencies.scenarios.ts index 153fe58c..495db55b 100644 --- a/api/src/services/agencies/agencies.scenarios.ts +++ b/api/src/services/agencies/agencies.scenarios.ts @@ -1,12 +1,44 @@ -import type { Prisma, agency } from '@prisma/client' +import type { Prisma } from '@prisma/client' import type { ScenarioData } from '@redwoodjs/testing/api' -export const standard = defineScenario({ +export const standard = defineScenario< + Prisma.OrganizationCreateArgs | Prisma.AgencyCreateArgs +>({ + organization: { + one: { + data: { + name: 'USDR', + }, + }, + two: { + data: { + name: 'Example Organization', + }, + }, + }, agency: { - one: { data: { name: 'String', code: 'String' } }, - two: { data: { name: 'String', code: 'String' } }, + one: (scenario) => ({ + data: { + name: 'Agency1', + organizationId: scenario.organization.one.id, + code: 'A1', + }, + include: { + organization: true, + }, + }), + two: (scenario) => ({ + data: { + name: 'Agency2', + organizationId: scenario.organization.two.id, + code: 'A2', + }, + include: { + organization: true, + }, + }), }, }) - -export type StandardScenario = ScenarioData +export type StandardScenario = ScenarioData & + ScenarioData diff --git a/api/src/services/agencies/agencies.test.ts b/api/src/services/agencies/agencies.test.ts index ce2b6114..d6bffd3e 100644 --- a/api/src/services/agencies/agencies.test.ts +++ b/api/src/services/agencies/agencies.test.ts @@ -6,6 +6,7 @@ import { createAgency, updateAgency, deleteAgency, + getOrCreateAgencies, } from './agencies' import type { StandardScenario } from './agencies.scenarios' @@ -16,6 +17,20 @@ import type { StandardScenario } from './agencies.scenarios' // https://redwoodjs.com/docs/testing#jest-expect-type-considerations describe('agencies', () => { + scenario( + 'get or creates new agencies', + async (scenario: StandardScenario) => { + const result = await getOrCreateAgencies(scenario.organization.one.name, [ + { name: 'Agency1', code: 'A1', abbreviation: 'A1' }, + { name: 'Agency3', code: 'A3', abbreviation: 'A3' }, + ]) + + expect(result.length).toEqual(2) + expect(result[0].name).toEqual('Agency1') + expect(result[0].id).toEqual(scenario.agency.one.id) + expect(result[1].name).toEqual('Agency3') + } + ) scenario('returns all agencies', async (scenario: StandardScenario) => { const result = await agencies() @@ -25,7 +40,7 @@ describe('agencies', () => { scenario('returns a single agency', async (scenario: StandardScenario) => { const result = await agency({ id: scenario.agency.one.id }) - expect(result).toEqual(scenario.agency.one) + expect(result.id).toEqual(scenario.agency.one.id) }) scenario('creates a agency', async () => { diff --git a/api/src/services/agencies/agencies.ts b/api/src/services/agencies/agencies.ts index d2c69ddb..09f42279 100644 --- a/api/src/services/agencies/agencies.ts +++ b/api/src/services/agencies/agencies.ts @@ -1,6 +1,7 @@ import type { QueryResolvers, MutationResolvers } from 'types/graphql' import { db } from 'src/lib/db' +import { logger } from 'src/lib/logger' export const agencies: QueryResolvers['agencies'] = () => { return db.agency.findMany() @@ -34,6 +35,62 @@ export const deleteAgency: MutationResolvers['deleteAgency'] = ({ id }) => { }) } +export const getOrCreateAgencies = async (orgName, agencyData) => { + // This function is used to create initial expenditure categories + // It is intended to only be called via the `onboardOrganization` script + // Hence, we hard-return if we detect a non-empty context + if (context && Object.keys(context).length > 0) { + logger.error( + { custom: context }, + `This function is intended to be called via the onboardOrganization script and not via GraphQL API. Skipping...` + ) + return + } + + try { + const agencies = [] + /* + { + name: 'Sample Name', + abbreviation: SAMPLEABBR, + code: SAMPLECODE, + } + */ + const organization = await db.organization.findFirst({ + where: { name: orgName }, + }) + for (const agency of agencyData) { + try { + logger.info(`Processing agency ${agency.name}`) + const existingAgency = await db.agency.findFirst({ + where: { name: agency.name, organizationId: organization.id }, + }) + if (existingAgency) { + logger.info( + `${agency.name} exists for organization ${organization.name}` + ) + agencies.push(existingAgency) + } else { + logger.info(`Creating ${agency.name}`) + agency.organizationId = organization.id + const newAgency = await db.agency.create({ data: agency }) + agencies.push(newAgency) + } + } catch (error) { + logger.error(error, `Error processing agency ${agency.name}`) + continue + } + } + logger.info(`Agencies processed: ${agencies.length}`) + return agencies + } catch (error) { + logger.error( + error, + `Error getting or creating agencies for organization ${orgName}` + ) + } +} + export const agenciesByOrganization: QueryResolvers['agenciesByOrganization'] = async ({ organizationId }) => { try { diff --git a/api/src/services/expenditureCategories/expenditureCategories.test.ts b/api/src/services/expenditureCategories/expenditureCategories.test.ts index eb02dbe3..d62efcbb 100644 --- a/api/src/services/expenditureCategories/expenditureCategories.test.ts +++ b/api/src/services/expenditureCategories/expenditureCategories.test.ts @@ -6,6 +6,7 @@ import { createExpenditureCategory, updateExpenditureCategory, deleteExpenditureCategory, + createOrSkipInitialExpenditureCategories, } from './expenditureCategories' import type { StandardScenario } from './expenditureCategories.scenarios' @@ -16,6 +17,16 @@ import type { StandardScenario } from './expenditureCategories.scenarios' // https://redwoodjs.com/docs/testing#jest-expect-type-considerations describe('expenditureCategories', () => { + scenario('creates initial expenditure categories', async () => { + const categoriesBefore = await expenditureCategories() + const result = await createOrSkipInitialExpenditureCategories() + const categoriesAfter = await expenditureCategories() + + expect(result).toEqual(undefined) + expect(categoriesBefore.length).toEqual(2) + expect(categoriesAfter.length).toEqual(5) + }) + scenario( 'returns all expenditureCategories', async (scenario: StandardScenario) => { diff --git a/api/src/services/expenditureCategories/expenditureCategories.ts b/api/src/services/expenditureCategories/expenditureCategories.ts index 23635266..b679ff8c 100644 --- a/api/src/services/expenditureCategories/expenditureCategories.ts +++ b/api/src/services/expenditureCategories/expenditureCategories.ts @@ -1,10 +1,13 @@ +import type { Prisma } from '@prisma/client' import type { QueryResolvers, MutationResolvers, ExpenditureCategoryRelationResolvers, } from 'types/graphql' +import { EXPENDITURE_CATEGORIES } from 'src/lib/constants' import { db } from 'src/lib/db' +import { logger } from 'src/lib/logger' export const expenditureCategories: QueryResolvers['expenditureCategories'] = () => { @@ -19,6 +22,48 @@ export const expenditureCategory: QueryResolvers['expenditureCategory'] = ({ }) } +export const createOrSkipInitialExpenditureCategories = async () => { + // This function is used to create initial expenditure categories + // It is intended to only be called via the `onboardOrganization` script + // Hence, we hard-return if we detect a non-empty context + if (context && Object.keys(context).length > 0) { + logger.error( + { custom: context }, + `This function is intended to be called via the onboardOrganization script and not via GraphQL API. Skipping...` + ) + return + } + + try { + const expenditureCategoriesData = Object.entries( + EXPENDITURE_CATEGORIES + ).map(([code, name]) => ({ code, name })) + await Promise.all( + expenditureCategoriesData.map( + async (data: Prisma.ExpenditureCategoryCreateArgs['data']) => { + const existingExpenditureCategory = + await db.expenditureCategory.findFirst({ + where: { name: data.name }, + }) + if (existingExpenditureCategory) { + logger.info( + `Expenditure category ${data.name} already exists. Skipping...` + ) + return + } + const record = await db.expenditureCategory.create({ data }) + logger.info(`Created expenditure category ${record.name}`) + } + ) + ) + } catch (error) { + logger.error( + error, + 'Error creating or skipping initial expenditure categories' + ) + } +} + export const createExpenditureCategory: MutationResolvers['createExpenditureCategory'] = ({ input }) => { return db.expenditureCategory.create({ diff --git a/api/src/services/inputTemplates/inputTemplates.test.ts b/api/src/services/inputTemplates/inputTemplates.test.ts index a311e608..96598b2a 100644 --- a/api/src/services/inputTemplates/inputTemplates.test.ts +++ b/api/src/services/inputTemplates/inputTemplates.test.ts @@ -6,6 +6,7 @@ import { createInputTemplate, updateInputTemplate, deleteInputTemplate, + getOrCreateInputTemplate, } from './inputTemplates' import type { StandardScenario } from './inputTemplates.scenarios' @@ -16,6 +17,19 @@ import type { StandardScenario } from './inputTemplates.scenarios' // https://redwoodjs.com/docs/testing#jest-expect-type-considerations describe('inputTemplates', () => { + scenario('gets or creates input template', async () => { + const result = await getOrCreateInputTemplate({ + name: 'NEW TEMPLATE', + version: '1.0.0', + effectiveDate: '2023-12-07T18:17:24.374Z', + }) + + expect(result.name).toEqual('NEW TEMPLATE') + expect(result.version).toEqual('1.0.0') + expect(result.effectiveDate).toEqual(new Date('2023-12-07T00:00:00.000Z')) + expect(result.updatedAt).toBeDefined() + }) + scenario('returns all inputTemplates', async (scenario: StandardScenario) => { const result = await inputTemplates() diff --git a/api/src/services/inputTemplates/inputTemplates.ts b/api/src/services/inputTemplates/inputTemplates.ts index 1583a69d..4599bbd2 100644 --- a/api/src/services/inputTemplates/inputTemplates.ts +++ b/api/src/services/inputTemplates/inputTemplates.ts @@ -5,6 +5,7 @@ import type { } from 'types/graphql' import { db } from 'src/lib/db' +import { logger } from 'src/lib/logger' export const inputTemplates: QueryResolvers['inputTemplates'] = () => { return db.inputTemplate.findMany() @@ -16,6 +17,43 @@ export const inputTemplate: QueryResolvers['inputTemplate'] = ({ id }) => { }) } +export const getOrCreateInputTemplate = async (inputTemplateInfo) => { + // This function is used to create initial expenditure categories + // It is intended to only be called via the `onboardOrganization` script + // Hence, we hard-return if we detect a non-empty context + if (context && Object.keys(context).length > 0) { + logger.error( + { custom: context }, + `This function is intended to be called via the onboardOrganization script and not via GraphQL API. Skipping...` + ) + return + } + + try { + let inputTemplateRecord + + const existingInputTemplate = await db.inputTemplate.findFirst({ + where: { name: inputTemplateInfo.name }, + }) + if (existingInputTemplate) { + logger.info(`Input template ${inputTemplateInfo.name} already exists`) + inputTemplateRecord = existingInputTemplate + } else { + logger.info(`Creating ${inputTemplateInfo.name}`) + const data = inputTemplateInfo + inputTemplateRecord = await db.inputTemplate.create({ + data, + }) + } + return inputTemplateRecord + } catch (error) { + logger.error( + error, + `Error getting or creating input template ${inputTemplateInfo.name}` + ) + } +} + export const createInputTemplate: MutationResolvers['createInputTemplate'] = ({ input, }) => { diff --git a/api/src/services/organizations/organizations.scenarios.ts b/api/src/services/organizations/organizations.scenarios.ts index 68678d6e..3ffe72e4 100644 --- a/api/src/services/organizations/organizations.scenarios.ts +++ b/api/src/services/organizations/organizations.scenarios.ts @@ -2,10 +2,50 @@ import type { Prisma, Organization } from '@prisma/client' import type { ScenarioData } from '@redwoodjs/testing/api' -export const standard = defineScenario({ +export const standard = defineScenario< + Prisma.OrganizationCreateArgs | Prisma.ReportingPeriodCreateArgs +>({ + reportingPeriod: { + one: { + data: { + name: 'Reporting Period 1', + startDate: '2024-01-12T15:48:11.499Z', + endDate: '2024-01-12T15:48:11.499Z', + organization: { create: { name: 'String' } }, + inputTemplate: { + create: { + name: 'String', + version: 'String', + effectiveDate: '2024-01-12T15:48:11.499Z', + }, + }, + outputTemplate: { + create: { + name: 'String', + version: 'String', + effectiveDate: '2024-01-12T15:48:11.499Z', + }, + }, + }, + }, + }, organization: { - one: { data: { name: 'String' } }, - two: { data: { name: 'String' } }, + one: (scenario) => ({ + data: { + name: 'USDR1', + preferences: { + current_reporting_period_id: scenario.reportingPeriod.one.id, + }, + }, + }), + two: (scenario) => ({ + data: { + name: 'USDR2', + preferences: { + current_reporting_period_id: scenario.reportingPeriod.one.id, + }, + }, + }), }, }) diff --git a/api/src/services/organizations/organizations.test.ts b/api/src/services/organizations/organizations.test.ts index 8c5757c1..591fe5e1 100644 --- a/api/src/services/organizations/organizations.test.ts +++ b/api/src/services/organizations/organizations.test.ts @@ -6,6 +6,7 @@ import { createOrganization, updateOrganization, deleteOrganization, + getOrCreateOrganization, } from './organizations' import type { StandardScenario } from './organizations.scenarios' @@ -16,10 +17,33 @@ import type { StandardScenario } from './organizations.scenarios' // https://redwoodjs.com/docs/testing#jest-expect-type-considerations describe('organizations', () => { - scenario('returns all organizations', async (scenario: StandardScenario) => { + scenario( + 'creates or gets an organization', + async (scenario: StandardScenario) => { + const resultGet = await getOrCreateOrganization( + 'USDR1', + 'Reporting Period 1' + ) + const resultCreate = await getOrCreateOrganization( + 'USDR3', + 'Reporting Period 1' + ) + const resultError = await getOrCreateOrganization('USDR5', 'NO PERIOD') + + expect(resultGet.id).toEqual(scenario.organization.one.id) + expect(resultCreate.name).toEqual('USDR3') + expect( + [scenario.organization.one.id, scenario.organization.two.id].includes( + resultCreate.id + ) + ).toBe(false) + expect(resultError).toBe(undefined) + } + ) + scenario('returns all organizations', async () => { const result = await organizations() - expect(result.length).toEqual(Object.keys(scenario.organization).length) + expect(result.length).toEqual(3) }) scenario( diff --git a/api/src/services/organizations/organizations.ts b/api/src/services/organizations/organizations.ts index be32dd24..bab29b7a 100644 --- a/api/src/services/organizations/organizations.ts +++ b/api/src/services/organizations/organizations.ts @@ -1,3 +1,4 @@ +import type { Prisma } from '@prisma/client' import type { QueryResolvers, MutationResolvers, @@ -5,6 +6,7 @@ import type { } from 'types/graphql' import { db } from 'src/lib/db' +import { logger } from 'src/lib/logger' export const organizations: QueryResolvers['organizations'] = () => { return db.organization.findMany() @@ -24,6 +26,54 @@ export const createOrganization: MutationResolvers['createOrganization'] = ({ }) } +export const getOrCreateOrganization = async (orgName, reportingPeriodName) => { + // This function is used to create initial expenditure categories + // It is intended to only be called via the `onboardOrganization` script + // Hence, we hard-return if we detect a non-empty context + if (context && Object.keys(context).length > 0) { + logger.error( + { custom: context }, + `This function is intended to be called via the onboardOrganization script and not via GraphQL API. Skipping...` + ) + return + } + + try { + let orgRecord + + const existingOrganization = await db.organization.findFirst({ + where: { name: orgName }, + }) + if (existingOrganization) { + logger.info(`Organization ${orgName} already exists`) + orgRecord = existingOrganization + } else { + logger.info(`Creating ${orgName}`) + const data: Prisma.OrganizationCreateArgs['data'] = { + name: orgName, + } + const reportingPeriod = await db.reportingPeriod.findFirst({ + where: { name: reportingPeriodName }, + }) + if (!reportingPeriod) { + logger.error( + `Reporting period ${reportingPeriodName} does not exist. Cannot create organization.` + ) + return + } + data.preferences = { + current_reporting_period_id: reportingPeriod.id, + } + orgRecord = await db.organization.create({ + data, + }) + } + return orgRecord + } catch (error) { + logger.error(error, `Error getting or creating organization: ${orgName}`) + } +} + export const createOrganizationAgencyAdmin: MutationResolvers['createOrganizationAgencyAdmin'] = async ({ input }) => { const { diff --git a/api/src/services/outputTemplates/outputTemplates.test.ts b/api/src/services/outputTemplates/outputTemplates.test.ts index 04a86a45..6cc45e1f 100644 --- a/api/src/services/outputTemplates/outputTemplates.test.ts +++ b/api/src/services/outputTemplates/outputTemplates.test.ts @@ -6,6 +6,7 @@ import { createOutputTemplate, updateOutputTemplate, deleteOutputTemplate, + getOrCreateOutputTemplate, } from './outputTemplates' import type { StandardScenario } from './outputTemplates.scenarios' @@ -16,6 +17,19 @@ import type { StandardScenario } from './outputTemplates.scenarios' // https://redwoodjs.com/docs/testing#jest-expect-type-considerations describe('outputTemplates', () => { + scenario('gets or creates output template', async () => { + const result = await getOrCreateOutputTemplate({ + name: 'NEW TEMPLATE', + version: '1.0.0', + effectiveDate: '2023-12-07T18:17:34.958Z', + }) + + expect(result.name).toEqual('NEW TEMPLATE') + expect(result.version).toEqual('1.0.0') + expect(result.effectiveDate).toEqual(new Date('2023-12-07T00:00:00.000Z')) + expect(result.updatedAt).toBeDefined() + }) + scenario( 'returns all outputTemplates', async (scenario: StandardScenario) => { diff --git a/api/src/services/outputTemplates/outputTemplates.ts b/api/src/services/outputTemplates/outputTemplates.ts index 0a1a866b..a41759f3 100644 --- a/api/src/services/outputTemplates/outputTemplates.ts +++ b/api/src/services/outputTemplates/outputTemplates.ts @@ -5,6 +5,7 @@ import type { } from 'types/graphql' import { db } from 'src/lib/db' +import { logger } from 'src/lib/logger' export const outputTemplates: QueryResolvers['outputTemplates'] = () => { return db.outputTemplate.findMany() @@ -16,6 +17,43 @@ export const outputTemplate: QueryResolvers['outputTemplate'] = ({ id }) => { }) } +export const getOrCreateOutputTemplate = async (outputTemplateInfo) => { + // This function is used to create initial expenditure categories + // It is intended to only be called via the `onboardOrganization` script + // Hence, we hard-return if we detect a non-empty context + if (context && Object.keys(context).length > 0) { + logger.error( + { custom: context }, + `This function is intended to be called via the onboardOrganization script and not via GraphQL API. Skipping...` + ) + return + } + + try { + let outputTemplateRecord + + const existingOutputTemplate = await db.outputTemplate.findFirst({ + where: { name: outputTemplateInfo.name }, + }) + if (existingOutputTemplate) { + logger.info(`Output template ${outputTemplateInfo.name} already exists`) + outputTemplateRecord = existingOutputTemplate + } else { + logger.info(`Creating ${outputTemplateInfo.name}`) + const data = outputTemplateInfo + outputTemplateRecord = await db.outputTemplate.create({ + data, + }) + } + return outputTemplateRecord + } catch (error) { + logger.error( + error, + `Error getting or creating output template ${outputTemplateInfo.name}` + ) + } +} + export const createOutputTemplate: MutationResolvers['createOutputTemplate'] = ({ input }) => { return db.outputTemplate.create({ diff --git a/api/src/services/passage/passage.test.ts b/api/src/services/passage/passage.test.ts index 92a53b79..12b4551a 100644 --- a/api/src/services/passage/passage.test.ts +++ b/api/src/services/passage/passage.test.ts @@ -59,8 +59,8 @@ describe('Passage User Management', () => { ) expect(mockedLogger.error).toHaveBeenCalledWith( - 'Failed to create Passage user', - error + error, + 'Failed to create Passage user' ) }) }) @@ -88,8 +88,8 @@ describe('Passage User Management', () => { ) expect(mockedLogger.error).toHaveBeenCalledWith( - 'Failed to delete Passage user', - error + error, + 'Failed to delete Passage user' ) }) }) diff --git a/api/src/services/passage/passage.ts b/api/src/services/passage/passage.ts index 53edfd5e..f4c52af9 100644 --- a/api/src/services/passage/passage.ts +++ b/api/src/services/passage/passage.ts @@ -15,7 +15,7 @@ const getPassageClient = async () => { return new Passage(passageConfig) } catch (error) { - logger.error('Error getting Passage client:', error) + logger.error(error, 'Error getting Passage client') throw new Error('Error getting Passage client') } } @@ -27,7 +27,7 @@ export const createPassageUser = async (email: string) => { const activatedUser = await passage.user.activate(newUser.id) return activatedUser } catch (error) { - logger.error('Failed to create Passage user', error) + logger.error(error, 'Failed to create Passage user') throw new Error('Failed to create Passage user', error) } } @@ -37,7 +37,7 @@ export const deletePassageUser = async (userId: string) => { const passage = await getPassageClient() return await passage.user.delete(userId) } catch (error) { - logger.error('Failed to delete Passage user', error) + logger.error(error, 'Failed to delete Passage user') throw new Error('Failed to delete Passage user') } } diff --git a/api/src/services/reportingPeriods/reportingPeriods.scenarios.ts b/api/src/services/reportingPeriods/reportingPeriods.scenarios.ts index 72f2c860..d52fd277 100644 --- a/api/src/services/reportingPeriods/reportingPeriods.scenarios.ts +++ b/api/src/services/reportingPeriods/reportingPeriods.scenarios.ts @@ -12,14 +12,14 @@ export const standard = defineScenario({ organization: { create: { name: 'String' } }, inputTemplate: { create: { - name: 'String', + name: 'INPUT TEMPLATE ONE', version: 'String', effectiveDate: '2024-01-12T15:48:11.499Z', }, }, outputTemplate: { create: { - name: 'String', + name: 'OUTPUT TEMPLATE ONE', version: 'String', effectiveDate: '2024-01-12T15:48:11.499Z', }, @@ -34,14 +34,14 @@ export const standard = defineScenario({ organization: { create: { name: 'String' } }, inputTemplate: { create: { - name: 'String', + name: 'INPUT TEMPLATE TWO', version: 'String', effectiveDate: '2024-01-12T15:48:11.499Z', }, }, outputTemplate: { create: { - name: 'String', + name: 'OUTPUT TEMPLATE TWO', version: 'String', effectiveDate: '2024-01-12T15:48:11.499Z', }, diff --git a/api/src/services/reportingPeriods/reportingPeriods.test.ts b/api/src/services/reportingPeriods/reportingPeriods.test.ts index 1d25072e..eaec5488 100644 --- a/api/src/services/reportingPeriods/reportingPeriods.test.ts +++ b/api/src/services/reportingPeriods/reportingPeriods.test.ts @@ -6,6 +6,7 @@ import { createReportingPeriod, updateReportingPeriod, deleteReportingPeriod, + getOrCreateReportingPeriod, } from './reportingPeriods' import type { StandardScenario } from './reportingPeriods.scenarios' @@ -16,6 +17,37 @@ import type { StandardScenario } from './reportingPeriods.scenarios' // https://redwoodjs.com/docs/testing#jest-expect-type-considerations describe('reportingPeriods', () => { + scenario('gets or creates reporting period', async () => { + const result = await getOrCreateReportingPeriod({ + name: 'NEW PERIOD', + startDate: '2023-12-07T18:38:12.341Z', + endDate: '2023-12-07T18:38:12.341Z', + + // previously existing input and output templates + inputTemplateName: 'INPUT TEMPLATE ONE', + outputTemplateName: 'OUTPUT TEMPLATE ONE', + }) + + expect(result.name).toEqual('NEW PERIOD') + expect(result.startDate).toEqual(new Date('2023-12-07T00:00:00.000Z')) + expect(result.endDate).toEqual(new Date('2023-12-07T00:00:00.000Z')) + expect(result.updatedAt).toBeDefined() + }) + + scenario('get or create returns null', async () => { + const result = await getOrCreateReportingPeriod({ + name: 'NEW PERIOD', + startDate: '2023-12-07T18:38:12.341Z', + endDate: '2023-12-07T18:38:12.341Z', + + // previously non existing input and output templates + inputTemplateName: 'NONEXISTENT INPUT TEMPLATE', + outputTemplateName: 'NONEXISTENT OUTPUT TEMPLATE', + }) + + expect(result).toEqual(undefined) + }) + scenario( 'returns all reportingPeriods', async (scenario: StandardScenario) => { diff --git a/api/src/services/reportingPeriods/reportingPeriods.ts b/api/src/services/reportingPeriods/reportingPeriods.ts index 36fba2c5..b4907b74 100644 --- a/api/src/services/reportingPeriods/reportingPeriods.ts +++ b/api/src/services/reportingPeriods/reportingPeriods.ts @@ -5,6 +5,7 @@ import type { } from 'types/graphql' import { db } from 'src/lib/db' +import { logger } from 'src/lib/logger' export const reportingPeriods: QueryResolvers['reportingPeriods'] = () => { return db.reportingPeriod.findMany() @@ -16,6 +17,67 @@ export const reportingPeriod: QueryResolvers['reportingPeriod'] = ({ id }) => { }) } +export const getOrCreateReportingPeriod = async (periodInfo) => { + // This function is used to create initial expenditure categories + // It is intended to only be called via the `onboardOrganization` script + // Hence, we hard-return if we detect a non-empty context + if (context && Object.keys(context).length > 0) { + logger.error( + { custom: context }, + `This function is intended to be called via the onboardOrganization script and not via GraphQL API. Skipping...` + ) + return + } + + try { + let reportingPeriodRecord + + const existingReportingPeriod = await db.reportingPeriod.findFirst({ + where: { name: periodInfo.name }, + }) + if (existingReportingPeriod) { + logger.info(`Reporting period ${periodInfo.name} already exists`) + reportingPeriodRecord = existingReportingPeriod + } else { + const outputTemplate = await db.outputTemplate.findFirst({ + where: { name: periodInfo.outputTemplateName }, + }) + if (!outputTemplate) { + logger.error( + `Output template ${periodInfo.outputTemplateName} does not exist. Cannot create reporting period.` + ) + return + } + const inputTemplate = await db.inputTemplate.findFirst({ + where: { name: periodInfo.inputTemplateName }, + }) + if (!inputTemplate) { + logger.error( + `Input template ${periodInfo.inputTemplateName} does not exist. Cannot create reporting period.` + ) + return + } + logger.info(`Creating ${periodInfo.name}`) + const data = { + name: periodInfo.name, + startDate: periodInfo.startDate, + endDate: periodInfo.endDate, + inputTemplateId: inputTemplate.id, + outputTemplateId: outputTemplate.id, + } + reportingPeriodRecord = await db.reportingPeriod.create({ + data, + }) + } + return reportingPeriodRecord + } catch (error) { + logger.error( + error, + `Error getting or creating reporting period ${periodInfo.name}` + ) + } +} + export const createReportingPeriod: MutationResolvers['createReportingPeriod'] = ({ input }) => { return db.reportingPeriod.create({ diff --git a/api/src/services/users/users.test.ts b/api/src/services/users/users.test.ts index ba193db3..166b24fd 100644 --- a/api/src/services/users/users.test.ts +++ b/api/src/services/users/users.test.ts @@ -15,6 +15,7 @@ import { runGeneralCreateOrUpdateValidations, runPermissionsCreateOrUpdateValidations, runUpdateSpecificValidations, + getOrCreateUsers, } from './users' import type { StandardScenario } from './users.scenarios' @@ -25,6 +26,28 @@ import type { StandardScenario } from './users.scenarios' // https://redwoodjs.com/docs/testing#jest-expect-type-considerations describe('user queries', () => { + scenario('gets or creates a user', async (scenario: StandardScenario) => { + const result = await getOrCreateUsers( + [ + { + email: 'uniqueemail1@test.com', + name: 'String', + role: 'ORGANIZATION_ADMIN', + agencyName: scenario.agency.one.name, + }, + { + email: 'newuser99@example.com', + name: 'String', + role: 'ORGANIZATION_STAFF', + agencyName: scenario.agency.two.name, + }, + ], + scenario.organization.one.name + ) + expect(result.length).toEqual(2) + expect(result[0].id).toEqual(scenario.user.one.id) + expect(result[1].email).toEqual('newuser99@example.com') + }) scenario( 'users query returns all users for USDR admin', async (scenario: StandardScenario) => { diff --git a/api/src/services/users/users.ts b/api/src/services/users/users.ts index 4aa6248d..b956d41d 100644 --- a/api/src/services/users/users.ts +++ b/api/src/services/users/users.ts @@ -16,6 +16,7 @@ import { AuthenticationError } from '@redwoodjs/graphql-server' import { ROLES } from 'src/lib/constants' import { db } from 'src/lib/db' import { logger } from 'src/lib/logger' +import { createPassageUser } from 'src/services/passage/passage' export const currentUserIsUSDRAdmin = (): boolean => { return context.currentUser?.roles?.includes(ROLES.USDR_ADMIN) @@ -229,6 +230,92 @@ export const usersByOrganization: QueryResolvers['usersByOrganization'] = } } +export const getOrCreateUsers = async (users, orgName) => { + // This function is used to create initial expenditure categories + // It is intended to only be called via the `onboardOrganization` script + // Hence, we hard-return if we detect a non-empty context + if (context && Object.keys(context).length > 0) { + logger.error( + { custom: context }, + `This function is intended to be called via the onboardOrganization script and not via GraphQL API. Skipping...` + ) + return + } + + try { + /* + Users are sent in the following Array format + { + name: 'Sample Name', + email: 'sample@example.com', + role: 'ORGANIZATION_STAFF', + agencyName: 'Sample Name', + passageId: '1234', // Optional + } + */ + const organization = await db.organization.findFirst({ + where: { name: orgName }, + }) + if (!organization) { + logger.error(`Organization ${orgName} not found`) + return + } + const orgAgencies = await db.agency.findMany({ + where: { organizationId: organization.id }, + }) + const agenciesByName = {} + for (const agency of orgAgencies) { + agenciesByName[agency.name] = agency + } + const userRecords = [] + for (const user of users) { + try { + logger.info(`Processing user ${user.email}`) + if (!agenciesByName[user.agencyName]) { + logger.error( + `Agency ${user.agencyName} not found for organization ${organization.name}` + ) + continue + } + const userData: Prisma.UserCreateArgs['data'] = { + email: user.email, + name: user.name, + agencyId: agenciesByName[user.agencyName].id, + role: user.role, + isActive: true, + } + const existingUser = await db.user.findFirst({ + where: { email: userData.email }, + }) + if (existingUser) { + logger.info(`User ${userData.email} already exists`) + userRecords.push(existingUser) + continue + } else { + if (process.env.AUTH_PROVIDER === 'passage' && !userData.passageId) { + logger.info(`Creating Passage user for ${userData.email}`) + const passageUser = await createPassageUser(userData.email) + userData.passageId = passageUser.id + } + const record = await db.user.create({ data: userData }) + logger.info(`User ${userData.email} created`) + logger.info(record) + userRecords.push(record) + } + } catch (error) { + logger.error(error, `Error processing user ${user.email}`) + continue + } + } + return userRecords + } catch (error) { + logger.error( + error, + `Error getting or creating users for organization ${orgName}` + ) + } +} + export const User: UserRelationResolvers = { agency: (_obj, { root }) => { return db.user.findUnique({ where: { id: root?.id } }).agency() diff --git a/scripts/onboardOrganization.ts b/scripts/onboardOrganization.ts new file mode 100644 index 00000000..20b9a52a --- /dev/null +++ b/scripts/onboardOrganization.ts @@ -0,0 +1,106 @@ +// To access your database +// Append api/* to import from api and web/* to import from web +import { getPrismaClient } from 'api/src/lib/db' +import { + getOrCreateOrganization +} from 'api/src/services/organizations/organizations' +import { + getOrCreateAgencies +} from 'api/src/services/agencies/agencies' +import { getOrCreateUsers } from 'api/src/services/users/users' +import { getOrCreateReportingPeriod } from 'api/src/services/reportingPeriods/reportingPeriods' +import { getOrCreateInputTemplate } from 'api/src/services/inputTemplates/inputTemplates' +import { getOrCreateOutputTemplate } from 'api/src/services/outputTemplates/outputTemplates' +import { createOrSkipInitialExpenditureCategories } from 'api/src/services/expenditureCategories/expenditureCategories' + +export default async ({ args }) => { + /* + Useful to create a new organization, agencies, and users. + + Example: + yarn redwood exec onboardOrganization \ + --orgName 'Sample Org' \ + --initializeExpenditureCategories true \ + --inputTemplateInfo '{ + "name": "Input Template 1", + "version": "1.0.0", + "effectiveDate": "2024-04-01T00:00:00.000Z" + }' \ + --outputTemplateInfo '{ + "name": "Output Template 1", + "version": "1.0.0", + "effectiveDate": "2024-04-01T00:00:00.000Z" + }' \ + --currentPeriodName '[April 1st - June 30th] Q2 2024' \ + --periodInfo '{ + "name": "[April 1st - June 30th] Q2 2024", + "startDate": "2024-04-01T00:00:00.000Z", + "endDate": "2024-06-30T23:59:59.999Z", + "inputTemplateName": "Input Template 1", + "outputTemplateName": "Output Template 1" + }' \ + --agencyInfo '[ + {"name": "Sample Agency", "abbreviation": "SA", "code": "SA"}, + {"name": "Sample Agency 2", "abbreviation": "SA2", "code": "SA2"} + ]' \ + --userInfo '[ + {"name": "Sample User", "email": "sample@example.com", "role": "ORGANIZATION_STAFF", "agencyName": "Sample Agency"}, + {"name": "Sample User 2", "email": "sample2@example.com", "role": "ORGANIZATION_ADMIN", "agencyName": "Sample Agency 2"} + ]' + */ + await getPrismaClient() + console.log(':: Executing script with args ::') + console.log(`Received following arguments: ${Object.keys(args)}`) + + if (!args.orgName) { + throw new Error('Organization name is required') + } + + if (args.initializeExpenditureCategories) { + console.log('Initializing expenditure categories') + await createOrSkipInitialExpenditureCategories() + } + + if (args.inputTemplateInfo) { + console.log('get or create input template') + console.log(JSON.parse(args.inputTemplateInfo)) + const parsedInputTemplateInfo = JSON.parse(args.inputTemplateInfo) + await getOrCreateInputTemplate(parsedInputTemplateInfo) + } + + if (args.outputTemplateInfo) { + console.log('get or create output template') + console.log(JSON.parse(args.outputTemplateInfo)) + const parsedOutputTemplateInfo = JSON.parse(args.outputTemplateInfo) + await getOrCreateOutputTemplate(parsedOutputTemplateInfo) + } + + if (args.periodInfo) { + console.log('get or create reporting period') + console.log(JSON.parse(args.periodInfo)) + const parsedPeriodInfo = JSON.parse(args.periodInfo) + await getOrCreateReportingPeriod(parsedPeriodInfo) + } + + if (args.orgName && args.currentPeriodName) { + console.log('Get or create Organization') + console.log(args.orgName) + console.log(args.currentPeriodName) + await getOrCreateOrganization(args.orgName, args.currentPeriodName) + } + + if (args.agencyInfo) { + console.log('Get or create Agencies') + console.log(JSON.parse(args.agencyInfo)) + const parsedAgencyInfo = JSON.parse(args.agencyInfo) + await getOrCreateAgencies(args.orgName, parsedAgencyInfo) + } + + if (args.userInfo) { + console.log('Get or create Users') + console.log(JSON.parse(args.userInfo)) + const parsedUserInfo = JSON.parse(args.userInfo) + await getOrCreateUsers(parsedUserInfo, args.orgName) + } + console.log(':: Script executed ::') +} diff --git a/web/src/components/Organization/OrganizationPickListsCell/OrganizationPickListsCell.tsx b/web/src/components/Organization/OrganizationPickListsCell/OrganizationPickListsCell.tsx index 9a7bfcd8..5cef5b3c 100644 --- a/web/src/components/Organization/OrganizationPickListsCell/OrganizationPickListsCell.tsx +++ b/web/src/components/Organization/OrganizationPickListsCell/OrganizationPickListsCell.tsx @@ -3,13 +3,14 @@ import type { FindOrganizationQueryVariables, } from 'types/graphql' -import { Label, SelectField } from '@redwoodjs/forms' +import { Label, SelectField, HiddenField } from '@redwoodjs/forms' import type { CellSuccessProps, CellFailureProps } from '@redwoodjs/web' export const QUERY = gql` query FindOrganizationQuery($id: Int!) { organization: organization(id: $id) { id + preferences reportingPeriods { id name @@ -37,20 +38,14 @@ export const Success = ({ }: CellSuccessProps) => { return (
-