diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 55bdf55c3..1701bc60a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,7 +1,4 @@ # Matched against repo root (asterisk) -* @sukanya-rath - -# Matched against directories -# /.github/workflows/ @sukanya-rath +* @bcgov/FIN_IMB_AMD # See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners diff --git a/.vscode/launch.json b/.vscode/launch.json index 2f8a3b6d5..35162ae13 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -50,6 +50,23 @@ "order": 1 } }, + { + // Start clamav server which depends on clamav also running in podman. + // clamav requires quite a bit of memory and therefore isn't included with the rest by detault. + "name": "ClamAV Server", + "request": "launch", + "runtimeArgs": ["run-script", "dev"], + "runtimeExecutable": "npm", + "skipFiles": ["/**"], + "type": "node", + "cwd": "${workspaceFolder}/clamav-service", + "preLaunchTask": "Launch ClamAV", + "outputCapture": "std", + "presentation": { + "group": "Individual Servers", + "order": 1 + } + }, { "name": "Admin-Frontend Server", "request": "launch", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 06b1214c1..994bdf598 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -9,6 +9,13 @@ "command": "podman-compose up -d database-migrations", "problemMatcher": [] }, + { + // This creates a new temporary container in podman to run clamav. + "label": "Launch ClamAV", + "type": "shell", + "command": "podman run --rm -d -p 3310:3310 clamav/clamav", + "problemMatcher": [] + }, { // After making changes to the database via Flyway, Run this task to // bring all those changes into Prisma to be available in the code. @@ -18,7 +25,9 @@ "options": { "cwd": "${workspaceFolder}/backend" }, - "dependsOn": ["Launch and migrate database"], + "dependsOn": [ + "Launch and migrate database" + ], "problemMatcher": [] }, { diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index ddc929686..0f93d6689 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -44,6 +44,7 @@ config.defaults({ schedulerTimeZone: process.env.REPORTS_SCHEDULER_CRON_TIMEZONE, schedulerExpireAnnountmentsCronTime: process.env.EXPIRE_ANNOUNCEMENTS_CRON_CRONTIME, + deleteAnnouncementsCronTime: process.env.DELETE_ANNOUNCEMENTS_CRON_CRONTIME, enableEmailExpiringAnnouncements: process.env.ENABLE_EMAIL_EXPIRING_ANNOUNCEMENTS?.toUpperCase() == 'TRUE', deleteAnnouncementsDurationInDays: parseInt( diff --git a/backend/src/external/services/s3-api.spec.ts b/backend/src/external/services/s3-api.spec.ts index 5bd12c6f3..5b0edd818 100644 --- a/backend/src/external/services/s3-api.spec.ts +++ b/backend/src/external/services/s3-api.spec.ts @@ -1,6 +1,11 @@ import { faker } from '@faker-js/faker'; -import { downloadFile } from './s3-api'; -import { GetObjectCommand, ListObjectsCommand } from '@aws-sdk/client-s3'; +import { downloadFile, deleteFiles } from './s3-api'; +import { + DeleteObjectsCommand, + GetObjectCommand, + ListObjectsCommand, +} from '@aws-sdk/client-s3'; +import { APP_ANNOUNCEMENTS_FOLDER } from '../../constants/admin'; const mockFindFirstOrThrow = jest.fn(); jest.mock('../../v1/prisma/prisma-client', () => ({ @@ -143,4 +148,45 @@ describe('S3Api', () => { expect(res.status).toHaveBeenCalledWith(400); }); }); + describe('deleteFiles', () => { + it('should handle multiple ids, ids that dont exist, and ids that have already been deleted', async () => { + const ids = ['id1', 'id2', 'id3', 'id4']; + const fileId1a = { Key: `${APP_ANNOUNCEMENTS_FOLDER}/id1/file1a` }; + const fileId1b = { Key: `${APP_ANNOUNCEMENTS_FOLDER}/id1/file1b` }; + const fileId1c = { Key: `${APP_ANNOUNCEMENTS_FOLDER}/id1/file1c` }; + const fileId2 = { + Key: `${APP_ANNOUNCEMENTS_FOLDER}/id2/file2`, + Code: 'NoSuchKey', + }; + const fileId3 = { + Key: `${APP_ANNOUNCEMENTS_FOLDER}/id3/file3`, + Code: 'OtherError', + }; + + mockSend.mockImplementation((...args) => { + const [command] = args; + if (command instanceof ListObjectsCommand) { + // test: multiple files in id1. id4 doesn't exist + if (command.input.Prefix == `${APP_ANNOUNCEMENTS_FOLDER}/id1`) + return { Contents: [fileId1a, fileId1b, fileId1c] }; + if (command.input.Prefix == `${APP_ANNOUNCEMENTS_FOLDER}/id2`) + return { Contents: [fileId2] }; + if (command.input.Prefix == `${APP_ANNOUNCEMENTS_FOLDER}/id3`) + return { Contents: [fileId3] }; + return {}; + } + if (command instanceof DeleteObjectsCommand) { + return { + Deleted: [fileId1a, fileId1b, fileId1c], + Errors: [fileId2, fileId3], + }; + } + }); + + const result = await deleteFiles(ids); + + //id3 had a file that failed to delete, so it shouldn't say that id3 was deleted + expect(result).toEqual(new Set(['id1', 'id2', 'id4'])); + }); + }); }); diff --git a/backend/src/external/services/s3-api.ts b/backend/src/external/services/s3-api.ts index b0e6edf06..33b12220c 100644 --- a/backend/src/external/services/s3-api.ts +++ b/backend/src/external/services/s3-api.ts @@ -1,7 +1,11 @@ import { GetObjectCommand, ListObjectsCommand, + DeleteObjectsCommand, S3Client, + type ObjectIdentifier, + type _Object, + type _Error, } from '@aws-sdk/client-s3'; import prisma from '../../v1/prisma/prisma-client'; import os from 'os'; @@ -13,15 +17,22 @@ import { S3_OPTIONS, } from '../../constants/admin'; +/* */ + +const getFileList = async (s3Client: S3Client, key: string) => { + const response = await s3Client.send( + new ListObjectsCommand({ + Bucket: S3_BUCKET, + Prefix: `${APP_ANNOUNCEMENTS_FOLDER}/${key}`, + }), + ); + return response.Contents ?? []; +}; + const getMostRecentFile = async (s3Client: S3Client, key: string) => { try { - const response = await s3Client.send( - new ListObjectsCommand({ - Bucket: S3_BUCKET, - Prefix: `${APP_ANNOUNCEMENTS_FOLDER}/${key}`, - }), - ); - const sortedData: any = response.Contents.sort((a: any, b: any) => { + const fileList = await getFileList(s3Client, key); + const sortedData = fileList.sort((a, b) => { const modifiedDateA: any = new Date(a.LastModified); const modifiedDateB: any = new Date(b.LastModified); return modifiedDateB - modifiedDateA; @@ -102,3 +113,68 @@ export const downloadFile = async (res, fileId: string) => { res.status(400).json({ message: 'Invalid request', error }); } }; + +/** + * Delete multiple files from the object store that follow the 'keep-history-strategy' + * @param ids + * @returns A Set of id's that are no longer in the object store + */ +export const deleteFiles = async (ids: string[]): Promise> => { + const s3Client = new S3Client(S3_OPTIONS); + try { + // Get all the files stored under each id + const filesPerId = await Promise.all( + ids.map((id) => getFileList(s3Client, id)), //TODO: try deleting the folders instead of each individual file. https://stackoverflow.com/a/73367823 + ); + const idsWithNoFiles = ids.filter( + (id, index) => filesPerId[index].length === 0, + ); + const files = filesPerId.flat(); + + // Group into 1000 items because DeleteObjectsCommand can only do 1000 at a time + const groupedFiles: _Object[][] = []; + for (let i = 0; i < files.length; i += 1000) + groupedFiles.push(files.slice(i, i + 1000)); + + // delete all files in object store + const responsePerGroup = await Promise.all( + groupedFiles.map((group) => + s3Client.send( + new DeleteObjectsCommand({ + Bucket: S3_BUCKET, + Delete: { Objects: group as ObjectIdentifier[] }, + }), + ), + ), + ); + + // report any errors + responsePerGroup.forEach((r) => + r.Errors.forEach((e) => { + if (e.Code == 'NoSuchKey') idsWithNoFiles.push(getIdFromKey(e.Key)); + logger.error(e.Message); + }), + ); + + // Return the id of all successful deleted + const successfulIds = responsePerGroup.flatMap((r) => + r.Deleted.reduce((acc, x) => { + acc.push(getIdFromKey(x.Key)); + return acc; + }, [] as string[]), + ); + + return new Set(idsWithNoFiles.concat(successfulIds)); //remove duplicates + } catch (error) { + logger.error(error); + return new Set(); + } +}; + +/** + * Given a string in this format, return the 'id' portion of the string + * ${APP_ANNOUNCEMENTS_FOLDER}/${id}/${file} + */ +function getIdFromKey(key: string): string { + return key.replace(`${APP_ANNOUNCEMENTS_FOLDER}/`, '').split('/', 1)[0]; +} diff --git a/backend/src/schedulers/delete-announcements-scheduler.spec.ts b/backend/src/schedulers/delete-announcements-scheduler.spec.ts new file mode 100644 index 000000000..c16f53db8 --- /dev/null +++ b/backend/src/schedulers/delete-announcements-scheduler.spec.ts @@ -0,0 +1,44 @@ +import waitFor from 'wait-for-expect'; +import deleteAnnouncementsJob from './delete-announcements-scheduler'; +import { announcementService } from '../v1/services/announcements-service'; + +jest.mock('../config', () => ({ + config: { + get: (key: string) => { + const settings = { + 'server:deleteAnnouncementsCronTime': '121212121', + }; + + return settings[key]; + }, + }, +})); + +jest.mock('./create-job', () => ({ + createJob: jest.fn((cronTime, callback, mutex, { title, message }) => { + return { + start: jest.fn(async () => { + console.log(`Mock run`); + try { + await callback(); // Simulate the callback execution + } catch (e) { + console.error(`Mock error`); + } finally { + console.log(`Mock end run`); + } + }), + }; + }), +})); +jest.mock('../v1/services/announcements-service'); + +describe('delete-announcements-scheduler', () => { + it('should run the function', async () => { + deleteAnnouncementsJob.start(); + await waitFor(async () => { + expect( + announcementService.deleteAnnouncementsSchedule, + ).toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/src/schedulers/delete-announcements-scheduler.ts b/backend/src/schedulers/delete-announcements-scheduler.ts new file mode 100644 index 000000000..3080f7eab --- /dev/null +++ b/backend/src/schedulers/delete-announcements-scheduler.ts @@ -0,0 +1,25 @@ +import { config } from '../config'; +import { logger as log } from '../logger'; +import advisoryLock from 'advisory-lock'; +import { createJob } from './create-job'; +import { announcementService } from '../v1/services/announcements-service'; + +const SCHEDULER_NAME = 'DeleteAnnouncements'; +const mutex = advisoryLock(config.get('server:databaseUrl'))( + `${SCHEDULER_NAME}-lock`, +); +const crontime = config.get('server:deleteAnnouncementsCronTime'); + +export default createJob( + crontime, + async () => { + log.info(`Starting scheduled job '${SCHEDULER_NAME}'.`); + await announcementService.deleteAnnouncementsSchedule(); + log.info(`Completed scheduled job '${SCHEDULER_NAME}'.`); + }, + mutex, + { + title: `Error in ${SCHEDULER_NAME}`, + message: `Error in scheduled job: ${SCHEDULER_NAME}`, + }, +); diff --git a/backend/src/schedulers/run.all.spec.ts b/backend/src/schedulers/run.all.spec.ts index e1165927b..8d1efd6d6 100644 --- a/backend/src/schedulers/run.all.spec.ts +++ b/backend/src/schedulers/run.all.spec.ts @@ -45,6 +45,15 @@ jest.mock('./email-expiring-announcements-scheduler', () => ({ }, })); +const mockDeleteAnnouncementsScheduler = jest.fn(); +jest.mock('./delete-announcements-scheduler', () => ({ + __esModule: true, + + default: { + start: () => mockDeleteAnnouncementsScheduler(), + }, +})); + describe('run.all', () => { it('should start all jobs', async () => { run(); @@ -53,5 +62,6 @@ describe('run.all', () => { expect(mockStartReportLock).toHaveBeenCalled(); expect(mockExpireAnnouncementsLock).toHaveBeenCalled(); expect(mockEmailExpiringAnnouncementsScheduler).toHaveBeenCalled(); + expect(mockDeleteAnnouncementsScheduler).toHaveBeenCalled(); }); }); diff --git a/backend/src/schedulers/run.all.ts b/backend/src/schedulers/run.all.ts index 753d89cce..939d5cb29 100644 --- a/backend/src/schedulers/run.all.ts +++ b/backend/src/schedulers/run.all.ts @@ -4,6 +4,7 @@ import deleteUserErrorsJob from './delete-user-errors-scheduler'; import lockReportsJob from './lock-reports-scheduler'; import expireAnnouncementsJob from './expire-announcements-scheduler'; import emailExpiringAnnouncementsJob from './email-expiring-announcements-scheduler'; +import deleteAnnouncementsJob from './delete-announcements-scheduler'; export const run = () => { try { @@ -12,6 +13,7 @@ export const run = () => { lockReportsJob?.start(); expireAnnouncementsJob?.start(); emailExpiringAnnouncementsJob?.start(); + deleteAnnouncementsJob?.start(); } catch (error) { /* istanbul ignore next */ logger.error(error); diff --git a/backend/src/v1/services/announcements-service.spec.ts b/backend/src/v1/services/announcements-service.spec.ts index 6bd9c2a7d..af1744827 100644 --- a/backend/src/v1/services/announcements-service.spec.ts +++ b/backend/src/v1/services/announcements-service.spec.ts @@ -28,23 +28,32 @@ const mockFindMany = jest.fn().mockResolvedValue([ }, ]); -const mockFindUniqueOrThrow = jest.fn(); const mockUpdateMany = jest.fn(); +const mockCreateAnnouncement = jest.fn(); +const mockFindUniqueOrThrow = jest.fn(); const mockUpdate = jest.fn(); +const mockDeleteMany = jest.fn(); + +const mockHistoryCreate = jest.fn(); +const mockFindManyResource = jest.fn(); +const mockDeleteManyHistory = jest.fn(); + const mockCreateResource = jest.fn(); const mockDeleteResource = jest.fn(); const mockUpdateResource = jest.fn(); -const mockHistoryCreate = jest.fn(); -const mockCreateAnnouncement = jest.fn(); +const mockDeleteManyResource = jest.fn(); + +const mockDeleteManyResourceHistory = jest.fn(); + jest.mock('../prisma/prisma-client', () => ({ __esModule: true, default: { announcement: { findMany: (...args) => mockFindMany(...args), - count: jest.fn().mockResolvedValue(2), updateMany: (...args) => mockUpdateMany(...args), create: (...args) => mockCreateAnnouncement(...args), findUniqueOrThrow: (...args) => mockFindUniqueOrThrow(...args), + count: jest.fn().mockResolvedValue(2), groupBy: jest.fn().mockResolvedValueOnce([ { status: 'PUBLISHED', _count: 1 }, { status: 'DRAFT', _count: 2 }, @@ -53,6 +62,9 @@ jest.mock('../prisma/prisma-client', () => ({ announcement_history: { create: (...args) => mockHistoryCreate(...args), }, + announcement_resource: { + findMany: (...args) => mockFindManyResource(...args), + }, $transaction: jest.fn().mockImplementation((cb) => cb({ announcement: { @@ -60,15 +72,21 @@ jest.mock('../prisma/prisma-client', () => ({ updateMany: (...args) => mockUpdateMany(...args), findUniqueOrThrow: (...args) => mockFindUniqueOrThrow(...args), update: (...args) => mockUpdate(...args), + deleteMany: (...args) => mockDeleteMany(...args), }, announcement_resource: { create: (...args) => mockCreateResource(...args), update: (...args) => mockUpdateResource(...args), delete: (...args) => mockDeleteResource(...args), + deleteMany: (...args) => mockDeleteManyResource(...args), }, announcement_history: { create: (...args) => mockHistoryCreate(...args), update: (...args) => mockUpdateResource(...args), + deleteMany: (...args) => mockDeleteManyHistory(...args), + }, + announcement_resource_history: { + deleteMany: (...args) => mockDeleteManyResourceHistory(...args), }, $executeRawUnsafe: jest.fn(), }), @@ -76,11 +94,17 @@ jest.mock('../prisma/prisma-client', () => ({ }, })); +const mockS3ApiDeleteFiles = jest.fn(); +jest.mock('../../external/services/s3-api', () => ({ + deleteFiles: (...args) => mockS3ApiDeleteFiles(...args), +})); + jest.mock('../../config', () => ({ config: { get: (key: string) => { const settings = { 'server:schedulerTimeZone': 'America/Vancouver', + 'server:deleteAnnouncementsDurationInDays': '90', }; return settings[key]; }, @@ -969,4 +993,99 @@ describe('AnnouncementsService', () => { }); }); }); + + describe('deleteAnnouncementsSchedule', () => { + it('should delete announcements and associated resources successfully', async () => { + mockFindMany.mockResolvedValueOnce([ + { announcement_id: 1, title: 'Announcement 1' }, + { announcement_id: 2, title: 'Announcement 2' }, + ]); + mockFindManyResource.mockResolvedValueOnce([ + { announcement_id: 1, attachment_file_id: 'file1' }, + { announcement_id: 2, attachment_file_id: 'file2' }, + ]); + + mockS3ApiDeleteFiles.mockResolvedValue(new Set(['file1', 'file2'])); // files deleted + + mockDeleteManyResourceHistory.mockResolvedValue({}); + mockDeleteManyResource.mockResolvedValue({}); + mockDeleteManyHistory.mockResolvedValue({}); + mockDeleteMany.mockResolvedValue({}); + + await announcementService.deleteAnnouncementsSchedule(); + + expect(mockS3ApiDeleteFiles).toHaveBeenCalledWith(['file1', 'file2']); + + // Two files, so each of these are called twice + expect(mockDeleteManyResourceHistory).toHaveBeenCalledTimes(2); + expect(mockDeleteManyResource).toHaveBeenCalledTimes(2); + expect(mockDeleteManyHistory).toHaveBeenCalledTimes(2); + expect(mockDeleteMany).toHaveBeenCalledTimes(2); + }); + + it("shouldn't delete from the database when s3 fails to delete files", async () => { + mockFindMany.mockResolvedValueOnce([ + { announcement_id: 1, title: 'Announcement 1' }, + { announcement_id: 2, title: 'Announcement 2' }, + ]); + mockFindManyResource.mockResolvedValueOnce([ + { announcement_id: 1, attachment_file_id: 'file1' }, + { announcement_id: 2, attachment_file_id: 'file2' }, + ]); + + mockS3ApiDeleteFiles.mockResolvedValue(new Set(['file2'])); // only deleted one file + + await announcementService.deleteAnnouncementsSchedule(); + + // Even though there are two files, only one of them was deleted + expect(mockDeleteManyResourceHistory).toHaveBeenCalledTimes(1); + expect(mockDeleteManyResource).toHaveBeenCalledTimes(1); + expect(mockDeleteManyHistory).toHaveBeenCalledTimes(1); + expect(mockDeleteMany).toHaveBeenCalledTimes(1); + expect(mockDeleteMany).toHaveBeenCalledWith({ + where: { announcement_id: 2 }, + }); + }); + + it("shouldn't do anything if there's nothing to delete", async () => { + //test that no announcements were found + mockFindMany.mockResolvedValueOnce([]); + await announcementService.deleteAnnouncementsSchedule(); + expect(mockFindManyResource).toHaveBeenCalledTimes(0); //should return before this function is called + }); + + it("shouldn't do anything if nothing is safe to delete", async () => { + //test that if the resources that were found couldn't be deleted from s3, then nothing happens + mockFindMany.mockResolvedValueOnce([ + { announcement_id: 1, title: 'Announcement 1' }, + { announcement_id: 2, title: 'Announcement 2' }, + ]); + mockFindManyResource.mockResolvedValueOnce([ + { announcement_id: 1, attachment_file_id: 'file1' }, + { announcement_id: 2, attachment_file_id: 'file2' }, + ]); + mockS3ApiDeleteFiles.mockResolvedValue(new Set([])); // didn't delete anything + + await announcementService.deleteAnnouncementsSchedule(); + expect(mockDeleteManyHistory).toHaveBeenCalledTimes(0); //should return before this function is called + }); + + it('should log if database failed to delete', async () => { + //test that if the resources that were found couldn't be deleted from s3, then nothing happens + mockFindMany.mockResolvedValueOnce([ + { announcement_id: 1, title: 'Announcement 1' }, + { announcement_id: 2, title: 'Announcement 2' }, + ]); + mockFindManyResource.mockResolvedValueOnce([ + { announcement_id: 1, attachment_file_id: 'file1' }, + { announcement_id: 2, attachment_file_id: 'file2' }, + ]); + mockS3ApiDeleteFiles.mockResolvedValue(new Set(['file1'])); // didn't delete anything + mockDeleteManyResourceHistory.mockRejectedValue(new Error('err')); + + await announcementService.deleteAnnouncementsSchedule(); + expect(mockDeleteManyResourceHistory).toHaveBeenCalledTimes(1); + expect(mockDeleteManyHistory).toHaveBeenCalledTimes(0); //should error before this function is called + }); + }); }); diff --git a/backend/src/v1/services/announcements-service.ts b/backend/src/v1/services/announcements-service.ts index 61321fd54..79c0d6eef 100644 --- a/backend/src/v1/services/announcements-service.ts +++ b/backend/src/v1/services/announcements-service.ts @@ -26,6 +26,7 @@ import { } from '../types/announcements'; import { UserInputError } from '../types/errors'; import { utils } from './utils-service'; +import { deleteFiles } from '../../external/services/s3-api'; const saveHistory = async ( tx: Omit< @@ -547,4 +548,114 @@ are found, marks them as expired */ ...announcementsMetrics, }; }, + + /** Delete records, history, and object store. If any part of an item fails to delete, then the whole item's collection is not deleted. */ + async deleteAnnouncementsSchedule() { + const cutoffDate = convert( + ZonedDateTime.now().minusDays( + config.get('server:deleteAnnouncementsDurationInDays'), + ), + ).toDate(); + + // Get list of ids that are after the cutoffDate + const announcementsToDelete = await prisma.announcement.findMany({ + where: { + OR: [ + { + status: AnnouncementStatus.Expired, + expires_on: { lt: cutoffDate }, + }, + { + status: AnnouncementStatus.Deleted, + updated_date: { lt: cutoffDate }, + }, + ], + }, + select: { + announcement_id: true, + title: true, + }, + }); + + const announcementIds = announcementsToDelete.map((a) => a.announcement_id); + + if (announcementIds.length === 0) { + logger.info('No announcements to delete.'); + return; + } + + // Get list of object store ids associated with the announcement id's (history will always share ) + const attachmentResources = await prisma.announcement_resource.findMany({ + where: { + announcement_id: { in: announcementIds }, + resource_type: 'ATTACHMENT', + }, + select: { + announcement_id: true, + attachment_file_id: true, + }, + }); + const lookupFileIdFromId = attachmentResources.reduce( + (acc, { announcement_id, attachment_file_id }) => { + acc[announcement_id] = attachment_file_id; + return acc; + }, + {} as Record, + ); + + // Delete files in announcements + const successfulDeletions = await deleteFiles( + Object.values(lookupFileIdFromId), + ); + + // Update list of announcements to delete, we don't want to delete any announcements that have a reference to a file that wasn't deleted + const announcementsWithResources = new Set( + attachmentResources.map((a) => a.announcement_id), + ); + const safeToDelete = announcementsToDelete.filter( + (a) => + !announcementsWithResources.has(a.announcement_id) || // Keep this record if it didn't have any files, or, + successfulDeletions.has(lookupFileIdFromId[a.announcement_id]), // Keep this record if it does have files and those files were successful removed + ); + + if (safeToDelete.length === 0) { + logger.info('No announcements could be deleted.'); + return; + } + + // Delete from database + await Promise.all( + safeToDelete.map(async (x) => { + try { + await prisma.$transaction(async (tx) => { + await tx.announcement_resource_history.deleteMany({ + where: { + announcement_id: x.announcement_id, + }, + }); + await tx.announcement_resource.deleteMany({ + where: { + announcement_id: x.announcement_id, + }, + }); + await tx.announcement_history.deleteMany({ + where: { + announcement_id: x.announcement_id, + }, + }); + await tx.announcement.deleteMany({ + where: { + announcement_id: x.announcement_id, + }, + }); + }); + logger.info(`Deleted announcement titled '${x.title}'`); + } catch (err) { + logger.error( + `Failed to delete announcement '${x.title}' (ID: ${x.announcement_id}). Error: ${err.message}`, + ); + } + }), + ); + }, }; diff --git a/charts/fin-pay-transparency/templates/configmap.yaml b/charts/fin-pay-transparency/templates/configmap.yaml index c8b09504b..779bb3bb7 100644 --- a/charts/fin-pay-transparency/templates/configmap.yaml +++ b/charts/fin-pay-transparency/templates/configmap.yaml @@ -13,6 +13,7 @@ data: DELETE_USER_ERRORS_CRON_CRONTIME: {{ .Values.global.config.delete_user_errors_cron_crontime | quote }} LOCK_REPORT_CRON_CRONTIME: {{ .Values.global.config.lock_report_cron_crontime | quote }} EXPIRE_ANNOUNCEMENTS_CRON_CRONTIME: {{ .Values.global.config.expire_announcements_cron_crontime | quote }} + DELETE_ANNOUNCEMENTS_CRON_CRONTIME: {{ .Values.global.config.delete_announcements_cron_crontime | quote }} EMAIL_EXPIRING_ANNOUNCEMENTS_CRON_CRONTIME: {{ .Values.global.config.email_expiring_announcements_cron_crontime | quote }} ENABLE_EMAIL_EXPIRING_ANNOUNCEMENTS: {{ .Values.global.config.enable_email_expiring_announcements | quote }} REPORTS_SCHEDULER_CRON_TIMEZONE: {{ .Values.global.config.reports_scheduler_cron_timezone | quote }} diff --git a/charts/fin-pay-transparency/values-dev.yaml b/charts/fin-pay-transparency/values-dev.yaml index 3ec4f3ddf..3005fde8b 100644 --- a/charts/fin-pay-transparency/values-dev.yaml +++ b/charts/fin-pay-transparency/values-dev.yaml @@ -47,6 +47,7 @@ global: delete_user_errors_cron_crontime: "30 0 1 */6 *" #At 12:30 AM on the 1st day of every 6th month lock_report_cron_crontime: "15 0 * * *" # 12:15 AM PST/PDT expire_announcements_cron_crontime: "0 6,18 * * *" # 6am & 6pm daily + delete_announcements_cron_crontime: "0 0 1 * *" # 1st day of every month email_expiring_announcements_cron_crontime: "0 7 * * *" # 7:00 AM PST/PDT enable_email_expiring_announcements: "false" reports_scheduler_cron_timezone: "America/Vancouver" diff --git a/charts/fin-pay-transparency/values-prod.yaml b/charts/fin-pay-transparency/values-prod.yaml index ee35240b5..599bc139c 100644 --- a/charts/fin-pay-transparency/values-prod.yaml +++ b/charts/fin-pay-transparency/values-prod.yaml @@ -47,6 +47,7 @@ global: delete_user_errors_cron_crontime: "30 0 1 */6 *" #At 12:30 AM on the 1st day of every 6th month lock_report_cron_crontime: "15 0 * * *" # 12:15 AM PST/PDT expire_announcements_cron_crontime: "0 6,18 * * *" # 6am & 6pm daily + delete_announcements_cron_crontime: "0 0 1 * *" # 1st day of every month email_expiring_announcements_cron_crontime: "0 7 * * *" # 7:00 AM PST/PDT enable_email_expiring_announcements: "true" reports_scheduler_cron_timezone: "America/Vancouver" diff --git a/charts/fin-pay-transparency/values-test.yaml b/charts/fin-pay-transparency/values-test.yaml index 1e7df6792..7faf3fc0c 100644 --- a/charts/fin-pay-transparency/values-test.yaml +++ b/charts/fin-pay-transparency/values-test.yaml @@ -47,6 +47,7 @@ global: delete_user_errors_cron_crontime: "30 0 1 */6 *" #At 12:30 AM on the 1st day of every 6th month lock_report_cron_crontime: "15 0 * * *" # 12:15 AM PST/PDT expire_announcements_cron_crontime: "0 6,18 * * *" # 6am & 6pm daily + delete_announcements_cron_crontime: "0 0 1 * *" # 1st day of every month email_expiring_announcements_cron_crontime: "0 7 * * *" # 7:00 AM PST/PDT enable_email_expiring_announcements: "true" reports_scheduler_cron_timezone: "America/Vancouver" diff --git a/charts/fin-pay-transparency/values.yaml b/charts/fin-pay-transparency/values.yaml index 35e1c9b76..45ffae5b5 100644 --- a/charts/fin-pay-transparency/values.yaml +++ b/charts/fin-pay-transparency/values.yaml @@ -48,6 +48,7 @@ global: delete_user_errors_cron_crontime: "30 0 1 */6 *" #At 12:30 AM on the 1st day of every 6th month lock_report_cron_crontime: "15 0 * * *" # 12:15 AM PST/PDT expire_announcements_cron_crontime: "0 6,18 * * *" # 6am & 6pm daily + delete_announcements_cron_crontime: "0 0 1 * *" # 1st day of every month email_expiring_announcements_cron_crontime: "0 7 * * *" # 7:00 AM PST/PDT enable_email_expiring_announcements: "true" reports_scheduler_cron_timezone: "America/Vancouver"