From 382e97e5b2d933acde722ca5faf1913db7af7978 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Wed, 3 Dec 2025 20:39:11 +0100 Subject: [PATCH] Refactorings, error handling and logout --- apps/api/src/services/CampaignService.ts | 5 + apps/api/src/services/DomainService.ts | 25 +++- apps/api/src/services/SegmentService.ts | 4 +- apps/api/src/services/WorkflowService.ts | 10 ++ apps/landing/src/lib/network.ts | 20 ++- apps/web/src/components/DashboardLayout.tsx | 38 +++++- apps/web/src/lib/hooks/useDashboardStats.ts | 9 +- apps/web/src/lib/network.ts | 13 +- apps/web/src/pages/campaigns/[id].tsx | 66 +++++++-- apps/web/src/pages/campaigns/index.tsx | 41 +++++- apps/web/src/pages/templates/[id].tsx | 144 +++++++++++--------- apps/web/src/pages/workflows/[id].tsx | 120 ++++------------ 12 files changed, 306 insertions(+), 189 deletions(-) diff --git a/apps/api/src/services/CampaignService.ts b/apps/api/src/services/CampaignService.ts index 6ea24f4..01fd5a5 100644 --- a/apps/api/src/services/CampaignService.ts +++ b/apps/api/src/services/CampaignService.ts @@ -116,6 +116,11 @@ export class CampaignService { } if (data.segmentId !== undefined) { + // Prevent changing segment on scheduled campaigns + if (campaign.status === CampaignStatus.SCHEDULED) { + throw new HttpException(400, 'Cannot change segment for scheduled campaigns'); + } + if (data.segmentId) { const segment = await prisma.segment.findFirst({ where: {id: data.segmentId, projectId}, diff --git a/apps/api/src/services/DomainService.ts b/apps/api/src/services/DomainService.ts index b25cba0..d837052 100644 --- a/apps/api/src/services/DomainService.ts +++ b/apps/api/src/services/DomainService.ts @@ -109,7 +109,28 @@ export class DomainService { ); } - // Check if domain is used in any campaigns + // Check if domain is used in any workflow steps (via templates) + const workflowStepsUsingDomain = await prisma.workflowStep.count({ + where: { + workflow: { + projectId: domain.projectId, + }, + template: { + from: { + contains: `@${domainName}`, + }, + }, + }, + }); + + if (workflowStepsUsingDomain > 0) { + throw new HttpException( + 409, + `Cannot delete domain: it is currently used in ${workflowStepsUsingDomain} workflow step(s). Update the workflow templates first.`, + ); + } + + // Check if domain is used in any active campaigns const campaignsUsingDomain = await prisma.campaign.count({ where: { projectId: domain.projectId, @@ -117,7 +138,7 @@ export class DomainService { contains: `@${domainName}`, }, status: { - not: 'SENT', // Allow deletion if all campaigns using it are completed + in: ['DRAFT', 'SCHEDULED', 'SENDING'], }, }, }); diff --git a/apps/api/src/services/SegmentService.ts b/apps/api/src/services/SegmentService.ts index bfbe5d6..d393020 100644 --- a/apps/api/src/services/SegmentService.ts +++ b/apps/api/src/services/SegmentService.ts @@ -180,12 +180,12 @@ export class SegmentService { // First verify segment exists and belongs to project await this.get(projectId, segmentId); - // Check if segment is used in any campaigns + // Check if segment is used in any active campaigns const campaignsUsingSegment = await prisma.campaign.count({ where: { segmentId, status: { - not: 'SENT', // Allow deletion if all campaigns using it are completed + in: ['DRAFT', 'SCHEDULED', 'SENDING'], }, }, }); diff --git a/apps/api/src/services/WorkflowService.ts b/apps/api/src/services/WorkflowService.ts index 17a5af3..9110d56 100644 --- a/apps/api/src/services/WorkflowService.ts +++ b/apps/api/src/services/WorkflowService.ts @@ -242,6 +242,16 @@ export class WorkflowService { // Verify workflow exists and belongs to project const workflow = await this.get(projectId, workflowId); + // Check if workflow has active executions + const activeExecutions = await this.hasActiveExecutions(workflowId); + + if (activeExecutions > 0) { + throw new HttpException( + 409, + `Cannot delete workflow: it has ${activeExecutions} active execution(s). Please wait for them to complete or cancel them first.`, + ); + } + await prisma.workflow.delete({ where: {id: workflowId}, }); diff --git a/apps/landing/src/lib/network.ts b/apps/landing/src/lib/network.ts index 55b844b..5c92a1b 100644 --- a/apps/landing/src/lib/network.ts +++ b/apps/landing/src/lib/network.ts @@ -11,6 +11,17 @@ interface TypedSchema extends ZodSchema { _type: unknown; } +interface ApiResponse { + message?: string; + error?: { + message?: string; + code?: string; + [key: string]: unknown; + }; + + [key: string]: unknown; +} + export class network { /** * Fetcher function that includes toast support @@ -31,13 +42,14 @@ export class network { credentials: 'include', }); - const res = await response.json(); + const res = (await response.json()) as ApiResponse; if (response.status >= 400) { - - throw new Error(res?.message ?? 'Something went wrong!'); + // Extract error message from standardized error response or fall back to direct message property + const errorMessage = res.error?.message ?? res.message ?? 'Something went wrong!'; + throw new Error(errorMessage); } - return res; + return res as T; } } diff --git a/apps/web/src/components/DashboardLayout.tsx b/apps/web/src/components/DashboardLayout.tsx index cd61146..afd81e9 100644 --- a/apps/web/src/components/DashboardLayout.tsx +++ b/apps/web/src/components/DashboardLayout.tsx @@ -1,5 +1,6 @@ import {useActiveProject} from '../lib/contexts/ActiveProjectProvider'; import {useUser} from '../lib/hooks/useUser'; +import {network} from '../lib/network'; import { Activity, BarChart3, @@ -60,7 +61,7 @@ const navigation: NavSection[] = [ export function DashboardLayout({children}: DashboardLayoutProps) { const router = useRouter(); - const {data: user} = useUser(); + const {data: user, mutate: mutateUser} = useUser(); const {activeProject, availableProjects, setActiveProject} = useActiveProject(); const [showProjectMenu, setShowProjectMenu] = useState(false); const [showUserMenu, setShowUserMenu] = useState(false); @@ -86,10 +87,31 @@ export function DashboardLayout({children}: DashboardLayoutProps) { } }, [showProjectMenu, showUserMenu]); - const handleLogout = () => { - localStorage.removeItem('token'); - localStorage.removeItem('activeProjectId'); - void router.push('/auth/login'); + const handleLogout = async () => { + try { + // Call the logout endpoint to clear the cookie + await network.fetch('GET', '/auth/logout'); + + // Clear local storage + localStorage.removeItem('token'); + localStorage.removeItem('activeProjectId'); + + // Clear SWR cache for user data + await mutateUser(null, false); + + // Close the menu + setShowUserMenu(false); + + // Redirect to login + await router.push('/auth/login'); + } catch (error) { + console.error('Logout failed:', error); + // Even if the API call fails, try to redirect to login + localStorage.removeItem('token'); + localStorage.removeItem('activeProjectId'); + await mutateUser(null, false); + await router.push('/auth/login'); + } }; return ( @@ -209,7 +231,11 @@ export function DashboardLayout({children}: DashboardLayoutProps) { {showUserMenu && (
- - - + + + + + + setIsTestEmailDialogOpen(true)}> + + Send Test Email + + setShowSendDialog(true)}> + + Send Now + + setIsScheduleDialogOpen(true)}> + + Schedule for Later + + +
@@ -680,6 +710,16 @@ export default function CampaignDetailsPage() { confirmText="Send Now" variant="default" /> + + ); } diff --git a/apps/web/src/pages/campaigns/index.tsx b/apps/web/src/pages/campaigns/index.tsx index 0fbb928..aac0cae 100644 --- a/apps/web/src/pages/campaigns/index.tsx +++ b/apps/web/src/pages/campaigns/index.tsx @@ -17,7 +17,7 @@ import type {Campaign} from '@plunk/db'; import {CampaignStatus} from '@plunk/db'; import {DashboardLayout} from '../../components/DashboardLayout'; import {network} from '../../lib/network'; -import {Calendar, Copy, Mail, Plus, Users} from 'lucide-react'; +import {Calendar, Copy, Mail, Plus, Trash2, Users} from 'lucide-react'; import {NextSeo} from 'next-seo'; import Link from 'next/link'; import {useState} from 'react'; @@ -37,6 +37,8 @@ export default function CampaignsPage() { const [statusFilter, setStatusFilter] = useState('ALL'); const [showCancelDialog, setShowCancelDialog] = useState(false); const [campaignToCancel, setCampaignToCancel] = useState(null); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [campaignToDelete, setCampaignToDelete] = useState(null); const {data, mutate, isLoading} = useSWR( `/campaigns?page=${page}&pageSize=20${statusFilter !== 'ALL' ? `&status=${statusFilter}` : ''}`, @@ -87,6 +89,20 @@ export default function CampaignsPage() { } }; + const handleDelete = async () => { + if (!campaignToDelete) return; + + try { + await network.fetch('DELETE', `/campaigns/${campaignToDelete}`); + toast.success('Campaign deleted successfully'); + void mutate(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to delete campaign'); + } finally { + setCampaignToDelete(null); + } + }; + return ( <> @@ -266,6 +282,19 @@ export default function CampaignsPage() { + {campaign.status === 'DRAFT' && ( + + )} + {(campaign.status === 'SCHEDULED' || campaign.status === 'SENDING') && ( + - - - - - - ); -} - // Add Step Dialog Component interface AddStepDialogProps { open: boolean;