diff --git a/apps/platform/db/migrations/20250723154123_add_audits_table.js b/apps/platform/db/migrations/20250723154123_add_audits_table.js new file mode 100644 index 00000000..6b429cdd --- /dev/null +++ b/apps/platform/db/migrations/20250723154123_add_audits_table.js @@ -0,0 +1,37 @@ +exports.up = async function(knex) { + await knex.schema.createTable('audits', (table) => { + table.increments() + table.integer('project_id') + .unsigned() + .notNullable() + .references('id') + .inTable('projects') + .onDelete('CASCADE') + table.integer('admin_id') + .unsigned() + .notNullable() + .references('id') + .inTable('admins') + .onDelete('CASCADE') + table.string('item_type', 50).notNull() + table.integer('item_id').unsigned().notNull() + table.string('event', 50).notNull().index() + table.json('object').nullable() + table.json('object_changes').nullable() + + table.timestamp('created_at') + .notNull() + .defaultTo(knex.fn.now()) + .index() + table.timestamp('updated_at') + .notNull() + .defaultTo(knex.fn.now()) + + table.index(['item_type', 'item_id']) + }) +} + +exports.down = async function(knex) { + await knex.schema + .dropTable('audits') +} diff --git a/apps/platform/src/auth/Audit.ts b/apps/platform/src/auth/Audit.ts new file mode 100644 index 00000000..be0dd58a --- /dev/null +++ b/apps/platform/src/auth/Audit.ts @@ -0,0 +1,22 @@ +import Model from '../core/Model' + +export default class Audit extends Model { + project_id!: number + admin_id!: number + event!: string + object!: Record + object_changes!: Record + item_id!: number + item_type!: string + + static jsonAttributes = ['object', 'object_changes'] +} + +export interface Auditable { + id: number + $tableName: string +} + +export type WithAdmin = T & { + admin_id?: number +} diff --git a/apps/platform/src/auth/AuditService.ts b/apps/platform/src/auth/AuditService.ts new file mode 100644 index 00000000..a0f2679b --- /dev/null +++ b/apps/platform/src/auth/AuditService.ts @@ -0,0 +1,60 @@ +import { RequireAtLeastOne } from '../core/Types' +import { deepDiff } from '../utilities' +import Audit, { Auditable } from './Audit' + +type AuditParams = { + project_id: number + event: string + item_id: number + item_type: string +} + +type AuditCreateParams = RequireAtLeastOne<{ + project_id: number + event: string + admin_id: number + object?: Auditable + previous?: Auditable +}, 'object' | 'previous'> + +export const createAuditLog = async (data: AuditCreateParams) => { + return await Audit.insert({ + project_id: data.project_id, + event: data.event, + admin_id: data.admin_id, + object: data.object, + object_changes: deepDiff(data.object ?? {}, data.previous ?? {}), + item_id: data.object ? data.object.id : data.previous?.id, + item_type: data.object ? data.object.$tableName : data.previous?.$tableName, + }) +} + +export const getAuditLogs = async ({ + project_id, + event, + item_id, + item_type, +}: AuditParams) => { + return Audit.all(q => + q.where('project_id', project_id) + .where('event', event) + .where('item_id', item_id) + .where('item_type', item_type) + .orderBy('created_at', 'desc'), + ) +} + +export const getLastAuditLog = async ({ + project_id, + event, + item_id, + item_type, +}: AuditParams) => { + return Audit.first(q => + q.where('project_id', project_id) + .where('event', event) + .where('item_id', item_id) + .where('item_type', item_type) + .orderBy('created_at', 'desc'), + ) +} diff --git a/apps/platform/src/campaigns/Campaign.ts b/apps/platform/src/campaigns/Campaign.ts index a8239961..167227bc 100644 --- a/apps/platform/src/campaigns/Campaign.ts +++ b/apps/platform/src/campaigns/Campaign.ts @@ -68,6 +68,7 @@ export type SentCampaign = Campaign & { send_at: Date } export type CampaignParams = Omit export type CampaignCreateParams = Omit export type CampaignUpdateParams = Omit +export type CampaignCreateWithAdminParams = CampaignCreateParams & { admin_id?: number } export type CampaignSendState = 'pending' | 'sent' | 'throttled' | 'failed' | 'bounced' | 'aborted' export type CampaignSendReferenceType = 'journey' | 'trigger' diff --git a/apps/platform/src/campaigns/CampaignController.ts b/apps/platform/src/campaigns/CampaignController.ts index bf640d9b..8e51c78c 100644 --- a/apps/platform/src/campaigns/CampaignController.ts +++ b/apps/platform/src/campaigns/CampaignController.ts @@ -86,7 +86,10 @@ const campaignCreateParams: JSONSchemaType = { router.post('/', async ctx => { const payload = validate(campaignCreateParams, ctx.request.body) - ctx.body = await createCampaign(ctx.state.project.id, payload) + ctx.body = await createCampaign(ctx.state.project.id, { + ...payload, + admin_id: ctx.state.admin?.id, + }) }) router.param('campaignId', checkCampaignId) @@ -148,7 +151,10 @@ const campaignUpdateParams: JSONSchemaType> = { router.patch('/:campaignId', async ctx => { const payload = validate(campaignUpdateParams, ctx.request.body) - ctx.body = await updateCampaign(ctx.state.campaign!.id, ctx.state.project.id, payload) + ctx.body = await updateCampaign(ctx.state.campaign!.id, ctx.state.project.id, { + ...payload, + admin_id: ctx.state.admin?.id, + }) }) router.get('/:campaignId/users', async ctx => { @@ -157,17 +163,17 @@ router.get('/:campaignId/users', async ctx => { }) router.delete('/:campaignId', async ctx => { - const { id, project_id, deleted_at } = ctx.state.campaign! - if (deleted_at) { - await deleteCampaign(id, project_id) + const campaign = ctx.state.campaign! + if (campaign.deleted_at) { + await deleteCampaign(campaign, ctx.state.admin?.id) } else { - await archiveCampaign(id, project_id) + await archiveCampaign(campaign, ctx.state.admin?.id) } ctx.body = true }) router.post('/:campaignId/duplicate', async ctx => { - ctx.body = await duplicateCampaign(ctx.state.campaign!) + ctx.body = await duplicateCampaign(ctx.state.campaign!, ctx.state.admin?.id) }) router.get('/:campaignId/preview', async ctx => { diff --git a/apps/platform/src/campaigns/CampaignService.ts b/apps/platform/src/campaigns/CampaignService.ts index e1ea28c4..598b5281 100644 --- a/apps/platform/src/campaigns/CampaignService.ts +++ b/apps/platform/src/campaigns/CampaignService.ts @@ -26,6 +26,8 @@ import App from '../app' import CampaignAbortJob from './CampaignAbortJob' import { getRuleQuery } from '../rules/RuleEngine' import { getJourneysForCampaign } from '../journey/JourneyService' +import { createAuditLog } from '../auth/AuditService' +import { WithAdmin } from '../auth/Audit' export const CacheKeys = { pendingStats: 'campaigns:pending_stats', @@ -84,7 +86,7 @@ export const getCampaign = async (id: number, projectId: number): Promise => { +export const createCampaign = async (projectId: number, { tags, admin_id, ...params }: WithAdmin): Promise => { const subscription = await Subscription.find(params.subscription_id) if (!subscription) { throw new RequestError('Unable to find associated subscription', 404) @@ -108,10 +110,19 @@ export const createCampaign = async (projectId: number, { tags, ...params }: Cam }) } + if (admin_id) { + await createAuditLog({ + project_id: projectId, + admin_id, + event: 'create', + object: campaign, + }) + } + return await getCampaign(campaign.id, projectId) as Campaign } -export const updateCampaign = async (id: number, projectId: number, { tags, ...params }: Partial): Promise => { +export const updateCampaign = async (id: number, projectId: number, { tags, admin_id, ...params }: WithAdmin>): Promise => { // Ensure finished campaigns are no longer modified const campaign = await getCampaign(id, projectId) as Campaign @@ -150,7 +161,7 @@ export const updateCampaign = async (id: number, projectId: number, { tags, ...p data.state = 'running' } - await Campaign.update(qb => qb.where('id', id), { + const newCampaign = await Campaign.updateAndFetch(id, { ...data, send_at, }) @@ -172,16 +183,50 @@ export const updateCampaign = async (id: number, projectId: number, { tags, ...p await CampaignAbortJob.from({ ...campaign, reschedule: isRescheduling }).queue() } + if (admin_id) { + const event = data.state === 'aborting' + ? 'aborted' + : data.state === 'loading' + ? 'launched' + : 'updated' + await createAuditLog({ + project_id: projectId, + admin_id, + event, + object: newCampaign, + previous: campaign, + }) + } + return await getCampaign(id, projectId) } -export const archiveCampaign = async (id: number, projectId: number) => { - await Campaign.archive(id, qb => qb.where('project_id', projectId)) - return getCampaign(id, projectId) +export const archiveCampaign = async (campaign: Campaign, adminId?: number) => { + await Campaign.archive(campaign.id, qb => qb.where('project_id', campaign.project_id)) + + if (adminId) { + await createAuditLog({ + project_id: campaign.project_id, + admin_id: adminId, + event: 'archive', + previous: campaign, + }) + } + + return getCampaign(campaign.id, campaign.project_id) } -export const deleteCampaign = async (id: number, projectId: number) => { - return await Campaign.deleteById(id, qb => qb.where('project_id', projectId)) +export const deleteCampaign = async (campaign: Campaign, adminId?: number) => { + const results = await Campaign.deleteById(campaign.id, qb => qb.where('project_id', campaign.project_id)) + if (adminId) { + await createAuditLog({ + project_id: campaign.project_id, + admin_id: adminId, + event: 'delete', + previous: campaign, + }) + } + return results } export const getCampaignUsers = async (id: number, params: PageParams, projectId: number) => { @@ -494,14 +539,14 @@ export const clearCampaign = async (campaign: Campaign) => { .delete() } -export const duplicateCampaign = async (campaign: Campaign) => { - const params: Partial = pick(campaign, ['project_id', 'list_ids', 'exclusion_list_ids', 'provider_id', 'subscription_id', 'channel', 'name', 'type']) +export const duplicateCampaign = async (campaign: Campaign, adminId?: number) => { + const params: CampaignCreateParams = pick(campaign, ['project_id', 'list_ids', 'exclusion_list_ids', 'provider_id', 'subscription_id', 'channel', 'name', 'type']) params.name = `Copy of ${params.name}` - params.state = campaign.type === 'blast' ? 'draft' : 'running' - const cloneId = await Campaign.insert(params) + const { id: cloneId } = await createCampaign(campaign.project_id, { ...params, admin_id: adminId }) for (const template of campaign.templates) { await duplicateTemplate(template, cloneId) } + return await getCampaign(cloneId, campaign.project_id) } diff --git a/apps/platform/src/client/Client.ts b/apps/platform/src/client/Client.ts index 0d7a96c1..6e80ecd6 100644 --- a/apps/platform/src/client/Client.ts +++ b/apps/platform/src/client/Client.ts @@ -1,11 +1,6 @@ +import { RequireAtLeastOne } from '../core/Types' import type { User } from '../users/User' -type RequireAtLeastOne = - Pick> - & { - [K in Keys]-?: Required> & Partial>> - }[Keys] - export type ClientIdentityKeys = { anonymous_id: string external_id: string diff --git a/apps/platform/src/core/Model.ts b/apps/platform/src/core/Model.ts index 38b12bec..98763121 100644 --- a/apps/platform/src/core/Model.ts +++ b/apps/platform/src/core/Model.ts @@ -116,4 +116,4 @@ export class UniversalModel extends Model { } } -export type ModelParams = 'id' | 'created_at' | 'updated_at' | 'parseJson' | 'project_id' | 'toJSON' +export type ModelParams = 'id' | 'created_at' | 'updated_at' | 'parseJson' | 'project_id' | 'toJSON' | '$tableName' diff --git a/apps/platform/src/core/Types.ts b/apps/platform/src/core/Types.ts new file mode 100644 index 00000000..7fe84bfc --- /dev/null +++ b/apps/platform/src/core/Types.ts @@ -0,0 +1,5 @@ +export type RequireAtLeastOne = + Pick> + & { + [K in Keys]-?: Required> & Partial>> + }[Keys] diff --git a/apps/platform/src/core/models/RawModel.ts b/apps/platform/src/core/models/RawModel.ts index 0309eb0a..d0654bc4 100644 --- a/apps/platform/src/core/models/RawModel.ts +++ b/apps/platform/src/core/models/RawModel.ts @@ -1,6 +1,10 @@ import { pluralize, snakeCase } from '../../utilities' export class RawModel { + get $tableName() { + return (this.constructor as typeof RawModel).tableName + } + static jsonAttributes: string[] = [] static virtualAttributes: string[] = [] diff --git a/apps/platform/src/utilities/index.ts b/apps/platform/src/utilities/index.ts index 60fa51e4..9c66b1eb 100644 --- a/apps/platform/src/utilities/index.ts +++ b/apps/platform/src/utilities/index.ts @@ -249,6 +249,38 @@ export function deepEqual(a: T, b: T): boolean { ) } +const isObject = (value: any): boolean => { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +type Obj = Record + +export const deepDiff = (newObj: Obj, oldObj: Obj): Obj => { + const diff: Obj = {} + + const keys = new Set([...Object.keys(newObj || {}), ...Object.keys(oldObj || {})]) + + for (const key of keys) { + const newVal = newObj ? newObj[key] : undefined + const oldVal = oldObj ? oldObj[key] : undefined + + if (newVal === oldVal) { + continue + } + + if (isObject(newVal) && isObject(oldVal)) { + const nestedDiff = deepDiff(newVal, oldVal) + if (Object.keys(nestedDiff).length > 0) { + diff[key] = nestedDiff + } + } else { + diff[key] = { old: oldVal, new: newVal } + } + } + + return diff +} + type ChunkCallback = (chunk: T[]) => Promise export const chunk = async (