From 2e6b485532962b2b49bfcf1d356566c2080bcae4 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Tue, 2 Dec 2025 09:46:12 +0100 Subject: [PATCH] Data management tab --- apps/api/src/controllers/Contacts.ts | 54 +++ apps/api/src/controllers/Events.ts | 56 ++- apps/api/src/services/ContactService.ts | 151 ++++++++ apps/api/src/services/EventService.ts | 182 +++++++++ .../src/components/DataManagementSettings.tsx | 361 ++++++++++++++++++ apps/web/src/pages/settings/index.tsx | 11 +- 6 files changed, 812 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/DataManagementSettings.tsx diff --git a/apps/api/src/controllers/Contacts.ts b/apps/api/src/controllers/Contacts.ts index 394b8f2..10e9fa9 100644 --- a/apps/api/src/controllers/Contacts.ts +++ b/apps/api/src/controllers/Contacts.ts @@ -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', + }); + } + } } diff --git a/apps/api/src/controllers/Events.ts b/apps/api/src/controllers/Events.ts index c3c1fab..f4f6cda 100644 --- a/apps/api/src/controllers/Events.ts +++ b/apps/api/src/controllers/Events.ts @@ -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', + }); + } + } } diff --git a/apps/api/src/services/ContactService.ts b/apps/api/src/services/ContactService.ts index 0002da8..c0088d4 100644 --- a/apps/api/src/services/ContactService.ts +++ b/apps/api/src/services/ContactService.ts @@ -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>` + 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}; + } } diff --git a/apps/api/src/services/EventService.ts b/apps/api/src/services/EventService.ts index d1c2203..83921f0 100644 --- a/apps/api/src/services/EventService.ts +++ b/apps/api/src/services/EventService.ts @@ -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}; + } } diff --git a/apps/web/src/components/DataManagementSettings.tsx b/apps/web/src/components/DataManagementSettings.tsx new file mode 100644 index 0000000..c375910 --- /dev/null +++ b/apps/web/src/components/DataManagementSettings.tsx @@ -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(null); + const [selectedEvent, setSelectedEvent] = useState(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( + selectedField ? `/contacts/fields/${encodeURIComponent(selectedField)}/usage` : null, + ); + + // Fetch event usage for selected event + const {data: eventUsage} = useSWR( + 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 ( +
+ {/* Custom Contact Fields */} + + + Custom Contact Fields + + Manage custom fields stored in your contact data. You can only delete fields that are not + used in any segments or campaigns. + + + + {customFields.length === 0 ? ( +

No custom fields found

+ ) : ( + + + + Field Name + Type + Actions + + + + {customFields.map(field => ( + + + {field.field.replace('data.', '')} + + + {field.type} + + + + + + ))} + +
+ )} +
+
+ + {/* Custom Events */} + + + Custom Events + + 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. + + + + {customEvents.length === 0 ? ( +

No custom events found

+ ) : ( + + + + Event Name + Actions + + + + {customEvents.map(eventName => ( + + {eventName} + + + + + ))} + +
+ )} +
+
+ + {/* Delete Confirmation Dialog */} + + + + + {selectedField ? 'Delete Field' : 'Delete Event'} + + + {selectedField + ? `Are you sure you want to delete the field "${selectedField.replace('data.', '')}"?` + : `Are you sure you want to delete the event "${selectedEvent}"?`} + + + + {selectedField && fieldUsage && ( +
+
+

+ Contacts with this field: {fieldUsage.contactCount} +

+

+ Used in segments: {fieldUsage.usedInSegments.length} +

+

+ Used in campaigns: {fieldUsage.usedInCampaigns.length} +

+
+ + {!fieldUsage.canDelete && ( + + +
+

Cannot delete this field

+

+ This field is currently used in: +

+ {fieldUsage.usedInSegments.length > 0 && ( +
    + {fieldUsage.usedInSegments.map(segment => ( +
  • Segment: {segment.name}
  • + ))} +
+ )} + {fieldUsage.usedInCampaigns.length > 0 && ( +
    + {fieldUsage.usedInCampaigns.map(campaign => ( +
  • Campaign: {campaign.name}
  • + ))} +
+ )} +
+
+ )} +
+ )} + + {selectedEvent && eventUsage && ( +
+
+

+ Total events: {eventUsage.totalCount} +

+

+ Unique contacts: {eventUsage.uniqueContacts} +

+

+ Used in segments: {eventUsage.usedInSegments.length} +

+

+ Used in workflows: {eventUsage.usedInWorkflows.length} +

+
+ + {!eventUsage.canDelete && ( + + +
+

Cannot delete this event

+

+ This event is currently used in: +

+ {eventUsage.usedInSegments.length > 0 && ( +
    + {eventUsage.usedInSegments.map(segment => ( +
  • Segment: {segment.name}
  • + ))} +
+ )} + {eventUsage.usedInWorkflows.length > 0 && ( +
    + {eventUsage.usedInWorkflows.map(workflow => ( +
  • Workflow: {workflow.name}
  • + ))} +
+ )} +
+
+ )} +
+ )} + + + + + +
+
+
+ ); +} diff --git a/apps/web/src/pages/settings/index.tsx b/apps/web/src/pages/settings/index.tsx index 919fb2e..ca6b37a 100644 --- a/apps/web/src/pages/settings/index.tsx +++ b/apps/web/src/pages/settings/index.tsx @@ -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() { + + {/* Data Management Tab */} + + +