From d24259e8d202edd1b8c7771a63cc212c982b52e1 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Wed, 1 Apr 2026 18:49:51 +0200 Subject: [PATCH] feat: Add type to campaign --- apps/api/src/controllers/Campaigns.ts | 8 ++- apps/api/src/services/CampaignService.ts | 12 +++- apps/api/src/services/EmailService.ts | 7 +- apps/web/src/pages/campaigns/[id].tsx | 49 +++++++++++-- apps/web/src/pages/campaigns/create.tsx | 72 ++++++++++++++++--- .../migration.sql | 2 + packages/db/prisma/schema.prisma | 3 + packages/shared/src/schemas/index.ts | 2 + packages/types/src/api/campaign.ts | 4 +- 9 files changed, 139 insertions(+), 20 deletions(-) create mode 100644 packages/db/prisma/migrations/20260401164418_add_campaign_type/migration.sql diff --git a/apps/api/src/controllers/Campaigns.ts b/apps/api/src/controllers/Campaigns.ts index 3f165f9..fd9e073 100644 --- a/apps/api/src/controllers/Campaigns.ts +++ b/apps/api/src/controllers/Campaigns.ts @@ -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, diff --git a/apps/api/src/services/CampaignService.ts b/apps/api/src/services/CampaignService.ts index 37fdf6d..e82fb92 100644 --- a/apps/api/src/services/CampaignService.ts +++ b/apps/api/src/services/CampaignService.ts @@ -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 { 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) { diff --git a/apps/api/src/services/EmailService.ts b/apps/api/src/services/EmailService.ts index f075ec5..0f07c5f 100644 --- a/apps/api/src/services/EmailService.ts +++ b/apps/api/src/services/EmailService.ts @@ -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 { - // 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}, diff --git a/apps/web/src/pages/campaigns/[id].tsx b/apps/web/src/pages/campaigns/[id].tsx index ce809ae..56d8451 100644 --- a/apps/web/src/pages/campaigns/[id].tsx +++ b/apps/web/src/pages/campaigns/[id].tsx @@ -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() { /> +
+ +
+ + +
+
+

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.'}

diff --git a/apps/web/src/pages/campaigns/create.tsx b/apps/web/src/pages/campaigns/create.tsx index d5f7561..5c54e07 100644 --- a/apps/web/src/pages/campaigns/create.tsx +++ b/apps/web/src/pages/campaigns/create.tsx @@ -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.MARKETING); const [audienceType, setAudienceType] = useState(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() { - {/* Email Settings */} + {/* Campaign Type */} + + +
+ + +
+
+
+ + {/* Email Settings */} + + + @@ -294,7 +339,7 @@ export default function CreateCampaignPage() { {/* Email Content */} - +
@@ -309,7 +354,7 @@ export default function CreateCampaignPage() { {/* Audience Selection */} - +
@@ -327,8 +372,8 @@ export default function CreateCampaignPage() {
-

All subscribed contacts

+

+ {campaignType === TemplateType.TRANSACTIONAL ? 'All contacts' : 'All subscribed contacts'} +

- This campaign will be sent to all contacts who haven'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"}

@@ -438,6 +487,13 @@ export default function CreateCampaignPage() {
)} +
+ Type + + {campaignType === TemplateType.MARKETING ? 'Marketing' : 'Transactional'} + +
+
Audience diff --git a/packages/db/prisma/migrations/20260401164418_add_campaign_type/migration.sql b/packages/db/prisma/migrations/20260401164418_add_campaign_type/migration.sql new file mode 100644 index 0000000..edf4589 --- /dev/null +++ b/packages/db/prisma/migrations/20260401164418_add_campaign_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "campaigns" ADD COLUMN "type" "TemplateType" NOT NULL DEFAULT 'MARKETING'; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 72152bf..30605ea 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -291,6 +291,9 @@ model Campaign { fromName String? replyTo String? + // Campaign type + type TemplateType @default(MARKETING) + // Audience selection audienceType CampaignAudienceType @default(ALL) audienceCondition Json? // For FILTERED: manual filter condition with AND/OR logic (same structure as Segment.condition) diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index d194296..7e5cf4b 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -349,6 +349,7 @@ export const CampaignSchemas = { from: email, fromName: z.string().max(100).nullish(), replyTo: email.nullish(), + type: z.nativeEnum(TemplateType).default(TemplateType.MARKETING), audienceType: z.nativeEnum(CampaignAudienceType), audienceCondition: filterConditionSchema.optional(), segmentId: uuid.optional(), @@ -364,6 +365,7 @@ export const CampaignSchemas = { from: z.string().optional(), fromName: z.string().max(100).nullish(), replyTo: z.string().nullish(), + type: z.nativeEnum(TemplateType).optional(), audienceType: z.nativeEnum(CampaignAudienceType).optional(), audienceCondition: filterConditionSchema.optional(), segmentId: z.string().optional(), diff --git a/packages/types/src/api/campaign.ts b/packages/types/src/api/campaign.ts index 67f561d..58b129b 100644 --- a/packages/types/src/api/campaign.ts +++ b/packages/types/src/api/campaign.ts @@ -2,7 +2,7 @@ * Campaign service types */ -import type {CampaignAudienceType} from '@plunk/db'; +import type {CampaignAudienceType, TemplateType} from '@plunk/db'; import type {FilterCondition} from '../segments/index.js'; /** @@ -16,6 +16,7 @@ export interface CreateCampaignData { from: string; fromName?: string | null; replyTo?: string | null; + type?: TemplateType; audienceType: CampaignAudienceType; audienceCondition?: FilterCondition; segmentId?: string; @@ -32,6 +33,7 @@ export interface UpdateCampaignData { from?: string; fromName?: string | null; replyTo?: string | null; + type?: TemplateType; audienceType?: CampaignAudienceType; audienceCondition?: FilterCondition; segmentId?: string;