feat: add audit tables to campaign actions

This commit is contained in:
Chris Anderson 2025-07-23 13:55:48 -05:00
parent ad9c3c8756
commit 49306af8f9
11 changed files with 233 additions and 26 deletions

View file

@ -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')
}

View file

@ -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<string, any>
object_changes!: Record<string, any>
item_id!: number
item_type!: string
static jsonAttributes = ['object', 'object_changes']
}
export interface Auditable {
id: number
$tableName: string
}
export type WithAdmin<T> = T & {
admin_id?: number
}

View file

@ -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'),
)
}

View file

@ -68,6 +68,7 @@ export type SentCampaign = Campaign & { send_at: Date }
export type CampaignParams = Omit<Campaign, ModelParams | 'delivery' | 'eventName' | 'templates' | 'lists' | 'exclusion_lists' | 'subscription' | 'provider' | 'journeys' | 'deleted_at' | 'progress' | 'isAborted' | 'isAbortedOrDraft'>
export type CampaignCreateParams = Omit<CampaignParams, 'state'>
export type CampaignUpdateParams = Omit<CampaignParams, 'channel' | 'type'>
export type CampaignCreateWithAdminParams = CampaignCreateParams & { admin_id?: number }
export type CampaignSendState = 'pending' | 'sent' | 'throttled' | 'failed' | 'bounced' | 'aborted'
export type CampaignSendReferenceType = 'journey' | 'trigger'

View file

@ -86,7 +86,10 @@ const campaignCreateParams: JSONSchemaType<CampaignCreateParams> = {
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<Partial<CampaignUpdateParams>> = {
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 => {

View file

@ -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<Campai
return campaign
}
export const createCampaign = async (projectId: number, { tags, ...params }: CampaignCreateParams): Promise<Campaign> => {
export const createCampaign = async (projectId: number, { tags, admin_id, ...params }: WithAdmin<CampaignCreateParams>): Promise<Campaign> => {
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<CampaignParams>): Promise<Campaign | undefined> => {
export const updateCampaign = async (id: number, projectId: number, { tags, admin_id, ...params }: WithAdmin<Partial<CampaignParams>>): Promise<Campaign | undefined> => {
// 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<Campaign> = 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)
}

View file

@ -1,11 +1,6 @@
import { RequireAtLeastOne } from '../core/Types'
import type { User } from '../users/User'
type RequireAtLeastOne<T, Keys extends keyof T = keyof T> =
Pick<T, Exclude<keyof T, Keys>>
& {
[K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>
}[Keys]
export type ClientIdentityKeys = {
anonymous_id: string
external_id: string

View file

@ -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'

View file

@ -0,0 +1,5 @@
export type RequireAtLeastOne<T, Keys extends keyof T = keyof T> =
Pick<T, Exclude<keyof T, Keys>>
& {
[K in Keys]-?: Required<Pick<T, K>> & Partial<Pick<T, Exclude<Keys, K>>>
}[Keys]

View file

@ -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[] = []

View file

@ -249,6 +249,38 @@ export function deepEqual<T>(a: T, b: T): boolean {
)
}
const isObject = (value: any): boolean => {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
type Obj = Record<string, any>
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<T> = (chunk: T[]) => Promise<void>
export const chunk = async <T>(