diff --git a/src/index.ts b/src/index.ts index df915c662..b25a79720 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import config from 'config'; import { logger } from './services/logger.service'; import { checkConfig } from '@utils/server/checkConfig.util'; import buildSchema from '@utils/schema/buildSchema'; +import { startJobs } from 'jobs'; // Needed for survey.model, as xmlhttprequest is not defined in servers global.XMLHttpRequest = require('xhr2'); @@ -51,6 +52,9 @@ const launchServer = async () => { logger.info(`🚀 Server ready at ws://localhost:${PORT}/graphql`); }); }); + + // Start all the custom jobs + startJobs(); }; launchServer(); diff --git a/src/jobs/anonymizeBeneficiaries.ts b/src/jobs/anonymizeBeneficiaries.ts new file mode 100644 index 000000000..b759a26bc --- /dev/null +++ b/src/jobs/anonymizeBeneficiaries.ts @@ -0,0 +1,74 @@ +import { Record, User } from '@models'; +import { Types } from 'mongoose'; + +/** Staff resource ID */ +const AID_RESOURCE_ID = new Types.ObjectId('64e6e0933c7bf3962bf4f04c'); +const FAMILY_RESOURCE_ID = new Types.ObjectId('64de75fd3fb2a11c988dddb2'); + +/** Anonymizes the beneficiary data, if didn't log in for more than 18 months */ +export const anonymizeBeneficiaries = async () => { + // For all family records, check if + // in the last 18 months they received aid + + // Get all the family records + const allFamilies = await Record.find({ + resource: FAMILY_RESOURCE_ID, + }); + + // For each family record, check if exists + // an aid record in the last 18 months + for (const family of allFamilies) { + const aidGivenToFamily = await Record.exists({ + resource: AID_RESOURCE_ID, + createdAt: { + $gt: new Date(Date.now() - 18 * 30 * 24 * 60 * 60 * 1000), + }, // 18 months ago + 'data.owner_resource': family._id.toString(), + }); + + // If no aid was given to the family in the last 18 months + if (!aidGivenToFamily) { + // Find all members of the family + const members = await Record.find({ + _id: { $in: family?.data?.members }, + }); + + // Anonymize all the members + members.forEach((member) => { + if (!member.data) { + return; + } + // Anonymize the member + member._createdBy = new User({ + name: 'ANONYMOUS', + username: `${member._id.toString()}@oort-anonymous.com`, + }); + + member.data = { + ...member.data, + location: 'ANONYMOUS', + surname: 'ANONYMOUS', + firstname: 'ANONYMOUS', + phone: 'ANONYMOUS', + nom_employes: 'ANONYMOUS', + gender: 'ANONYMOUS', + birthdate: 'ANONYMOUS', + prenom_employes: 'ANONYMOUS', + nom_prenom_employes: 'ANONYMOUS', + tel_staff: 'ANONYMOUS', + email_staff: 'ANONYMOUS', + birthdate_employes: 'ANONYMOUS', + file_gdpr_staff: [], + }; + + member._lastUpdatedBy = new User({ + name: 'ANONYMOUS', + username: `${member._id.toString()}@oort-anonymous.com`, + }); + }); + + // Save all the records + await Record.bulkSave(members); + } + } +}; diff --git a/src/jobs/anonymizeStaff.ts b/src/jobs/anonymizeStaff.ts new file mode 100644 index 000000000..4eda17a40 --- /dev/null +++ b/src/jobs/anonymizeStaff.ts @@ -0,0 +1,78 @@ +import { Record, User } from '@models'; +import { deleteFile } from '@utils/files'; +import { Types } from 'mongoose'; +import { posterizeAge } from './utils/posterizeAge'; +import { logger } from '@services/logger.service'; + +/** Staff resource ID */ +const STAFF_RESOURCE_ID = new Types.ObjectId('649e9ec5eae9f89219921eff'); + +/** Anonymizes the staff data, if didn't log in for more than 6 months */ +export const anonymizeStaff = async () => { + // Get all users with lastLogin 6 months ago + const usersToDelete = await User.find({ + $expr: { + $lt: [ + { + $ifNull: ['$lastLogin', '$modifiedAt'], + }, + new Date(Date.now() - 6 * 30 * 24 * 60 * 60 * 1000), // 6 months ago + ], + }, + }); + + // Find all the records of staff with the users found above + const usersStaffRecords = await Record.find({ + resource: STAFF_RESOURCE_ID, + user: { $in: usersToDelete.map((user) => user._id) }, + }); + + // Hide the info on the user + usersToDelete.forEach((user) => { + user.username = `${user._id.toString()}@oort-anonymous.com`; + user.firstName = 'ANONYMOUS'; + user.lastName = 'ANONYMOUS'; + user.name = 'ANONYMOUS'; + user.roles = []; + user.oid = null; + }); + + await User.bulkSave(usersToDelete); + + // Hold all files that should be deleted in blob storage + const filesToDelete = []; + + // Hide info on the staff record + usersStaffRecords.forEach((staffRecord) => { + if (!staffRecord.data) { + return; + } + + // Add all files to delete + (staffRecord.data.file_gdpr_staff || []).forEach((file) => { + filesToDelete.push(file.content); + }); + + staffRecord.data = { + ...staffRecord.data, + nom_employes: 'ANONYMOUS', + prenom_employes: 'ANONYMOUS', + tel_staff: 'ANONYMOUS', + email_staff: `${staffRecord.data.linked_user[0]}@oort-anonymous.com`, + file_gdpr_staff: [], + birthdate_employes: posterizeAge({ + birthdate: staffRecord.data.birthdate_employes, + }), + }; + }); + + // Delete all files + Promise.all(filesToDelete.map((file) => deleteFile('forms', file))).catch( + (err) => { + logger.error(`Error deleting files: ${err}`); + } + ); + + // Save all the records + await Record.bulkSave(usersStaffRecords); +}; diff --git a/src/jobs/index.ts b/src/jobs/index.ts new file mode 100644 index 000000000..3e993e1a7 --- /dev/null +++ b/src/jobs/index.ts @@ -0,0 +1,64 @@ +import { CronJob } from 'cron'; +import { anonymizeStaff } from './anonymizeStaff'; +import { anonymizeBeneficiaries } from './anonymizeBeneficiaries'; +import { logger } from '@services/logger.service'; +import config from 'config'; + +/** All available jobs */ +const JOBS: { + name: string; + description: string; + fn: () => Promise; + // Schedule in cron format + schedule: string; + // Environments where the job should be started + envs: string[]; +}[] = [ + { + name: 'Anonymize staff', + description: "Anonymizes staff, if didn't log in for more than 6 months", + // Every week + schedule: '0 0 * * 0', + fn: anonymizeStaff, + envs: ['alimentaide'], + }, + { + name: 'Anonymize beneficiaries', + description: + 'Anonymizes all members of a family, if the family did not receive aid for the last 18 months', + // Every week + schedule: '0 0 * * 0', + fn: anonymizeBeneficiaries, + envs: ['alimentaide'], + }, +]; + +/** Starts all the jobs */ +export const startJobs = () => { + const isDev = config.util.getEnv('NODE_ENV') === 'development'; + const env = config.util.getEnv('NODE_CONFIG_ENV'); + + // Start all the jobs + JOBS.forEach((job) => { + // Check if the job should be started + if (!isDev && !job.envs.includes(env)) { + return; + } + + // Start the job + new CronJob( + job.schedule, + async () => { + try { + await job.fn(); + } catch (error) { + logger.error(error); + } + }, + null, + true + ).start(); + + logger.info(`🤖 Job "${job.name}" started`); + }); +}; diff --git a/src/jobs/utils/posterizeAge.ts b/src/jobs/utils/posterizeAge.ts new file mode 100644 index 000000000..9cbcac1ca --- /dev/null +++ b/src/jobs/utils/posterizeAge.ts @@ -0,0 +1,63 @@ +/** Defined age groups */ +const AGE_GROUPS = [ + [0, 3], + [4, 14], + [15, 17], + [18, 25], + [26, 64], + [65, 79], + [80, null], +]; + +/** + * Get the age of a person + * + * @param birthdate Date of birth of the person + * @returns The age of the person + */ +const getAge = (birthdate: Date) => { + const today = new Date(); + const birthDate = new Date(birthdate); + let age = today.getFullYear() - birthDate.getFullYear(); + const m = today.getMonth() - birthDate.getMonth(); + if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) { + age--; + } + return age; +}; + +/** + * Gets the age group of a person + * + * @param param Params object + * @param param.age Age of the person + * @param param.birthdate Birthdate of the person + * @returns The age group of the person + */ +export const posterizeAge = ({ + age, + birthdate, +}: { + age?: number; + birthdate?: string; +}): number | string | null => { + if (age && typeof age === 'number') { + // Find the age group + const ageGroup = AGE_GROUPS.find( + (group) => age >= group[0] && (!group[1] || age <= group[1]) + ); + + // Get random age in the age group + const min = ageGroup[0]; + const max = ageGroup[1] || 100; + return Math.floor(Math.random() * (max - min + 1)) + min; + } else if (birthdate && !isNaN(new Date(birthdate).getTime())) { + const newAge = posterizeAge({ age: getAge(new Date(birthdate)) }) as number; + + // Random month and day + return `${new Date().getFullYear() - newAge}-${ + Math.floor(Math.random() * 11) + 1 + }-${Math.floor(Math.random() * 27) + 1}`; + } + return null; +}; diff --git a/src/models/user.model.ts b/src/models/user.model.ts index fa384e4d7..79538430e 100644 --- a/src/models/user.model.ts +++ b/src/models/user.model.ts @@ -34,6 +34,7 @@ const userSchema = new Schema( type: mongoose.Schema.Types.Mixed, }, deleteAt: { type: Date, expires: 0 }, // Date of when we must remove the user + lastLogin: Date, }, { timestamps: { createdAt: 'createdAt', updatedAt: 'modifiedAt' }, @@ -56,6 +57,7 @@ export interface User extends Document { attributes?: any; modifiedAt?: Date; deleteAt?: Date; + lastLogin?: Date; } userSchema.index( diff --git a/src/server/middlewares/auth.ts b/src/server/middlewares/auth.ts index 9a818626e..8ab263e8d 100644 --- a/src/server/middlewares/auth.ts +++ b/src/server/middlewares/auth.ts @@ -59,6 +59,7 @@ if (config.get('auth.provider') === AuthenticationType.keycloak) { user.firstName = token.given_name; user.lastName = token.family_name; user.name = token.name; + user.lastLogin = new Date(); user.oid = token.sub; user.deleteAt = undefined; // deactivate the planned deletion user @@ -77,6 +78,7 @@ if (config.get('auth.provider') === AuthenticationType.keycloak) { if (!user.lastName) { user.lastName = token.family_name; } + user.lastLogin = new Date(); user .save() .then(() => { @@ -86,7 +88,15 @@ if (config.get('auth.provider') === AuthenticationType.keycloak) { userAuthCallback(err2, done, token, user); }); } else { - userAuthCallback(null, done, token, user); + user.lastLogin = new Date(); + user + .save() + .then(() => { + userAuthCallback(null, done, token, user); + }) + .catch((err2) => { + userAuthCallback(err2, done, token, user); + }); } } } else { @@ -97,6 +107,7 @@ if (config.get('auth.provider') === AuthenticationType.keycloak) { username: token.email, name: token.name, oid: token.sub, + lastLogin: new Date(), roles: [], positionAttributes: [], }); @@ -171,6 +182,7 @@ if (config.get('auth.provider') === AuthenticationType.keycloak) { user.firstName = token.given_name; user.lastName = token.family_name; user.name = token.name; + user.lastLogin = new Date(); user.oid = token.oid; updateUser(user, req).then(() => { user @@ -191,6 +203,7 @@ if (config.get('auth.provider') === AuthenticationType.keycloak) { if (!user.lastName) { user.lastName = token.family_name; } + user.lastLogin = new Date(); user .save() .then(() => { @@ -211,6 +224,7 @@ if (config.get('auth.provider') === AuthenticationType.keycloak) { lastName: token.family_name, username: token.preferred_username, name: token.name, + lastLogin: new Date(), oid: token.oid, roles: [], positionAttributes: [], diff --git a/src/utils/files/deleteFile.ts b/src/utils/files/deleteFile.ts new file mode 100644 index 000000000..04415e509 --- /dev/null +++ b/src/utils/files/deleteFile.ts @@ -0,0 +1,35 @@ +import { BlobServiceClient } from '@azure/storage-blob'; +import { logger } from '@services/logger.service'; +import { GraphQLError } from 'graphql'; +import i18next from 'i18next'; +import config from 'config'; + +/** Azure storage connection string */ +const AZURE_STORAGE_CONNECTION_STRING: string = config.get( + 'blobStorage.connectionString' +); + +/** + * Delete a file in Azure storage. + * + * @param containerName main container name + * @param path path to the blob + */ +export const deleteFile = async ( + containerName: string, + path: string +): Promise => { + try { + const blobServiceClient = BlobServiceClient.fromConnectionString( + AZURE_STORAGE_CONNECTION_STRING + ); + const containerClient = blobServiceClient.getContainerClient(containerName); + const file = containerClient.getBlockBlobClient(path); + await file.deleteIfExists(); + } catch (err) { + logger.error(err.message, { stack: err.stack }); + throw new GraphQLError( + i18next.t('utils.files.uploadFile.errors.fileCannotBeDeleted') + ); + } +}; diff --git a/src/utils/files/index.ts b/src/utils/files/index.ts index 83ce4875c..b730181c9 100644 --- a/src/utils/files/index.ts +++ b/src/utils/files/index.ts @@ -1,5 +1,6 @@ export * from './fileBuilder'; export * from './uploadFile'; +export * from './deleteFile'; export * from './downloadFile'; export * from './templateBuilder'; export * from './getColumns';