From a28be9adc9dc63ba3f5cdb0b2a66f26d33363b5b Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Wed, 3 Dec 2025 21:30:54 +0100 Subject: [PATCH] Reset and deletion of project --- apps/api/src/controllers/Users.ts | 155 +++- apps/web/src/pages/settings/index.tsx | 731 ++++++++++++------ .../migration.sql | 20 +- packages/db/prisma/schema.prisma | 16 +- 4 files changed, 655 insertions(+), 267 deletions(-) rename packages/db/prisma/migrations/{20251201192357_init => 20251203202641_init}/migration.sql (96%) diff --git a/apps/api/src/controllers/Users.ts b/apps/api/src/controllers/Users.ts index 9ef21a9..b945c57 100644 --- a/apps/api/src/controllers/Users.ts +++ b/apps/api/src/controllers/Users.ts @@ -1,20 +1,10 @@ import {randomBytes} from 'node:crypto'; -import {Controller, Get, Middleware, Patch, Post, Put} from '@overnightjs/core'; +import {Controller, Delete, Get, Middleware, Patch, Post, Put} from '@overnightjs/core'; import {BillingLimitSchemas, ProjectSchemas} from '@plunk/shared'; import type {NextFunction, Request, Response} from 'express'; -import { - DASHBOARD_URI, - SMTP_DOMAIN, - SMTP_ENABLED, - SMTP_PORT_SECURE, - SMTP_PORT_SUBMISSION, - STRIPE_ENABLED, - STRIPE_PRICE_EMAIL_USAGE, - STRIPE_PRICE_ONBOARDING, - TRACKING_TOGGLE_ENABLED, -} from '../app/constants.js'; +import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ONBOARDING} from '../app/constants.js'; import {stripe} from '../app/stripe.js'; import {prisma} from '../database/prisma.js'; import {NotAuthenticated, NotFound} from '../exceptions/index.js'; @@ -86,7 +76,7 @@ export class Users { members: { create: { userId: auth.userId, - role: 'ADMIN', + role: 'OWNER', }, }, }, @@ -614,4 +604,143 @@ export class Users { return res.status(200).json(metrics); } + + @Post('@me/projects/:id/reset') + @Middleware([isAuthenticated]) + @CatchAsync + public async resetProject(req: Request, res: Response, next: NextFunction) { + const auth = res.locals.auth as AuthResponse; + const {id} = req.params; + + if (!auth.userId) { + throw new NotAuthenticated(); + } + + if (!id) { + throw new NotFound('Project ID is required'); + } + + // Verify user has admin/owner access to this project + const membership = await prisma.membership.findFirst({ + where: { + userId: auth.userId, + projectId: id, + role: { + in: ['ADMIN', 'OWNER'], + }, + }, + }); + + if (!membership) { + throw new NotFound('Project not found or you do not have permission to reset it'); + } + + // Delete all project data in a transaction + await prisma.$transaction(async tx => { + // Delete all emails + await tx.email.deleteMany({ + where: {projectId: id}, + }); + + // Delete all events + await tx.event.deleteMany({ + where: {projectId: id}, + }); + + // Delete all campaigns + await tx.campaign.deleteMany({ + where: {projectId: id}, + }); + + // Delete all workflows + await tx.workflow.deleteMany({ + where: {projectId: id}, + }); + + // Delete all segments + await tx.segment.deleteMany({ + where: {projectId: id}, + }); + + // Delete all contacts + await tx.contact.deleteMany({ + where: {projectId: id}, + }); + + // Delete all templates + await tx.template.deleteMany({ + where: {projectId: id}, + }); + + // Delete all API requests + await tx.apiRequest.deleteMany({ + where: {projectId: id}, + }); + }); + + return res.status(200).json({success: true, message: 'Project reset successfully'}); + } + + @Delete('@me/projects/:id') + @Middleware([isAuthenticated]) + @CatchAsync + public async deleteProject(req: Request, res: Response, next: NextFunction) { + const auth = res.locals.auth as AuthResponse; + const {id} = req.params; + + if (!auth.userId) { + throw new NotAuthenticated(); + } + + if (!id) { + throw new NotFound('Project ID is required'); + } + + // Verify user has owner or admin access to this project + const membership = await prisma.membership.findFirst({ + where: { + userId: auth.userId, + projectId: id, + role: { + in: ['OWNER', 'ADMIN'], + }, + }, + }); + + if (!membership) { + throw new NotFound( + 'Project not found or you do not have permission to delete it. Only project owners and admins can delete projects.', + ); + } + + // Get project to check for active subscription + const project = await prisma.project.findUnique({ + where: {id}, + select: { + subscription: true, + customer: true, + }, + }); + + if (!project) { + throw new NotFound('Project not found'); + } + + // If project has an active subscription, cancel it first + if (STRIPE_ENABLED && stripe && project.subscription) { + try { + await stripe.subscriptions.cancel(project.subscription); + } catch (error) { + // Log but don't fail if subscription cancellation fails + console.error('Failed to cancel subscription:', error); + } + } + + // Delete the project (cascading deletes will handle related data) + await prisma.project.delete({ + where: {id}, + }); + + return res.status(200).json({success: true, message: 'Project deleted successfully'}); + } } diff --git a/apps/web/src/pages/settings/index.tsx b/apps/web/src/pages/settings/index.tsx index ca6b37a..ac1fcfa 100644 --- a/apps/web/src/pages/settings/index.tsx +++ b/apps/web/src/pages/settings/index.tsx @@ -78,6 +78,10 @@ export default function Settings() { const [successMessage, setSuccessMessage] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const [showRegenerateDialog, setShowRegenerateDialog] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showResetDialog, setShowResetDialog] = useState(false); + const [deleteConfirmText, setDeleteConfirmText] = useState(''); + const [resetConfirmText, setResetConfirmText] = useState(''); const [isLoadingBilling, setIsLoadingBilling] = useState(false); const billingEnabled = config?.features.billing.enabled ?? false; @@ -264,6 +268,57 @@ export default function Settings() { } }; + const handleResetProject = async () => { + if (!activeProject || resetConfirmText !== 'RESET') return; + + try { + setErrorMessage(null); + setSuccessMessage(null); + + await network.fetch('POST', `/users/@me/projects/${activeProject.id}/reset`); + + setSuccessMessage('Project reset successfully. All data has been cleared.'); + setShowResetDialog(false); + setResetConfirmText(''); + + // Refresh the page to reload data + setTimeout(() => { + window.location.reload(); + }, 1500); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : 'Failed to reset project'); + setShowResetDialog(false); + setResetConfirmText(''); + } + }; + + const handleDeleteProject = async () => { + if (!activeProject || deleteConfirmText !== 'DELETE') return; + + try { + setErrorMessage(null); + setSuccessMessage(null); + + await network.fetch('DELETE', `/users/@me/projects/${activeProject.id}`); + + setSuccessMessage('Project deleted successfully. Redirecting...'); + setShowDeleteDialog(false); + setDeleteConfirmText(''); + + // Refresh projects list and redirect to dashboard + await projectsMutate(); + + // Redirect to dashboard after a short delay + setTimeout(() => { + window.location.href = '/'; + }, 1500); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : 'Failed to delete project'); + setShowDeleteDialog(false); + setDeleteConfirmText(''); + } + }; + if (!activeProject) { return ( <> @@ -282,277 +337,463 @@ export default function Settings() {
- {/* Header */} -
-

Settings

-

Manage your project settings and preferences

-
+ {/* Header */} +
+

Settings

+

Manage your project settings and preferences

+
- {/* Tabs */} - - - {buildTabs({billingEnabled, smtpEnabled}).map(tab => { - const Icon = tab.icon; - return ( - - - {tab.label} - - ); - })} - + {/* Tabs */} + + + {buildTabs({billingEnabled, smtpEnabled}).map(tab => { + const Icon = tab.icon; + return ( + + + {tab.label} + + ); + })} + - {/* General Tab */} - - - - Project Settings - Update your project name and basic information - - -
- - ( - - Project Name - - - - - - )} - /> - - {/* Email Tracking Toggle - only show if feature is available */} - {trackingToggleEnabled && ( + {/* General Tab */} + + + + Project Settings + Update your project name and basic information + + + + ( - -
- Email Tracking - - Enable open and click tracking for emails sent from this project. When disabled, emails - will be sent without tracking pixels. - -
+ + Project Name - + + )} /> - )} -
- -
+ {/* Email Tracking Toggle - only show if feature is available */} + {trackingToggleEnabled && ( + ( + +
+ Email Tracking + + Enable open and click tracking for emails sent from this project. When disabled, + emails will be sent without tracking pixels. + +
+ + + +
+ )} + /> + )} - {/* API Keys */} -
-
-

API Keys

-
- - -
- {/* Success/Error Messages */} - - {successMessage && ( - - {successMessage} - - )} - {errorMessage && ( - - {errorMessage} - - )} - - - -
-
-
- - {/* Billing Tab */} - -
- {/* Unpaid Invoice Banner */} - - - - - Billing & Subscription - Manage your subscription and billing information - - -
- {/* Success/Error Messages */} - - {successMessage && ( - - {successMessage} - - )} - {errorMessage && ( - - {errorMessage} - - )} - - - {activeProject.subscription ? ( - // Has subscription - show billing portal button -
-
-
- - Active Subscription -
-

- Your subscription is active. Manage your billing details, update payment methods, or cancel - your subscription through the billing portal. -

-
- -
- -
-
- ) : ( - // No subscription - show start subscription button -
-
-
- - No Active Subscription -
-

- Start a subscription to unlock premium features and support the development of Plunk. -

-
- -
- -
-
- )} -
+ {/* Success/Error Messages */} + + {successMessage && ( + + {successMessage} + + )} + {errorMessage && ( + + {errorMessage} + + )} + + +
- {/* Billing Limits */} - + {/* API Keys - Separate Card */} + + +
+
+ API Credentials + Use these keys to integrate with the Plunk API +
+ +
+
+ + + + +
- {/* Current Month Consumption */} - + {/* Danger Zone - Separate Card */} + + +
+
+ +
+
+ Danger Zone + + Irreversible actions that affect your project data + +
+
+
+ + {/* Reset Project */} +
+
+
+
+ +

Reset Project Data

+
+

+ Clear all campaigns, contacts, workflows, templates, and events. This gives you a blank + project to start fresh. +

+
+ Preserved: + API keys, domains, billing information +
+
+ +
+
- {/* Past Invoices */} - +
+
+
+ +

Delete Project Permanently

+
+

+ Permanently delete this project and all associated data. This action{' '} + cannot be undone. + {activeProject?.subscription && ' Your subscription will be canceled.'} +

+
+ + Permanent Deletion + + All data will be lost +
+
+ +
+
+
+
+
+ + {/* Billing Tab */} + +
+ {/* Unpaid Invoice Banner */} + + + + + Billing & Subscription + Manage your subscription and billing information + + +
+ {/* Success/Error Messages */} + + {successMessage && ( + + {successMessage} + + )} + {errorMessage && ( + + {errorMessage} + + )} + + + {activeProject.subscription ? ( + // Has subscription - show billing portal button +
+
+
+ + Active Subscription +
+

+ Your subscription is active. Manage your billing details, update payment methods, or + cancel your subscription through the billing portal. +

+
+ +
+ +
+
+ ) : ( + // No subscription - show start subscription button +
+
+
+ + No Active Subscription +
+

+ Start a subscription to unlock premium features and support the development of Plunk. +

+
+ +
+ +
+
+ )} +
+
+
+ + {/* Billing Limits */} + + + {/* Current Month Consumption */} + + + {/* Past Invoices */} + +
+
+ + {/* Domains Tab */} + + + + + {/* SMTP Tab */} + + + + + {/* Data Management Tab */} + + + +
+
+ + {/* Regenerate Keys Confirmation Dialog */} + + + + + + Regenerate API Keys + + +

Are you sure you want to regenerate your API keys?

+ + +
+ Warning: This action will immediately invalidate your current API keys. Any + applications using the old keys will stop working until you update them with the new keys. +
+
+
+
+ + + + +
+
+ + {/* Reset Project Confirmation Dialog */} + + + +
+ +
+ Reset Project Data? + + All your campaigns, contacts, workflows, and templates will be permanently deleted. Your API keys and + settings will remain intact. + +
+ +
+ + setResetConfirmText(e.target.value)} + placeholder="Type RESET here" + className="text-center" + autoFocus />
- - {/* Domains Tab */} - - - + + + + +
+
- {/* SMTP Tab */} - - - + {/* Delete Project Confirmation Dialog */} + + + +
+ +
+ Delete Project Permanently? + +

+ This will permanently delete your entire project and all + data. This action cannot be undone. +

+ {activeProject?.subscription && ( +

+ Your active subscription will be canceled +

+ )} +
+
- {/* Data Management Tab */} - - - - - +
+ + setDeleteConfirmText(e.target.value)} + placeholder="Type DELETE here" + className="text-center" + autoFocus + /> +
- {/* Regenerate Keys Confirmation Dialog */} - - - - - - Regenerate API Keys - - -

Are you sure you want to regenerate your API keys?

- - -
- Warning: This action will immediately invalidate your current API keys. Any - applications using the old keys will stop working until you update them with the new keys. -
-
-
-
- - - - -
-
-
+ + + + + + + ); } diff --git a/packages/db/prisma/migrations/20251201192357_init/migration.sql b/packages/db/prisma/migrations/20251203202641_init/migration.sql similarity index 96% rename from packages/db/prisma/migrations/20251201192357_init/migration.sql rename to packages/db/prisma/migrations/20251203202641_init/migration.sql index d642687..dfdb2c5 100644 --- a/packages/db/prisma/migrations/20251201192357_init/migration.sql +++ b/packages/db/prisma/migrations/20251203202641_init/migration.sql @@ -450,6 +450,18 @@ CREATE INDEX "emails_createdAt_idx" ON "emails"("createdAt"); -- CreateIndex CREATE INDEX "emails_projectId_sourceType_createdAt_idx" ON "emails"("projectId", "sourceType", "createdAt"); +-- CreateIndex +CREATE INDEX "emails_contactId_openedAt_idx" ON "emails"("contactId", "openedAt"); + +-- CreateIndex +CREATE INDEX "emails_contactId_clickedAt_idx" ON "emails"("contactId", "clickedAt"); + +-- CreateIndex +CREATE INDEX "emails_contactId_bouncedAt_idx" ON "emails"("contactId", "bouncedAt"); + +-- CreateIndex +CREATE INDEX "emails_contactId_complainedAt_idx" ON "emails"("contactId", "complainedAt"); + -- CreateIndex CREATE INDEX "events_projectId_name_idx" ON "events"("projectId", "name"); @@ -462,6 +474,12 @@ CREATE INDEX "events_emailId_idx" ON "events"("emailId"); -- CreateIndex CREATE INDEX "events_createdAt_idx" ON "events"("createdAt"); +-- CreateIndex +CREATE INDEX "events_projectId_contactId_name_createdAt_idx" ON "events"("projectId", "contactId", "name", "createdAt"); + +-- CreateIndex +CREATE INDEX "events_contactId_name_createdAt_idx" ON "events"("contactId", "name", "createdAt"); + -- CreateIndex CREATE INDEX "api_requests_projectId_createdAt_idx" ON "api_requests"("projectId", "createdAt" DESC); @@ -484,7 +502,7 @@ CREATE INDEX "api_requests_projectId_statusCode_createdAt_idx" ON "api_requests" ALTER TABLE "memberships" ADD CONSTRAINT "memberships_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "memberships" ADD CONSTRAINT "memberships_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "memberships" ADD CONSTRAINT "memberships_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "domains" ADD CONSTRAINT "domains_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 8637fee..17f3653 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -78,7 +78,7 @@ model Project { model Membership { user User @relation(fields: [userId], references: [id]) userId String - project Project @relation(fields: [projectId], references: [id]) + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) projectId String // Details @@ -158,7 +158,7 @@ model Template { // Content subject String - body String // HTML content with {{variable}} placeholders + body String // HTML content with {{variable}} placeholders from String fromName String? replyTo String? @@ -508,12 +508,12 @@ model Email { toName String? // Recipient display name for To header // Content (denormalized for history) - subject String - body String // Rendered HTML - from String - fromName String? - replyTo String? - headers Json? // Custom email headers + subject String + body String // Rendered HTML + from String + fromName String? + replyTo String? + headers Json? // Custom email headers attachments Json? // Array of {filename: string, content: string (base64), contentType: string} // AWS SES Message ID (for tracking webhooks)