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'});
}
} }
+486 -245
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 (
<> <>
@@ -282,277 +337,463 @@ export default function Settings() {
<NextSeo title="Settings" /> <NextSeo title="Settings" />
<DashboardLayout> <DashboardLayout>
<div className="space-y-8"> <div className="space-y-8">
{/* Header */} {/* Header */}
<div> <div>
<h1 className="text-3xl font-bold text-neutral-900">Settings</h1> <h1 className="text-3xl font-bold text-neutral-900">Settings</h1>
<p className="text-neutral-500 mt-2">Manage your project settings and preferences</p> <p className="text-neutral-500 mt-2">Manage your project settings and preferences</p>
</div> </div>
{/* Tabs */} {/* Tabs */}
<Tabs value={currentTab} onValueChange={handleTabChange} className="max-w-4xl"> <Tabs value={currentTab} onValueChange={handleTabChange} className="max-w-4xl">
<TabsList> <TabsList>
{buildTabs({billingEnabled, smtpEnabled}).map(tab => { {buildTabs({billingEnabled, smtpEnabled}).map(tab => {
const Icon = tab.icon; const Icon = tab.icon;
return ( return (
<TabsTrigger key={tab.id} value={tab.id} className="flex items-center gap-2"> <TabsTrigger key={tab.id} value={tab.id} className="flex items-center gap-2">
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
{tab.label} {tab.label}
</TabsTrigger> </TabsTrigger>
); );
})} })}
</TabsList> </TabsList>
{/* General Tab */} {/* General Tab */}
<TabsContent value="general"> <TabsContent value="general">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Project Settings</CardTitle> <CardTitle>Project Settings</CardTitle>
<CardDescription>Update your project name and basic information</CardDescription> <CardDescription>Update your project name and basic information</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({field}) => (
<FormItem>
<FormLabel>Project Name</FormLabel>
<FormControl>
<Input placeholder="My Awesome Project" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Email Tracking Toggle - only show if feature is available */}
{trackingToggleEnabled && (
<FormField <FormField
control={form.control} control={form.control}
name="trackingEnabled" name="name"
render={({field}) => ( render={({field}) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border border-neutral-200 p-4"> <FormItem>
<div className="space-y-0.5"> <FormLabel>Project Name</FormLabel>
<FormLabel className="text-base">Email Tracking</FormLabel>
<FormDescription>
Enable open and click tracking for emails sent from this project. When disabled, emails
will be sent without tracking pixels.
</FormDescription>
</div>
<FormControl> <FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} /> <Input placeholder="My Awesome Project" {...field} />
</FormControl> </FormControl>
<FormMessage />
</FormItem> </FormItem>
)} )}
/> />
)}
<div className="flex justify-end"> {/* Email Tracking Toggle - only show if feature is available */}
<Button type="submit" disabled={form.formState.isSubmitting}> {trackingToggleEnabled && (
{form.formState.isSubmitting ? 'Saving...' : 'Save Changes'} <FormField
</Button> control={form.control}
</div> name="trackingEnabled"
render={({field}) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border border-neutral-200 p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Email Tracking</FormLabel>
<FormDescription>
Enable open and click tracking for emails sent from this project. When disabled,
emails will be sent without tracking pixels.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
)}
{/* API Keys */} <div className="flex justify-end">
<div className="space-y-4 pt-4 border-t border-neutral-200"> <Button type="submit" disabled={form.formState.isSubmitting}>
<div className="flex items-center justify-between mb-2"> {form.formState.isSubmitting ? 'Saving...' : 'Save Changes'}
<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> </Button>
</div> </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 && (
<motion.div <motion.div
initial={{opacity: 0, y: -10}} initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}} animate={{opacity: 1, y: 0}}
exit={{opacity: 0}} exit={{opacity: 0}}
className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800" className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800"
> >
{successMessage} {successMessage}
</motion.div> </motion.div>
)} )}
{errorMessage && ( {errorMessage && (
<motion.div <motion.div
initial={{opacity: 0, y: -10}} initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}} animate={{opacity: 1, y: 0}}
exit={{opacity: 0}} exit={{opacity: 0}}
className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800" className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800"
> >
{errorMessage} {errorMessage}
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
</form> </form>
</Form> </Form>
</CardContent>
</Card>
</TabsContent>
{/* Billing Tab */}
<TabsContent value="billing">
<div className="space-y-6">
{/* Unpaid Invoice Banner */}
<UnpaidInvoiceBanner projectId={activeProject.id} hasSubscription={!!activeProject.subscription} />
<Card>
<CardHeader>
<CardTitle>Billing & Subscription</CardTitle>
<CardDescription>Manage your subscription and billing information</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Success/Error Messages */}
<AnimatePresence mode="wait">
{successMessage && (
<motion.div
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0}}
className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800"
>
{successMessage}
</motion.div>
)}
{errorMessage && (
<motion.div
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0}}
className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800"
>
{errorMessage}
</motion.div>
)}
</AnimatePresence>
{activeProject.subscription ? (
// Has subscription - show billing portal button
<div className="space-y-4">
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center gap-2 text-green-800 mb-2">
<CreditCard className="h-5 w-5" />
<span className="font-medium">Active Subscription</span>
</div>
<p className="text-sm text-green-700">
Your subscription is active. Manage your billing details, update payment methods, or cancel
your subscription through the billing portal.
</p>
</div>
<div className="flex justify-start">
<Button onClick={handleManageBilling} disabled={isLoadingBilling}>
{isLoadingBilling ? 'Loading...' : 'Manage Billing'}
</Button>
</div>
</div>
) : (
// No subscription - show start subscription button
<div className="space-y-4">
<div className="p-4 bg-neutral-50 border border-neutral-200 rounded-lg">
<div className="flex items-center gap-2 text-neutral-800 mb-2">
<CreditCard className="h-5 w-5" />
<span className="font-medium">No Active Subscription</span>
</div>
<p className="text-sm text-neutral-600">
Start a subscription to unlock premium features and support the development of Plunk.
</p>
</div>
<div className="flex justify-start">
<Button onClick={handleStartSubscription} disabled={isLoadingBilling}>
{isLoadingBilling ? 'Loading...' : 'Start Subscription'}
</Button>
</div>
</div>
)}
</div>
</CardContent> </CardContent>
</Card> </Card>
{/* Billing Limits */} {/* API Keys - Separate Card */}
<BillingLimits projectId={activeProject.id} hasSubscription={!!activeProject.subscription} /> <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>
{/* Current Month Consumption */} {/* Danger Zone - Separate Card */}
<BillingConsumption projectId={activeProject.id} hasSubscription={!!activeProject.subscription} /> <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>
{/* Past Invoices */} {/* Delete Project */}
<BillingInvoices <div className="group">
projectId={activeProject.id} <div className="flex items-start justify-between gap-4 p-5 rounded-lg border-2 border-red-200 bg-red-50/50 transition-all">
hasSubscription={!!activeProject.subscription} <div className="flex-1">
onManageBilling={handleManageBilling} <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>
{/* Billing Tab */}
<TabsContent value="billing">
<div className="space-y-6">
{/* Unpaid Invoice Banner */}
<UnpaidInvoiceBanner projectId={activeProject.id} hasSubscription={!!activeProject.subscription} />
<Card>
<CardHeader>
<CardTitle>Billing & Subscription</CardTitle>
<CardDescription>Manage your subscription and billing information</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Success/Error Messages */}
<AnimatePresence mode="wait">
{successMessage && (
<motion.div
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0}}
className="p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-800"
>
{successMessage}
</motion.div>
)}
{errorMessage && (
<motion.div
initial={{opacity: 0, y: -10}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0}}
className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800"
>
{errorMessage}
</motion.div>
)}
</AnimatePresence>
{activeProject.subscription ? (
// Has subscription - show billing portal button
<div className="space-y-4">
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center gap-2 text-green-800 mb-2">
<CreditCard className="h-5 w-5" />
<span className="font-medium">Active Subscription</span>
</div>
<p className="text-sm text-green-700">
Your subscription is active. Manage your billing details, update payment methods, or
cancel your subscription through the billing portal.
</p>
</div>
<div className="flex justify-start">
<Button onClick={handleManageBilling} disabled={isLoadingBilling}>
{isLoadingBilling ? 'Loading...' : 'Manage Billing'}
</Button>
</div>
</div>
) : (
// No subscription - show start subscription button
<div className="space-y-4">
<div className="p-4 bg-neutral-50 border border-neutral-200 rounded-lg">
<div className="flex items-center gap-2 text-neutral-800 mb-2">
<CreditCard className="h-5 w-5" />
<span className="font-medium">No Active Subscription</span>
</div>
<p className="text-sm text-neutral-600">
Start a subscription to unlock premium features and support the development of Plunk.
</p>
</div>
<div className="flex justify-start">
<Button onClick={handleStartSubscription} disabled={isLoadingBilling}>
{isLoadingBilling ? 'Loading...' : 'Start Subscription'}
</Button>
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Billing Limits */}
<BillingLimits projectId={activeProject.id} hasSubscription={!!activeProject.subscription} />
{/* Current Month Consumption */}
<BillingConsumption projectId={activeProject.id} hasSubscription={!!activeProject.subscription} />
{/* Past Invoices */}
<BillingInvoices
projectId={activeProject.id}
hasSubscription={!!activeProject.subscription}
onManageBilling={handleManageBilling}
/>
</div>
</TabsContent>
{/* Domains Tab */}
<TabsContent value="domains">
<DomainsSettings projectId={activeProject.id} />
</TabsContent>
{/* SMTP Tab */}
<TabsContent value="smtp">
<SmtpSettings smtpConfig={smtpConfig} />
</TabsContent>
{/* Data Management Tab */}
<TabsContent value="data">
<DataManagementSettings />
</TabsContent>
</Tabs>
</div>
{/* Regenerate Keys Confirmation Dialog */}
<Dialog open={showRegenerateDialog} onOpenChange={setShowRegenerateDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-orange-500" />
Regenerate API Keys
</DialogTitle>
<DialogDescription className="space-y-2">
<p>Are you sure you want to regenerate your API keys?</p>
<Alert className="bg-orange-50 border-orange-200 text-orange-900 text-xs">
<AlertTriangle className="h-4 w-4" />
<div className="ml-2">
<strong>Warning:</strong> 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.
</div>
</Alert>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowRegenerateDialog(false)}>
Cancel
</Button>
<Button variant="default" onClick={handleRegenerateKeys} className="bg-orange-600 hover:bg-orange-700">
Regenerate Keys
</Button>
</DialogFooter>
</DialogContent>
</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> </div>
</TabsContent>
{/* Domains Tab */} <DialogFooter className="flex-col-reverse gap-2">
<TabsContent value="domains"> <Button
<DomainsSettings projectId={activeProject.id} /> variant="outline"
</TabsContent> 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>
{/* SMTP Tab */} {/* Delete Project Confirmation Dialog */}
<TabsContent value="smtp"> <Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<SmtpSettings smtpConfig={smtpConfig} /> <DialogContent className="sm:max-w-md">
</TabsContent> <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>
{/* Data Management Tab */} <div className="py-4">
<TabsContent value="data"> <label className="text-sm font-medium text-gray-700 block mb-2 text-center">
<DataManagementSettings /> Type <span className="font-mono font-semibold text-red-600 bg-red-50 px-2 py-0.5 rounded">DELETE</span>{' '}
</TabsContent> to confirm
</Tabs> </label>
</div> <Input
value={deleteConfirmText}
onChange={e => setDeleteConfirmText(e.target.value)}
placeholder="Type DELETE here"
className="text-center"
autoFocus
/>
</div>
{/* Regenerate Keys Confirmation Dialog */} <DialogFooter className="flex-col-reverse gap-2">
<Dialog open={showRegenerateDialog} onOpenChange={setShowRegenerateDialog}> <Button
<DialogContent> variant="outline"
<DialogHeader> onClick={() => {
<DialogTitle className="flex items-center gap-2"> setShowDeleteDialog(false);
<AlertTriangle className="h-5 w-5 text-orange-500" /> setDeleteConfirmText('');
Regenerate API Keys }}
</DialogTitle> className="w-full"
<DialogDescription className="space-y-2"> >
<p>Are you sure you want to regenerate your API keys?</p> Cancel
<Alert className="bg-orange-50 border-orange-200 text-orange-900 text-xs"> </Button>
<AlertTriangle className="h-4 w-4" /> <Button
<div className="ml-2"> onClick={handleDeleteProject}
<strong>Warning:</strong> This action will immediately invalidate your current API keys. Any disabled={deleteConfirmText !== 'DELETE'}
applications using the old keys will stop working until you update them with the new keys. className="w-full bg-red-600 hover:bg-red-700 disabled:opacity-50"
</div> >
</Alert> Delete Forever
</DialogDescription> </Button>
</DialogHeader> </DialogFooter>
<DialogFooter> </DialogContent>
<Button variant="outline" onClick={() => setShowRegenerateDialog(false)}> </Dialog>
Cancel </DashboardLayout>
</Button>
<Button variant="default" onClick={handleRegenerateKeys} className="bg-orange-600 hover:bg-orange-700">
Regenerate Keys
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</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;
+8 -8
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
@@ -158,7 +158,7 @@ model Template {
// Content // Content
subject String subject String
body String // HTML content with {{variable}} placeholders body String // HTML content with {{variable}} placeholders
from String from String
fromName String? fromName String?
replyTo String? replyTo String?
@@ -508,12 +508,12 @@ model Email {
toName String? // Recipient display name for To header toName String? // Recipient display name for To header
// Content (denormalized for history) // Content (denormalized for history)
subject String subject String
body String // Rendered HTML body String // Rendered HTML
from String from String
fromName String? fromName String?
replyTo String? replyTo String?
headers Json? // Custom email headers headers Json? // Custom email headers
attachments Json? // Array of {filename: string, content: string (base64), contentType: string} attachments Json? // Array of {filename: string, content: string (base64), contentType: string}
// AWS SES Message ID (for tracking webhooks) // AWS SES Message ID (for tracking webhooks)