Reset and deletion of project
This commit is contained in:
@@ -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'});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-1
@@ -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;
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user