feat: Add type to campaign

This commit is contained in:
Dries Augustyns
2026-04-01 18:49:51 +02:00
parent 3343e891bd
commit d24259e8d2
9 changed files with 139 additions and 20 deletions
+5 -3
View File
@@ -1,5 +1,5 @@
import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus, TemplateType} from '@plunk/db';
import {CampaignSchemas, UtilitySchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express';
@@ -20,7 +20,7 @@ export class Campaigns {
@CatchAsync
private async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
const {name, description, subject, body, from, fromName, replyTo, type, audienceType, audienceCondition, segmentId} =
CampaignSchemas.create.parse(req.body);
if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) {
@@ -42,6 +42,7 @@ export class Campaigns {
from,
fromName,
replyTo,
type,
audienceType,
audienceCondition,
segmentId,
@@ -109,7 +110,7 @@ export class Campaigns {
private async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params);
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
const {name, description, subject, body, from, fromName, replyTo, type, audienceType, audienceCondition, segmentId} =
req.body;
// Validate audience-specific fields if audienceType is being updated
@@ -134,6 +135,7 @@ export class Campaigns {
from,
fromName,
replyTo,
type: type as TemplateType | undefined,
audienceType,
audienceCondition,
segmentId,
+10 -2
View File
@@ -1,5 +1,5 @@
import type {Campaign, Contact, Prisma} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus, EmailSourceType} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus, EmailSourceType, TemplateType} from '@plunk/db';
import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types';
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
import signale from 'signale';
@@ -59,6 +59,7 @@ export class CampaignService {
from: data.from,
fromName: data.fromName,
replyTo: data.replyTo,
type: data.type ?? TemplateType.MARKETING,
audienceType: data.audienceType,
audienceCondition: toPrismaJson(data.audienceCondition || null),
segmentId: data.segmentId,
@@ -100,6 +101,10 @@ export class CampaignService {
const updateData: Prisma.CampaignUpdateInput = buildEmailFieldsUpdate(data) as Prisma.CampaignUpdateInput;
// Handle campaign-specific fields
if (data.type !== undefined) {
updateData.type = data.type;
}
if (data.audienceType !== undefined) {
updateData.audienceType = data.audienceType;
}
@@ -262,6 +267,7 @@ export class CampaignService {
from: campaign.from,
fromName: campaign.fromName,
replyTo: campaign.replyTo,
type: campaign.type,
audienceType: campaign.audienceType,
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
segmentId: campaign.segmentId,
@@ -482,6 +488,7 @@ export class CampaignService {
from: campaign.from,
fromName: campaign.fromName || undefined,
replyTo: campaign.replyTo || undefined,
isTransactional: campaign.type === TemplateType.TRANSACTIONAL,
});
} catch (error) {
signale.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
@@ -708,7 +715,8 @@ export class CampaignService {
): Promise<Prisma.ContactWhereInput> {
const baseWhere: Prisma.ContactWhereInput = {
projectId,
subscribed: true, // Only send to subscribed contacts
// Transactional campaigns send to all contacts regardless of subscription status
...(campaign.type !== TemplateType.TRANSACTIONAL && {subscribed: true}),
};
switch (campaign.audienceType) {
+5 -2
View File
@@ -38,6 +38,7 @@ interface SendEmailParams {
workflowExecutionId?: string;
workflowStepExecutionId?: string;
recipientEmail?: string; // Optional custom recipient email (overrides contact.email)
isTransactional?: boolean; // Override source type to TRANSACTIONAL (e.g. for transactional campaigns)
}
/**
@@ -116,10 +117,12 @@ export class EmailService {
* Send a campaign email
*/
public static async sendCampaignEmail(params: SendEmailParams): Promise<Email> {
// Check if template is transactional to determine source type
// Check if campaign or template is transactional to determine source type
let sourceType: EmailSourceType = EmailSourceType.CAMPAIGN;
if (params.templateId) {
if (params.isTransactional) {
sourceType = EmailSourceType.TRANSACTIONAL;
} else if (params.templateId) {
const template = await prisma.template.findUnique({
where: {id: params.templateId},
select: {type: true},
+45 -4
View File
@@ -29,7 +29,7 @@ import {
StickySaveBar,
} from '@plunk/ui';
import type {Campaign, Segment} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
import {CampaignAudienceType, CampaignStatus, TemplateType} from '@plunk/db';
import {CampaignSchemas} from '@plunk/shared';
import {DashboardLayout} from '../../components/DashboardLayout';
import {EmailSettings} from '../../components/EmailSettings';
@@ -216,6 +216,7 @@ export default function CampaignDetailsPage() {
from: editedCampaign.from,
fromName: editedCampaign.fromName || null,
replyTo: editedCampaign.replyTo || null,
type: editedCampaign.type,
audienceType: editedCampaign.audienceType,
segmentId: editedCampaign.segmentId || undefined,
});
@@ -232,6 +233,7 @@ export default function CampaignDetailsPage() {
from: updated.data.from,
fromName: updated.data.fromName || '',
replyTo: updated.data.replyTo || '',
type: updated.data.type,
audienceType: updated.data.audienceType,
segmentId: updated.data.segmentId || undefined,
});
@@ -254,6 +256,7 @@ export default function CampaignDetailsPage() {
from: campaign.data.from,
fromName: campaign.data.fromName || '',
replyTo: campaign.data.replyTo || '',
type: campaign.data.type,
audienceType: campaign.data.audienceType,
segmentId: campaign.data.segmentId || undefined,
});
@@ -274,6 +277,7 @@ export default function CampaignDetailsPage() {
editedCampaign.from !== campaign.data.from ||
(editedCampaign.fromName || '') !== (campaign.data.fromName || '') ||
(editedCampaign.replyTo || '') !== (campaign.data.replyTo || '') ||
editedCampaign.type !== campaign.data.type ||
editedCampaign.audienceType !== campaign.data.audienceType ||
(editedCampaign.segmentId || null) !== (campaign.data.segmentId || null);
@@ -461,6 +465,40 @@ export default function CampaignDetailsPage() {
/>
</div>
<div>
<Label>Campaign Type</Label>
<div className="grid grid-cols-2 gap-3 mt-2">
<button
type="button"
onClick={() => setEditedCampaign({...editedCampaign, type: TemplateType.MARKETING})}
className={`text-left p-3 rounded-lg border-2 transition-colors ${
(editedCampaign.type ?? c.type) === TemplateType.MARKETING
? 'border-neutral-900 bg-neutral-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<p className="font-medium text-sm text-neutral-900">Marketing</p>
<p className="text-xs text-neutral-500 mt-1">
Subscribed contacts only, includes unsubscribe link.
</p>
</button>
<button
type="button"
onClick={() => setEditedCampaign({...editedCampaign, type: TemplateType.TRANSACTIONAL})}
className={`text-left p-3 rounded-lg border-2 transition-colors ${
(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
? 'border-neutral-900 bg-neutral-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<p className="font-medium text-sm text-neutral-900">Transactional</p>
<p className="text-xs text-neutral-500 mt-1">
All contacts regardless of subscription. No unsubscribe footer.
</p>
</button>
</div>
</div>
<div>
<Label htmlFor="subject">Subject Line *</Label>
<Input
@@ -513,8 +551,8 @@ export default function CampaignDetailsPage() {
<SelectContent>
<SelectItemWithDescription
value={CampaignAudienceType.ALL}
title="All Subscribed Contacts"
description="Send to everyone who hasn't unsubscribed"
title={(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL ? 'All Contacts' : 'All Subscribed Contacts'}
description={(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL ? 'Send to all contacts regardless of subscription status' : "Send to everyone who hasn't unsubscribed"}
/>
<SelectItemWithDescription
value={CampaignAudienceType.SEGMENT}
@@ -581,7 +619,10 @@ export default function CampaignDetailsPage() {
<Info className="h-3.5 w-3.5 text-blue-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-blue-800">
This count will be recalculated right before sending to ensure accuracy. The final number may
differ if contacts subscribe, unsubscribe, or segment membership changes.
differ if contacts{' '}
{(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL
? 'are added or removed, or segment membership changes.'
: 'subscribe, unsubscribe, or segment membership changes.'}
</p>
</div>
</div>
+64 -8
View File
@@ -15,7 +15,7 @@ import {
Textarea,
} from '@plunk/ui';
import type {Segment, Template} from '@plunk/db';
import {CampaignAudienceType} from '@plunk/db';
import {CampaignAudienceType, TemplateType} from '@plunk/db';
import {NextSeo} from 'next-seo';
import {DashboardLayout} from '../../components/DashboardLayout';
import {EmailSettings} from '../../components/EmailSettings';
@@ -41,6 +41,7 @@ export default function CreateCampaignPage() {
const [from, setFrom] = useState('');
const [fromName, setFromName] = useState('');
const [replyTo, setReplyTo] = useState('');
const [campaignType, setCampaignType] = useState<TemplateType>(TemplateType.MARKETING);
const [audienceType, setAudienceType] = useState<CampaignAudienceType>(CampaignAudienceType.ALL);
const [segmentId, setSegmentId] = useState('');
const [saving, setSaving] = useState(false);
@@ -150,6 +151,7 @@ export default function CreateCampaignPage() {
from,
fromName: fromName || null,
replyTo: replyTo || null,
type: campaignType,
audienceType,
segmentId: audienceType === CampaignAudienceType.SEGMENT ? segmentId : undefined,
audienceFilter: audienceType === CampaignAudienceType.FILTERED ? [] : undefined,
@@ -256,11 +258,54 @@ export default function CreateCampaignPage() {
</CardContent>
</Card>
{/* Email Settings */}
{/* Campaign Type */}
<Card>
<CardHeader>
<StepHeader
stepNumber={2}
title="Campaign Type"
description="Choose how this campaign should be treated"
/>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setCampaignType(TemplateType.MARKETING)}
className={`text-left p-4 rounded-lg border-2 transition-colors ${
campaignType === TemplateType.MARKETING
? 'border-neutral-900 bg-neutral-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<p className="font-medium text-sm text-neutral-900">Marketing</p>
<p className="text-xs text-neutral-500 mt-1">
Sent to subscribed contacts only. Includes unsubscribe link.
</p>
</button>
<button
type="button"
onClick={() => setCampaignType(TemplateType.TRANSACTIONAL)}
className={`text-left p-4 rounded-lg border-2 transition-colors ${
campaignType === TemplateType.TRANSACTIONAL
? 'border-neutral-900 bg-neutral-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<p className="font-medium text-sm text-neutral-900">Transactional</p>
<p className="text-xs text-neutral-500 mt-1">
Sent to all contacts regardless of subscription status. No unsubscribe footer.
</p>
</button>
</div>
</CardContent>
</Card>
{/* Email Settings */}
<Card>
<CardHeader>
<StepHeader
stepNumber={3}
title="Email Settings"
description="Configure sender information and subject"
/>
@@ -294,7 +339,7 @@ export default function CreateCampaignPage() {
{/* Email Content */}
<Card className="overflow-visible">
<CardHeader>
<StepHeader stepNumber={3} title="Email Content" description="Design your email message" />
<StepHeader stepNumber={4} title="Email Content" description="Design your email message" />
</CardHeader>
<CardContent>
<div className="space-y-2">
@@ -309,7 +354,7 @@ export default function CreateCampaignPage() {
{/* Audience Selection */}
<Card>
<CardHeader>
<StepHeader stepNumber={4} title="Audience" description="Choose who will receive this campaign" />
<StepHeader stepNumber={5} title="Audience" description="Choose who will receive this campaign" />
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
@@ -327,8 +372,8 @@ export default function CreateCampaignPage() {
<SelectContent>
<SelectItemWithDescription
value={CampaignAudienceType.ALL}
title="All Subscribed Contacts"
description="Send to everyone who hasn't unsubscribed"
title={campaignType === TemplateType.TRANSACTIONAL ? 'All Contacts' : 'All Subscribed Contacts'}
description={campaignType === TemplateType.TRANSACTIONAL ? 'Send to all contacts regardless of subscription status' : "Send to everyone who hasn't unsubscribed"}
/>
<SelectItemWithDescription
value={CampaignAudienceType.SEGMENT}
@@ -388,9 +433,13 @@ export default function CreateCampaignPage() {
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
<Users className="h-5 w-5 text-blue-600 mt-0.5" />
<div>
<p className="text-sm font-medium text-blue-900">All subscribed contacts</p>
<p className="text-sm font-medium text-blue-900">
{campaignType === TemplateType.TRANSACTIONAL ? 'All contacts' : 'All subscribed contacts'}
</p>
<p className="text-xs text-blue-700 mt-1">
This campaign will be sent to all contacts who haven&#39;t unsubscribed
{campaignType === TemplateType.TRANSACTIONAL
? 'This campaign will be sent to all contacts regardless of subscription status'
: "This campaign will be sent to all contacts who haven't unsubscribed"}
</p>
</div>
</div>
@@ -438,6 +487,13 @@ export default function CreateCampaignPage() {
</div>
)}
<div className="flex justify-between py-2 border-b border-neutral-100">
<span className="text-neutral-500">Type</span>
<span className="font-medium">
{campaignType === TemplateType.MARKETING ? 'Marketing' : 'Transactional'}
</span>
</div>
<div className="flex justify-between py-2 border-b border-neutral-100">
<span className="text-neutral-500">Audience</span>
<span className="font-medium">