Reset and deletion of project

This commit is contained in:
Dries Augustyns
2025-12-03 21:30:54 +01:00
parent 382e97e5b2
commit a28be9adc9
4 changed files with 655 additions and 267 deletions
+142 -13
View File
@@ -1,20 +1,10 @@
import {randomBytes} from 'node:crypto'; 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 {BillingLimitSchemas, ProjectSchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
import { import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ONBOARDING} from '../app/constants.js';
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 {stripe} from '../app/stripe.js'; import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js'; import {prisma} from '../database/prisma.js';
import {NotAuthenticated, NotFound} from '../exceptions/index.js'; import {NotAuthenticated, NotFound} from '../exceptions/index.js';
@@ -86,7 +76,7 @@ export class Users {
members: { members: {
create: { create: {
userId: auth.userId, userId: auth.userId,
role: 'ADMIN', role: 'OWNER',
}, },
}, },
}, },
@@ -614,4 +604,143 @@ export class Users {
return res.status(200).json(metrics); 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'});
}
} }
+272 -31
View File
@@ -78,6 +78,10 @@ export default function Settings() {
const [successMessage, setSuccessMessage] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [showRegenerateDialog, setShowRegenerateDialog] = useState(false); 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 [isLoadingBilling, setIsLoadingBilling] = useState(false);
const billingEnabled = config?.features.billing.enabled ?? 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) { if (!activeProject) {
return ( return (
<> <>
@@ -336,8 +391,8 @@ export default function Settings() {
<div className="space-y-0.5"> <div className="space-y-0.5">
<FormLabel className="text-base">Email Tracking</FormLabel> <FormLabel className="text-base">Email Tracking</FormLabel>
<FormDescription> <FormDescription>
Enable open and click tracking for emails sent from this project. When disabled, emails Enable open and click tracking for emails sent from this project. When disabled,
will be sent without tracking pixels. emails will be sent without tracking pixels.
</FormDescription> </FormDescription>
</div> </div>
<FormControl> <FormControl>
@@ -354,33 +409,6 @@ export default function Settings() {
</Button> </Button>
</div> </div>
{/* API Keys */}
<div className="space-y-4 pt-4 border-t border-neutral-200">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium text-neutral-900">API Keys</h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={promptRegenerateKeys}
className="text-xs"
>
Regenerate Keys
</Button>
</div>
<ApiKeyDisplay
label="Public API Key"
value={activeProject.public}
description="Use this key for client-side integrations"
/>
<ApiKeyDisplay
label="Secret API Key"
value={activeProject.secret}
description="Keep this key secure and never expose it publicly"
isSecret
/>
</div>
{/* Success/Error Messages */} {/* Success/Error Messages */}
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{successMessage && ( {successMessage && (
@@ -408,6 +436,111 @@ export default function Settings() {
</Form> </Form>
</CardContent> </CardContent>
</Card> </Card>
{/* API Keys - Separate Card */}
<Card className="mt-6">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>API Credentials</CardTitle>
<CardDescription>Use these keys to integrate with the Plunk API</CardDescription>
</div>
<Button type="button" variant="outline" size="sm" onClick={promptRegenerateKeys}>
Regenerate Keys
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<ApiKeyDisplay
label="Public API Key"
value={activeProject.public}
description="Use this key for client-side integrations"
/>
<ApiKeyDisplay
label="Secret API Key"
value={activeProject.secret}
description="Keep this key secure and never expose it publicly"
isSecret
/>
</CardContent>
</Card>
{/* Danger Zone - Separate Card */}
<Card className="border-red-200 mt-6">
<CardHeader className="border-b border-red-100 bg-gradient-to-r from-red-50 to-orange-50">
<div className="flex items-center gap-3">
<div className="p-2 bg-white rounded-lg shadow-sm border border-red-200">
<AlertTriangle className="h-5 w-5 text-red-600" />
</div>
<div>
<CardTitle className="text-lg text-red-900">Danger Zone</CardTitle>
<CardDescription className="text-red-700">
Irreversible actions that affect your project data
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="pt-6 space-y-6">
{/* Reset Project */}
<div className="group">
<div className="flex items-start justify-between gap-4 p-5 rounded-lg border border-gray-200 bg-white transition-all">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Database className="h-4 w-4 text-orange-600" />
<h4 className="font-semibold text-gray-900">Reset Project Data</h4>
</div>
<p className="text-sm text-gray-600 mb-3">
Clear all campaigns, contacts, workflows, templates, and events. This gives you a blank
project to start fresh.
</p>
<div className="flex items-start gap-2 text-xs text-gray-500">
<span className="font-medium">Preserved:</span>
<span>API keys, domains, billing information</span>
</div>
</div>
<Button
type="button"
variant="outline"
onClick={() => setShowResetDialog(true)}
className="shrink-0 border-orange-300 text-orange-700 hover:bg-orange-50 hover:border-orange-400"
>
Reset Data
</Button>
</div>
</div>
{/* Delete Project */}
<div className="group">
<div className="flex items-start justify-between gap-4 p-5 rounded-lg border-2 border-red-200 bg-red-50/50 transition-all">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="h-4 w-4 text-red-600" />
<h4 className="font-semibold text-red-900">Delete Project Permanently</h4>
</div>
<p className="text-sm text-red-800 mb-3">
Permanently delete this project and all associated data. This action{' '}
<strong>cannot be undone</strong>.
{activeProject?.subscription && ' Your subscription will be canceled.'}
</p>
<div className="flex items-center gap-2 text-xs">
<span className="px-2 py-1 bg-red-100 text-red-700 rounded font-medium">
Permanent Deletion
</span>
<span className="text-red-600">All data will be lost</span>
</div>
</div>
<Button
type="button"
variant="default"
onClick={() => setShowDeleteDialog(true)}
className="shrink-0 bg-red-600 hover:bg-red-700 text-white shadow-sm hover:shadow-md transition-all"
>
Delete Project
</Button>
</div>
</div>
</CardContent>
</Card>
</TabsContent> </TabsContent>
{/* Billing Tab */} {/* Billing Tab */}
@@ -456,8 +589,8 @@ export default function Settings() {
<span className="font-medium">Active Subscription</span> <span className="font-medium">Active Subscription</span>
</div> </div>
<p className="text-sm text-green-700"> <p className="text-sm text-green-700">
Your subscription is active. Manage your billing details, update payment methods, or cancel Your subscription is active. Manage your billing details, update payment methods, or
your subscription through the billing portal. cancel your subscription through the billing portal.
</p> </p>
</div> </div>
@@ -552,6 +685,114 @@ export default function Settings() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Reset Project Confirmation Dialog */}
<Dialog open={showResetDialog} onOpenChange={setShowResetDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="space-y-3">
<div className="mx-auto w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center">
<Database className="h-6 w-6 text-orange-600" />
</div>
<DialogTitle className="text-center text-xl">Reset Project Data?</DialogTitle>
<DialogDescription className="text-center text-base">
All your campaigns, contacts, workflows, and templates will be permanently deleted. Your API keys and
settings will remain intact.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<label className="text-sm font-medium text-gray-700 block mb-2 text-center">
Type{' '}
<span className="font-mono font-semibold text-orange-600 bg-orange-50 px-2 py-0.5 rounded">RESET</span>{' '}
to confirm
</label>
<Input
value={resetConfirmText}
onChange={e => setResetConfirmText(e.target.value)}
placeholder="Type RESET here"
className="text-center"
autoFocus
/>
</div>
<DialogFooter className="flex-col-reverse gap-2">
<Button
variant="outline"
onClick={() => {
setShowResetDialog(false);
setResetConfirmText('');
}}
className="w-full"
>
Cancel
</Button>
<Button
onClick={handleResetProject}
disabled={resetConfirmText !== 'RESET'}
className="w-full bg-orange-600 hover:bg-orange-700 disabled:opacity-50"
>
Reset Data
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Project Confirmation Dialog */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="space-y-3">
<div className="mx-auto w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<AlertTriangle className="h-6 w-6 text-red-600" />
</div>
<DialogTitle className="text-center text-xl">Delete Project Permanently?</DialogTitle>
<DialogDescription className="text-center text-base space-y-2">
<p>
This will <strong className="text-red-600">permanently delete</strong> your entire project and all
data. This action cannot be undone.
</p>
{activeProject?.subscription && (
<p className="text-sm text-red-700 bg-red-50 rounded p-2 border border-red-200">
Your active subscription will be canceled
</p>
)}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<label className="text-sm font-medium text-gray-700 block mb-2 text-center">
Type <span className="font-mono font-semibold text-red-600 bg-red-50 px-2 py-0.5 rounded">DELETE</span>{' '}
to confirm
</label>
<Input
value={deleteConfirmText}
onChange={e => setDeleteConfirmText(e.target.value)}
placeholder="Type DELETE here"
className="text-center"
autoFocus
/>
</div>
<DialogFooter className="flex-col-reverse gap-2">
<Button
variant="outline"
onClick={() => {
setShowDeleteDialog(false);
setDeleteConfirmText('');
}}
className="w-full"
>
Cancel
</Button>
<Button
onClick={handleDeleteProject}
disabled={deleteConfirmText !== 'DELETE'}
className="w-full bg-red-600 hover:bg-red-700 disabled:opacity-50"
>
Delete Forever
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DashboardLayout> </DashboardLayout>
</> </>
); );
@@ -450,6 +450,18 @@ CREATE INDEX "emails_createdAt_idx" ON "emails"("createdAt");
-- CreateIndex -- CreateIndex
CREATE INDEX "emails_projectId_sourceType_createdAt_idx" ON "emails"("projectId", "sourceType", "createdAt"); 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 -- CreateIndex
CREATE INDEX "events_projectId_name_idx" ON "events"("projectId", "name"); CREATE INDEX "events_projectId_name_idx" ON "events"("projectId", "name");
@@ -462,6 +474,12 @@ CREATE INDEX "events_emailId_idx" ON "events"("emailId");
-- CreateIndex -- CreateIndex
CREATE INDEX "events_createdAt_idx" ON "events"("createdAt"); 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 -- CreateIndex
CREATE INDEX "api_requests_projectId_createdAt_idx" ON "api_requests"("projectId", "createdAt" DESC); 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; ALTER TABLE "memberships" ADD CONSTRAINT "memberships_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey -- 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 -- AddForeignKey
ALTER TABLE "domains" ADD CONSTRAINT "domains_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "domains" ADD CONSTRAINT "domains_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+1 -1
View File
@@ -78,7 +78,7 @@ model Project {
model Membership { model Membership {
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
userId String userId String
project Project @relation(fields: [projectId], references: [id]) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String projectId String
// Details // Details