diff --git a/docs/openapi/index.yaml b/docs/openapi/index.yaml index b580498bd5..423fc0cd19 100644 --- a/docs/openapi/index.yaml +++ b/docs/openapi/index.yaml @@ -16,6 +16,70 @@ paths: $ref: "./paths/index.yaml" components: schemas: + activityReport: + type: object + properties: + id: + type: number + userId: + type: number + lastUpdatedById: + type: number + resourcesUsed: + type: string + additionalNotes: + type: string + numberOfParticipants: + type: number + deliveryMethod: + type: string + duration: + type: number + endDate: + type: string + startDate: + type: string + participantType: + type: string + requester: + type: string + programTypes: + type: array + items: + type: string + targetPopulations: + type: array + items: + type: string + reason: + type: array + items: + type: string + participants: + type: array + items: + type: string + topics: + type: array + items: + type: string + pageState: + type: object + properties: + 1: + type: string + 2: + type: string + 3: + type: string + 4: + type: string + status: + type: string + ttaType: + type: array + items: + type: string approvingUser: type: object properties: @@ -23,6 +87,22 @@ components: type: number name: type: string + participant: + type: object + properties: + participantId: + type: number + name: + type: string + permission: + type: object + properties: + userId: + type: number + scopeId: + type: number + regionId: + type: number user: type: object properties: @@ -44,12 +124,3 @@ components: type: array items: $ref: '#/components/schemas/permission' - permission: - type: object - properties: - userId: - type: number - scopeId: - type: number - regionId: - type: number diff --git a/docs/openapi/paths/activity-reports/activity-reports-id.yaml b/docs/openapi/paths/activity-reports/activity-reports-id.yaml new file mode 100644 index 0000000000..9483b0888c --- /dev/null +++ b/docs/openapi/paths/activity-reports/activity-reports-id.yaml @@ -0,0 +1,41 @@ +get: + tags: + - activity-reports + summary: Retrieve an activity report + parameters: + - in: path + name: activityReportId + required: true + schema: + type: number + responses: + 200: + description: The activity report with an Id of {activityReportId} + content: + application/json: + schema: + $ref: '../../index.yaml#/components/schemas/activityReport' +put: + tags: + - activity-reports + summary: Update an activity report + requestBody: + description: A new activity report + required: true + content: + application/json: + schema: + $ref: '../../index.yaml#/components/schemas/activityReport' + parameters: + - in: path + name: activityReportId + required: true + schema: + type: number + responses: + 200: + description: The updated activity report + content: + application/json: + schema: + $ref: '../../index.yaml#/components/schemas/activityReport' diff --git a/docs/openapi/paths/activity-reports/activity-reports.yaml b/docs/openapi/paths/activity-reports/activity-reports.yaml new file mode 100644 index 0000000000..e5ea38db56 --- /dev/null +++ b/docs/openapi/paths/activity-reports/activity-reports.yaml @@ -0,0 +1,18 @@ +post: + tags: + - activity-reports + summary: Create a new activity report + requestBody: + description: A new activity report + required: true + content: + application/json: + schema: + $ref: '../../index.yaml#/components/schemas/activityReport' + responses: + 200: + description: Successfully created activity report + content: + application/json: + schema: + $ref: '../../index.yaml#/components/schemas/activityReport' diff --git a/docs/openapi/paths/activity-reports/participants.yaml b/docs/openapi/paths/activity-reports/participants.yaml new file mode 100644 index 0000000000..4ccd0048f0 --- /dev/null +++ b/docs/openapi/paths/activity-reports/participants.yaml @@ -0,0 +1,23 @@ +get: + tags: + - activity-reports + summary: > + Get possible participants for an activity report + description: > + A participant is either a grant or nonGrantee. + responses: + 200: + description: The possible participants + content: + application/json: + schema: + type: object + properties: + grants: + type: array + items: + $ref: '../../index.yaml#/components/schemas/participant' + nonGrantees: + type: array + items: + $ref: '../../index.yaml#/components/schemas/participant' diff --git a/docs/openapi/paths/index.yaml b/docs/openapi/paths/index.yaml index e4a5a33507..b793e06ba9 100644 --- a/docs/openapi/paths/index.yaml +++ b/docs/openapi/paths/index.yaml @@ -10,5 +10,11 @@ $ref: './admin.yaml' '/admin/users': $ref: './adminAllUsers.yaml' +'/activity-reports': + $ref: './activity-reports/activity-reports.yaml' '/activity-reports/approvers': - $ref: './activity-reports/approvers.yaml' \ No newline at end of file + $ref: './activity-reports/approvers.yaml' +'/activity-reports/participants': + $ref: './activity-reports/participants.yaml' +'/activity-reports/{activityReportId}': + $ref: './activity-reports/activity-reports-id.yaml' diff --git a/package.json b/package.json index 560d2b6447..5c6bf620e6 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "server:debug": "nodemon src/index.js --exec babel-node --inspect", "client": "yarn --cwd frontend start", "test": "jest src", - "test:ci": "cross-env JEST_JUNIT_OUTPUT_DIR=reports JEST_JUNIT_OUTPUT_NAME=unit.xml POSTGRES_USERNAME=postgres POSTGRES_DB=ttasmarthub CURRENT_USER_ID=5 CI=true jest src tools --coverage --reporters=default --reporters=jest-junit", + "test:ci": "cross-env JEST_JUNIT_OUTPUT_DIR=reports JEST_JUNIT_OUTPUT_NAME=unit.xml POSTGRES_USERNAME=postgres POSTGRES_DB=ttasmarthub CURRENT_USER_ID=5 CI=true jest src tools --runInBand --coverage --reporters=default --reporters=jest-junit", "test:all": "yarn test:ci && yarn --cwd frontend test:ci", "lint": "eslint src", "lint:ci": "eslint -f eslint-formatter-multiple src", @@ -148,6 +148,7 @@ "http-codes": "^1.0.0", "lodash": "^4.17.20", "memorystore": "^1.6.2", + "moment": "^2.29.1", "mz": "^2.7.0", "newrelic": "^7.0.1", "pg": "^8.3.3", diff --git a/src/lib/apiErrorHandler.js b/src/lib/apiErrorHandler.js index 566a7611ee..7eb9aa8cc1 100644 --- a/src/lib/apiErrorHandler.js +++ b/src/lib/apiErrorHandler.js @@ -21,7 +21,7 @@ async function handleSequelizeError(req, res, error, logContext) { responseBody: { ...error, errorStack: error.stack }, responseCode: INTERNAL_SERVER_ERROR, }); - logger.error(`${logContext.namespace} id: ${requestErrorId} Sequelize error`); + logger.error(`${logContext.namespace} id: ${requestErrorId} Sequelize error ${error.stack}`); } catch (err) { logger.error(`${logContext.namespace} - Sequelize error - unable to save to db - ${error}`); } @@ -35,7 +35,7 @@ export const handleError = async (req, res, error, logContext) => { if (error instanceof Sequelize.Error) { await handleSequelizeError(req, res, error, logContext); } else { - logger.error(`${logContext.namespace} - UNEXPECTED ERROR - ${error}`); + logger.error(`${logContext.namespace} - UNEXPECTED ERROR - ${error.stack}`); res.status(INTERNAL_SERVER_ERROR).end(); } }; diff --git a/src/migrations/20210106152317-create-non-grantee.js b/src/migrations/20210106152317-create-non-grantee.js new file mode 100644 index 0000000000..66108dee45 --- /dev/null +++ b/src/migrations/20210106152317-create-non-grantee.js @@ -0,0 +1,30 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + queryInterface.createTable('NonGrantees', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER, + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW'), + }, + }); + }, + + down: async (queryInterface) => { + queryInterface.dropTable('NonGrantees'); + }, +}; diff --git a/src/migrations/20210106160931-create-activity-reports.js b/src/migrations/20210106160931-create-activity-reports.js new file mode 100644 index 0000000000..7e3c2f0290 --- /dev/null +++ b/src/migrations/20210106160931-create-activity-reports.js @@ -0,0 +1,114 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + queryInterface.createTable('ActivityReports', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER, + }, + resourcesUsed: { + allowNull: true, + type: Sequelize.STRING, + }, + additionalNotes: { + allowNull: true, + type: Sequelize.STRING, + }, + numberOfParticipants: { + allowNull: true, + type: Sequelize.INTEGER, + }, + deliveryMethod: { + allowNull: true, + type: Sequelize.STRING, + }, + duration: { + allowNull: true, + type: Sequelize.DECIMAL(3, 1), + }, + endDate: { + allowNull: true, + type: Sequelize.DATEONLY, + }, + startDate: { + allowNull: true, + type: Sequelize.DATEONLY, + }, + participantType: { + allowNull: true, + type: Sequelize.STRING, + }, + requester: { + allowNull: true, + type: Sequelize.STRING, + }, + status: { + allowNull: false, + type: Sequelize.STRING, + }, + programTypes: { + allowNull: true, + type: Sequelize.ARRAY(Sequelize.STRING), + }, + targetPopulations: { + allowNull: true, + type: Sequelize.ARRAY(Sequelize.STRING), + }, + reason: { + allowNull: true, + type: Sequelize.ARRAY(Sequelize.STRING), + }, + participants: { + allowNull: true, + type: Sequelize.ARRAY(Sequelize.STRING), + }, + topics: { + allowNull: true, + type: Sequelize.ARRAY(Sequelize.STRING), + }, + ttaType: { + allowNull: true, + type: Sequelize.ARRAY(Sequelize.STRING), + }, + pageState: { + allowNull: true, + type: Sequelize.JSON, + }, + userId: { + allowNull: false, + type: Sequelize.INTEGER, + references: { + model: { + tableName: 'Users', + }, + key: 'id', + }, + }, + lastUpdatedById: { + allowNull: false, + type: Sequelize.INTEGER, + references: { + model: { + tableName: 'Users', + }, + key: 'id', + }, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW'), + }, + }); + }, + + down: async (queryInterface) => { + queryInterface.dropTable('ActivityReports'); + }, +}; diff --git a/src/migrations/20210106160953-create-activity-participants.js b/src/migrations/20210106160953-create-activity-participants.js new file mode 100644 index 0000000000..8d12b0a4f4 --- /dev/null +++ b/src/migrations/20210106160953-create-activity-participants.js @@ -0,0 +1,53 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + queryInterface.createTable('ActivityParticipants', { + id: { + allowNull: false, + autoIncrement: true, + primaryKey: true, + type: Sequelize.INTEGER, + }, + activityReportId: { + type: Sequelize.INTEGER, + allowNull: false, + references: { + model: { + tableName: 'ActivityReports', + }, + }, + }, + grantId: { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: { + tableName: 'Grants', + }, + }, + }, + nonGranteeId: { + type: Sequelize.INTEGER, + allowNull: true, + references: { + model: { + tableName: 'NonGrantees', + }, + }, + }, + createdAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW'), + }, + updatedAt: { + allowNull: false, + type: Sequelize.DATE, + defaultValue: Sequelize.fn('NOW'), + }, + }); + }, + + down: async (queryInterface) => { + queryInterface.dropTable('ActivityParticipants'); + }, +}; diff --git a/src/models/activityParticipant.js b/src/models/activityParticipant.js new file mode 100644 index 0000000000..2e1cfc0f7b --- /dev/null +++ b/src/models/activityParticipant.js @@ -0,0 +1,54 @@ +import { Model } from 'sequelize'; + +export default (sequelize, DataTypes) => { + class ActivityParticipant extends Model { + static associate(models) { + ActivityParticipant.belongsTo(models.ActivityReport, { foreignKey: 'activityReportId' }); + ActivityParticipant.belongsTo(models.Grant, { foreignKey: 'grantId', as: 'grant' }); + ActivityParticipant.belongsTo(models.NonGrantee, { foreignKey: 'nonGranteeId', as: 'nonGrantee' }); + } + } + ActivityParticipant.init({ + activityReportId: { + allowNull: false, + type: DataTypes.INTEGER, + }, + grantId: { + allowNull: true, + type: DataTypes.INTEGER, + }, + nonGranteeId: { + allowNull: true, + type: DataTypes.INTEGER, + }, + participantId: { + type: DataTypes.VIRTUAL, + get() { + if (this.grant) { + return this.grant.id; + } + return this.nonGrantee.id; + }, + }, + name: { + type: DataTypes.VIRTUAL, + get() { + if (this.grant) { + return this.grant.name; + } + return this.nonGrantee.name; + }, + }, + }, { + sequelize, + modelName: 'ActivityParticipant', + validate: { + oneNull() { + if (this.grantId && this.nonGranteeId) { + throw new Error('Can not specify both grantId and nonGranteeId'); + } + }, + }, + }); + return ActivityParticipant; +}; diff --git a/src/models/activityReport.js b/src/models/activityReport.js new file mode 100644 index 0000000000..416deac06e --- /dev/null +++ b/src/models/activityReport.js @@ -0,0 +1,104 @@ +import { Model } from 'sequelize'; +import moment from 'moment'; + +function formatDate(fieldName) { + const raw = this.getDataValue(fieldName); + if (raw) { + return moment(raw).format('MM/DD/YYYY'); + } + return null; +} + +export default (sequelize, DataTypes) => { + class ActivityReport extends Model { + static associate(models) { + ActivityReport.belongsTo(models.User, { foreignKey: 'userId', as: 'author' }); + ActivityReport.belongsTo(models.User, { foreignKey: 'lastUpdatedById', as: 'lastUpdatedBy' }); + ActivityReport.hasMany(models.ActivityParticipant, { foreignKey: 'activityReportId', as: 'activityParticipants' }); + } + } + ActivityReport.init({ + userId: { + type: DataTypes.INTEGER, + }, + lastUpdatedById: { + type: DataTypes.INTEGER, + }, + resourcesUsed: { + type: DataTypes.STRING, + }, + additionalNotes: { + type: DataTypes.STRING, + }, + numberOfParticipants: { + type: DataTypes.INTEGER, + }, + deliveryMethod: { + type: DataTypes.STRING, + }, + duration: { + type: DataTypes.DECIMAL(3, 1), + }, + endDate: { + type: DataTypes.DATEONLY, + get: formatDate, + }, + startDate: { + type: DataTypes.DATEONLY, + get: formatDate, + }, + participantType: { + allowNull: false, + type: DataTypes.STRING, + }, + requester: { + type: DataTypes.STRING, + }, + programTypes: { + type: DataTypes.ARRAY(DataTypes.STRING), + }, + targetPopulations: { + type: DataTypes.ARRAY(DataTypes.STRING), + }, + reason: { + type: DataTypes.ARRAY(DataTypes.STRING), + }, + participants: { + type: DataTypes.ARRAY(DataTypes.STRING), + }, + topics: { + type: DataTypes.ARRAY(DataTypes.STRING), + }, + pageState: { + type: DataTypes.JSON, + }, + status: { + allowNull: false, + type: DataTypes.STRING, + validate: { + checkRequiredForSubmission() { + const requiredForSubmission = [ + this.deliveryMethod, + this.duration, + this.endDate, + this.requestor, + this.startDate, + this.ttaType, + ]; + if (this.status !== 'draft') { + if (requiredForSubmission.includes(null)) { + throw new Error('Missing required field(s)'); + } + } + }, + }, + }, + ttaType: { + type: DataTypes.ARRAY(DataTypes.STRING), + }, + }, { + sequelize, + modelName: 'ActivityReport', + }); + return ActivityReport; +}; diff --git a/src/models/grant.js b/src/models/grant.js index 9cade1f2d4..9188968530 100644 --- a/src/models/grant.js +++ b/src/models/grant.js @@ -12,7 +12,7 @@ module.exports = (sequelize, DataTypes) => { class Grant extends Model { static associate(models) { Grant.belongsTo(models.Region, { foreignKey: 'regionId' }); - Grant.belongsTo(models.Grantee, { foreignKey: 'granteeId' }); + Grant.belongsTo(models.Grantee, { foreignKey: 'granteeId', as: 'grantee' }); Grant.belongsToMany(models.Goal, { through: models.GrantGoal, foreignKey: 'grantId', as: 'goals' }); } } @@ -25,6 +25,16 @@ module.exports = (sequelize, DataTypes) => { status: DataTypes.STRING, startDate: DataTypes.DATE, endDate: DataTypes.DATE, + granteeId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + name: { + type: DataTypes.VIRTUAL, + get() { + return `${this.grantee.name} - ${this.number}`; + }, + }, }, { sequelize, modelName: 'Grant', diff --git a/src/models/grantee.js b/src/models/grantee.js index 495528de64..573e93be8e 100644 --- a/src/models/grantee.js +++ b/src/models/grantee.js @@ -15,7 +15,11 @@ module.exports = (sequelize, DataTypes) => { } } Grantee.init({ - name: DataTypes.STRING, + name: { + type: DataTypes.STRING, + allowNull: false, + unique: false, + }, }, { sequelize, modelName: 'Grantee', diff --git a/src/models/nonGrantee.js b/src/models/nonGrantee.js new file mode 100644 index 0000000000..cbfef39ac8 --- /dev/null +++ b/src/models/nonGrantee.js @@ -0,0 +1,26 @@ +const { + Model, +} = require('sequelize'); + +/** + * NonGrantee table + * + * @param {} sequelize + * @param {*} DataTypes + */ +module.exports = (sequelize, DataTypes) => { + class NonGrantee extends Model { + static associate() {} + } + NonGrantee.init({ + name: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + }, { + sequelize, + modelName: 'NonGrantee', + }); + return NonGrantee; +}; diff --git a/src/routes/activityReports/handlers.js b/src/routes/activityReports/handlers.js index bf1ba50b44..5c5ee7e533 100644 --- a/src/routes/activityReports/handlers.js +++ b/src/routes/activityReports/handlers.js @@ -1,9 +1,9 @@ -import { Op } from 'sequelize'; -import { - User, Permission, -} from '../../models'; import handleErrors from '../../lib/apiErrorHandler'; import SCOPES from '../../middleware/scopeConstants'; +import { + reportParticipants, activityReportById, createOrUpdate, reportExists, +} from '../../services/activityReports'; +import { userById, usersWithPermissions } from '../../services/users'; const { READ_WRITE_REPORTS, APPROVE_REPORTS } = SCOPES; @@ -13,18 +13,6 @@ const logContext = { namespace, }; -const userById = async (userId) => User.findOne({ - attributes: ['id'], - where: { - id: { - [Op.eq]: userId, - }, - }, - include: [ - { model: Permission, as: 'permissions', attributes: ['scopeId', 'regionId'] }, - ], -}); - /** * Gets all users that have approve permissions for the current user's * regions. @@ -39,18 +27,7 @@ export async function getApprovers(req, res) { .map((p) => p.regionId); try { - const users = await User.findAll({ - attributes: ['id', 'name'], - where: { - [Op.and]: [ - { '$permissions.scopeId$': APPROVE_REPORTS }, - { '$permissions.regionId$': reportRegions }, - ], - }, - include: [ - { model: Permission, as: 'permissions', attributes: [] }, - ], - }); + const users = await usersWithPermissions(reportRegions, [APPROVE_REPORTS]); res.json(users); } catch (error) { await handleErrors(req, res, error, logContext); @@ -69,3 +46,80 @@ export async function submitReport(req, res) { console.log('submit'); res.sendStatus(204); } + +export async function getParticipants(req, res) { + const participants = await reportParticipants(); + res.json(participants); +} + +/** + * Retrieve an activity report + * + * @param {*} req - request + * @param {*} res - response + */ +export async function getReport(req, res) { + const { activityReportId } = req.params; + const report = await activityReportById(activityReportId); + if (!report) { + res.sendStatus(404); + } else { + res.json(report); + } +} + +/** + * save an activity report + * + * @param {*} req - request + * @param {*} res - response + */ +export async function saveReport(req, res) { + try { + const newReport = req.body; + if (!newReport) { + res.sendStatus(400); + return; + } + const userId = parseInt(req.session.userId, 10); + const { activityReportId } = req.params; + + if (!await reportExists(activityReportId)) { + res.sendStatus(404); + return; + } + + newReport.userId = userId; + newReport.lastUpdatedById = userId; + + const report = await createOrUpdate(newReport, activityReportId); + res.json(report); + } catch (error) { + await handleErrors(req, res, error, logContext); + } +} + +/** + * create an activity report + * + * @param {*} req - request + * @param {*} res - response + */ +export async function createReport(req, res) { + try { + const newReport = req.body; + if (!newReport) { + res.sendStatus(400); + return; + } + const userId = parseInt(req.session.userId, 10); + newReport.status = 'draft'; + newReport.userId = userId; + newReport.lastUpdatedById = userId; + + const report = await createOrUpdate(newReport); + res.json(report); + } catch (error) { + await handleErrors(req, res, error, logContext); + } +} diff --git a/src/routes/activityReports/handlers.test.js b/src/routes/activityReports/handlers.test.js new file mode 100644 index 0000000000..9d96a9d6e2 --- /dev/null +++ b/src/routes/activityReports/handlers.test.js @@ -0,0 +1,205 @@ +import db, { + ActivityReport, ActivityParticipant, User, Permission, +} from '../../models'; +import { + getApprovers, saveReport, createReport, getReport, +} from './handlers'; + +import SCOPES from '../../middleware/scopeConstants'; + +const mockUser = { + id: 100, + homeRegionId: 1, + permissions: [ + { + userId: 100, + regionId: 5, + scopeId: SCOPES.READ_WRITE_REPORTS, + }, + { + userId: 100, + regionId: 6, + scopeId: SCOPES.READ_WRITE_REPORTS, + }, + ], +}; + +const mockSession = jest.fn(); +mockSession.userId = mockUser.id; + +const mockResponse = { + json: jest.fn(), + sendStatus: jest.fn(), + status: jest.fn(() => ({ + end: jest.fn(), + })), +}; + +const reportObject = { + participantType: 'grantee', + status: 'draft', + userId: mockUser.id, + lastUpdatedById: mockUser.id, + resourcesUsed: 'test', +}; + +describe('Activity Report handlers', () => { + let user; + + beforeAll(async () => { + user = await User.create(mockUser, { include: [{ model: Permission, as: 'permissions' }] }); + }); + + afterAll(async () => { + await ActivityParticipant.destroy({ where: {} }); + await ActivityReport.destroy({ where: {} }); + await User.destroy({ where: { id: user.id } }); + db.sequelize.close(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getApprovers', () => { + const approverOne = { + id: 50, + name: 'region 5', + permissions: [ + { + userId: 50, + regionId: 5, + scopeId: SCOPES.APPROVE_REPORTS, + }, + ], + }; + const approverTwo = { + id: 51, + name: 'region 6', + permissions: [ + { + userId: 51, + regionId: 6, + scopeId: SCOPES.APPROVE_REPORTS, + }, + ], + }; + + const approvers = [ + { + id: 50, + name: 'region 5', + }, + { + id: 51, + name: 'region 6', + }, + ]; + + beforeEach(async () => { + await User.create(approverOne, { include: [{ model: Permission, as: 'permissions' }] }); + await User.create(approverTwo, { include: [{ model: Permission, as: 'permissions' }] }); + }); + + afterEach(async () => { + await User.destroy({ where: { id: [50, 51] } }); + }); + + it("returns a list of users that have approving permissions on the user's regions", async () => { + await getApprovers({ session: mockSession }, mockResponse); + expect(mockResponse.json).toHaveBeenCalledWith(approvers); + }); + }); + + describe('saveReport', () => { + it('updates an already saved report', async () => { + const res = await ActivityReport.create(reportObject); + const request = { + session: mockSession, + params: { activityReportId: res.dataValues.id }, + body: { + resourcesUsed: 'updated', + }, + }; + const expected = { + id: res.dataValues.id, + ...reportObject, + resourcesUsed: 'updated', + }; + + await saveReport(request, mockResponse); + expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining(expected)); + }); + + it('handles reports that are not found', async () => { + const request = { + session: mockSession, + params: { activityReportId: 1000 }, + body: {}, + }; + await saveReport(request, mockResponse); + expect(mockResponse.sendStatus).toHaveBeenCalledWith(404); + }); + + it('handles empty requests', async () => { + const request = { + session: mockSession, + params: { activityReportId: 1000 }, + }; + await saveReport(request, mockResponse); + expect(mockResponse.sendStatus).toHaveBeenCalledWith(400); + }); + }); + + describe('createReport', () => { + it('creates a new report', async () => { + const beginningARCount = await ActivityReport.count(); + const report = { + participantType: 'grantee', + activityParticipants: [{ participantId: 1 }], + }; + const request = { + body: report, + session: mockSession, + }; + + await createReport(request, mockResponse); + const endARCount = await ActivityReport.count(); + expect(endARCount - beginningARCount).toBe(1); + }); + + it('handles empty requests', async () => { + const request = { + session: mockSession, + }; + await createReport(request, mockResponse); + expect(mockResponse.sendStatus).toHaveBeenCalledWith(400); + }); + }); + + describe('getReport', () => { + it('sends a previously saved activity report', async () => { + const res = await ActivityReport.create(reportObject); + const request = { + session: mockSession, + params: { activityReportId: res.dataValues.id }, + }; + const expected = { + id: res.dataValues.id, + ...reportObject, + }; + + await getReport(request, mockResponse); + expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining(expected)); + }); + + it('handles reports that are not found', async () => { + const request = { + session: mockSession, + params: { activityReportId: 1000 }, + }; + await getReport(request, mockResponse); + expect(mockResponse.sendStatus).toHaveBeenCalledWith(404); + }); + }); +}); diff --git a/src/routes/activityReports/index.js b/src/routes/activityReports/index.js index 024c71a3bf..9db4ac0c03 100644 --- a/src/routes/activityReports/index.js +++ b/src/routes/activityReports/index.js @@ -1,6 +1,6 @@ import express from 'express'; import { - getApprovers, submitReport, + getApprovers, submitReport, saveReport, createReport, getReport, getParticipants, } from './handlers'; const router = express.Router(); @@ -9,7 +9,11 @@ const router = express.Router(); * API for activity reports */ +router.post('/', createReport); router.get('/approvers', getApprovers); -router.post('/submit', submitReport); +router.get('/participants', getParticipants); +router.get('/:activityReportId', getReport); +router.put('/:activityReportId', saveReport); +router.post('/:activityReportId/submit', submitReport); export default router; diff --git a/src/routes/admin/user.js b/src/routes/admin/user.js index e8bf1481e7..7df2297319 100644 --- a/src/routes/admin/user.js +++ b/src/routes/admin/user.js @@ -1,7 +1,7 @@ -import { Op } from 'sequelize'; import { User, Permission, sequelize, } from '../../models'; +import { userById } from '../../services/users'; import handleErrors from '../../lib/apiErrorHandler'; const namespace = 'SERVICE:USER'; @@ -10,18 +10,6 @@ const logContext = { namespace, }; -export const userById = async (userId) => User.findOne({ - attributes: ['id', 'name', 'hsesUserId', 'email', 'phoneNumber', 'homeRegionId', 'role'], - where: { - id: { - [Op.eq]: userId, - }, - }, - include: [ - { model: Permission, as: 'permissions', attributes: ['userId', 'scopeId', 'regionId'] }, - ], -}); - /** * Gets one user from the database. * diff --git a/src/routes/apiDirectory.js b/src/routes/apiDirectory.js index ecc59d8fc9..da9579a68e 100644 --- a/src/routes/apiDirectory.js +++ b/src/routes/apiDirectory.js @@ -6,7 +6,7 @@ import authMiddleware, { login } from '../middleware/authMiddleware'; import handleErrors from '../lib/apiErrorHandler'; import adminRouter from './user'; import activityReportsRouter from './activityReports'; -import { userById } from './admin/user'; +import { userById } from '../services/users'; export const loginPath = '/login'; diff --git a/src/seeders/20201209172017-approvers.js b/src/seeders/20201209172017-approvers.js index c2732c1d2b..48be10fcd3 100644 --- a/src/seeders/20201209172017-approvers.js +++ b/src/seeders/20201209172017-approvers.js @@ -23,12 +23,12 @@ const permissions = [ }, { userId: 3, - regionId: 1, + regionId: 2, scopeId: APPROVE_REPORTS, }, { userId: 4, - regionId: 2, + regionId: 3, scopeId: APPROVE_REPORTS, }, ]; diff --git a/src/seeders/20210107170250-nonGrantees.js b/src/seeders/20210107170250-nonGrantees.js new file mode 100644 index 0000000000..7bee280c9d --- /dev/null +++ b/src/seeders/20210107170250-nonGrantees.js @@ -0,0 +1,45 @@ +const nonGrantees = [ + { + name: 'CCDF / Child Care Administrator', + }, + { + name: 'Head Start Collaboration Office', + }, + { + name: 'QRIS System', + }, + { + name: 'Regional Head Start Association', + }, + { + name: 'Regional TTA/Other Specialists', + }, + { + name: 'State CCR&R', + }, + { + name: 'State Early Learning Standards', + }, + { + name: 'State Education System', + }, + { + name: 'State Health System', + }, + { + name: 'State Head Start Association', + }, + { + name: 'State Professional Development / Continuing Education', + }, +]; + +module.exports = { + up: async (queryInterface) => { + await queryInterface.bulkInsert('NonGrantees', nonGrantees, {}); + }, + + down: async (queryInterface) => { + await queryInterface.bulkDelete('NonGrantees', null, {}); + }, +}; diff --git a/src/services/activityReports.js b/src/services/activityReports.js new file mode 100644 index 0000000000..94083ec269 --- /dev/null +++ b/src/services/activityReports.js @@ -0,0 +1,140 @@ +import _ from 'lodash'; +import { Op } from 'sequelize'; + +import { + ActivityReport, + sequelize, + ActivityParticipant, + Grant, + Grantee, + NonGrantee, +} from '../models'; + +async function saveReportParticipants( + activityReportId, + participantIds, + participantType, + transaction, +) { + await ActivityParticipant.destroy({ + where: { + activityReportId: { + [Op.eq]: activityReportId, + }, + }, + transaction, + }); + + await Promise.all(participantIds.map(async (participantId) => { + const activityParticipant = { + activityReportId, + }; + + if (participantType === 'grantee') { + activityParticipant.grantId = participantId; + } else if (participantType === 'non-grantee') { + activityParticipant.nonGranteeId = participantId; + } + + return ActivityParticipant.create(activityParticipant, { transaction }); + })); +} + +async function update(newReport, activityReportId, transaction) { + const result = await ActivityReport.update(newReport, { + where: { + id: { + [Op.eq]: activityReportId, + }, + }, + returning: true, + plain: true, + transaction, + fields: _.keys(newReport), + }); + return result[1]; +} + +async function create(report, transaction) { + return ActivityReport.create(report, { transaction }); +} + +export function activityReportById(activityReportId) { + return ActivityReport.findOne({ + where: { + id: { + [Op.eq]: activityReportId, + }, + }, + include: [ + { + model: ActivityParticipant, + attributes: ['id', 'name', 'participantId'], + as: 'activityParticipants', + required: false, + include: [ + { + model: Grant, + attributes: ['id', 'number'], + as: 'grant', + required: false, + include: [{ + model: Grantee, + as: 'grantee', + attributes: ['name'], + }], + }, + { + model: NonGrantee, + as: 'nonGrantee', + required: false, + }, + ], + }, + ], + }); +} + +export async function createOrUpdate(newActivityReport, activityReportId) { + let savedReport; + await sequelize.transaction(async (transaction) => { + if (activityReportId) { + savedReport = await update(newActivityReport, activityReportId, transaction); + } else { + savedReport = await create(newActivityReport, transaction); + } + + if (newActivityReport.activityParticipants) { + const { participantType, id } = savedReport; + const participantIds = newActivityReport.activityParticipants.map((g) => g.participantId); + await saveReportParticipants(id, participantIds, participantType, transaction); + } + }); + return activityReportById(savedReport.id); +} + +export async function reportParticipants() { + const rawGrants = await Grant.findAll({ + attributes: ['id', 'name', 'number'], + include: [{ + model: Grantee, + as: 'grantee', + }], + }); + + const grants = rawGrants.map((g) => ({ + participantId: g.id, + name: g.name, + })); + + const nonGrantees = await NonGrantee.findAll({ + raw: true, + attributes: [['id', 'participantId'], 'name'], + }); + return { grants, nonGrantees }; +} + +export async function reportExists(activityReportId) { + const report = await ActivityReport.findOne({ where: { id: activityReportId } }); + return !_.isNull(report); +} diff --git a/src/services/users.js b/src/services/users.js new file mode 100644 index 0000000000..89be60d0f9 --- /dev/null +++ b/src/services/users.js @@ -0,0 +1,36 @@ +import { Op } from 'sequelize'; + +import { + User, + Permission, +} from '../models'; + +export async function userById(userId) { + return User.findOne({ + attributes: ['id', 'name', 'hsesUserId', 'email', 'phoneNumber', 'homeRegionId', 'role'], + where: { + id: { + [Op.eq]: userId, + }, + }, + include: [ + { model: Permission, as: 'permissions', attributes: ['userId', 'scopeId', 'regionId'] }, + ], + }); +} + +export async function usersWithPermissions(regions, scopes) { + return User.findAll({ + attributes: ['id', 'name'], + raw: true, + where: { + [Op.and]: [ + { '$permissions.scopeId$': scopes }, + { '$permissions.regionId$': regions }, + ], + }, + include: [ + { model: Permission, as: 'permissions', attributes: [] }, + ], + }); +} diff --git a/yarn.lock b/yarn.lock index bd3eb15fe8..9cb11e51d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7128,7 +7128,7 @@ moment-timezone@^0.5.21, moment-timezone@^0.5.x: dependencies: moment ">= 2.9.0" -"moment@>= 2.9.0", moment@^2.24.0: +"moment@>= 2.9.0", moment@^2.24.0, moment@^2.29.1: version "2.29.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==