diff --git a/.env.dist b/.env.dist index b8ee3f0e3..9f701730c 100644 --- a/.env.dist +++ b/.env.dist @@ -52,3 +52,7 @@ RABBITMQ_PORT= # MODULES FRONT_OFFICE_URI= BACK_OFFICE_URI= + +# MIXPANEL CONFIGURATION +MIXPANEL_TOKEN= +MIXPANEL_HOST= diff --git a/config/custom-environment-variables.js b/config/custom-environment-variables.js index dacdb425e..1533e242c 100644 --- a/config/custom-environment-variables.js +++ b/config/custom-environment-variables.js @@ -3,6 +3,7 @@ */ module.exports = { server: { + port: 'SERVER_PORT', url: 'SERVER_URL', allowedOrigins: 'SERVER_ALLOWED_ORIGINS', }, @@ -38,6 +39,7 @@ module.exports = { clientId: 'AUTH_CLIENT_ID', tenantId: 'AUTH_TENANT_ID', allowedIssuers: 'AUTH_ALLOWED_ISSUERS', + audience: 'AUTH_AUDIENCE', }, encryption: { key: 'ENCRYPTION_KEY', @@ -61,4 +63,8 @@ module.exports = { clientSecret: 'COMMON_SERVICES_CLIENT_SECRET', scope: 'COMMON_SERVICES_SCOPE', }, + mixpanel: { + token: 'MIXPANEL_TOKEN', + host: 'MIXPANEL_HOST', + }, }; diff --git a/package-lock.json b/package-lock.json index 298003627..1b276c5f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "lodash": "^4.17.21", "migrate": "^1.8.0", "mime-types": "^2.1.33", + "mixpanel": "^0.18.0", "mongoose": "^7.4.3", "node-cache": "^5.1.2", "node-fetch": "^2.6.1", @@ -12830,6 +12831,29 @@ "node": ">= 6" } }, + "node_modules/mixpanel": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/mixpanel/-/mixpanel-0.18.0.tgz", + "integrity": "sha512-VyUoiLB/S/7abYYHGD5x0LijeuJCUabG8Hb+FvYU3Y99xHf1Qh+s4/pH9lt50fRitAHncWbU1FE01EknUfVVjQ==", + "dependencies": { + "https-proxy-agent": "5.0.0" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/mixpanel/node_modules/https-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", diff --git a/package.json b/package.json index 051dd4d45..c2516a528 100644 --- a/package.json +++ b/package.json @@ -39,9 +39,9 @@ "@azure/storage-blob": "^12.5.0", "@casl/ability": "^6.5.0", "@casl/mongoose": "^7.2.1", + "@graphql-tools/schema": "^10.0.0", "@turf/helpers": "^6.5.0", "@turf/turf": "^6.5.0", - "@graphql-tools/schema": "^10.0.0", "@ucast/mongo2js": "^1.3.3", "amqplib": "^0.8.0", "axios": "^1.4.0", @@ -79,6 +79,7 @@ "lodash": "^4.17.21", "migrate": "^1.8.0", "mime-types": "^2.1.33", + "mixpanel": "^0.18.0", "mongoose": "^7.4.3", "node-cache": "^5.1.2", "node-fetch": "^2.6.1", diff --git a/src/index.ts b/src/index.ts index df915c662..3ae76d8f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import subscriberSafe from './server/subscriberSafe'; import pullJobScheduler from './server/pullJobScheduler'; import customNotificationScheduler from './server/customNotificationScheduler'; import { startDatabase } from './server/database'; +import { initMixpanel } from './server/mixpanel'; import config from 'config'; import { logger } from './services/logger.service'; import { checkConfig } from '@utils/server/checkConfig.util'; @@ -22,6 +23,9 @@ declare global { } } +/** Init Mixpanel */ +initMixpanel(); + // Ensure that all mandatory keys exist checkConfig(); diff --git a/src/models/form.model.ts b/src/models/form.model.ts index 2cd04d72c..4bf6c3a9e 100644 --- a/src/models/form.model.ts +++ b/src/models/form.model.ts @@ -54,6 +54,7 @@ interface FormDocument extends Document { versions?: any[]; channel?: any; layouts?: any; + logEvents?: boolean; } /** Interface of form */ @@ -82,6 +83,7 @@ const schema = new Schema
( graphQLTypeName: String, structure: mongoose.Schema.Types.Mixed, core: Boolean, + logEvents: Boolean, status: { type: String, enum: Object.values(status), diff --git a/src/routes/download/index.ts b/src/routes/download/index.ts index 59fecd48a..5e37686de 100644 --- a/src/routes/download/index.ts +++ b/src/routes/download/index.ts @@ -31,6 +31,7 @@ import { formatFilename } from '@utils/files/format.helper'; import { sendEmail } from '@utils/email'; import exportBatch from '@utils/files/exportBatch'; import { accessibleBy } from '@casl/mongoose'; +import { downloadFormFileEvent } from '@server/mixpanel'; /** * Exports files in csv or xlsx format, excepted if specified otherwise @@ -117,7 +118,19 @@ router.get('/form/records/:id', async (req, res) => { ); // If the export is only of a template, build and export it, else build and export a file with the records if (req.query.template) { - return await templateBuilder(res, form.name, columns); + const file = await templateBuilder(res, form.name, columns); + // Log event + if (form.logEvents) { + const user = req.context.user; + downloadFormFileEvent( + form, + form.name, + user, + null, + 'Export of the template of the form to upload new records.' + ); + } + return file; } else { const records = await Record.find(filter); const rows = await getRows( @@ -131,7 +144,19 @@ router.get('/form/records/:id', async (req, res) => { }); const type = (req.query ? req.query.type : 'xlsx').toString(); const filename = formatFilename(form.name); - return await fileBuilder(res, filename, columns, rows, type); + const file = await fileBuilder(res, filename, columns, rows, type); + // Log event + if (form.logEvents) { + const user = req.context.user; + downloadFormFileEvent( + form, + filename, + user, + null, + 'Export of the records of the form' + ); + } + return file; } } else { return res.status(404).send(i18next.t('common.errors.dataNotFound')); @@ -259,7 +284,19 @@ router.get('/form/records/:id/history', async (req, res) => { dateLocale, type, }; - return await historyFileBuilder(res, history, meta, options); + const file = await historyFileBuilder(res, history, meta, options); + // Log event + if (form.logEvents) { + const user = req.context.user; + downloadFormFileEvent( + form, + 'Record history ' + meta.record, + user, + meta, + 'Export versions of the record' + ); + } + return file; } else { return res.status(404).send(req.t('common.errors.dataNotFound')); } @@ -549,6 +586,17 @@ router.get('/file/:form/:blob/:record/:field', async (req, res) => { const path = `files/${sanitize(req.params.blob)}`; await downloadFile('forms', blobName, path); res.download(path, () => { + // Log event + if (form.logEvents) { + const user = req.context.user; + downloadFormFileEvent( + form, + blobName, + user, + null, + 'Export another type of file from blob storage. Path: ' + path + ); + } fs.unlink(path, () => { logger.info('file deleted'); }); diff --git a/src/schema/mutation/addRecord.mutation.ts b/src/schema/mutation/addRecord.mutation.ts index e9406cce8..6231183ed 100644 --- a/src/schema/mutation/addRecord.mutation.ts +++ b/src/schema/mutation/addRecord.mutation.ts @@ -10,9 +10,10 @@ import { logger } from '@services/logger.service'; import { graphQLAuthCheck } from '@schema/shared'; import { Types } from 'mongoose'; import { Context } from '@server/apollo/context'; +import { recordEvent } from '@server/mixpanel'; /** Arguments for the addRecord mutation */ -type AddRecordArgs = { +export type AddRecordArgs = { form?: string | Types.ObjectId; data: any; }; @@ -133,6 +134,12 @@ export default { publisher.publish(channel.id, { notification }); } await record.save(); + + // Log event + if (form.logEvents) { + recordEvent('Add record', form, record, user, args); + } + return record; } catch (err) { logger.error(err.message, { stack: err.stack }); diff --git a/src/schema/mutation/convertRecord.mutation.ts b/src/schema/mutation/convertRecord.mutation.ts index 7244ce980..cbe60e815 100644 --- a/src/schema/mutation/convertRecord.mutation.ts +++ b/src/schema/mutation/convertRecord.mutation.ts @@ -12,9 +12,10 @@ import { logger } from '@services/logger.service'; import { graphQLAuthCheck } from '@schema/shared'; import { Types } from 'mongoose'; import { Context } from '@server/apollo/context'; +import { recordEvent } from '@server/mixpanel'; /** Arguments for the convertRecord mutation */ -type ConvertRecordArgs = { +export type ConvertRecordArgs = { id: string | Types.ObjectId; form: string | Types.ObjectId; copyRecord: boolean; @@ -90,13 +91,48 @@ export default { name: targetForm.name, }, }); - return await targetRecord.save(); + + const record = await targetRecord.save(); + + // Log event + if (oldForm.logEvents || targetForm.logEvents) { + recordEvent( + 'Convert record', + targetForm, + record, + user, + args, + oldRecord, + 'Convert record coping record to the target form (record in the old form still exists)', + oldForm + ); + } + + return record; } else { const update: any = { form: args.form, //modifiedAt: new Date(), }; - return await Record.findByIdAndUpdate(args.id, update, { new: true }); + const record = await Record.findByIdAndUpdate(args.id, update, { + new: true, + }); + + // Log event + if (oldForm.logEvents || targetForm.logEvents) { + recordEvent( + 'Convert record', + targetForm, + record, + user, + args, + oldRecord, + 'Convert record without copy record (overwriting record)', + oldForm + ); + } + + return record; } } catch (err) { logger.error(err.message, { stack: err.stack }); diff --git a/src/schema/mutation/deleteRecord.mutation.ts b/src/schema/mutation/deleteRecord.mutation.ts index 2adc93e4c..eff8e5bec 100644 --- a/src/schema/mutation/deleteRecord.mutation.ts +++ b/src/schema/mutation/deleteRecord.mutation.ts @@ -11,9 +11,10 @@ import { logger } from '@services/logger.service'; import { graphQLAuthCheck } from '@schema/shared'; import { Types } from 'mongoose'; import { Context } from '@server/apollo/context'; +import { recordEvent } from '@server/mixpanel'; /** Arguments for the deleteRecord mutation */ -type DeleteRecordArgs = { +export type DeleteRecordArgs = { id: string | Types.ObjectId; hardDelete?: boolean; }; @@ -47,13 +48,43 @@ export default { // Delete the record if (args.hardDelete) { - return await Record.findByIdAndDelete(record._id); + const deletion = await Record.findByIdAndDelete(record._id); + + // Log event + if (form.logEvents) { + recordEvent( + 'Delete record', + form, + record, + user, + args, + null, + 'Record hard deleted' + ); + } + + return deletion; } else { - return await Record.findByIdAndUpdate( + const deletion = await Record.findByIdAndUpdate( record._id, { archived: true }, { new: true } ); + + // Log event + if (form.logEvents) { + recordEvent( + 'Delete record', + form, + record, + user, + args, + null, + 'Record archived (soft deleted)' + ); + } + + return deletion; } } catch (err) { logger.error(err.message, { stack: err.stack }); diff --git a/src/schema/mutation/deleteRecords.mutation.ts b/src/schema/mutation/deleteRecords.mutation.ts index d86499539..5594340d0 100644 --- a/src/schema/mutation/deleteRecords.mutation.ts +++ b/src/schema/mutation/deleteRecords.mutation.ts @@ -12,9 +12,10 @@ import { logger } from '@services/logger.service'; import { graphQLAuthCheck } from '@schema/shared'; import { Types } from 'mongoose'; import { Context } from '@server/apollo/context'; +import { recordEvent } from '@server/mixpanel'; /** Arguments for the deleteRecords mutation */ -type DeleteRecordsArgs = { +export type DeleteRecordsArgs = { ids: string[] | Types.ObjectId[]; hardDelete?: boolean; }; @@ -57,6 +58,22 @@ export default { const result = await Record.deleteMany({ _id: { $in: toDelete.map((x) => x._id) }, }); + + // Log events + for (const record of toDelete) { + if (record.form.logEvents) { + recordEvent( + 'Delete record', + record.form, + record, + user, + args, + null, + 'Record hard deleted. Record deleted along with many others' + ); + } + } + return result.deletedCount; } else { const result = await Record.updateMany( @@ -66,6 +83,22 @@ export default { }, { new: true } ); + + // Log events + for (const record of toDelete) { + if (record.form.logEvents) { + recordEvent( + 'Delete record', + record.form, + record, + user, + args, + null, + 'Record archived (soft deleted). Record archived along with many others' + ); + } + } + return result.modifiedCount; } } catch (err) { diff --git a/src/schema/mutation/editForm.mutation.ts b/src/schema/mutation/editForm.mutation.ts index f1e69f5c2..cbaf64f24 100644 --- a/src/schema/mutation/editForm.mutation.ts +++ b/src/schema/mutation/editForm.mutation.ts @@ -36,6 +36,7 @@ import checkDefaultFields from '@utils/form/checkDefaultFields'; import { preserveChildProperties } from '@utils/form/preserveChildProperties'; import { graphQLAuthCheck } from '@schema/shared'; import { Context } from '@server/apollo/context'; +import { editFormEvent } from '@server/mixpanel'; /** * List of keys of the structure's object which we want to inherit to the children forms when they are modified on the core form @@ -77,7 +78,7 @@ type PermissionChange = { }; /** Arguments for the editForm mutation */ -type EditFormArgs = { +export type EditFormArgs = { id: string | mongoose.Types.ObjectId; structure?: any; status?: StatusType; @@ -232,6 +233,9 @@ export default { : {}), }; update.idShape = idShape; + // Save the logEvents + update.logEvents = structure.logEvents || false; + const fields = []; for (const page of structure.pages) { await extractFields(page, fields, form.core); @@ -553,6 +557,11 @@ export default { await updateIncrementalIds(form, update.idShape); } + // Log event + if (form.logEvents) { + editFormEvent(form, args, user); + } + // Return updated form return resForm; } catch (err) { diff --git a/src/schema/mutation/editRecord.mutation.ts b/src/schema/mutation/editRecord.mutation.ts index 0639e3fd6..ae9075ade 100644 --- a/src/schema/mutation/editRecord.mutation.ts +++ b/src/schema/mutation/editRecord.mutation.ts @@ -21,6 +21,7 @@ import { filter, isEqual, keys, union, has, get } from 'lodash'; import { logger } from '@services/logger.service'; import { graphQLAuthCheck } from '@schema/shared'; import { Context } from '@server/apollo/context'; +import { recordEvent } from '@server/mixpanel'; /** * Checks if the user has the permission to update all the fields they're trying to update @@ -56,7 +57,7 @@ export const hasInaccessibleFields = ( }; /** Arguments for the editRecord mutation */ -type EditRecordArgs = { +export type EditRecordArgs = { id: string | Types.ObjectId; data?: any; version?: string | Types.ObjectId; @@ -94,7 +95,7 @@ export default { const oldRecord: Record = await Record.findById(args.id); const parentForm: Form = await Form.findById( oldRecord.form, - 'fields permissions resource structure' + 'id name logEvents fields permissions resource structure' ); if (!oldRecord || !parentForm) { throw new GraphQLError(context.i18next.t('common.errors.dataNotFound')); @@ -119,6 +120,20 @@ export default { parentForm, context ); + + // Log events + if (parentForm.logEvents) { + recordEvent( + 'Edit record', + parentForm, + triggeredRecord, + user, + args, + oldRecord, + 'Draft record' + ); + } + return triggeredRecord; } @@ -202,7 +217,22 @@ export default { ); const record = Record.findByIdAndUpdate(args.id, update, { new: true }); await version.save(); - return await record; + const resRecord = await record; + + // Log events + if (parentForm.logEvents) { + recordEvent( + 'Edit record', + parentForm, + resRecord, + user, + args, + oldRecord, + 'Classic record edition' + ); + } + + return resRecord; } else { const oldVersion = await Version.findOne({ $and: [ @@ -232,7 +262,22 @@ export default { }; const record = Record.findByIdAndUpdate(args.id, update, { new: true }); await version.save(); - return await record; + const resRecord = await record; + + // Log events + if (parentForm.logEvents) { + recordEvent( + 'Edit record', + parentForm, + resRecord, + user, + args, + oldRecord, + 'Record version updated' + ); + } + + return resRecord; } } catch (err) { logger.error(err.message, { stack: err.stack }); diff --git a/src/schema/mutation/editRecords.mutation.ts b/src/schema/mutation/editRecords.mutation.ts index 5c6a139da..e4ae322dd 100644 --- a/src/schema/mutation/editRecords.mutation.ts +++ b/src/schema/mutation/editRecords.mutation.ts @@ -19,6 +19,7 @@ import { logger } from '@services/logger.service'; import { graphQLAuthCheck } from '@schema/shared'; import { Types } from 'mongoose'; import { Context } from '@server/apollo/context'; +import { recordEvent } from '@server/mixpanel'; /** Interface for records with an error */ interface RecordWithError extends Record { @@ -29,7 +30,7 @@ interface RecordWithError extends Record { } /** Arguments for the editRecords mutation */ -type EditRecordsArgs = { +export type EditRecordsArgs = { ids: string[] | Types.ObjectId[]; data: any; template?: string | Types.ObjectId; @@ -139,6 +140,20 @@ export default { } ); await version.save(); + + // Log events + if (record.form.logEvents) { + recordEvent( + 'Edit record', + record.form, + newRecord, + user, + update, + record, + 'Classic record edition. Record edited along with many others' + ); + } + records.push(newRecord); } } diff --git a/src/schema/mutation/restoreRecord.mutation.ts b/src/schema/mutation/restoreRecord.mutation.ts index a589c3697..6d619c842 100644 --- a/src/schema/mutation/restoreRecord.mutation.ts +++ b/src/schema/mutation/restoreRecord.mutation.ts @@ -6,9 +6,10 @@ import { logger } from '@services/logger.service'; import { graphQLAuthCheck } from '@schema/shared'; import { Types } from 'mongoose'; import { Context } from '@server/apollo/context'; +import { recordEvent } from '@server/mixpanel'; /** Arguments for the restoreRecord mutation */ -type RestoreRecordArgs = { +export type RestoreRecordArgs = { id: string | Types.ObjectId; }; @@ -38,11 +39,18 @@ export default { ); } // Update the record - return await Record.findByIdAndUpdate( + const resRecord = await Record.findByIdAndUpdate( record._id, { archived: false }, { new: true } ); + + // Log event + if (record.form.logEvents) { + recordEvent('Restore record', record.form, resRecord, user, args); + } + + return resRecord; } catch (err) { logger.error(err.message, { stack: err.stack }); if (err instanceof GraphQLError) { diff --git a/src/schema/query/me.query.ts b/src/schema/query/me.query.ts index 6809ab2da..7b3275844 100644 --- a/src/schema/query/me.query.ts +++ b/src/schema/query/me.query.ts @@ -3,6 +3,7 @@ import { UserType } from '../types'; import { logger } from '@services/logger.service'; import { graphQLAuthCheck } from '@schema/shared'; import { Context } from '@server/apollo/context'; +import { loginEvent } from '@server/mixpanel'; /** * Return user from logged user id. @@ -12,9 +13,10 @@ export default { type: UserType, resolve: async (parent, args, context: Context) => { graphQLAuthCheck(context); - try { - return context.user; + const user = context.user; + loginEvent(user); + return user; } catch (err) { logger.error(err.message, { stack: err.stack }); if (err instanceof GraphQLError) { diff --git a/src/schema/types/form.type.ts b/src/schema/types/form.type.ts index fa336c349..b7b2b7af5 100644 --- a/src/schema/types/form.type.ts +++ b/src/schema/types/form.type.ts @@ -72,6 +72,9 @@ export const FormType = new GraphQLObjectType({ return parent.core ? parent.core : false; }, }, + logEvents: { + type: GraphQLBoolean, + }, records: { type: RecordConnectionType, args: { diff --git a/src/server/apollo/context.ts b/src/server/apollo/context.ts index 1d4428d26..ef9474a7b 100644 --- a/src/server/apollo/context.ts +++ b/src/server/apollo/context.ts @@ -14,7 +14,7 @@ export interface Context { } /** User interface with specified AppAbility */ -interface UserWithAbility extends User { +export interface UserWithAbility extends User { ability: AppAbility; } diff --git a/src/server/mixpanel.ts b/src/server/mixpanel.ts new file mode 100644 index 000000000..7fcbbecd5 --- /dev/null +++ b/src/server/mixpanel.ts @@ -0,0 +1,144 @@ +import config from 'config'; +import { Form, Record, RecordHistoryMeta } from '@models'; +import { UserWithAbility } from './apollo/context'; +import { EditFormArgs } from '@schema/mutation/editForm.mutation'; +import { EditRecordArgs } from '@schema/mutation/editRecord.mutation'; +import { AddRecordArgs } from '@schema/mutation/addRecord.mutation'; +import { ConvertRecordArgs } from '@schema/mutation/convertRecord.mutation'; +import { DeleteRecordsArgs } from '@schema/mutation/deleteRecords.mutation'; +import { RestoreRecordArgs } from '@schema/mutation/restoreRecord.mutation'; +import { DeleteRecordArgs } from '@schema/mutation/deleteRecord.mutation'; +import { EditRecordsArgs } from '@schema/mutation/editRecords.mutation'; + +// Mixpanel factory +import Mixpanel from 'mixpanel'; +let mixpanel; + +/** + * Init mixpanel connection to store logs + */ +export const initMixpanel = async () => { + if (config.get('mixpanel.token') && config.get('mixpanel.host')) { + mixpanel = Mixpanel.init(config.get('mixpanel.token'), { + host: config.get('mixpanel.host'), + debug: true, + }); + } +}; + +/** + * Register login event + * + * @param user user responsible for the action + */ +export const loginEvent = async (user: UserWithAbility) => { + if (mixpanel) { + mixpanel.track('Login', { + distinct_id: user.id ?? user._id, + user, + }); + } +}; + +/** + * Register creation/update of records events. + * + * @param type type of record event. It can be 'Add record', 'Edit record', 'Delete record', 'Convert record' or 'Restore record' + * @param form form of the record + * @param record record with the updated/new/deleted data + * @param user user responsible for the action + * @param args arguments of the record update + * @param oldRecord record with the outdated data + * @param details extra details about the event to be register + * @param oldForm when converting record, the old form of the original record + */ +export const recordEvent = async ( + type: + | 'Add record' + | 'Edit record' + | 'Delete record' + | 'Convert record' + | 'Restore record', + form: Form, + record: Record, + user: UserWithAbility, + args: + | EditRecordArgs + | EditRecordsArgs + | AddRecordArgs + | ConvertRecordArgs + | DeleteRecordArgs + | DeleteRecordsArgs + | RestoreRecordArgs, + oldRecord?: Record, + details?: string, + oldForm?: Form +) => { + if (mixpanel) { + mixpanel.track(type, { + distinct_id: user.id ?? user._id, + user, + parent_form: form, + edition_arguments: args, + record, + ...(oldRecord && { record_old_data: oldRecord }), + ...(oldForm && { oldForm }), + ...(details && { details }), + }); + } +}; + +/** + * Register form events. + * + * @param form form object without the updated data + * @param args arguments of the form update + * @param user user responsible for the action + * @param details extra details about the event to be register + */ +export const editFormEvent = async ( + form: Form, + args: EditFormArgs, + user: UserWithAbility, + details?: string +) => { + if (mixpanel) { + mixpanel.track('Edit form', { + distinct_id: user.id ?? user._id, + user, + form_id: form.id, + form_old_data: form, + edition_arguments: args, + ...(details && { details }), + }); + } +}; + +/** + * Register download file from from events. + * + * @param form form object without the updated data + * @param fileName name of the file downloaded + * @param user user responsible for the action + * @param recordMeta record metadate export details + * @param details extra details about the event to be register + */ +export const downloadFormFileEvent = async ( + form: Form, + fileName: string, + user: UserWithAbility, + recordMeta?: RecordHistoryMeta, + details?: string +) => { + if (mixpanel) { + mixpanel.track('Download file from form', { + distinct_id: user.id ?? user._id, + user, + form_id: form.id, + form: form, + file_name: fileName, + ...(recordMeta && { record_metadate: recordMeta }), + ...(details && { details }), + }); + } +};