Data management tab

This commit is contained in:
Dries Augustyns
2025-12-02 09:46:12 +01:00
parent 61cb93d04c
commit 2e6b485532
6 changed files with 812 additions and 3 deletions
+54
View File
@@ -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',
});
}
}
}
+55 -1
View File
@@ -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',
});
}
}
}
+151
View File
@@ -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};
}
}
+182
View File
@@ -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};
}
}
@@ -0,0 +1,361 @@
import {useState} from 'react';
import {
Alert,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Badge,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@plunk/ui';
import {Trash2, AlertCircle, Loader2} from 'lucide-react';
import {toast} from 'sonner';
import useSWR from 'swr';
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
import {network} from '../lib/network';
interface FieldData {
field: string;
type: 'string' | 'number' | 'boolean' | 'date';
}
interface FieldUsage {
usedInSegments: Array<{id: string; name: string}>;
usedInCampaigns: Array<{id: string; name: string}>;
contactCount: number;
canDelete: boolean;
}
interface EventUsage {
usedInSegments: Array<{id: string; name: string}>;
usedInWorkflows: Array<{id: string; name: string}>;
totalCount: number;
uniqueContacts: number;
canDelete: boolean;
}
export function DataManagementSettings() {
const {activeProject} = useActiveProject();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [selectedField, setSelectedField] = useState<string | null>(null);
const [selectedEvent, setSelectedEvent] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// Fetch contact fields
const {data: fieldsData, mutate: mutateFields} = useSWR<{fields: FieldData[]; count: number}>(
activeProject?.id ? `/contacts/fields` : null,
);
// Fetch event names
const {data: eventsData, mutate: mutateEvents} = useSWR<{eventNames: string[]}>(
activeProject?.id ? `/events/names` : null,
);
// Fetch field usage for selected field
const {data: fieldUsage} = useSWR<FieldUsage>(
selectedField ? `/contacts/fields/${encodeURIComponent(selectedField)}/usage` : null,
);
// Fetch event usage for selected event
const {data: eventUsage} = useSWR<EventUsage>(
selectedEvent ? `/events/${encodeURIComponent(selectedEvent)}/usage` : null,
);
// Filter out standard fields and only show custom data fields
const customFields =
fieldsData?.fields.filter(f => f.field.startsWith('data.')) || [];
// Filter out system events
const customEvents =
eventsData?.eventNames.filter(
name => !name.startsWith('email.') && !name.startsWith('segment.'),
) || [];
const handleDeleteField = async () => {
if (!selectedField || !fieldUsage?.canDelete) return;
setIsDeleting(true);
try {
const result = await network.fetch<{deletedFrom: number}>(
'DELETE',
`/contacts/fields/${encodeURIComponent(selectedField)}`,
);
toast.success(`Field deleted from ${result.deletedFrom} contact(s)`);
setDeleteDialogOpen(false);
setSelectedField(null);
mutateFields();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete field');
} finally {
setIsDeleting(false);
}
};
const handleDeleteEvent = async () => {
if (!selectedEvent || !eventUsage?.canDelete) return;
setIsDeleting(true);
try {
const result = await network.fetch<{deletedCount: number}>(
'DELETE',
`/events/${encodeURIComponent(selectedEvent)}`,
);
toast.success(`Deleted ${result.deletedCount} event(s)`);
setDeleteDialogOpen(false);
setSelectedEvent(null);
mutateEvents();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete event');
} finally {
setIsDeleting(false);
}
};
const openFieldDeleteDialog = (field: string) => {
setSelectedField(field);
setSelectedEvent(null);
setDeleteDialogOpen(true);
};
const openEventDeleteDialog = (eventName: string) => {
setSelectedEvent(eventName);
setSelectedField(null);
setDeleteDialogOpen(true);
};
return (
<div className="space-y-6">
{/* Custom Contact Fields */}
<Card>
<CardHeader>
<CardTitle>Custom Contact Fields</CardTitle>
<CardDescription>
Manage custom fields stored in your contact data. You can only delete fields that are not
used in any segments or campaigns.
</CardDescription>
</CardHeader>
<CardContent>
{customFields.length === 0 ? (
<p className="text-sm text-muted-foreground">No custom fields found</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Field Name</TableHead>
<TableHead>Type</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{customFields.map(field => (
<TableRow key={field.field}>
<TableCell className="font-mono text-sm">
{field.field.replace('data.', '')}
</TableCell>
<TableCell>
<Badge variant="secondary">{field.type}</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => openFieldDeleteDialog(field.field)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Custom Events */}
<Card>
<CardHeader>
<CardTitle>Custom Events</CardTitle>
<CardDescription>
Manage custom events tracked in your project. You can only delete events that are not used
in any segments or workflows. System events (email.*, segment.*) cannot be deleted.
</CardDescription>
</CardHeader>
<CardContent>
{customEvents.length === 0 ? (
<p className="text-sm text-muted-foreground">No custom events found</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Event Name</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{customEvents.map(eventName => (
<TableRow key={eventName}>
<TableCell className="font-mono text-sm">{eventName}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => openEventDeleteDialog(eventName)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{selectedField ? 'Delete Field' : 'Delete Event'}
</DialogTitle>
<DialogDescription>
{selectedField
? `Are you sure you want to delete the field "${selectedField.replace('data.', '')}"?`
: `Are you sure you want to delete the event "${selectedEvent}"?`}
</DialogDescription>
</DialogHeader>
{selectedField && fieldUsage && (
<div className="space-y-4">
<div className="space-y-2">
<p className="text-sm">
<strong>Contacts with this field:</strong> {fieldUsage.contactCount}
</p>
<p className="text-sm">
<strong>Used in segments:</strong> {fieldUsage.usedInSegments.length}
</p>
<p className="text-sm">
<strong>Used in campaigns:</strong> {fieldUsage.usedInCampaigns.length}
</p>
</div>
{!fieldUsage.canDelete && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm font-medium">Cannot delete this field</p>
<p className="text-sm">
This field is currently used in:
</p>
{fieldUsage.usedInSegments.length > 0 && (
<ul className="mt-2 list-disc list-inside text-sm">
{fieldUsage.usedInSegments.map(segment => (
<li key={segment.id}>Segment: {segment.name}</li>
))}
</ul>
)}
{fieldUsage.usedInCampaigns.length > 0 && (
<ul className="mt-2 list-disc list-inside text-sm">
{fieldUsage.usedInCampaigns.map(campaign => (
<li key={campaign.id}>Campaign: {campaign.name}</li>
))}
</ul>
)}
</div>
</Alert>
)}
</div>
)}
{selectedEvent && eventUsage && (
<div className="space-y-4">
<div className="space-y-2">
<p className="text-sm">
<strong>Total events:</strong> {eventUsage.totalCount}
</p>
<p className="text-sm">
<strong>Unique contacts:</strong> {eventUsage.uniqueContacts}
</p>
<p className="text-sm">
<strong>Used in segments:</strong> {eventUsage.usedInSegments.length}
</p>
<p className="text-sm">
<strong>Used in workflows:</strong> {eventUsage.usedInWorkflows.length}
</p>
</div>
{!eventUsage.canDelete && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<div className="ml-2">
<p className="text-sm font-medium">Cannot delete this event</p>
<p className="text-sm">
This event is currently used in:
</p>
{eventUsage.usedInSegments.length > 0 && (
<ul className="mt-2 list-disc list-inside text-sm">
{eventUsage.usedInSegments.map(segment => (
<li key={segment.id}>Segment: {segment.name}</li>
))}
</ul>
)}
{eventUsage.usedInWorkflows.length > 0 && (
<ul className="mt-2 list-disc list-inside text-sm">
{eventUsage.usedInWorkflows.map(workflow => (
<li key={workflow.id}>Workflow: {workflow.name}</li>
))}
</ul>
)}
</div>
</Alert>
)}
</div>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDeleteDialogOpen(false);
setSelectedField(null);
setSelectedEvent(null);
}}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={selectedField ? handleDeleteField : handleDeleteEvent}
disabled={
isDeleting ||
(!!selectedField && !fieldUsage?.canDelete) ||
(!!selectedEvent && !eventUsage?.canDelete)
}
>
{isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+9 -2
View File
@@ -32,7 +32,7 @@ import {
} from '@plunk/ui';
import {AnimatePresence, motion} from 'framer-motion';
import {NextSeo} from 'next-seo';
import {AlertTriangle, CreditCard, Globe, Mail, Settings as SettingsIcon} from 'lucide-react';
import {AlertTriangle, CreditCard, Database, Globe, Mail, Settings as SettingsIcon} from 'lucide-react';
import type {z} from 'zod';
import {useRouter} from 'next/router';
import {DashboardLayout} from '../../components/DashboardLayout';
@@ -43,12 +43,13 @@ import {BillingInvoices} from '../../components/BillingInvoices';
import {UnpaidInvoiceBanner} from '../../components/UnpaidInvoiceBanner';
import {ApiKeyDisplay} from '../../components/ApiKeyDisplay';
import {SmtpSettings} from '../../components/SmtpSettings';
import {DataManagementSettings} from '../../components/DataManagementSettings';
import {useActiveProject} from '../../lib/contexts/ActiveProjectProvider';
import {network} from '../../lib/network';
import {useProjects} from '../../lib/hooks/useProject';
import {useConfig} from '../../lib/hooks/useConfig';
type TabId = 'general' | 'billing' | 'domains' | 'smtp';
type TabId = 'general' | 'billing' | 'domains' | 'smtp' | 'data';
interface Tab {
id: TabId;
@@ -64,6 +65,7 @@ const buildTabs = (options: {billingEnabled: boolean; smtpEnabled: boolean}): Ta
{id: 'billing', label: 'Billing', icon: CreditCard, condition: billingEnabled},
{id: 'domains', label: 'Domains', icon: Globe},
{id: 'smtp', label: 'SMTP', icon: Mail, condition: smtpEnabled},
{id: 'data', label: 'Data', icon: Database},
];
return allTabs.filter(tab => tab.condition !== false);
};
@@ -513,6 +515,11 @@ export default function Settings() {
<TabsContent value="smtp">
<SmtpSettings smtpConfig={smtpConfig} />
</TabsContent>
{/* Data Management Tab */}
<TabsContent value="data">
<DataManagementSettings />
</TabsContent>
</Tabs>
</div>