From 7a1a32b5ff446cf09265658d129331fe871511fc Mon Sep 17 00:00:00 2001 From: Goeme Nthomiwa Date: Tue, 24 Sep 2024 17:04:02 -0700 Subject: [PATCH 1/6] fix: geo 1158 fix textarea to correctly count characters (#781) --- .../announcements/AnnouncementForm.vue | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/admin-frontend/src/components/announcements/AnnouncementForm.vue b/admin-frontend/src/components/announcements/AnnouncementForm.vue index e0c95718..b4018f39 100644 --- a/admin-frontend/src/components/announcements/AnnouncementForm.vue +++ b/admin-frontend/src/components/announcements/AnnouncementForm.vue @@ -94,7 +94,15 @@ counter rows="3" :error-messages="errors.description" - > + > + +
Time settings
@@ -463,7 +471,7 @@ const { handleSubmit, setErrors, errors, meta, values } = useForm({ description(value) { if (!value) return 'Description is required.'; - if (value.length > 2000) + if (getDescriptionLength(value) > 2000) return 'Description should have a maximum of 2000 characters.'; return true; @@ -526,7 +534,7 @@ const { handleSubmit, setErrors, errors, meta, values } = useForm({ const { value: announcementTitle } = useField('title'); const { value: status } = useField('status'); -const { value: announcementDescription } = useField('description'); +const { value: announcementDescription } = useField('description'); const { value: activeOn } = useField('active_on') as any; const { value: expiresOn } = useField('expires_on') as any; const { value: noExpiry } = useField('no_expiry') as any; @@ -535,6 +543,10 @@ const { value: linkDisplayName } = useField('linkDisplayName') as any; const { value: fileDisplayName } = useField('fileDisplayName') as any; const { value: attachment } = useField('attachment') as any; +const getDescriptionLength = (value: string) => { + return value.replace(/(\r\n|\n|\r)/g, ' ').length; +}; + watch(noExpiry, () => { if (noExpiry.value) { expiresOn.value = undefined; From 2b28a79e731b4149790c5c1485fcd6e47034c9eb Mon Sep 17 00:00:00 2001 From: Brock Anderson Date: Wed, 25 Sep 2024 09:06:24 -0700 Subject: [PATCH 2/6] feat: GEO-1120 - admin dashboard num employers widget (#780) --- .../src/components/DashboardPage.vue | 6 +- .../__tests__/DashboardPage.spec.ts | 2 +- .../dashboard/NumEmployerLogins.vue | 24 ------ .../dashboard/NumEmployerLogons.vue | 75 +++++++++++++++++++ .../__tests__/NumEmployerLogons.spec.ts | 55 ++++++++++++++ .../src/services/__tests__/apiService.spec.ts | 25 ++++++- admin-frontend/src/services/apiService.ts | 14 ++++ admin-frontend/src/types/employers.ts | 3 + admin-frontend/src/utils/constant.js | 1 + .../dashboard/employer-metrics-routes.spec.ts | 51 +++++++++++++ .../dashboard/employer-metrics-routes.ts | 22 ++++++ backend/src/v1/routes/dashboard/index.ts | 2 + .../src/v1/services/employer-service.spec.ts | 30 ++++++++ backend/src/v1/services/employer-service.ts | 16 ++++ backend/src/v1/types/employers.ts | 3 + 15 files changed, 301 insertions(+), 28 deletions(-) delete mode 100644 admin-frontend/src/components/dashboard/NumEmployerLogins.vue create mode 100644 admin-frontend/src/components/dashboard/NumEmployerLogons.vue create mode 100644 admin-frontend/src/components/dashboard/__tests__/NumEmployerLogons.spec.ts create mode 100644 admin-frontend/src/types/employers.ts create mode 100644 backend/src/v1/routes/dashboard/employer-metrics-routes.spec.ts create mode 100644 backend/src/v1/routes/dashboard/employer-metrics-routes.ts create mode 100644 backend/src/v1/services/employer-service.spec.ts create mode 100644 backend/src/v1/services/employer-service.ts create mode 100644 backend/src/v1/types/employers.ts diff --git a/admin-frontend/src/components/DashboardPage.vue b/admin-frontend/src/components/DashboardPage.vue index a76ebfb2..5d660aee 100644 --- a/admin-frontend/src/components/DashboardPage.vue +++ b/admin-frontend/src/components/DashboardPage.vue @@ -32,7 +32,7 @@ >
- + @@ -79,7 +79,7 @@ import RecentlySubmittedReports from './dashboard/RecentlySubmittedReports.vue'; import RecentlyViewedReports from './dashboard/RecentlyViewedReports.vue'; import NumSubmissionsInYear from './dashboard/NumSubmissionsInYear.vue'; -import NumEmployerLogins from './dashboard/NumEmployerLogins.vue'; +import NumEmployerLogons from './dashboard/NumEmployerLogons.vue'; import PublicAnnouncements from './dashboard/PublicAnnouncements.vue'; import ToolTip from './ToolTip.vue'; import { ref, onMounted } from 'vue'; @@ -92,6 +92,7 @@ const recentlySubmittedReports = ref(); const recentlyViewedReports = ref(); const numSubmissionsInYear = ref(); const publicAnnouncements = ref(); +const numEmployerLogons = ref(); onMounted(() => { //Periodically refresh the widgets @@ -105,5 +106,6 @@ async function refresh() { await recentlyViewedReports.value?.refresh(); await numSubmissionsInYear.value?.refresh(); await publicAnnouncements.value?.refresh(); + await numEmployerLogons.value?.refresh(); } diff --git a/admin-frontend/src/components/__tests__/DashboardPage.spec.ts b/admin-frontend/src/components/__tests__/DashboardPage.spec.ts index 6b80cc75..1c2ef8f6 100644 --- a/admin-frontend/src/components/__tests__/DashboardPage.spec.ts +++ b/admin-frontend/src/components/__tests__/DashboardPage.spec.ts @@ -5,7 +5,7 @@ import { createVuetify } from 'vuetify'; import * as components from 'vuetify/components'; import * as directives from 'vuetify/directives'; import DashboardPage from '../DashboardPage.vue'; -import NumEmployerLogins from '../dashboard/NumEmployerLogins.vue'; +import NumEmployerLogins from '../dashboard/NumEmployerLogons.vue'; // Mock the ResizeObserver const ResizeObserverMock = vi.fn(() => ({ diff --git a/admin-frontend/src/components/dashboard/NumEmployerLogins.vue b/admin-frontend/src/components/dashboard/NumEmployerLogins.vue deleted file mode 100644 index fbaf3795..00000000 --- a/admin-frontend/src/components/dashboard/NumEmployerLogins.vue +++ /dev/null @@ -1,24 +0,0 @@ - - - diff --git a/admin-frontend/src/components/dashboard/NumEmployerLogons.vue b/admin-frontend/src/components/dashboard/NumEmployerLogons.vue new file mode 100644 index 00000000..3adfeea0 --- /dev/null +++ b/admin-frontend/src/components/dashboard/NumEmployerLogons.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/admin-frontend/src/components/dashboard/__tests__/NumEmployerLogons.spec.ts b/admin-frontend/src/components/dashboard/__tests__/NumEmployerLogons.spec.ts new file mode 100644 index 00000000..ab32ced6 --- /dev/null +++ b/admin-frontend/src/components/dashboard/__tests__/NumEmployerLogons.spec.ts @@ -0,0 +1,55 @@ +import { createTestingPinia } from '@pinia/testing'; +import { render, screen, waitFor } from '@testing-library/vue'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createVuetify } from 'vuetify'; +import * as components from 'vuetify/components'; +import * as directives from 'vuetify/directives'; +import { EmployerMetrics } from '../../../types/employers'; +import NumEmployerLogons from '../NumEmployerLogons.vue'; + +global.ResizeObserver = require('resize-observer-polyfill'); +const pinia = createTestingPinia(); +const vuetify = createVuetify({ components, directives }); + +const mockEmployerMetrics: EmployerMetrics = { + num_employers_logged_on_to_date: 6, +}; +const mockGetEmployerMetrics = vi.fn().mockResolvedValue(mockEmployerMetrics); + +vi.mock('../../../services/apiService', () => ({ + default: { + getEmployerMetrics: (...args) => { + return mockGetEmployerMetrics(...args); + }, + }, +})); + +const wrappedRender = () => { + return render(NumEmployerLogons, { + global: { + plugins: [pinia, vuetify], + }, + }); +}; + +describe('NumEmployerLogons', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('displays the number of employers who have logged on to date', async () => { + await wrappedRender(); + expect(mockGetEmployerMetrics).toHaveBeenCalled(); + + await waitFor(() => { + expect( + screen.getByText( + `${mockEmployerMetrics.num_employers_logged_on_to_date}`, + { + exact: true, + }, + ), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/admin-frontend/src/services/__tests__/apiService.spec.ts b/admin-frontend/src/services/__tests__/apiService.spec.ts index 5f4a0aca..0329effa 100644 --- a/admin-frontend/src/services/__tests__/apiService.spec.ts +++ b/admin-frontend/src/services/__tests__/apiService.spec.ts @@ -1,6 +1,7 @@ import { AxiosError } from 'axios'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AnnouncementStatus } from '../../types/announcements'; +import { EmployerMetrics } from '../../types/employers'; import { ReportMetrics } from '../../types/reports'; import ApiService from '../apiService'; @@ -652,9 +653,31 @@ describe('ApiService', () => { }); }); + describe('getEmployerMetrics', () => { + describe('when the data are successfully retrieved from the backend', () => { + it('returns an object with the expected metrics', async () => { + const mockBackendResponse = { + num_employers_logged_on_to_date: 8, + }; + const mockAxiosResponse = { + data: mockBackendResponse, + }; + vi.spyOn(ApiService.apiAxios, 'get').mockResolvedValueOnce( + mockAxiosResponse, + ); + + const resp: EmployerMetrics = await ApiService.getEmployerMetrics(); + expect(resp).toEqual(mockBackendResponse); + }); + }); + }); + describe('getAnnouncementsMetrics', () => { it('returns an announcement metrics', async () => { - const mockBackendResponse = { draft: { count: 1 }, published: { count: 2 } }; + const mockBackendResponse = { + draft: { count: 1 }, + published: { count: 2 }, + }; const mockAxiosResponse = { data: mockBackendResponse, }; diff --git a/admin-frontend/src/services/apiService.ts b/admin-frontend/src/services/apiService.ts index 69273874..005313b8 100644 --- a/admin-frontend/src/services/apiService.ts +++ b/admin-frontend/src/services/apiService.ts @@ -13,6 +13,7 @@ import { AnnouncementSortType, IAnnouncementSearchResult, } from '../types/announcements'; +import { EmployerMetrics } from '../types/employers'; import { IReportSearchResult, ReportMetrics } from '../types/reports'; import { ApiRoutes, POWERBI_RESOURCE } from '../utils/constant'; import AuthService from './authService'; @@ -276,6 +277,19 @@ export default { } }, + async getEmployerMetrics(): Promise { + try { + const resp = await apiAxios.get(ApiRoutes.EMPLOYER_METRICS); + if (resp?.data) { + return resp.data; + } + throw new Error('Unable to fetch employer metrics'); + } catch (e) { + console.log(`Failed to get employer metrics from API - ${e}`); + throw e; + } + }, + async getAnnouncements( offset: number = 0, limit: number = 20, diff --git a/admin-frontend/src/types/employers.ts b/admin-frontend/src/types/employers.ts new file mode 100644 index 00000000..bdb54b2b --- /dev/null +++ b/admin-frontend/src/types/employers.ts @@ -0,0 +1,3 @@ +export type EmployerMetrics = { + num_employers_logged_on_to_date: number; +}; diff --git a/admin-frontend/src/utils/constant.js b/admin-frontend/src/utils/constant.js index bd7f1db3..3cf53b60 100644 --- a/admin-frontend/src/utils/constant.js +++ b/admin-frontend/src/utils/constant.js @@ -32,6 +32,7 @@ export const ApiRoutes = Object.freeze({ CLAMAV_SCAN: `${clamavBaseRoot}/`, RESOURCES: `${baseRoot}/v1/resources`, REPORT_METRICS: `${baseRoot}/v1/dashboard/reports-metrics`, + EMPLOYER_METRICS: `${baseRoot}/v1/dashboard/employer-metrics`, }); export const PAGE_TITLES = Object.freeze({ diff --git a/backend/src/v1/routes/dashboard/employer-metrics-routes.spec.ts b/backend/src/v1/routes/dashboard/employer-metrics-routes.spec.ts new file mode 100644 index 00000000..4580af48 --- /dev/null +++ b/backend/src/v1/routes/dashboard/employer-metrics-routes.spec.ts @@ -0,0 +1,51 @@ +import express, { Application } from 'express'; +import request from 'supertest'; +import router from './employer-metrics-routes'; + +let app: Application; +const getEmployerMetricsMock = jest.fn(); +jest.mock('../../services/employer-service', () => ({ + employerService: { + getEmployerMetrics: (...args) => getEmployerMetricsMock(...args), + }, +})); +describe('employer-metrics-routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + app = express(); + app.use('/dashboard', router); + }); + + describe('GET /employer-metrics', () => { + describe('200', () => { + it('should return the announcement metrics', async () => { + // Arrange + getEmployerMetricsMock.mockResolvedValueOnce({}); + + // Act + const response = await request(app).get('/dashboard/employer-metrics'); + + // Assert + expect(getEmployerMetricsMock).toHaveBeenCalledTimes(1); + expect(response.status).toBe(200); + }); + }); + describe('500', () => { + it('should return 500 if an error occurs', async () => { + // Arrange + getEmployerMetricsMock.mockRejectedValueOnce( + new Error('An error occurred'), + ); + + // Act + const response = await request(app).get('/dashboard/employer-metrics'); + + // Assert + expect(response.status).toBe(500); + expect(response.body).toEqual({ + error: 'An error occurred while fetching the employer metrics', + }); + }); + }); + }); +}); diff --git a/backend/src/v1/routes/dashboard/employer-metrics-routes.ts b/backend/src/v1/routes/dashboard/employer-metrics-routes.ts new file mode 100644 index 00000000..3aa40902 --- /dev/null +++ b/backend/src/v1/routes/dashboard/employer-metrics-routes.ts @@ -0,0 +1,22 @@ +import { Router } from 'express'; +import { logger } from '../../../logger'; +import { employerService } from '../../services/employer-service'; + +const router = Router(); + +/** + * Get employer metrics + */ +router.get('/employer-metrics', async (req, res) => { + try { + const metrics = await employerService.getEmployerMetrics(); + res.json(metrics); + } catch (error) { + logger.error(error); + res.status(500).send({ + error: 'An error occurred while fetching the employer metrics', + }); + } +}); + +export default router; diff --git a/backend/src/v1/routes/dashboard/index.ts b/backend/src/v1/routes/dashboard/index.ts index 12bced86..82caec89 100644 --- a/backend/src/v1/routes/dashboard/index.ts +++ b/backend/src/v1/routes/dashboard/index.ts @@ -1,10 +1,12 @@ import { Router } from 'express'; import announcementMetricsRouter from './announcement-metrics-routes'; +import employerMetricsRouter from './employer-metrics-routes'; import reportsMetricsRouter from './report-metrics-routes'; const router = Router(); router.use(announcementMetricsRouter); router.use(reportsMetricsRouter); +router.use(employerMetricsRouter); export default router; diff --git a/backend/src/v1/services/employer-service.spec.ts b/backend/src/v1/services/employer-service.spec.ts new file mode 100644 index 00000000..6b5e4269 --- /dev/null +++ b/backend/src/v1/services/employer-service.spec.ts @@ -0,0 +1,30 @@ +import { employerService } from '../services/employer-service'; +import { EmployerMetrics } from '../types/employers'; + +const mockCountPayTransparencyCompanies = jest.fn(); + +jest.mock('../prisma/prisma-client-readonly-replica', () => ({ + __esModule: true, + ...jest.requireActual('../prisma/prisma-client-readonly-replica'), + default: { + pay_transparency_company: { + count: () => mockCountPayTransparencyCompanies(), + }, + }, +})); + +describe('employer-service', () => { + describe('getEmployerMetrics', () => { + it('delegates request to the database', async () => { + const numCompaniesLoggedOnToDate = 16; + mockCountPayTransparencyCompanies.mockResolvedValue( + numCompaniesLoggedOnToDate, + ); + const employerMetrics: EmployerMetrics = + await employerService.getEmployerMetrics(); + expect(employerMetrics.num_employers_logged_on_to_date).toBe( + numCompaniesLoggedOnToDate, + ); + }); + }); +}); diff --git a/backend/src/v1/services/employer-service.ts b/backend/src/v1/services/employer-service.ts new file mode 100644 index 00000000..e3c116d9 --- /dev/null +++ b/backend/src/v1/services/employer-service.ts @@ -0,0 +1,16 @@ +import prismaReadOnlyReplica from '../prisma/prisma-client-readonly-replica'; +import { EmployerMetrics } from '../types/employers'; + +export const employerService = { + /** + * Get employer metrics + * @returns EmployerMetrics + */ + async getEmployerMetrics(): Promise { + const numEmployers = + await prismaReadOnlyReplica.pay_transparency_company.count(); + return { + num_employers_logged_on_to_date: numEmployers, + }; + }, +}; diff --git a/backend/src/v1/types/employers.ts b/backend/src/v1/types/employers.ts new file mode 100644 index 00000000..bdb54b2b --- /dev/null +++ b/backend/src/v1/types/employers.ts @@ -0,0 +1,3 @@ +export type EmployerMetrics = { + num_employers_logged_on_to_date: number; +}; From a2ff3192cb95c46c61f59e364646115e38da7e92 Mon Sep 17 00:00:00 2001 From: jer3k <99355997+jer3k@users.noreply.github.com> Date: Thu, 26 Sep 2024 10:45:07 -0700 Subject: [PATCH 3/6] docs: GEO-1125 Announcement diagrams (#782) --- .../architecture/announcements/states.mermaid | 18 ++++++++ .../announcements/states.mermaid.png | Bin 0 -> 36294 bytes .../announcements/user-actions.mermaid | 42 ++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 .diagrams/architecture/announcements/states.mermaid create mode 100644 .diagrams/architecture/announcements/states.mermaid.png create mode 100644 .diagrams/architecture/announcements/user-actions.mermaid diff --git a/.diagrams/architecture/announcements/states.mermaid b/.diagrams/architecture/announcements/states.mermaid new file mode 100644 index 00000000..37963cb2 --- /dev/null +++ b/.diagrams/architecture/announcements/states.mermaid @@ -0,0 +1,18 @@ +--- +config: + layout: elk + elk: + nodePlacementStrategy: NETWORK_SIMPLEX +--- +flowchart TD + draft>draft] + published>published] + expired>expired] + archived>archived] + + published --Save as--> published + expired & draft --Save as--> draft & published + published -.On
expires_on
date.-> expired + draft & published & expired --Archive--> archived + archived & expired -.Scheduled
task.-> delete[Delete after 90 days] + published --Unpublish--> draft \ No newline at end of file diff --git a/.diagrams/architecture/announcements/states.mermaid.png b/.diagrams/architecture/announcements/states.mermaid.png new file mode 100644 index 0000000000000000000000000000000000000000..2ab83db349a6c7d25ea0a665fe43453119f3866c GIT binary patch literal 36294 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:fa-file Draft Announcement] + published>fa:fa-file Published Announcement] + expired>fa:fa-file Expired Announcement] + archive>fa:fa-file Archived Announcement] + + %% New workflow + start --> choice + choice --New--> saveAny + + %% Existing workflows + choice --Edit draft/expired--> saveAny + choice --Edit published--> saveP + choice --Unpublish--> draft + published -.Scheduled task.-> email + + %% Saving workflow + saveAny --Draft--> draft + saveAny & saveP --Publish--> published + published -.After active_on date.-> active + + %% Expire workflows + published -.Scheduled task.-> scheduleExpire + scheduleExpire --> expired + + %% Delete workflows + choice --Archive--> archive + expired & archive -.Scheduled task.-> scheduleDelete \ No newline at end of file From 7d53a4cdf75e9ee5a16a6d2c64c01765f7a803d0 Mon Sep 17 00:00:00 2001 From: Goeme Nthomiwa Date: Thu, 26 Sep 2024 13:17:35 -0700 Subject: [PATCH 4/6] fix: Geo 1152 code review export objects instead of each method (#784) --- .../expire-announcements-scheduler.spec.ts | 2 +- .../expire-announcements-scheduler.ts | 4 +- .../src/v1/middlewares/validations/index.ts | 6 +- .../routes/admin-user-invites-routes.spec.ts | 22 +- .../v1/routes/admin-user-invites-routes.ts | 15 +- backend/src/v1/routes/analytic-routes.spec.ts | 5 +- backend/src/v1/routes/analytic-routes.ts | 4 +- .../src/v1/routes/announcement-routes.spec.ts | 14 +- backend/src/v1/routes/announcement-routes.ts | 18 +- .../announcement-metrics-routes.spec.ts | 4 +- .../dashboard/announcement-metrics-routes.ts | 4 +- backend/src/v1/routes/dashboard/index.spec.ts | 2 +- .../admin-user-invites-service.spec.ts | 19 +- .../v1/services/admin-user-invites-service.ts | 243 +++--- .../src/v1/services/analytic-service.spec.ts | 9 +- backend/src/v1/services/analytic-service.ts | 71 +- .../v1/services/announcements-service.spec.ts | 65 +- .../src/v1/services/announcements-service.ts | 737 +++++++++--------- .../src/v1/services/scheduler-service.spec.ts | 4 +- backend/src/v1/services/scheduler-service.ts | 4 +- backend/src/v1/services/utils-service.ts | 1 - 21 files changed, 634 insertions(+), 619 deletions(-) diff --git a/backend/src/schedulers/expire-announcements-scheduler.spec.ts b/backend/src/schedulers/expire-announcements-scheduler.spec.ts index 11ff98f9..743876bf 100644 --- a/backend/src/schedulers/expire-announcements-scheduler.spec.ts +++ b/backend/src/schedulers/expire-announcements-scheduler.spec.ts @@ -9,7 +9,7 @@ jest.mock('../v1/services/utils-service', () => ({ const mockExpireAnnouncements = jest.fn(); jest.mock('../v1/services/announcements-service', () => ({ - expireAnnouncements: () => mockExpireAnnouncements(), + announcementService: { expireAnnouncements: () => mockExpireAnnouncements() }, })); jest.mock('cron', () => ({ diff --git a/backend/src/schedulers/expire-announcements-scheduler.ts b/backend/src/schedulers/expire-announcements-scheduler.ts index a35bcadb..8bfce380 100644 --- a/backend/src/schedulers/expire-announcements-scheduler.ts +++ b/backend/src/schedulers/expire-announcements-scheduler.ts @@ -1,7 +1,7 @@ import advisoryLock from 'advisory-lock'; import { config } from '../config'; import { logger as log } from '../logger'; -import { expireAnnouncements } from '../v1/services/announcements-service'; +import { announcementService } from '../v1/services/announcements-service'; import { createJob } from './create-job'; const mutex = advisoryLock(config.get('server:databaseUrl'))( @@ -14,7 +14,7 @@ export default createJob( crontime, async () => { log.info('Starting expireAnnounements scheduled job.'); - await expireAnnouncements(); + await announcementService.expireAnnouncements(); log.info('Completed expireAnnounements scheduled job.'); }, mutex, diff --git a/backend/src/v1/middlewares/validations/index.ts b/backend/src/v1/middlewares/validations/index.ts index f61ea188..6c0fcc5c 100644 --- a/backend/src/v1/middlewares/validations/index.ts +++ b/backend/src/v1/middlewares/validations/index.ts @@ -7,7 +7,7 @@ export type UseValidateOptions = { schema: ZodSchema; }; -export const useValidate = ({ mode, schema }: UseValidateOptions) => { +export const useValidate = ({ mode, schema, }: UseValidateOptions) => { return async (req: Request, res: Response, next: NextFunction) => { try { const data = req[mode]; @@ -15,7 +15,9 @@ export const useValidate = ({ mode, schema }: UseValidateOptions) => { req[mode] = results; next(); } catch (error) { - logger.error(error); + const { path, method } = req; + const errorMessage = `${method} - ${path} - Data validation failed`; + logger.error(errorMessage, error); return next(error); } }; diff --git a/backend/src/v1/routes/admin-user-invites-routes.spec.ts b/backend/src/v1/routes/admin-user-invites-routes.spec.ts index 8d4ca2ab..d8bca08f 100644 --- a/backend/src/v1/routes/admin-user-invites-routes.spec.ts +++ b/backend/src/v1/routes/admin-user-invites-routes.spec.ts @@ -1,17 +1,21 @@ import { faker } from '@faker-js/faker'; -import { Application } from 'express'; +import express, { Application } from 'express'; +import bodyParser from 'body-parser'; import request from 'supertest'; import { UserInputError } from '../types/errors'; +import routes from '../routes/admin-user-invites-routes'; const mockDeleteInvite = jest.fn(); const mockGetPendingInvites = jest.fn(); const mockCreateInvite = jest.fn(); const mockResendInvite = jest.fn(); jest.mock('../services/admin-user-invites-service', () => ({ - deleteInvite: (...args) => mockDeleteInvite(...args), - getPendingInvites: (...args) => mockGetPendingInvites(...args), - createInvite: (...args) => mockCreateInvite(...args), - resendInvite: (...args) => mockResendInvite(...args), + adminUserInvitesService: { + deleteInvite: (...args) => mockDeleteInvite(...args), + getPendingInvites: (...args) => mockGetPendingInvites(...args), + createInvite: (...args) => mockCreateInvite(...args), + resendInvite: (...args) => mockResendInvite(...args), + }, })); jest.mock('../middlewares/authorization/authorize', () => ({ @@ -38,10 +42,10 @@ describe('admin-user-invites-routes', () => { beforeEach(() => { jest.clearAllMocks(); - app = require('express')(); - app.use(require('body-parser').json()); - app.use(require('../routes/admin-user-invites-routes').default); - app.use((err, req, res, next) => { + app = express(); + app.use(bodyParser.json()); + app.use(routes); + app.use((err, req, res, _) => { res.status(400).send({ error: err.message }); }); }); diff --git a/backend/src/v1/routes/admin-user-invites-routes.ts b/backend/src/v1/routes/admin-user-invites-routes.ts index 7ccfcb75..973a44b1 100644 --- a/backend/src/v1/routes/admin-user-invites-routes.ts +++ b/backend/src/v1/routes/admin-user-invites-routes.ts @@ -8,12 +8,7 @@ import { AddNewUserSchema, AddNewUserType, } from '../middlewares/validations/schemas'; -import { - createInvite, - deleteInvite, - getPendingInvites, - resendInvite, -} from '../services/admin-user-invites-service'; +import { adminUserInvitesService } from '../services/admin-user-invites-service'; import { utils } from '../services/utils-service'; import { UserInputError } from '../types/errors'; @@ -22,7 +17,7 @@ router.use(authorize([PTRT_ADMIN_ROLE_NAME])); router.get('', async (req: Request, res: Response) => { try { - const invites = await getPendingInvites(); + const invites = await adminUserInvitesService.getPendingInvites(); return res.status(200).json(invites); } catch (error) { logger.error(error); @@ -39,7 +34,7 @@ router.post( const userInfo = utils.getSessionUser(req); const jwtPayload = jsonwebtoken.decode(userInfo.jwt) as JwtPayload; const idirUserGuid = jwtPayload?.idir_user_guid; - await createInvite( + await adminUserInvitesService.createInvite( email.trim().toLowerCase(), role, firstName, @@ -59,7 +54,7 @@ router.post( router.patch('/:id', async (req: Request, res: Response) => { try { const { id } = req.params; - await resendInvite(id); + await adminUserInvitesService.resendInvite(id); return res.status(200).json({ message: 'Invite resent' }); } catch (error) { logger.error(error); @@ -70,7 +65,7 @@ router.patch('/:id', async (req: Request, res: Response) => { router.delete('/:id', async (req: Request, res: Response) => { try { const { id } = req.params; - await deleteInvite(id); + await adminUserInvitesService.deleteInvite(id); return res.status(200).json({ message: 'Invite deleted' }); } catch (error) { logger.error(error); diff --git a/backend/src/v1/routes/analytic-routes.spec.ts b/backend/src/v1/routes/analytic-routes.spec.ts index ebfbe5c1..33e79509 100644 --- a/backend/src/v1/routes/analytic-routes.spec.ts +++ b/backend/src/v1/routes/analytic-routes.spec.ts @@ -8,7 +8,10 @@ jest.mock('../services/analytic-service', () => { const actual = jest.requireActual('../services/analytic-service'); return { ...actual, - getEmbedInfo: (...args) => mockGetEmbedInfo(...args), + analyticsService: { + ...actual.analyticsService, + getEmbedInfo: (...args) => mockGetEmbedInfo(...args), + }, }; }); diff --git a/backend/src/v1/routes/analytic-routes.ts b/backend/src/v1/routes/analytic-routes.ts index 65bab988..365edf6d 100644 --- a/backend/src/v1/routes/analytic-routes.ts +++ b/backend/src/v1/routes/analytic-routes.ts @@ -1,6 +1,6 @@ import { Router, Request, Response } from 'express'; import { - getEmbedInfo, + analyticsService, PowerBiResourceName, } from '../services/analytic-service'; import { utils } from '../services/utils-service'; @@ -23,7 +23,7 @@ router.get( req: Request, res: Response, ) => { - const info = await getEmbedInfo(req.query.resources); + const info = await analyticsService.getEmbedInfo(req.query.resources); return res.status(200).json(info); }, diff --git a/backend/src/v1/routes/announcement-routes.spec.ts b/backend/src/v1/routes/announcement-routes.spec.ts index a0286161..465813bf 100644 --- a/backend/src/v1/routes/announcement-routes.spec.ts +++ b/backend/src/v1/routes/announcement-routes.spec.ts @@ -16,13 +16,15 @@ const mockCreateAnnouncement = jest.fn(); const mockUpdateAnnouncement = jest.fn(); const mockGetAnnouncementById = jest.fn(); jest.mock('../services/announcements-service', () => ({ - getAnnouncements: (...args) => { - return mockGetAnnouncements(...args); + announcementService: { + getAnnouncements: (...args) => { + return mockGetAnnouncements(...args); + }, + patchAnnouncements: (...args) => mockPatchAnnouncements(...args), + createAnnouncement: (...args) => mockCreateAnnouncement(...args), + updateAnnouncement: (...args) => mockUpdateAnnouncement(...args), + getAnnouncementById: (...args) => mockGetAnnouncementById(...args), }, - patchAnnouncements: (...args) => mockPatchAnnouncements(...args), - createAnnouncement: (...args) => mockCreateAnnouncement(...args), - updateAnnouncement: (...args) => mockUpdateAnnouncement(...args), - getAnnouncementById: (...args) => mockGetAnnouncementById(...args), })); const mockAuthenticateAdmin = jest.fn(); diff --git a/backend/src/v1/routes/announcement-routes.ts b/backend/src/v1/routes/announcement-routes.ts index 7b692597..e747baf7 100644 --- a/backend/src/v1/routes/announcement-routes.ts +++ b/backend/src/v1/routes/announcement-routes.ts @@ -7,13 +7,7 @@ import { authenticateAdmin } from '../middlewares/authorization/authenticate-adm import { authorize } from '../middlewares/authorization/authorize'; import { useUpload } from '../middlewares/storage/upload'; import { useValidate } from '../middlewares/validations'; -import { - createAnnouncement, - getAnnouncementById, - getAnnouncements, - patchAnnouncements, - updateAnnouncement, -} from '../services/announcements-service'; +import { announcementService } from '../services/announcements-service'; import { ExtendedRequest } from '../types'; import { AnnouncementDataSchema, @@ -66,7 +60,7 @@ router.get( try { // Query parameters are validated const query: AnnouncementQueryType = req.query; - const announcements = await getAnnouncements(query); + const announcements = await announcementService.getAnnouncements(query); res.status(200).json(announcements); } catch (error) { logger.error(error); @@ -102,7 +96,7 @@ router.patch( `Only the following statuses are supported: ${supportedStatuses}`, ); } - await patchAnnouncements(data, user.admin_user_id); + await announcementService.patchAnnouncements(data, user.admin_user_id); res .status(201) .json({ message: `Updated the status of the announcement(s)` }); @@ -130,7 +124,7 @@ router.post( // Request body is validated const data = req.body; // Create announcement - const announcement = await createAnnouncement(data, user.admin_user_id); + const announcement = await announcementService.createAnnouncement(data, user.admin_user_id); res.status(201).json(announcement); } catch (error) { logger.error(error); @@ -156,7 +150,7 @@ router.put( // Request body is validated const { file, ...data } = req.body; // Create announcement - const announcement = await updateAnnouncement( + const announcement = await announcementService.updateAnnouncement( req.params.id, data, user.admin_user_id, @@ -171,7 +165,7 @@ router.put( router.get('/:id', authenticateAdmin(), async (req: Request, res) => { try { - const announcement = await getAnnouncementById(req.params.id); + const announcement = await announcementService.getAnnouncementById(req.params.id); return res.json(announcement); } catch (error) { logger.error(error); diff --git a/backend/src/v1/routes/dashboard/announcement-metrics-routes.spec.ts b/backend/src/v1/routes/dashboard/announcement-metrics-routes.spec.ts index 07ef329b..e15b9809 100644 --- a/backend/src/v1/routes/dashboard/announcement-metrics-routes.spec.ts +++ b/backend/src/v1/routes/dashboard/announcement-metrics-routes.spec.ts @@ -5,7 +5,9 @@ import router from './announcement-metrics-routes'; let app: Application; const getAnnouncementMetricsMock = jest.fn(); jest.mock('../../services/announcements-service', () => ({ - getAnnouncementMetrics: (...args) => getAnnouncementMetricsMock(...args), + announcementService: { + getAnnouncementMetrics: (...args) => getAnnouncementMetricsMock(...args), + }, })); describe('announcement-metrics-routes', () => { beforeEach(() => { diff --git a/backend/src/v1/routes/dashboard/announcement-metrics-routes.ts b/backend/src/v1/routes/dashboard/announcement-metrics-routes.ts index 197c7dc8..66c83965 100644 --- a/backend/src/v1/routes/dashboard/announcement-metrics-routes.ts +++ b/backend/src/v1/routes/dashboard/announcement-metrics-routes.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { getAnnouncementMetrics } from '../../services/announcements-service'; +import { announcementService } from '../../services/announcements-service'; import { logger } from '../../../logger'; const router = Router(); @@ -9,7 +9,7 @@ const router = Router(); */ router.get('/announcement-metrics', async (req, res) => { try { - const metrics = await getAnnouncementMetrics(); + const metrics = await announcementService.getAnnouncementMetrics(); res.json(metrics); } catch (error) { diff --git a/backend/src/v1/routes/dashboard/index.spec.ts b/backend/src/v1/routes/dashboard/index.spec.ts index 90dba4ef..37a52d36 100644 --- a/backend/src/v1/routes/dashboard/index.spec.ts +++ b/backend/src/v1/routes/dashboard/index.spec.ts @@ -3,7 +3,7 @@ import request from 'supertest'; import router from '.'; jest.mock('../../services/announcements-service', () => ({ - getAnnouncementMetrics: jest.fn(), + announcementService: { getAnnouncementMetrics: jest.fn() }, })); jest.mock('../../services/admin-report-service', () => ({ adminReportService: { diff --git a/backend/src/v1/services/admin-user-invites-service.spec.ts b/backend/src/v1/services/admin-user-invites-service.spec.ts index 37917b36..57ada97d 100644 --- a/backend/src/v1/services/admin-user-invites-service.spec.ts +++ b/backend/src/v1/services/admin-user-invites-service.spec.ts @@ -5,10 +5,7 @@ import { } from '../../constants/admin'; import { UserInputError } from '../types/errors'; import { - createInvite, - deleteInvite, - getPendingInvites, - resendInvite, + adminUserInvitesService, } from './admin-user-invites-service'; const mockCreate = jest.fn(); @@ -52,7 +49,7 @@ describe('admin-user-invite-service', () => { describe('createInvite', () => { describe('when invitation does not exist', () => { it('should send a new invitation', async () => { - await createInvite( + await adminUserInvitesService.createInvite( faker.internet.email(), PTRT_USER_ROLE_NAME, faker.internet.userName(), @@ -63,7 +60,7 @@ describe('admin-user-invite-service', () => { }); it('should send a new invitation for admin', async () => { - await createInvite( + await adminUserInvitesService.createInvite( faker.internet.email(), PTRT_ADMIN_ROLE_NAME, faker.internet.userName(), @@ -81,7 +78,7 @@ describe('admin-user-invite-service', () => { describe('when invitation exists', () => { it('should send a update invitation and send email', async () => { mockOnboardingFindFirst.mockResolvedValue({}); - await createInvite( + await adminUserInvitesService.createInvite( faker.internet.email(), PTRT_ADMIN_ROLE_NAME, faker.internet.userName(), @@ -95,7 +92,7 @@ describe('admin-user-invite-service', () => { it('should throw a UserInputError', async () => { mockAdminUserFindFirst.mockResolvedValue({}); await expect( - createInvite( + adminUserInvitesService.createInvite( faker.internet.email(), PTRT_ADMIN_ROLE_NAME, faker.internet.userName(), @@ -113,7 +110,7 @@ describe('admin-user-invite-service', () => { const userInvites = [{ id: '1' }, { id: '2' }]; mockFindMany.mockResolvedValue(userInvites); - const result = await getPendingInvites(); + const result = await adminUserInvitesService.getPendingInvites(); expect(result).toEqual(userInvites); expect(mockFindMany).toHaveBeenCalledTimes(1); @@ -132,7 +129,7 @@ describe('admin-user-invite-service', () => { const deletedInvite = { id }; mockDelete.mockResolvedValue(deletedInvite); - const result = await deleteInvite(id); + const result = await adminUserInvitesService.deleteInvite(id); expect(result).toEqual(deletedInvite); expect(mockDelete).toHaveBeenCalledTimes(1); @@ -147,7 +144,7 @@ describe('admin-user-invite-service', () => { describe('resendInvite', () => { it('should send a new invitation', async () => { mockFindUniqueOrThrow.mockResolvedValue({}); - await resendInvite(faker.string.uuid()); + await adminUserInvitesService.resendInvite(faker.string.uuid()); expect(mockSendEmailWithRetry).toHaveBeenCalledTimes(1); }); }); diff --git a/backend/src/v1/services/admin-user-invites-service.ts b/backend/src/v1/services/admin-user-invites-service.ts index 077bf345..c1f17a68 100644 --- a/backend/src/v1/services/admin-user-invites-service.ts +++ b/backend/src/v1/services/admin-user-invites-service.ts @@ -9,142 +9,143 @@ import emailService from '../../external/services/ches'; import { EMAIL_TEMPLATES } from '../email-templates'; import prisma from '../prisma/prisma-client'; import { UserInputError } from '../types/errors'; -/** - * Create a new user invite and send email to user - * @param email - * @param role - * @param firstname - * @param createdBy - */ -const createInvite = async ( - email: string, - role: string, - firstname: string, - createdBy: string, -) => { - const existingActiveUser = await prisma.admin_user.findFirst({ - where: { - email: email, - is_active: true, - }, - }); - if (existingActiveUser) { - throw new UserInputError( - `There is already a user associated with email address '${email}'.`, + +export const adminUserInvitesService = { + /** + * Create a new user invite and send email to user + * @param email + * @param role + * @param firstname + * @param createdBy + */ + async createInvite( + email: string, + role: string, + firstname: string, + createdBy: string, + ) { + const existingActiveUser = await prisma.admin_user.findFirst({ + where: { + email: email, + is_active: true, + }, + }); + if (existingActiveUser) { + throw new UserInputError( + `There is already a user associated with email address '${email}'.`, + ); + } + const pendingUserRequest = await prisma.admin_user_onboarding.findFirst({ + where: { + email: email, + is_onboarded: false, + expiry_date: { lte: convert(ZonedDateTime.now(ZoneId.UTC)).toDate() }, + }, + }); + + const expiryDate = convert( + ZonedDateTime.now(ZoneId.UTC).plusHours( + config.get('server:adminInvitationDurationInHours'), + ), + ).toDate(); + + if (pendingUserRequest) { + await prisma.admin_user_onboarding.update({ + where: { + admin_user_onboarding_id: pendingUserRequest.admin_user_onboarding_id, + }, + data: { + expiry_date: expiryDate, + }, + }); + } else { + const roles = + role === PTRT_ADMIN_ROLE_NAME + ? [PTRT_ADMIN_ROLE_NAME, PTRT_USER_ROLE_NAME] + : [PTRT_USER_ROLE_NAME]; + await prisma.admin_user_onboarding.create({ + data: { + email: email, + first_name: firstname, + assigned_roles: roles.join(','), + is_onboarded: false, + created_by: createdBy, + expiry_date: expiryDate, + }, + }); + } + await this.sendUserEmailInvite(email, firstname); + }, + + /** + * Resend a user invite email to user and reset expiry date + * @param invitationId + */ + async resendInvite(invitationId: string) { + const pendingUserRequest = + await prisma.admin_user_onboarding.findUniqueOrThrow({ + where: { + admin_user_onboarding_id: invitationId, + }, + }); + + await this.sendUserEmailInvite( + pendingUserRequest.email, + pendingUserRequest.first_name, ); - } - const pendingUserRequest = await prisma.admin_user_onboarding.findFirst({ - where: { - email: email, - is_onboarded: false, - expiry_date: { lte: convert(ZonedDateTime.now(ZoneId.UTC)).toDate() }, - }, - }); - const expiryDate = convert( - ZonedDateTime.now(ZoneId.UTC).plusHours( - config.get('server:adminInvitationDurationInHours'), - ), - ).toDate(); + const expiryDate = convert( + ZonedDateTime.now(ZoneId.UTC).plusHours( + config.get('server:adminInvitationDurationInHours'), + ), + ).toDate(); - if (pendingUserRequest) { await prisma.admin_user_onboarding.update({ where: { - admin_user_onboarding_id: pendingUserRequest.admin_user_onboarding_id, + admin_user_onboarding_id: invitationId, }, data: { expiry_date: expiryDate, }, }); - } else { - const roles = - role === PTRT_ADMIN_ROLE_NAME - ? [PTRT_ADMIN_ROLE_NAME, PTRT_USER_ROLE_NAME] - : [PTRT_USER_ROLE_NAME]; - await prisma.admin_user_onboarding.create({ - data: { - email: email, - first_name: firstname, - assigned_roles: roles.join(','), + }, + + async sendUserEmailInvite(email: string, firstname: string) { + const htmlEmail = emailService?.generateHtmlEmail( + EMAIL_TEMPLATES.USER_INVITE.subject, + [email], + EMAIL_TEMPLATES.USER_INVITE.title, + '', + EMAIL_TEMPLATES.USER_INVITE.body(firstname), + ); + await emailService?.sendEmailWithRetry(htmlEmail, 3); + }, + + /** + * Get all pending user invites + * @returns {Promise} Promise object represents the list of pending user invites + */ + async getPendingInvites(): Promise { + const userInvites = await prisma.admin_user_onboarding.findMany({ + where: { is_onboarded: false, - created_by: createdBy, - expiry_date: expiryDate, + expiry_date: { gt: new Date() }, }, }); - } - await sendUserEmailInvite(email, firstname); -}; + return userInvites; + }, -/** - * Resend a user invite email to user and reset expiry date - * @param invitationId - */ -const resendInvite = async (invitationId: string) => { - const pendingUserRequest = - await prisma.admin_user_onboarding.findUniqueOrThrow({ + /** + * Delete a user invite + * @param {string} id - The id of the user invite to delete + * @returns {Promise} Promise object represents the deleted user invite + */ + async deleteInvite(id: string): Promise { + const deletedInvite = await prisma.admin_user_onboarding.delete({ where: { - admin_user_onboarding_id: invitationId, + admin_user_onboarding_id: id, }, }); - - await sendUserEmailInvite( - pendingUserRequest.email, - pendingUserRequest.first_name, - ); - - const expiryDate = convert( - ZonedDateTime.now(ZoneId.UTC).plusHours( - config.get('server:adminInvitationDurationInHours'), - ), - ).toDate(); - - await prisma.admin_user_onboarding.update({ - where: { - admin_user_onboarding_id: invitationId, - }, - data: { - expiry_date: expiryDate, - }, - }); -}; - -const sendUserEmailInvite = async (email: string, firstname: string) => { - const htmlEmail = emailService?.generateHtmlEmail( - EMAIL_TEMPLATES.USER_INVITE.subject, - [email], - EMAIL_TEMPLATES.USER_INVITE.title, - '', - EMAIL_TEMPLATES.USER_INVITE.body(firstname), - ); - await emailService?.sendEmailWithRetry(htmlEmail, 3); -}; - -/** - * Get all pending user invites - * @returns {Promise} Promise object represents the list of pending user invites - */ -const getPendingInvites = async (): Promise => { - const userInvites = await prisma.admin_user_onboarding.findMany({ - where: { - is_onboarded: false, - expiry_date: { gt: new Date() }, - }, - }); - return userInvites; -}; - -/** - * Delete a user invite - * @param {string} id - The id of the user invite to delete - * @returns {Promise} Promise object represents the deleted user invite - */ -const deleteInvite = async (id: string): Promise => { - const deletedInvite = await prisma.admin_user_onboarding.delete({ - where: { - admin_user_onboarding_id: id, - }, - }); - return deletedInvite; + return deletedInvite; + }, }; - -export { createInvite, deleteInvite, getPendingInvites, resendInvite }; diff --git a/backend/src/v1/services/analytic-service.spec.ts b/backend/src/v1/services/analytic-service.spec.ts index 4c24cdf1..ccb65141 100644 --- a/backend/src/v1/services/analytic-service.spec.ts +++ b/backend/src/v1/services/analytic-service.spec.ts @@ -1,5 +1,5 @@ import { - getEmbedInfo, + analyticsService, PowerBiEmbedInfo, PowerBiResourceName, } from './analytic-service'; @@ -47,11 +47,16 @@ describe('getEmbedInfo', () => { ], embedToken: { token: output.accessToken, expiration: output.expiry }, }); - const json = await getEmbedInfo([ + const json = await analyticsService.getEmbedInfo([ PowerBiResourceName.SubmissionAnalytics, PowerBiResourceName.DataAnalytics, ]); expect(mockGetEmbedParamsForReports).toHaveBeenCalledTimes(1); expect(json).toMatchObject(output); }); + + it('should throw error if invalid resource names', async () => { + await expect(analyticsService.getEmbedInfo([PowerBiResourceName.SubmissionAnalytics, 'invalid' as any])).rejects.toThrow('Invalid resource names'); + expect(mockGetEmbedParamsForReports).not.toHaveBeenCalled(); + }); }); diff --git a/backend/src/v1/services/analytic-service.ts b/backend/src/v1/services/analytic-service.ts index 5bb23efb..35dc9f6c 100644 --- a/backend/src/v1/services/analytic-service.ts +++ b/backend/src/v1/services/analytic-service.ts @@ -32,36 +32,47 @@ const resourceIds: Record = { }, }; -/** - * Generate embed token and embed urls for PowerBi resources - * @return Details like Embed URL, Access token and Expiry - */ -export async function getEmbedInfo( - resourceNames: PowerBiResourceName[], -): Promise { - const powerBi = new PowerBiService( - config.get('powerbi:powerBiUrl'), - config.get('entra:clientId'), - config.get('entra:clientSecret'), - config.get('entra:tenantId'), - ); +export const analyticsService = { + /** + * Generate embed token and embed urls for PowerBi resources + * @return Details like Embed URL, Access token and Expiry + */ + async getEmbedInfo( + resourceNames: PowerBiResourceName[], + ): Promise { + if ( + !Array.isArray(resourceNames) || + !resourceNames.every((name) => + Object.values(PowerBiResourceName).includes(name), + ) + ) { + throw new Error('Invalid resource names'); + } - const embedParams = await powerBi.getEmbedParamsForReports( - resourceNames.map((name) => resourceIds[name]), - ); + const powerBi = new PowerBiService( + config.get('powerbi:powerBiUrl'), + config.get('entra:clientId'), + config.get('entra:clientSecret'), + config.get('entra:tenantId'), + ); - const resources = []; - for (let i = 0; i < resourceNames.length; ++i) { - resources.push({ - name: resourceNames[i], - id: embedParams.resources[i].id, - embedUrl: embedParams.resources[i].embedUrl, - }); - } + const embedParams = await powerBi.getEmbedParamsForReports( + resourceNames.map((name) => resourceIds[name]), + ); - return { - resources: resources, - accessToken: embedParams.embedToken.token, - expiry: embedParams.embedToken.expiration, - }; -} + const resources = []; + for (let i = 0; i < resourceNames.length; ++i) { + resources.push({ + name: resourceNames[i], + id: embedParams.resources[i].id, + embedUrl: embedParams.resources[i].embedUrl, + }); + } + + return { + resources: resources, + accessToken: embedParams.embedToken.token, + expiry: embedParams.embedToken.expiration, + }; + }, +}; diff --git a/backend/src/v1/services/announcements-service.spec.ts b/backend/src/v1/services/announcements-service.spec.ts index 294db4b4..402ed46b 100644 --- a/backend/src/v1/services/announcements-service.spec.ts +++ b/backend/src/v1/services/announcements-service.spec.ts @@ -5,10 +5,9 @@ import { AnnouncementStatus, } from '../types/announcements'; import { UserInputError } from '../types/errors'; -import * as AnnouncementService from './announcements-service'; +import { announcementService } from './announcements-service'; import { utils } from './utils-service'; import { LocalDateTime, ZonedDateTime, ZoneId } from '@js-joda/core'; -import { getAnnouncementMetrics, updateAnnouncement } from './announcements-service'; const mockFindMany = jest.fn().mockResolvedValue([ { @@ -96,7 +95,7 @@ describe('AnnouncementsService', () => { describe('getAnnouncements', () => { describe('when no query is provided', () => { it('should return announcements', async () => { - const announcements = await AnnouncementService.getAnnouncements(); + const announcements = await announcementService.getAnnouncements(); expect(announcements.items).toHaveLength(2); expect(announcements.total).toBe(2); expect(announcements.offset).toBe(0); @@ -119,7 +118,7 @@ describe('AnnouncementsService', () => { describe('when filters are provided', () => { describe('when title is provided', () => { it('should return announcements', async () => { - await AnnouncementService.getAnnouncements({ + await announcementService.getAnnouncements({ filters: [ { key: 'title', operation: 'like', value: 'Announcement 1' }, ], @@ -143,7 +142,7 @@ describe('AnnouncementsService', () => { describe('when active_on filter is provided', () => { describe('when operation is "between"', () => { it('should return announcements', async () => { - await AnnouncementService.getAnnouncements({ + await announcementService.getAnnouncements({ filters: [ { key: 'active_on', @@ -165,7 +164,7 @@ describe('AnnouncementsService', () => { }); describe('when operation is "lte"', () => { it('should return announcements', async () => { - await AnnouncementService.getAnnouncements({ + await announcementService.getAnnouncements({ filters: [ { key: 'active_on', @@ -185,7 +184,7 @@ describe('AnnouncementsService', () => { }); describe('when operation is "gt"', () => { it('should return announcements', async () => { - await AnnouncementService.getAnnouncements({ + await announcementService.getAnnouncements({ filters: [ { key: 'active_on', @@ -207,7 +206,7 @@ describe('AnnouncementsService', () => { describe('when expires_on filter is provided', () => { describe('when operation is "between"', () => { it('should return announcements', async () => { - await AnnouncementService.getAnnouncements({ + await announcementService.getAnnouncements({ filters: [ { key: 'expires_on', @@ -229,7 +228,7 @@ describe('AnnouncementsService', () => { }); describe('when operation is "lte"', () => { it('should return announcements', async () => { - await AnnouncementService.getAnnouncements({ + await announcementService.getAnnouncements({ filters: [ { key: 'expires_on', @@ -249,7 +248,7 @@ describe('AnnouncementsService', () => { }); describe('when operation is "gt"', () => { it('should return announcements', async () => { - await AnnouncementService.getAnnouncements({ + await announcementService.getAnnouncements({ filters: [ { key: 'expires_on', @@ -279,7 +278,7 @@ describe('AnnouncementsService', () => { describe('when status filter is provided', () => { describe('in operation', () => { it('should return announcements', async () => { - await AnnouncementService.getAnnouncements({ + await announcementService.getAnnouncements({ filters: [ { key: 'status', @@ -300,7 +299,7 @@ describe('AnnouncementsService', () => { describe('notin operation', () => { it('should return announcements', async () => { - await AnnouncementService.getAnnouncements({ + await announcementService.getAnnouncements({ filters: [ { key: 'status', @@ -323,7 +322,7 @@ describe('AnnouncementsService', () => { describe('when sort is provided', () => { it('should return announcements', async () => { - await AnnouncementService.getAnnouncements({ + await announcementService.getAnnouncements({ sort: [{ field: 'title', order: 'asc' }], }); expect(mockFindMany).toHaveBeenCalledWith( @@ -336,7 +335,7 @@ describe('AnnouncementsService', () => { describe('when limit is provided', () => { it('should return announcements', async () => { - await AnnouncementService.getAnnouncements({ limit: 5 }); + await announcementService.getAnnouncements({ limit: 5 }); expect(mockFindMany).toHaveBeenCalledWith( expect.objectContaining({ take: 5, @@ -347,7 +346,7 @@ describe('AnnouncementsService', () => { describe('when offset is provided', () => { it('should return announcements', async () => { - await AnnouncementService.getAnnouncements({ offset: 5 }); + await announcementService.getAnnouncements({ offset: 5 }); expect(mockFindMany).toHaveBeenCalledWith( expect.objectContaining({ skip: 5, @@ -367,7 +366,7 @@ describe('AnnouncementsService', () => { ]; const mockUserId = 'user-id'; await expect( - AnnouncementService.patchAnnouncements(data, mockUserId), + announcementService.patchAnnouncements(data, mockUserId), ).rejects.toThrow(UserInputError); }); }); @@ -389,7 +388,7 @@ describe('AnnouncementsService', () => { announcement_resource: [], }, ]); - await AnnouncementService.patchAnnouncements( + await announcementService.patchAnnouncements( [ { id: '1', status: AnnouncementStatus.Deleted }, { id: '2', status: AnnouncementStatus.Draft }, @@ -445,7 +444,7 @@ describe('AnnouncementsService', () => { attachmentId: 'attachment-id', fileDisplayName: faker.lorem.words(3), }; - await AnnouncementService.createAnnouncement( + await announcementService.createAnnouncement( announcementInput, 'user-id', ); @@ -501,7 +500,7 @@ describe('AnnouncementsService', () => { linkDisplayName: '', linkUrl: '', }; - await AnnouncementService.createAnnouncement( + await announcementService.createAnnouncement( announcementInput, 'user-id', ); @@ -542,7 +541,7 @@ describe('AnnouncementsService', () => { linkDisplayName: faker.lorem.words(3), linkUrl: faker.internet.url(), }; - await AnnouncementService.updateAnnouncement( + await announcementService.updateAnnouncement( 'announcement-id', announcementInput, 'user-id', @@ -591,7 +590,7 @@ describe('AnnouncementsService', () => { active_on: faker.date.future().toISOString(), status: AnnouncementStatus.Published, }; - await AnnouncementService.updateAnnouncement( + await announcementService.updateAnnouncement( 'announcement-id', announcementInput, 'user-id', @@ -636,7 +635,7 @@ describe('AnnouncementsService', () => { linkDisplayName: faker.lorem.words(3), linkUrl: faker.internet.url(), }; - await AnnouncementService.updateAnnouncement( + await announcementService.updateAnnouncement( 'announcement-id', announcementInput, 'user-id', @@ -703,7 +702,7 @@ describe('AnnouncementsService', () => { attachmentId: attachmentId, fileDisplayName: faker.lorem.words(3), }; - await AnnouncementService.updateAnnouncement( + await announcementService.updateAnnouncement( 'announcement-id', announcementInput, 'user-id', @@ -757,7 +756,7 @@ describe('AnnouncementsService', () => { active_on: faker.date.future().toISOString(), status: 'PUBLISHED', }; - await updateAnnouncement( + await announcementService.updateAnnouncement( 'announcement-id', announcementInput, 'user-id', @@ -790,7 +789,7 @@ describe('AnnouncementsService', () => { attachmentId: faker.string.uuid(), fileDisplayName: faker.lorem.word(), }; - await AnnouncementService.updateAnnouncement( + await announcementService.updateAnnouncement( 'announcement-id', announcementInput, 'user-id', @@ -851,7 +850,7 @@ describe('AnnouncementsService', () => { linkDisplayName: '', linkUrl: '', }; - await AnnouncementService.updateAnnouncement( + await announcementService.updateAnnouncement( 'announcement-id', announcementInput, 'user-id', @@ -873,9 +872,9 @@ describe('AnnouncementsService', () => { it('exits without updating any announcements', async () => { mockFindMany.mockResolvedValue([]); const patchAnnouncementsMock = jest - .spyOn(AnnouncementService, 'patchAnnouncements') + .spyOn(announcementService, 'patchAnnouncements') .mockImplementation(); - await AnnouncementService.expireAnnouncements(); + await announcementService.expireAnnouncements(); expect(mockFindMany).toHaveBeenCalled(); expect(patchAnnouncementsMock).not.toHaveBeenCalled(); }); @@ -884,10 +883,10 @@ describe('AnnouncementsService', () => { it('updates the announcements', async () => { mockFindMany.mockResolvedValue([{ announcement_id: '123' }]); const patchAnnouncementsMock = jest.spyOn( - AnnouncementService, + announcementService, 'patchAnnouncements', ); - await AnnouncementService.expireAnnouncements(); + await announcementService.expireAnnouncements(); expect(mockFindMany).toHaveBeenCalled(); expect(patchAnnouncementsMock).toHaveBeenCalled(); }); @@ -904,7 +903,7 @@ describe('AnnouncementsService', () => { zone, ), ); - await AnnouncementService.getExpiringAnnouncements(); + await announcementService.getExpiringAnnouncements(); expect(mockFindMany).toHaveBeenCalledWith({ where: { @@ -920,7 +919,7 @@ describe('AnnouncementsService', () => { describe('getAnnouncementById', () => { it('should return announcement by id', async () => { - await AnnouncementService.getAnnouncementById('1'); + await announcementService.getAnnouncementById('1'); expect(mockFindUniqueOrThrow).toHaveBeenCalledWith({ where: { announcement_id: '1' }, include: { announcement_resource: true }, @@ -931,7 +930,7 @@ describe('AnnouncementsService', () => { describe('getAnnouncementMetrics', () => { it('should return the announcement metrics', async () => { // Act - const result = await getAnnouncementMetrics(); + const result = await announcementService.getAnnouncementMetrics(); // Assert expect(result).toEqual({ diff --git a/backend/src/v1/services/announcements-service.ts b/backend/src/v1/services/announcements-service.ts index a751f5ee..d1861025 100644 --- a/backend/src/v1/services/announcements-service.ts +++ b/backend/src/v1/services/announcements-service.ts @@ -123,207 +123,43 @@ const buildAnnouncementSortInput = (query: AnnouncementQueryType) => { const DEFAULT_PAGE_SIZE = 10; -/** - * Get announcements based on query parameters - * @param query - * @returns - */ -export const getAnnouncements = async ( - query: AnnouncementQueryType = {}, -): Promise> => { - const where = buildAnnouncementWhereInput(query); - const orderBy = buildAnnouncementSortInput(query); - const items = await prisma.announcement.findMany({ - where, - orderBy, - take: query.limit || DEFAULT_PAGE_SIZE, - skip: query.offset || 0, - include: { - announcement_resource: true, - }, - }); - const total = await prisma.announcement.count({ where }); - - return { - items, - total, - limit: query.limit || DEFAULT_PAGE_SIZE, - offset: query.offset || 0, - totalPages: Math.ceil(total / (query.limit || DEFAULT_PAGE_SIZE)), - }; -}; - -/** - * Get announcement by id - * @param id - * @returns - */ -export const getAnnouncementById = async ( - id: string, -): Promise => { - return prisma.announcement.findUniqueOrThrow({ - where: { - announcement_id: id, - }, - include: { - announcement_resource: true, - }, - }); -}; - -/** - * Patch announcements by ids. - * This method also copies the original record into the announcement history table. - * @param data - array of objects with format: - * { id: announcement_id, status: status} - * Currently the 'status' attribute is the only attribute that supports patching, - * and the only status values that are supported are ['DELETED', 'DRAFT', 'EXPIRED'] - * @param userId - user id who is patching the announcements, or undefined - * if no 'updated_by' user should be recorded - * @param tx - an optional Prisma transaction. if specified, performs the work within - * the given existing transaction. if omitted, performs the workin within a new - * transaction - */ -export const patchAnnouncements = async ( - data: PatchAnnouncementsType, - userId: string | null = null, - tx?: any, -) => { - const supportedStatuses = [ - AnnouncementStatus.Deleted, - AnnouncementStatus.Draft, - AnnouncementStatus.Expired, - ]; - const hasUnsupportedUpdates = data.filter( - (item) => supportedStatuses.indexOf(item.status as any) < 0, - ).length; - if (hasUnsupportedUpdates) { - throw new UserInputError( - `Invalid status. Only the following statuses are supported: ${supportedStatuses}`, - ); - } - - //An inner function which performs the database updates associated with - //this patch operation. - const applyPatch = async (tx) => { - const announcements = await tx.announcement.findMany({ - where: { announcement_id: { in: data.map((item) => item.id) } }, - include: { announcement_resource: true }, +export const announcementService = { + /** + * Get announcements based on query parameters + * @param query + * @returns + */ + async getAnnouncements( + query: AnnouncementQueryType = {}, + ): Promise> { + const where = buildAnnouncementWhereInput(query); + const orderBy = buildAnnouncementSortInput(query); + const items = await prisma.announcement.findMany({ + where, + orderBy, + take: query.limit || DEFAULT_PAGE_SIZE, + skip: query.offset || 0, + include: { + announcement_resource: true, + }, }); - - for (const announcement of announcements) { - await saveHistory(tx, announcement); - } - const updateDate = ZonedDateTime.now(ZoneId.UTC); - - const updates = data - .filter((item) => supportedStatuses.indexOf(item.status as any) >= 0) - .map((item) => ({ - announcement_id: item.id, - status: item.status, - updated_by: userId, - updated_date: convert(updateDate).toDate(), - })); - - const typeHints = { - updated_by: 'UUID', + const total = await prisma.announcement.count({ where }); + + return { + items, + total, + limit: query.limit || DEFAULT_PAGE_SIZE, + offset: query.offset || 0, + totalPages: Math.ceil(total / (query.limit || DEFAULT_PAGE_SIZE)), }; - //None of the data passed to this method is directly from user input. - //The input 'data' object is validated and then translated into another form - //(the 'updates' object). For these reasons it is considerer safe to - //run a database statement ('updateManyUnsafe') that does not internally - //perform data safety checks. - await utils.updateManyUnsafe( - tx, - updates, - typeHints, - 'announcement', - 'announcement_id', - ); - }; - - //If there is an existing transaction, apply the patch within it, otherwise - //create a new transaction and apply the patch within that. - const enterTransaction = tx - ? applyPatch(tx) - : prisma.$transaction(applyPatch); - - return enterTransaction; -}; - -/** - * Create announcement - * @param data - announcement data - */ -export const createAnnouncement = async ( - input: AnnouncementDataType, - currentUserId: string, -) => { - const resources: Prisma.announcement_resourceCreateManyAnnouncementInput[] = - []; - if (input.attachmentId && input.fileDisplayName) { - resources.push({ - display_name: input.fileDisplayName, - attachment_file_id: input.attachmentId, - resource_type: 'ATTACHMENT', - created_by: currentUserId, - updated_by: currentUserId, - }); - } - - if (input.linkUrl) { - resources.push({ - display_name: input.linkDisplayName, - resource_url: input.linkUrl, - resource_type: 'LINK', - created_by: currentUserId, - updated_by: currentUserId, - }); - } - - if ( - !isEmpty(input.active_on) && - ZonedDateTime.parse(input.active_on).isBefore(ZonedDateTime.now()) - ) { - input.active_on = ZonedDateTime.now().format(DateTimeFormatter.ISO_INSTANT); - } - - const data: Prisma.announcementCreateInput = { - title: input.title, - description: input.description, - announcement_status: { - connect: { code: input.status }, - }, - active_on: !isEmpty(input.active_on) ? input.active_on : undefined, - expires_on: !isEmpty(input.expires_on) ? input.expires_on : undefined, - admin_user_announcement_created_byToadmin_user: { - connect: { admin_user_id: currentUserId }, - }, - admin_user_announcement_updated_byToadmin_user: { - connect: { admin_user_id: currentUserId }, - }, - announcement_resource: - resources.length > 0 ? { createMany: { data: resources } } : undefined, - }; - - return prisma.announcement.create({ - data, - }); -}; - -/** - * Update announcement - * @param id - announcement id - * @param data - announcement data - */ -export const updateAnnouncement = async ( - id: string, - input: AnnouncementDataType, - currentUserId: string, -) => { - const updateDate = convert(LocalDateTime.now(ZoneId.UTC)).toDate(); - await prisma.$transaction(async (tx) => { - const announcementData = await tx.announcement.findUniqueOrThrow({ + }, + /** + * Get announcement by id + * @param id + * @returns + */ + async getAnnouncementById(id: string): Promise { + return prisma.announcement.findUniqueOrThrow({ where: { announcement_id: id, }, @@ -331,110 +167,116 @@ export const updateAnnouncement = async ( announcement_resource: true, }, }); + }, - await saveHistory(tx, announcementData); + /** + * Patch announcements by ids. + * This method also copies the original record into the announcement history table. + * @param data - array of objects with format: + * { id: announcement_id, status: status} + * Currently the 'status' attribute is the only attribute that supports patching, + * and the only status values that are supported are ['DELETED', 'DRAFT', 'EXPIRED'] + * @param userId - user id who is patching the announcements, or undefined + * if no 'updated_by' user should be recorded + * @param tx - an optional Prisma transaction. if specified, performs the work within + * the given existing transaction. if omitted, performs the workin within a new + * transaction + */ + async patchAnnouncements( + data: PatchAnnouncementsType, + userId: string | null = null, + tx?: any, + ) { + const supportedStatuses = [ + AnnouncementStatus.Deleted, + AnnouncementStatus.Draft, + AnnouncementStatus.Expired, + ]; + const hasUnsupportedUpdates = data.filter( + (item) => supportedStatuses.indexOf(item.status as any) < 0, + ).length; + if (hasUnsupportedUpdates) { + throw new UserInputError( + `Invalid status. Only the following statuses are supported: ${supportedStatuses}`, + ); + } - const currentLink = announcementData?.announcement_resource.find( - (x) => x.resource_type === 'LINK', - ); + //An inner function which performs the database updates associated with + //this patch operation. + const applyPatch = async (tx) => { + const announcements = await tx.announcement.findMany({ + where: { announcement_id: { in: data.map((item) => item.id) } }, + include: { announcement_resource: true }, + }); - if (input.linkUrl) { - if (currentLink) { - await tx.announcement_resource.update({ - where: { - announcement_resource_id: currentLink.announcement_resource_id, - }, - data: { - display_name: input.linkDisplayName, - resource_url: input.linkUrl, - updated_by: currentUserId, - update_date: updateDate, - }, - }); - } else { - await tx.announcement_resource.create({ - data: { - display_name: input.linkDisplayName, - resource_url: input.linkUrl, - announcement_resource_type: { - connect: { code: 'LINK' }, - }, - admin_user_announcement_resource_created_byToadmin_user: { - connect: { admin_user_id: currentUserId }, - }, - admin_user_announcement_resource_updated_byToadmin_user: { - connect: { admin_user_id: currentUserId }, - }, - announcement: { - connect: { - announcement_id: id, - }, - }, - }, - }); + for (const announcement of announcements) { + await saveHistory(tx, announcement); } - } else if (currentLink) { - await tx.announcement_resource.delete({ - where: { - announcement_resource_id: currentLink.announcement_resource_id, - }, + const updateDate = ZonedDateTime.now(ZoneId.UTC); + + const updates = data + .filter((item) => supportedStatuses.indexOf(item.status as any) >= 0) + .map((item) => ({ + announcement_id: item.id, + status: item.status, + updated_by: userId, + updated_date: convert(updateDate).toDate(), + })); + + const typeHints = { + updated_by: 'UUID', + }; + //None of the data passed to this method is directly from user input. + //The input 'data' object is validated and then translated into another form + //(the 'updates' object). For these reasons it is considerer safe to + //run a database statement ('updateManyUnsafe') that does not internally + //perform data safety checks. + await utils.updateManyUnsafe( + tx, + updates, + typeHints, + 'announcement', + 'announcement_id', + ); + }; + + //If there is an existing transaction, apply the patch within it, otherwise + //create a new transaction and apply the patch within that. + const enterTransaction = tx + ? applyPatch(tx) + : prisma.$transaction(applyPatch); + + return enterTransaction; + }, + + /** + * Create announcement + * @param data - announcement data + */ + async createAnnouncement(input: AnnouncementDataType, currentUserId: string) { + const resources: Prisma.announcement_resourceCreateManyAnnouncementInput[] = + []; + if (input.attachmentId && input.fileDisplayName) { + resources.push({ + display_name: input.fileDisplayName, + attachment_file_id: input.attachmentId, + resource_type: 'ATTACHMENT', + created_by: currentUserId, + updated_by: currentUserId, }); } - const currentAttachment = announcementData?.announcement_resource.find( - (x) => x.resource_type === 'ATTACHMENT', - ); - if (input.attachmentId) { - if (currentAttachment) { - await tx.announcement_resource.update({ - where: { - announcement_resource_id: - currentAttachment.announcement_resource_id, - }, - data: { - display_name: input.fileDisplayName, - attachment_file_id: input.attachmentId, - updated_by: currentUserId, - update_date: updateDate, - }, - }); - } else { - await tx.announcement_resource.create({ - data: { - display_name: input.fileDisplayName, - attachment_file_id: input.attachmentId, - announcement_resource_type: { - connect: { code: 'ATTACHMENT' }, - }, - admin_user_announcement_resource_created_byToadmin_user: { - connect: { admin_user_id: currentUserId }, - }, - admin_user_announcement_resource_updated_byToadmin_user: { - connect: { admin_user_id: currentUserId }, - }, - announcement: { - connect: { - announcement_id: id, - }, - }, - }, - }); - } - } else if (currentAttachment && !input.attachmentId) { - await tx.announcement_resource.update({ - where: { - announcement_resource_id: currentAttachment.announcement_resource_id, - }, - data: { - attachment_file_id: null, - updated_by: currentUserId, - update_date: updateDate, - }, + if (input.linkUrl) { + resources.push({ + display_name: input.linkDisplayName, + resource_url: input.linkUrl, + resource_type: 'LINK', + created_by: currentUserId, + updated_by: currentUserId, }); } if ( - input.status != AnnouncementStatus.Published && // If announcement is already published, don't change the active_on !isEmpty(input.active_on) && ZonedDateTime.parse(input.active_on).isBefore(ZonedDateTime.now()) ) { @@ -443,105 +285,262 @@ export const updateAnnouncement = async ( ); } - const data: Prisma.announcementUpdateInput = { + const data: Prisma.announcementCreateInput = { title: input.title, description: input.description, - updated_date: updateDate, announcement_status: { connect: { code: input.status }, }, - active_on: !isEmpty(input.active_on) ? input.active_on : null, - expires_on: !isEmpty(input.expires_on) ? input.expires_on : null, + active_on: !isEmpty(input.active_on) ? input.active_on : undefined, + expires_on: !isEmpty(input.expires_on) ? input.expires_on : undefined, + admin_user_announcement_created_byToadmin_user: { + connect: { admin_user_id: currentUserId }, + }, admin_user_announcement_updated_byToadmin_user: { connect: { admin_user_id: currentUserId }, }, + announcement_resource: + resources.length > 0 ? { createMany: { data: resources } } : undefined, }; - return tx.announcement.update({ - where: { announcement_id: id }, + return prisma.announcement.create({ data, }); - }); -}; + }, -/* Identifies announcements that should be expired. If any such announcements + /** + * Update announcement + * @param id - announcement id + * @param data - announcement data + */ + async updateAnnouncement( + id: string, + input: AnnouncementDataType, + currentUserId: string, + ) { + const updateDate = convert(LocalDateTime.now(ZoneId.UTC)).toDate(); + await prisma.$transaction(async (tx) => { + const announcementData = await tx.announcement.findUniqueOrThrow({ + where: { + announcement_id: id, + }, + include: { + announcement_resource: true, + }, + }); + + await saveHistory(tx, announcementData); + + const currentLink = announcementData?.announcement_resource.find( + (x) => x.resource_type === 'LINK', + ); + + if (input.linkUrl) { + if (currentLink) { + await tx.announcement_resource.update({ + where: { + announcement_resource_id: currentLink.announcement_resource_id, + }, + data: { + display_name: input.linkDisplayName, + resource_url: input.linkUrl, + updated_by: currentUserId, + update_date: updateDate, + }, + }); + } else { + await tx.announcement_resource.create({ + data: { + display_name: input.linkDisplayName, + resource_url: input.linkUrl, + announcement_resource_type: { + connect: { code: 'LINK' }, + }, + admin_user_announcement_resource_created_byToadmin_user: { + connect: { admin_user_id: currentUserId }, + }, + admin_user_announcement_resource_updated_byToadmin_user: { + connect: { admin_user_id: currentUserId }, + }, + announcement: { + connect: { + announcement_id: id, + }, + }, + }, + }); + } + } else if (currentLink) { + await tx.announcement_resource.delete({ + where: { + announcement_resource_id: currentLink.announcement_resource_id, + }, + }); + } + + const currentAttachment = announcementData?.announcement_resource.find( + (x) => x.resource_type === 'ATTACHMENT', + ); + if (input.attachmentId) { + if (currentAttachment) { + await tx.announcement_resource.update({ + where: { + announcement_resource_id: + currentAttachment.announcement_resource_id, + }, + data: { + display_name: input.fileDisplayName, + attachment_file_id: input.attachmentId, + updated_by: currentUserId, + update_date: updateDate, + }, + }); + } else { + await tx.announcement_resource.create({ + data: { + display_name: input.fileDisplayName, + attachment_file_id: input.attachmentId, + announcement_resource_type: { + connect: { code: 'ATTACHMENT' }, + }, + admin_user_announcement_resource_created_byToadmin_user: { + connect: { admin_user_id: currentUserId }, + }, + admin_user_announcement_resource_updated_byToadmin_user: { + connect: { admin_user_id: currentUserId }, + }, + announcement: { + connect: { + announcement_id: id, + }, + }, + }, + }); + } + } else if (currentAttachment && !input.attachmentId) { + await tx.announcement_resource.update({ + where: { + announcement_resource_id: + currentAttachment.announcement_resource_id, + }, + data: { + attachment_file_id: null, + updated_by: currentUserId, + update_date: updateDate, + }, + }); + } + + if ( + input.status != AnnouncementStatus.Published && // If announcement is already published, don't change the active_on + !isEmpty(input.active_on) && + ZonedDateTime.parse(input.active_on).isBefore(ZonedDateTime.now()) + ) { + input.active_on = ZonedDateTime.now().format( + DateTimeFormatter.ISO_INSTANT, + ); + } + + const data: Prisma.announcementUpdateInput = { + title: input.title, + description: input.description, + updated_date: updateDate, + announcement_status: { + connect: { code: input.status }, + }, + active_on: !isEmpty(input.active_on) ? input.active_on : null, + expires_on: !isEmpty(input.expires_on) ? input.expires_on : null, + admin_user_announcement_updated_byToadmin_user: { + connect: { admin_user_id: currentUserId }, + }, + }; + + return tx.announcement.update({ + where: { announcement_id: id }, + data, + }); + }); + }, + + /* Identifies announcements that should be expired. If any such announcements are found, marks them as expired */ -export const expireAnnouncements = async () => { - const nowUtc = convert(ZonedDateTime.now(ZoneId.UTC)).toDate(); - await prisma.$transaction(async (tx) => { - const announcements = await prisma.announcement.findMany({ - select: { - announcement_id: true, - }, + async expireAnnouncements() { + const nowUtc = convert(ZonedDateTime.now(ZoneId.UTC)).toDate(); + await prisma.$transaction(async (tx) => { + const announcements = await prisma.announcement.findMany({ + select: { + announcement_id: true, + }, + where: { + status: AnnouncementStatus.Published, + expires_on: { + not: null, + lte: nowUtc, + }, + }, + }); + const patchData = announcements.map((a) => { + return { + id: a.announcement_id, + status: AnnouncementStatus.Expired as any, + }; + }); + if (patchData.length) { + logger.info( + `Marking ${patchData.length} announcement(s) as ${AnnouncementStatus.Expired}`, + ); + await this.patchAnnouncements(patchData, undefined, tx); + } else { + logger.info(`Found no announcements that need to be expired`); + } + }); + }, + + /** Get announcements that are 10 days away from expiring */ + async getExpiringAnnouncements(): Promise { + const zone = ZoneId.of(config.get('server:schedulerTimeZone')); + const targetDate = ZonedDateTime.now(zone) + .plusDays(14) + .withHour(0) + .withMinute(0) + .withSecond(0) + .withNano(0); + + const items = await prisma.announcement.findMany({ where: { status: AnnouncementStatus.Published, expires_on: { - not: null, - lte: nowUtc, + gte: convert(targetDate).toDate(), + lt: convert(targetDate.plusDays(1)).toDate(), }, }, }); - const patchData = announcements.map((a) => { - return { - id: a.announcement_id, - status: AnnouncementStatus.Expired as any, - }; - }); - if (patchData.length) { - logger.info( - `Marking ${patchData.length} announcement(s) as ${AnnouncementStatus.Expired}`, - ); - await patchAnnouncements(patchData, undefined, tx); - } else { - logger.info(`Found no announcements that need to be expired`); - } - }); -}; -/** Get announcements that are 10 days away from expiring */ -export const getExpiringAnnouncements = async (): Promise => { - const zone = ZoneId.of(config.get('server:schedulerTimeZone')); - const targetDate = ZonedDateTime.now(zone) - .plusDays(14) - .withHour(0) - .withMinute(0) - .withSecond(0) - .withNano(0); - - const items = await prisma.announcement.findMany({ - where: { - status: AnnouncementStatus.Published, - expires_on: { - gte: convert(targetDate).toDate(), - lt: convert(targetDate.plusDays(1)).toDate(), - }, - }, - }); - - logger.info( - `Found ${items.length} expiring announcements between ${targetDate.toString()} and ${targetDate.plusDays(1).toString()}`, - ); - return items; -}; + logger.info( + `Found ${items.length} expiring announcements between ${targetDate.toString()} and ${targetDate.plusDays(1).toString()}`, + ); + return items; + }, -/** - * Get announcement metrics - * @param param0 - * @returns - */ -export const getAnnouncementMetrics = async () => { - const announcementsData = await prisma.announcement.groupBy({ - where: { status: { in: ['PUBLISHED', 'DRAFT'] } }, - by: ['status'], - _count: true, - }); + /** + * Get announcement metrics + * @param param0 + * @returns + */ + async getAnnouncementMetrics() { + const announcementsData = await prisma.announcement.groupBy({ + where: { status: { in: ['PUBLISHED', 'DRAFT'] } }, + by: ['status'], + _count: true, + }); - const announcementsMetrics = announcementsData.reduce((acc, curr) => { - const key = curr.status.toLowerCase(); - return { ...acc, [key]: { count: curr._count } }; - }, {}); + const announcementsMetrics = announcementsData.reduce((acc, curr) => { + const key = curr.status.toLowerCase(); + return { ...acc, [key]: { count: curr._count } }; + }, {}); - return { - ...announcementsMetrics, - }; + return { + ...announcementsMetrics, + }; + }, }; diff --git a/backend/src/v1/services/scheduler-service.spec.ts b/backend/src/v1/services/scheduler-service.spec.ts index 17b61b11..3f4aec08 100644 --- a/backend/src/v1/services/scheduler-service.spec.ts +++ b/backend/src/v1/services/scheduler-service.spec.ts @@ -74,7 +74,9 @@ jest.mock('../../external/services/ches', () => ({ const mockGetExpiringAnnouncements = jest.fn(); jest.mock('./announcements-service', () => ({ - getExpiringAnnouncements: () => mockGetExpiringAnnouncements(), + announcementService: { + getExpiringAnnouncements: () => mockGetExpiringAnnouncements(), + }, })); const mockGetUsers = jest.fn(); diff --git a/backend/src/v1/services/scheduler-service.ts b/backend/src/v1/services/scheduler-service.ts index fa8aa861..ee05eb14 100644 --- a/backend/src/v1/services/scheduler-service.ts +++ b/backend/src/v1/services/scheduler-service.ts @@ -8,7 +8,7 @@ import prisma from '../prisma/prisma-client'; import { enumReportStatus } from './report-service'; import { logger } from '../../logger'; import { Prisma } from '@prisma/client'; -import { getExpiringAnnouncements } from './announcements-service'; +import { announcementService } from './announcements-service'; import { SSO } from './sso-service'; import emailService from '../../external/services/ches'; import { config } from '../../config'; @@ -50,7 +50,7 @@ const schedulerService = { async sendAnnouncementExpiringEmails() { if (!config.get('server:enableEmailExpiringAnnouncements')) return; - const expiring = await getExpiringAnnouncements(); + const expiring = await announcementService.getExpiringAnnouncements(); if (!expiring.length) return; const sso = await SSO.init(); diff --git a/backend/src/v1/services/utils-service.ts b/backend/src/v1/services/utils-service.ts index 1f48e205..9daa2560 100644 --- a/backend/src/v1/services/utils-service.ts +++ b/backend/src/v1/services/utils-service.ts @@ -5,7 +5,6 @@ import jsonwebtoken from 'jsonwebtoken'; import { config } from '../../config'; import { logger as log, logger } from '../../logger'; -const fs = require('fs'); axios.interceptors.response.use((response) => { const headers = response.headers; if (headers && headers['x-correlation-id']) { From 1cfd64ec20481e4ba1420eefde833f9238bfca2d Mon Sep 17 00:00:00 2001 From: jer3k <99355997+jer3k@users.noreply.github.com> Date: Thu, 26 Sep 2024 13:43:38 -0700 Subject: [PATCH 5/6] feat: GEO-1121 Single analytics powerBi report with error notifications. (#783) --- .github/workflows/.deploy.yml | 4 +-- .../src/components/AnalyticsPage.vue | 32 +++++++++++++------ admin-frontend/src/utils/constant.js | 4 +-- backend/src/config/index.ts | 5 +-- backend/src/v1/routes/analytic-routes.spec.ts | 13 +++----- .../src/v1/services/analytic-service.spec.ts | 17 ++++++---- backend/src/v1/services/analytic-service.ts | 16 ++-------- .../templates/secret.yaml | 4 +-- 8 files changed, 44 insertions(+), 51 deletions(-) diff --git a/.github/workflows/.deploy.yml b/.github/workflows/.deploy.yml index b854ed7b..cdf5c92e 100644 --- a/.github/workflows/.deploy.yml +++ b/.github/workflows/.deploy.yml @@ -152,9 +152,7 @@ jobs: --set-string global.secrets.s3Endpoint="${{secrets.S3_ENDPOINT }}" \ --set-string global.secrets.s3Bucket="${{secrets.S3_BUCKET }}" \ --set-string global.secrets.powerBiAnalyticsWorkspaceId="${{secrets.POWERBI_ANALYTICS_WORKSPACE_ID }}" \ - --set-string global.secrets.powerBiAnalyticsSubmissionAnalyticsId="${{secrets.POWERBI_ANALYTICS_SUBMISSIONANALYTICS_ID }}" \ - --set-string global.secrets.powerBiAnalyticsUserBehaviourId="${{secrets.POWERBI_ANALYTICS_USERBEHAVIOUR_ID }}" \ - --set-string global.secrets.powerBiAnalyticsDataAnalyticsId="${{secrets.POWERBI_ANALYTICS_DATAANALYTICS_ID }}" \ + --set-string global.secrets.powerBiAnalyticsReportId="${{secrets.POWERBI_ANALYTICS_REPORT_ID }}" \ --set-string global.serverAdminFrontend="${{ inputs.admin-frontend-url }}" \ --set-string global.serverFrontend="${{ inputs.frontend-url }}" \ --set-string crunchy.pgBackRest.s3.bucket="${{ secrets.S3_BUCKET }}" \ diff --git a/admin-frontend/src/components/AnalyticsPage.vue b/admin-frontend/src/components/AnalyticsPage.vue index 66c54f54..177ac344 100644 --- a/admin-frontend/src/components/AnalyticsPage.vue +++ b/admin-frontend/src/components/AnalyticsPage.vue @@ -1,5 +1,5 @@