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 {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 {CampaignSchemas, UtilitySchemas} from '@plunk/shared';
import type {NextFunction, Request, Response} from 'express'; import type {NextFunction, Request, Response} from 'express';
@@ -20,7 +20,7 @@ export class Campaigns {
@CatchAsync @CatchAsync
private async create(req: Request, res: Response, _next: NextFunction) { private async create(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth; 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); CampaignSchemas.create.parse(req.body);
if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) { if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) {
@@ -42,6 +42,7 @@ export class Campaigns {
from, from,
fromName, fromName,
replyTo, replyTo,
type,
audienceType, audienceType,
audienceCondition, audienceCondition,
segmentId, segmentId,
@@ -109,7 +110,7 @@ export class Campaigns {
private async update(req: Request, res: Response, _next: NextFunction) { private async update(req: Request, res: Response, _next: NextFunction) {
const auth = res.locals.auth; const auth = res.locals.auth;
const {id} = UtilitySchemas.id.parse(req.params); 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; req.body;
// Validate audience-specific fields if audienceType is being updated // Validate audience-specific fields if audienceType is being updated
@@ -134,6 +135,7 @@ export class Campaigns {
from, from,
fromName, fromName,
replyTo, replyTo,
type: type as TemplateType | undefined,
audienceType, audienceType,
audienceCondition, audienceCondition,
segmentId, segmentId,
+10 -2
View File
@@ -1,5 +1,5 @@
import type {Campaign, Contact, Prisma} from '@plunk/db'; 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 type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types';
import {fromPrismaJson, toPrismaJson} from '@plunk/types'; import {fromPrismaJson, toPrismaJson} from '@plunk/types';
import signale from 'signale'; import signale from 'signale';
@@ -59,6 +59,7 @@ export class CampaignService {
from: data.from, from: data.from,
fromName: data.fromName, fromName: data.fromName,
replyTo: data.replyTo, replyTo: data.replyTo,
type: data.type ?? TemplateType.MARKETING,
audienceType: data.audienceType, audienceType: data.audienceType,
audienceCondition: toPrismaJson(data.audienceCondition || null), audienceCondition: toPrismaJson(data.audienceCondition || null),
segmentId: data.segmentId, segmentId: data.segmentId,
@@ -100,6 +101,10 @@ export class CampaignService {
const updateData: Prisma.CampaignUpdateInput = buildEmailFieldsUpdate(data) as Prisma.CampaignUpdateInput; const updateData: Prisma.CampaignUpdateInput = buildEmailFieldsUpdate(data) as Prisma.CampaignUpdateInput;
// Handle campaign-specific fields // Handle campaign-specific fields
if (data.type !== undefined) {
updateData.type = data.type;
}
if (data.audienceType !== undefined) { if (data.audienceType !== undefined) {
updateData.audienceType = data.audienceType; updateData.audienceType = data.audienceType;
} }
@@ -262,6 +267,7 @@ export class CampaignService {
from: campaign.from, from: campaign.from,
fromName: campaign.fromName, fromName: campaign.fromName,
replyTo: campaign.replyTo, replyTo: campaign.replyTo,
type: campaign.type,
audienceType: campaign.audienceType, audienceType: campaign.audienceType,
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue, audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
segmentId: campaign.segmentId, segmentId: campaign.segmentId,
@@ -482,6 +488,7 @@ export class CampaignService {
from: campaign.from, from: campaign.from,
fromName: campaign.fromName || undefined, fromName: campaign.fromName || undefined,
replyTo: campaign.replyTo || undefined, replyTo: campaign.replyTo || undefined,
isTransactional: campaign.type === TemplateType.TRANSACTIONAL,
}); });
} catch (error) { } catch (error) {
signale.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error); signale.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
@@ -708,7 +715,8 @@ export class CampaignService {
): Promise<Prisma.ContactWhereInput> { ): Promise<Prisma.ContactWhereInput> {
const baseWhere: Prisma.ContactWhereInput = { const baseWhere: Prisma.ContactWhereInput = {
projectId, 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) { switch (campaign.audienceType) {
+5 -2
View File
@@ -38,6 +38,7 @@ interface SendEmailParams {
workflowExecutionId?: string; workflowExecutionId?: string;
workflowStepExecutionId?: string; workflowStepExecutionId?: string;
recipientEmail?: string; // Optional custom recipient email (overrides contact.email) 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 * Send a campaign email
*/ */
public static async sendCampaignEmail(params: SendEmailParams): Promise<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; let sourceType: EmailSourceType = EmailSourceType.CAMPAIGN;
if (params.templateId) { if (params.isTransactional) {
sourceType = EmailSourceType.TRANSACTIONAL;
} else if (params.templateId) {
const template = await prisma.template.findUnique({ const template = await prisma.template.findUnique({
where: {id: params.templateId}, where: {id: params.templateId},
select: {type: true}, select: {type: true},
+45 -4
View File
@@ -29,7 +29,7 @@ import {
StickySaveBar, StickySaveBar,
} from '@plunk/ui'; } from '@plunk/ui';
import type {Campaign, Segment} from '@plunk/db'; 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 {CampaignSchemas} from '@plunk/shared';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {EmailSettings} from '../../components/EmailSettings'; import {EmailSettings} from '../../components/EmailSettings';
@@ -216,6 +216,7 @@ export default function CampaignDetailsPage() {
from: editedCampaign.from, from: editedCampaign.from,
fromName: editedCampaign.fromName || null, fromName: editedCampaign.fromName || null,
replyTo: editedCampaign.replyTo || null, replyTo: editedCampaign.replyTo || null,
type: editedCampaign.type,
audienceType: editedCampaign.audienceType, audienceType: editedCampaign.audienceType,
segmentId: editedCampaign.segmentId || undefined, segmentId: editedCampaign.segmentId || undefined,
}); });
@@ -232,6 +233,7 @@ export default function CampaignDetailsPage() {
from: updated.data.from, from: updated.data.from,
fromName: updated.data.fromName || '', fromName: updated.data.fromName || '',
replyTo: updated.data.replyTo || '', replyTo: updated.data.replyTo || '',
type: updated.data.type,
audienceType: updated.data.audienceType, audienceType: updated.data.audienceType,
segmentId: updated.data.segmentId || undefined, segmentId: updated.data.segmentId || undefined,
}); });
@@ -254,6 +256,7 @@ export default function CampaignDetailsPage() {
from: campaign.data.from, from: campaign.data.from,
fromName: campaign.data.fromName || '', fromName: campaign.data.fromName || '',
replyTo: campaign.data.replyTo || '', replyTo: campaign.data.replyTo || '',
type: campaign.data.type,
audienceType: campaign.data.audienceType, audienceType: campaign.data.audienceType,
segmentId: campaign.data.segmentId || undefined, segmentId: campaign.data.segmentId || undefined,
}); });
@@ -274,6 +277,7 @@ export default function CampaignDetailsPage() {
editedCampaign.from !== campaign.data.from || editedCampaign.from !== campaign.data.from ||
(editedCampaign.fromName || '') !== (campaign.data.fromName || '') || (editedCampaign.fromName || '') !== (campaign.data.fromName || '') ||
(editedCampaign.replyTo || '') !== (campaign.data.replyTo || '') || (editedCampaign.replyTo || '') !== (campaign.data.replyTo || '') ||
editedCampaign.type !== campaign.data.type ||
editedCampaign.audienceType !== campaign.data.audienceType || editedCampaign.audienceType !== campaign.data.audienceType ||
(editedCampaign.segmentId || null) !== (campaign.data.segmentId || null); (editedCampaign.segmentId || null) !== (campaign.data.segmentId || null);
@@ -461,6 +465,40 @@ export default function CampaignDetailsPage() {
/> />
</div> </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> <div>
<Label htmlFor="subject">Subject Line *</Label> <Label htmlFor="subject">Subject Line *</Label>
<Input <Input
@@ -513,8 +551,8 @@ export default function CampaignDetailsPage() {
<SelectContent> <SelectContent>
<SelectItemWithDescription <SelectItemWithDescription
value={CampaignAudienceType.ALL} value={CampaignAudienceType.ALL}
title="All Subscribed Contacts" title={(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL ? 'All Contacts' : 'All Subscribed Contacts'}
description="Send to everyone who hasn't unsubscribed" description={(editedCampaign.type ?? c.type) === TemplateType.TRANSACTIONAL ? 'Send to all contacts regardless of subscription status' : "Send to everyone who hasn't unsubscribed"}
/> />
<SelectItemWithDescription <SelectItemWithDescription
value={CampaignAudienceType.SEGMENT} 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" /> <Info className="h-3.5 w-3.5 text-blue-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-blue-800"> <p className="text-xs text-blue-800">
This count will be recalculated right before sending to ensure accuracy. The final number may 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> </p>
</div> </div>
</div> </div>
+64 -8
View File
@@ -15,7 +15,7 @@ import {
Textarea, Textarea,
} from '@plunk/ui'; } from '@plunk/ui';
import type {Segment, Template} from '@plunk/db'; import type {Segment, Template} from '@plunk/db';
import {CampaignAudienceType} from '@plunk/db'; import {CampaignAudienceType, TemplateType} from '@plunk/db';
import {NextSeo} from 'next-seo'; import {NextSeo} from 'next-seo';
import {DashboardLayout} from '../../components/DashboardLayout'; import {DashboardLayout} from '../../components/DashboardLayout';
import {EmailSettings} from '../../components/EmailSettings'; import {EmailSettings} from '../../components/EmailSettings';
@@ -41,6 +41,7 @@ export default function CreateCampaignPage() {
const [from, setFrom] = useState(''); const [from, setFrom] = useState('');
const [fromName, setFromName] = useState(''); const [fromName, setFromName] = useState('');
const [replyTo, setReplyTo] = useState(''); const [replyTo, setReplyTo] = useState('');
const [campaignType, setCampaignType] = useState<TemplateType>(TemplateType.MARKETING);
const [audienceType, setAudienceType] = useState<CampaignAudienceType>(CampaignAudienceType.ALL); const [audienceType, setAudienceType] = useState<CampaignAudienceType>(CampaignAudienceType.ALL);
const [segmentId, setSegmentId] = useState(''); const [segmentId, setSegmentId] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -150,6 +151,7 @@ export default function CreateCampaignPage() {
from, from,
fromName: fromName || null, fromName: fromName || null,
replyTo: replyTo || null, replyTo: replyTo || null,
type: campaignType,
audienceType, audienceType,
segmentId: audienceType === CampaignAudienceType.SEGMENT ? segmentId : undefined, segmentId: audienceType === CampaignAudienceType.SEGMENT ? segmentId : undefined,
audienceFilter: audienceType === CampaignAudienceType.FILTERED ? [] : undefined, audienceFilter: audienceType === CampaignAudienceType.FILTERED ? [] : undefined,
@@ -256,11 +258,54 @@ export default function CreateCampaignPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Email Settings */} {/* Campaign Type */}
<Card> <Card>
<CardHeader> <CardHeader>
<StepHeader <StepHeader
stepNumber={2} 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" title="Email Settings"
description="Configure sender information and subject" description="Configure sender information and subject"
/> />
@@ -294,7 +339,7 @@ export default function CreateCampaignPage() {
{/* Email Content */} {/* Email Content */}
<Card className="overflow-visible"> <Card className="overflow-visible">
<CardHeader> <CardHeader>
<StepHeader stepNumber={3} title="Email Content" description="Design your email message" /> <StepHeader stepNumber={4} title="Email Content" description="Design your email message" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-2"> <div className="space-y-2">
@@ -309,7 +354,7 @@ export default function CreateCampaignPage() {
{/* Audience Selection */} {/* Audience Selection */}
<Card> <Card>
<CardHeader> <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> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -327,8 +372,8 @@ export default function CreateCampaignPage() {
<SelectContent> <SelectContent>
<SelectItemWithDescription <SelectItemWithDescription
value={CampaignAudienceType.ALL} value={CampaignAudienceType.ALL}
title="All Subscribed Contacts" title={campaignType === TemplateType.TRANSACTIONAL ? 'All Contacts' : 'All Subscribed Contacts'}
description="Send to everyone who hasn't unsubscribed" description={campaignType === TemplateType.TRANSACTIONAL ? 'Send to all contacts regardless of subscription status' : "Send to everyone who hasn't unsubscribed"}
/> />
<SelectItemWithDescription <SelectItemWithDescription
value={CampaignAudienceType.SEGMENT} 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"> <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" /> <Users className="h-5 w-5 text-blue-600 mt-0.5" />
<div> <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"> <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> </p>
</div> </div>
</div> </div>
@@ -438,6 +487,13 @@ export default function CreateCampaignPage() {
</div> </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"> <div className="flex justify-between py-2 border-b border-neutral-100">
<span className="text-neutral-500">Audience</span> <span className="text-neutral-500">Audience</span>
<span className="font-medium"> <span className="font-medium">
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "campaigns" ADD COLUMN "type" "TemplateType" NOT NULL DEFAULT 'MARKETING';
+3
View File
@@ -291,6 +291,9 @@ model Campaign {
fromName String? fromName String?
replyTo String? replyTo String?
// Campaign type
type TemplateType @default(MARKETING)
// Audience selection // Audience selection
audienceType CampaignAudienceType @default(ALL) audienceType CampaignAudienceType @default(ALL)
audienceCondition Json? // For FILTERED: manual filter condition with AND/OR logic (same structure as Segment.condition) audienceCondition Json? // For FILTERED: manual filter condition with AND/OR logic (same structure as Segment.condition)
+2
View File
@@ -349,6 +349,7 @@ export const CampaignSchemas = {
from: email, from: email,
fromName: z.string().max(100).nullish(), fromName: z.string().max(100).nullish(),
replyTo: email.nullish(), replyTo: email.nullish(),
type: z.nativeEnum(TemplateType).default(TemplateType.MARKETING),
audienceType: z.nativeEnum(CampaignAudienceType), audienceType: z.nativeEnum(CampaignAudienceType),
audienceCondition: filterConditionSchema.optional(), audienceCondition: filterConditionSchema.optional(),
segmentId: uuid.optional(), segmentId: uuid.optional(),
@@ -364,6 +365,7 @@ export const CampaignSchemas = {
from: z.string().optional(), from: z.string().optional(),
fromName: z.string().max(100).nullish(), fromName: z.string().max(100).nullish(),
replyTo: z.string().nullish(), replyTo: z.string().nullish(),
type: z.nativeEnum(TemplateType).optional(),
audienceType: z.nativeEnum(CampaignAudienceType).optional(), audienceType: z.nativeEnum(CampaignAudienceType).optional(),
audienceCondition: filterConditionSchema.optional(), audienceCondition: filterConditionSchema.optional(),
segmentId: z.string().optional(), segmentId: z.string().optional(),
+3 -1
View File
@@ -2,7 +2,7 @@
* Campaign service types * Campaign service types
*/ */
import type {CampaignAudienceType} from '@plunk/db'; import type {CampaignAudienceType, TemplateType} from '@plunk/db';
import type {FilterCondition} from '../segments/index.js'; import type {FilterCondition} from '../segments/index.js';
/** /**
@@ -16,6 +16,7 @@ export interface CreateCampaignData {
from: string; from: string;
fromName?: string | null; fromName?: string | null;
replyTo?: string | null; replyTo?: string | null;
type?: TemplateType;
audienceType: CampaignAudienceType; audienceType: CampaignAudienceType;
audienceCondition?: FilterCondition; audienceCondition?: FilterCondition;
segmentId?: string; segmentId?: string;
@@ -32,6 +33,7 @@ export interface UpdateCampaignData {
from?: string; from?: string;
fromName?: string | null; fromName?: string | null;
replyTo?: string | null; replyTo?: string | null;
type?: TemplateType;
audienceType?: CampaignAudienceType; audienceType?: CampaignAudienceType;
audienceCondition?: FilterCondition; audienceCondition?: FilterCondition;
segmentId?: string; segmentId?: string;