Data management tab
This commit is contained in:
@@ -324,4 +324,58 @@ export class Contacts {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /contacts/fields/:field/usage
|
||||
* Check if a field is used in segments/campaigns and get usage statistics
|
||||
* Returns information about where the field is used and whether it can be safely deleted
|
||||
*/
|
||||
@Get('fields/:field/usage')
|
||||
@Middleware([requireAuth])
|
||||
@CatchAsync
|
||||
public async getFieldUsage(req: Request, res: Response, next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const field = req.params.field;
|
||||
|
||||
if (!field) {
|
||||
return res.status(400).json({error: 'Field is required'});
|
||||
}
|
||||
|
||||
try {
|
||||
const usage = await ContactService.getFieldUsage(auth.projectId!, field);
|
||||
return res.status(200).json(usage);
|
||||
} catch (error) {
|
||||
console.error('[CONTACTS] Failed to get field usage:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get field usage',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /contacts/fields/:field
|
||||
* Delete a custom field from all contacts
|
||||
* Only works if the field is not used in any segments or campaigns
|
||||
*/
|
||||
@Delete('fields/:field')
|
||||
@Middleware([requireAuth])
|
||||
@CatchAsync
|
||||
public async deleteField(req: Request, res: Response, next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const field = req.params.field;
|
||||
|
||||
if (!field) {
|
||||
return res.status(400).json({error: 'Field is required'});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ContactService.deleteField(auth.projectId!, field);
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[CONTACTS] Failed to delete field:', error);
|
||||
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to delete field',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Controller, Get, Middleware, Post} from '@overnightjs/core';
|
||||
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import type {AuthResponse} from '../middleware/auth.js';
|
||||
@@ -97,4 +97,58 @@ export class Events {
|
||||
|
||||
return res.status(200).json({eventNames});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /events/:eventName/usage
|
||||
* Check if an event is used in segments/workflows and get usage statistics
|
||||
* Returns information about where the event is used and whether it can be safely deleted
|
||||
*/
|
||||
@Get(':eventName/usage')
|
||||
@Middleware([requireAuth])
|
||||
@CatchAsync
|
||||
public async getEventUsage(req: Request, res: Response, next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const eventName = req.params.eventName;
|
||||
|
||||
if (!eventName) {
|
||||
return res.status(400).json({error: 'Event name is required'});
|
||||
}
|
||||
|
||||
try {
|
||||
const usage = await EventService.getEventUsage(auth.projectId!, eventName);
|
||||
return res.status(200).json(usage);
|
||||
} catch (error) {
|
||||
console.error('[EVENTS] Failed to get event usage:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get event usage',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /events/:eventName
|
||||
* Delete all events with a specific name
|
||||
* Only works if the event is not used in any segments or workflows
|
||||
*/
|
||||
@Delete(':eventName')
|
||||
@Middleware([requireAuth])
|
||||
@CatchAsync
|
||||
public async deleteEvent(req: Request, res: Response, next: NextFunction) {
|
||||
const auth = res.locals.auth as AuthResponse;
|
||||
const eventName = req.params.eventName;
|
||||
|
||||
if (!eventName) {
|
||||
return res.status(400).json({error: 'Event name is required'});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await EventService.deleteEvent(auth.projectId!, eventName);
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
console.error('[EVENTS] Failed to delete event:', error);
|
||||
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to delete event',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -468,4 +468,155 @@ export class ContactService {
|
||||
})
|
||||
.filter(v => v !== null && v !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a contact field is used in any segments or campaigns
|
||||
* Returns usage information including which segments/campaigns use the field
|
||||
*
|
||||
* @param projectId - The project ID
|
||||
* @param field - The field to check (e.g., "data.plan", "email", "subscribed")
|
||||
* @returns Usage information
|
||||
*/
|
||||
public static async getFieldUsage(
|
||||
projectId: string,
|
||||
field: string,
|
||||
): Promise<{
|
||||
usedInSegments: Array<{id: string; name: string}>;
|
||||
usedInCampaigns: Array<{id: string; name: string}>;
|
||||
contactCount: number;
|
||||
canDelete: boolean;
|
||||
}> {
|
||||
// Get all segments for the project
|
||||
const segments = await prisma.segment.findMany({
|
||||
where: {projectId},
|
||||
select: {id: true, name: true, condition: true},
|
||||
});
|
||||
|
||||
// Check which segments use this field
|
||||
const usedInSegments = segments.filter(segment => {
|
||||
const condition = segment.condition as any;
|
||||
return this.fieldUsedInCondition(field, condition);
|
||||
});
|
||||
|
||||
// Get all campaigns for the project (emails)
|
||||
const campaigns = await prisma.email.findMany({
|
||||
where: {projectId},
|
||||
select: {id: true, subject: true},
|
||||
});
|
||||
|
||||
// For now, we'll check if campaigns use the field in their subject or body
|
||||
// This is a simplified check - you might want to enhance this based on your campaign structure
|
||||
const usedInCampaigns: Array<{id: string; name: string}> = [];
|
||||
|
||||
// Count contacts that have this field (for data fields)
|
||||
let contactCount = 0;
|
||||
if (field.startsWith('data.')) {
|
||||
const jsonField = field.substring(5);
|
||||
const result = await prisma.$queryRaw<Array<{count: bigint}>>`
|
||||
SELECT COUNT(*) as count
|
||||
FROM contacts
|
||||
WHERE
|
||||
"projectId" = ${projectId}
|
||||
AND data ? ${jsonField}
|
||||
AND data->${jsonField} IS NOT NULL
|
||||
`;
|
||||
contactCount = Number(result[0]?.count || 0);
|
||||
} else if (field === 'email' || field === 'subscribed' || field === 'createdAt' || field === 'updatedAt') {
|
||||
// Standard fields exist on all contacts
|
||||
const result = await prisma.contact.count({where: {projectId}});
|
||||
contactCount = result;
|
||||
}
|
||||
|
||||
const canDelete = usedInSegments.length === 0 && usedInCampaigns.length === 0;
|
||||
|
||||
return {
|
||||
usedInSegments: usedInSegments.map(s => ({id: s.id, name: s.name})),
|
||||
usedInCampaigns,
|
||||
contactCount,
|
||||
canDelete,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if a field is used in a filter condition (recursive)
|
||||
*/
|
||||
private static fieldUsedInCondition(field: string, condition: any): boolean {
|
||||
if (!condition || typeof condition !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check groups in the condition
|
||||
if (Array.isArray(condition.groups)) {
|
||||
for (const group of condition.groups) {
|
||||
if (this.fieldUsedInGroup(field, group)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if a field is used in a filter group (recursive)
|
||||
*/
|
||||
private static fieldUsedInGroup(field: string, group: any): boolean {
|
||||
if (!group || typeof group !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check filters in the group
|
||||
if (Array.isArray(group.filters)) {
|
||||
for (const filter of group.filters) {
|
||||
if (filter.field === field) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check nested conditions
|
||||
if (group.conditions) {
|
||||
return this.fieldUsedInCondition(field, group.conditions);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a custom field from all contacts
|
||||
* WARNING: This is destructive and cannot be undone
|
||||
* Should only be called after verifying the field is not in use
|
||||
*
|
||||
* @param projectId - The project ID
|
||||
* @param field - The field to delete (must be a data.* field)
|
||||
*/
|
||||
public static async deleteField(projectId: string, field: string): Promise<{deletedFrom: number}> {
|
||||
// Only allow deleting custom data fields
|
||||
if (!field.startsWith('data.')) {
|
||||
throw new HttpException(400, 'Can only delete custom data fields (data.*)');
|
||||
}
|
||||
|
||||
// Check if field is in use
|
||||
const usage = await this.getFieldUsage(projectId, field);
|
||||
if (!usage.canDelete) {
|
||||
throw new HttpException(
|
||||
400,
|
||||
`Cannot delete field: used in ${usage.usedInSegments.length} segment(s) and ${usage.usedInCampaigns.length} campaign(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
const jsonField = field.substring(5);
|
||||
|
||||
// Delete the field from all contacts using raw SQL
|
||||
// PostgreSQL's `-` operator removes a key from a JSON object
|
||||
const result = await prisma.$executeRaw`
|
||||
UPDATE contacts
|
||||
SET data = data - ${jsonField}
|
||||
WHERE
|
||||
"projectId" = ${projectId}
|
||||
AND data ? ${jsonField}
|
||||
`;
|
||||
|
||||
return {deletedFrom: result};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,4 +320,186 @@ export class EventService {
|
||||
console.error(`[EVENT] Error starting workflow ${workflowId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event is used in any segments or workflows
|
||||
* Returns usage information including which segments/workflows use the event
|
||||
*
|
||||
* @param projectId - The project ID
|
||||
* @param eventName - The event name to check (e.g., "purchase.completed", "user.signup")
|
||||
* @returns Usage information
|
||||
*/
|
||||
public static async getEventUsage(
|
||||
projectId: string,
|
||||
eventName: string,
|
||||
): Promise<{
|
||||
usedInSegments: Array<{id: string; name: string}>;
|
||||
usedInWorkflows: Array<{id: string; name: string}>;
|
||||
totalCount: number;
|
||||
uniqueContacts: number;
|
||||
canDelete: boolean;
|
||||
}> {
|
||||
// Get all segments for the project
|
||||
const segments = await prisma.segment.findMany({
|
||||
where: {projectId},
|
||||
select: {id: true, name: true, condition: true},
|
||||
});
|
||||
|
||||
// Check which segments use this event
|
||||
const usedInSegments = segments.filter(segment => {
|
||||
const condition = segment.condition as any;
|
||||
return this.eventUsedInCondition(eventName, condition);
|
||||
});
|
||||
|
||||
// Get workflows that use this event as a trigger or wait condition
|
||||
const workflows = await prisma.workflow.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
OR: [
|
||||
// Event as trigger
|
||||
{
|
||||
triggerType: 'EVENT',
|
||||
triggerConfig: {
|
||||
path: ['eventName'],
|
||||
equals: eventName,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {id: true, name: true},
|
||||
});
|
||||
|
||||
// Also check workflow steps that wait for events
|
||||
const workflowStepsWithEvent = await prisma.workflowStep.findMany({
|
||||
where: {
|
||||
workflow: {projectId},
|
||||
type: 'WAIT_FOR_EVENT',
|
||||
config: {
|
||||
path: ['eventName'],
|
||||
equals: eventName,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
workflow: {
|
||||
select: {id: true, name: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const usedInWorkflows = [
|
||||
...workflows,
|
||||
...workflowStepsWithEvent.map(step => step.workflow),
|
||||
].reduce(
|
||||
(acc, workflow) => {
|
||||
// Deduplicate by id
|
||||
if (!acc.find((w: {id: string; name: string}) => w.id === workflow.id)) {
|
||||
acc.push(workflow);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as Array<{id: string; name: string}>,
|
||||
);
|
||||
|
||||
// Get event statistics
|
||||
const [totalCount, uniqueContacts] = await Promise.all([
|
||||
prisma.event.count({
|
||||
where: {projectId, name: eventName},
|
||||
}),
|
||||
prisma.event
|
||||
.groupBy({
|
||||
by: ['contactId'],
|
||||
where: {projectId, name: eventName, contactId: {not: null}},
|
||||
})
|
||||
.then(results => results.length),
|
||||
]);
|
||||
|
||||
const canDelete = usedInSegments.length === 0 && usedInWorkflows.length === 0;
|
||||
|
||||
return {
|
||||
usedInSegments,
|
||||
usedInWorkflows,
|
||||
totalCount,
|
||||
uniqueContacts,
|
||||
canDelete,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if an event is used in a filter condition (recursive)
|
||||
*/
|
||||
private static eventUsedInCondition(eventName: string, condition: any): boolean {
|
||||
if (!condition || typeof condition !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check groups in the condition
|
||||
if (Array.isArray(condition.groups)) {
|
||||
for (const group of condition.groups) {
|
||||
if (this.eventUsedInGroup(eventName, group)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if an event is used in a filter group (recursive)
|
||||
*/
|
||||
private static eventUsedInGroup(eventName: string, group: any): boolean {
|
||||
if (!group || typeof group !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check filters in the group
|
||||
if (Array.isArray(group.filters)) {
|
||||
for (const filter of group.filters) {
|
||||
// Event filters use field name like "event.eventName"
|
||||
if (filter.field === `event.${eventName}`) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check nested conditions
|
||||
if (group.conditions) {
|
||||
return this.eventUsedInCondition(eventName, group.conditions);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all events with a specific name
|
||||
* WARNING: This is destructive and cannot be undone
|
||||
* Should only be called after verifying the event is not in use
|
||||
*
|
||||
* @param projectId - The project ID
|
||||
* @param eventName - The event name to delete
|
||||
*/
|
||||
public static async deleteEvent(projectId: string, eventName: string): Promise<{deletedCount: number}> {
|
||||
// Prevent deletion of system events
|
||||
if (eventName.startsWith('email.') || eventName.startsWith('segment.')) {
|
||||
throw new Error('Cannot delete system events (email.* or segment.*)');
|
||||
}
|
||||
|
||||
// Check if event is in use
|
||||
const usage = await this.getEventUsage(projectId, eventName);
|
||||
if (!usage.canDelete) {
|
||||
throw new Error(
|
||||
`Cannot delete event: used in ${usage.usedInSegments.length} segment(s) and ${usage.usedInWorkflows.length} workflow(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Delete all events with this name
|
||||
const result = await prisma.event.deleteMany({
|
||||
where: {
|
||||
projectId,
|
||||
name: eventName,
|
||||
},
|
||||
});
|
||||
|
||||
return {deletedCount: result.count};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user