mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-09-01 12:26:08 +08:00
feat: add audit tables to campaign actions
This commit is contained in:
parent
ad9c3c8756
commit
49306af8f9
11 changed files with 233 additions and 26 deletions
|
@ -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')
|
||||
}
|
22
apps/platform/src/auth/Audit.ts
Normal file
22
apps/platform/src/auth/Audit.ts
Normal 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
|
||||
}
|
60
apps/platform/src/auth/AuditService.ts
Normal file
60
apps/platform/src/auth/AuditService.ts
Normal 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'),
|
||||
)
|
||||
}
|
|
@ -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'
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
5
apps/platform/src/core/Types.ts
Normal file
5
apps/platform/src/core/Types.ts
Normal 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]
|
|
@ -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[] = []
|
||||
|
||||
|
|
|
@ -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>(
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue