Initial push of Plunk Next

This commit is contained in:
Dries Augustyns
2025-12-01 09:56:56 +01:00
parent 07cea20262
commit ff1876d580
566 changed files with 89036 additions and 28423 deletions
+20 -18
View File
@@ -1,20 +1,22 @@
{
"name": "@plunk/shared",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"private": true,
"dependencies": {
"dayjs": "^1.11.12",
"zod": "^3.23.8"
},
"devDependencies": {
"typescript": "^5.5.3"
},
"scripts": {
"watch": "tsc && tsc -w",
"build": "tsc",
"dev": "yarn watch",
"clean": "rimraf node_modules dist .turbo"
}
"name": "@plunk/shared",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"clean": "rimraf node_modules .turbo dist",
"build": "tsc"
},
"devDependencies": {
"@plunk/db": "*",
"@plunk/typescript-config": "*",
"@types/node": "24.10.0",
"typescript": "5.7.2"
},
"dependencies": {
"zod": "^3.23.8"
},
"exports": {
".": "./dist/index.js"
}
}
+2 -290
View File
@@ -1,290 +1,2 @@
import { TemplateStyle, TemplateType } from "@prisma/client";
import { z } from "zod";
const email = z
.string({ invalid_type_error: "Email needs to be a string", required_error: "Email is required" })
.email({ message: "Invalid email address" })
.transform((e) => e.toLowerCase());
const password = z.string().min(6, "Password needs to be at least 6 characters long");
const id = z
.string({ invalid_type_error: "ID needs to be a string", required_error: "ID is required" })
.uuid({ message: "Id needs to be a valid UUID" });
export const UtilitySchemas = {
id: z.object({
id,
}),
email: z.object({
email,
}),
pagination: z.object({
page: z
.number({
invalid_type_error: "Page needs to be a number",
required_error: "Page is required",
})
.min(1, "Page needs to be at least 1")
.default(1)
.or(
z.string().transform((s) => {
return Number(s);
}),
),
}),
};
export const UserSchemas = {
credentials: z.object({
email: email,
password: password,
}),
};
const zodSchema = z.record(
z.union(
[
z
.string({
invalid_type_error:
"Metadata can only be a string, array of strings or a non-persistent object (https://docs.useplunk.com/working-with-contacts/metadata#non-persistent-metadata)",
})
.transform((s) => {
return { persistent: true, value: s };
}),
z
.array(
z.string({
invalid_type_error:
"Metadata can only be a string, array of strings or a non-persistent object (https://docs.useplunk.com/working-with-contacts/metadata#non-persistent-metadata)",
}),
)
.transform((s) => {
return { persistent: true, value: s };
}),
z.object(
{
persistent: z.boolean({ invalid_type_error: "Persistent should be a boolean" }).optional().default(true),
value: z.union([z.string(), z.array(z.string())], {
invalid_type_error:
"Metadata can only be a string, array of strings or a non-persistent object (https://docs.useplunk.com/working-with-contacts/metadata#non-persistent-metadata)",
}),
},
{
invalid_type_error:
"Metadata can only be a string, array of strings or a non-persistent object (https://docs.useplunk.com/working-with-contacts/metadata#non-persistent-metadata)",
},
),
],
{
invalid_type_error:
"Metadata can only be a string, array of strings or a non-persistent object (https://docs.useplunk.com/working-with-contacts/metadata#non-persistent-metadata)",
},
),
{ invalid_type_error: "Metadata should be an object (https://docs.useplunk.com/working-with-contacts/metadata)" },
);
export const EventSchemas = {
post: z.object({
email,
subscribed: z
.boolean({
invalid_type_error:
"Subscribed should be a boolean. Read more: https://docs.useplunk.com/api-reference/actions/track",
})
.nullish(),
event: z
.string({
required_error: "Event is required. Read more: https://docs.useplunk.com/api-reference/actions/track",
invalid_type_error: "Event can only be a string. Read more: https://docs.useplunk.com/api-reference/actions/track",
})
.transform((n) => n.toLowerCase())
.transform((n) => n.replace(/ /g, "-")),
data: zodSchema.nullish(),
}),
send: z.object({
subscribed: z.boolean({ invalid_type_error: "Subscribed should be a boolean" }).nullish(),
from: email.nullish(),
name: z.string().nullish(),
reply: email.nullish(),
to: z
.array(email)
.max(5, "You can only send transactional emails to 5 people at a time")
.or(email.transform((e) => [e])),
subject: z.string({
required_error: "Subject is required. Read more: https://docs.useplunk.com/api-reference/transactional/send",
}),
body: z.string({
required_error: "Body is required. Read more: https://docs.useplunk.com/api-reference/transactional/send",
}),
headers: z.record(z.string()).nullish(),
attachments: z.array(z.object({
filename: z.string(),
content: z.string(), // Base64 encoded content
contentType: z.string(),
})).max(5, "You can only include up to 5 attachments").nullish(),
}),
};
export const CampaignSchemas = {
send: z.object({
id,
live: z.boolean().default(false),
delay: z.number().int("Delay needs to be a whole number").nonnegative("Delay needs to be a positive number"),
}),
create: z.object({
subject: z
.string()
.min(1, "Subject needs to be at least 1 character long")
.max(70, "Subject needs to be less than 70 characters long"),
body: z.string().min(1, "Body needs to be at least 1 character long"),
email: email.nullish().or(z.literal("")),
from: z.string().nullish(),
recipients: z.array(z.string()),
style: z.nativeEnum(TemplateStyle).default("PLUNK"),
}),
update: z.object({
id,
subject: z
.string()
.min(1, "Subject needs to be at least 1 character long")
.max(70, "Subject needs to be less than 70 characters long"),
body: z.string().min(1, "Body needs to be at least 1 character long"),
email: email.nullish().or(z.literal("")),
from: z.string().nullish(),
recipients: z.array(z.string()),
style: z.nativeEnum(TemplateStyle).default("PLUNK"),
}),
};
export const ActionSchemas = {
create: z.object({
name: z.string().min(1, "Name needs to be at least 1 character long"),
runOnce: z.boolean().default(false),
delay: z.number().int("Delay needs to be a whole number").nonnegative("Delay needs to be a positive number"),
template: id,
events: z.array(id).min(1, "Select at least one event"),
notevents: z.array(id).optional().default([]),
}),
update: z.object({
id,
name: z.string().min(1, "Name needs to be at least 1 character long"),
runOnce: z.boolean().default(false),
delay: z.number().int("Delay needs to be a whole number").nonnegative("Delay needs to be a positive number"),
template: id,
events: z.array(id).default([]),
notevents: z.array(id).optional().default([]),
}),
};
export const ContactSchemas = {
create: z.object({
email,
data: z
.object({})
.catchall(z.union([z.string(), z.array(z.string())]))
.or(z.string().transform((s) => (s === "" ? null : JSON.parse(s))))
.nullish(),
subscribed: z.boolean(),
}),
manage: z
.object({
id: id.optional(),
email: email.optional(),
data: z
.object({})
.catchall(z.union([z.string(), z.array(z.string()), z.null()]))
.or(z.string().transform((s) => (s === "" ? null : JSON.parse(s))))
.nullish(),
subscribed: z.boolean().nullish(),
})
.refine(
(data) => {
return data.id || data.email;
},
{ message: "Either id or email should be specified" },
)
.refine(
(data) => {
// if id and email are both present
return !(data.id && data.email);
},
{ message: "Either id or email should be specified" },
),
};
export const TemplateSchemas = {
create: z.object({
subject: z.string().min(1, "Subject can't be empty").max(70, "Subject needs to be less than 70 characters long"),
body: z.string().min(1, "Body can't be empty"),
email: email.nullish().or(z.literal("")),
from: z.string().nullish(),
type: z.nativeEnum(TemplateType).default("MARKETING"),
style: z.nativeEnum(TemplateStyle).default("PLUNK"),
}),
update: z.object({
id,
subject: z.string().min(1, "Subject can't be empty").max(70, "Subject needs to be less than 70 characters long"),
body: z.string().min(1, "Body can't be empty"),
email: email.nullish().or(z.literal("")),
from: z.string().nullish(),
type: z.nativeEnum(TemplateType).default("MARKETING"),
style: z.nativeEnum(TemplateStyle).default("PLUNK"),
}),
};
export const MembershipSchemas = {
invite: z.object({
id,
email,
role: z.enum(["MEMBER", "ADMIN"]).default("MEMBER"),
}),
kick: z.object({
id,
email,
}),
};
export const ProjectSchemas = {
secret: z.object({
secret: z.string(),
}),
create: z.object({
name: z.string().min(1, "Name can't be empty"),
url: z
.string()
.regex(/^(?:(?:https?):\/\/)?(?:[\w-]+\.)+[a-z]{2,}(?:\/[^\s]*)?$/)
.transform((u) => (u.startsWith("http") ? u : `https://${u}`)),
}),
update: z.object({
id: id,
name: z.string().min(1, "Name can't be empty"),
url: z
.string()
.regex(/^(?:(?:https?):\/\/)?(?:[\w-]+\.)+[a-z]{2,}(?:\/[^\s]*)?$/)
.transform((u) => (u.startsWith("http") ? u : `https://${u}`)),
}),
analytics: z.object({
method: z.enum(["week", "month", "year"]).default("week"),
}),
};
export const IdentitySchemas = {
create: z.object({
id: id,
email: email.refine(
(e) => {
return !["gmail.com", "outlook.com", "hotmail.com", "yahoo.com", "useplunk.com", "useplunk.dev"].includes(
e.split("@")[1],
);
},
{ message: "Please use your own domain" },
),
}),
update: z.object({
id: id,
from: z.string().min(1, "Name can't be empty"),
}),
};
export * from './schemas/index.js';
export * from './operators.js';
+151
View File
@@ -0,0 +1,151 @@
/**
* Operator Behavior Documentation
*
* This file documents the consistent behavior of operators across the entire application.
* These operators are used in:
* - Segment filtering (SegmentService)
* - Workflow CONDITION steps (WorkflowExecutionService)
*
* IMPORTANT: Both implementations MUST behave identically for consistency.
*/
/**
* All supported operators
*/
export const OPERATORS = {
// String operators
EQUALS: 'equals',
NOT_EQUALS: 'notEquals',
CONTAINS: 'contains',
NOT_CONTAINS: 'notContains',
// Numeric operators
GREATER_THAN: 'greaterThan',
LESS_THAN: 'lessThan',
GREATER_THAN_OR_EQUAL: 'greaterThanOrEqual',
LESS_THAN_OR_EQUAL: 'lessThanOrEqual',
// Existence operators
EXISTS: 'exists',
NOT_EXISTS: 'notExists',
// Temporal operators (segments only)
WITHIN: 'within',
} as const;
/**
* Operators available for segments
*/
export const SEGMENT_OPERATORS = [
OPERATORS.EQUALS,
OPERATORS.NOT_EQUALS,
OPERATORS.CONTAINS,
OPERATORS.NOT_CONTAINS,
OPERATORS.GREATER_THAN,
OPERATORS.LESS_THAN,
OPERATORS.GREATER_THAN_OR_EQUAL,
OPERATORS.LESS_THAN_OR_EQUAL,
OPERATORS.EXISTS,
OPERATORS.NOT_EXISTS,
OPERATORS.WITHIN,
] as const;
/**
* Operators available for workflow conditions
* Note: 'within' is NOT supported in workflow conditions
*/
export const WORKFLOW_CONDITION_OPERATORS = [
OPERATORS.EQUALS,
OPERATORS.NOT_EQUALS,
OPERATORS.CONTAINS,
OPERATORS.NOT_CONTAINS,
OPERATORS.GREATER_THAN,
OPERATORS.LESS_THAN,
OPERATORS.GREATER_THAN_OR_EQUAL,
OPERATORS.LESS_THAN_OR_EQUAL,
OPERATORS.EXISTS,
OPERATORS.NOT_EXISTS,
] as const;
/**
* Operator Behavior Rules
*
* CRITICAL: These rules apply to BOTH SegmentService and WorkflowExecutionService
*
* 1. EQUALS (=)
* - Matches when actualValue === expectedValue
* - CAN match null/undefined if expectedValue is also null/undefined
* - String fields: case-insensitive (email)
* - Boolean fields: strict comparison
*
* 2. NOT_EQUALS (≠)
* - ONLY matches when field EXISTS and actualValue !== expectedValue
* - Does NOT match missing fields (undefined/null)
* - Use notExists operator if you want to find missing fields
* - Use OR[notEquals, notExists] if you want both
*
* 3. CONTAINS (substring)
* - ONLY matches when field EXISTS and contains substring
* - Does NOT match missing fields (undefined/null)
* - String fields: case-insensitive (email)
* - Converts values to strings before comparison
*
* 4. NOT_CONTAINS (not substring)
* - ONLY matches when field EXISTS and does NOT contain substring
* - Does NOT match missing fields (undefined/null)
* - Use notExists operator if you want to find missing fields
* - Use OR[notContains, notExists] if you want both
*
* 5. GREATER_THAN (>)
* - ONLY matches when field EXISTS and Number(actualValue) > Number(expectedValue)
* - Does NOT match missing fields (undefined/null)
* - Converts values to numbers before comparison
*
* 6. LESS_THAN (<)
* - ONLY matches when field EXISTS and Number(actualValue) < Number(expectedValue)
* - Does NOT match missing fields (undefined/null)
* - Converts values to numbers before comparison
*
* 7. GREATER_THAN_OR_EQUAL (≥)
* - ONLY matches when field EXISTS and Number(actualValue) >= Number(expectedValue)
* - Does NOT match missing fields (undefined/null)
* - Converts values to numbers before comparison
*
* 8. LESS_THAN_OR_EQUAL (≤)
* - ONLY matches when field EXISTS and Number(actualValue) <= Number(expectedValue)
* - Does NOT match missing fields (undefined/null)
* - Converts values to numbers before comparison
*
* 9. EXISTS
* - Matches when actualValue !== undefined && actualValue !== null
* - Matches empty strings, zero, false (these are valid existing values)
*
* 10. NOT_EXISTS
* - Matches when actualValue === undefined || actualValue === null
* - Does NOT match empty strings, zero, or false
*
* 11. WITHIN (temporal - segments only)
* - Matches when date field is within specified time period
* - Requires unit: 'days' | 'hours' | 'minutes'
* - Example: within 7 days = createdAt >= (now - 7 days)
*/
/**
* Example: Finding contacts without a field vs with a different value
*
* To find contacts where plan is NOT "basic":
* - Use: { field: 'data.plan', operator: 'notEquals', value: 'basic' }
* - Result: Matches contacts with plan='premium', plan='free', etc.
* - Does NOT match: Contacts without plan field
*
* To find contacts WITHOUT a plan field:
* - Use: { field: 'data.plan', operator: 'notExists' }
* - Result: Matches contacts where plan is undefined or null
*
* To find contacts where plan is NOT "basic" OR plan doesn't exist:
* - Use: OR[
* { field: 'data.plan', operator: 'notEquals', value: 'basic' },
* { field: 'data.plan', operator: 'notExists' }
* ]
* - Result: Matches all except contacts with plan='basic'
*/
+356
View File
@@ -0,0 +1,356 @@
import {CampaignAudienceType, TemplateType, WorkflowStepType, WorkflowTriggerType} from '@plunk/db';
import {z} from 'zod';
const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null(), z.date()]);
type Literal = z.infer<typeof literalSchema>;
type Json = Literal | {[key: string]: Json} | Json[];
const jsonSchema: z.ZodType<Json> = z.lazy(() => z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)]));
const uuid = z.string().uuid();
const email = z.string().email();
export const UtilitySchemas = {
id: z.object({
id: uuid,
}),
email: z.object({
email,
}),
pagination: z.object({
page: z
.union([z.number(), z.string()])
.transform(value => parseInt(value as string, 10))
.nullish()
.transform(value => value ?? 1),
limit: z
.union([z.number(), z.string()])
.transform(value => parseInt(value as string, 10))
.nullish()
.transform(value => value ?? 20),
sort: z.enum(['alphabetical', 'latest']).default('latest'),
}),
query: z.object({
query: z.string().min(3),
filters: z
.union([z.array(z.string()), z.string()])
.transform(value => (Array.isArray(value) ? value : value.split('_').filter(Boolean)))
.optional()
.default([]),
}),
} as const;
export const AuthenticationSchemas = {
login: z.object({
email,
password: z.string().min(6),
}),
signup: z.object({
email,
password: z.string().min(6),
}),
resetPassword: z.object({
email,
}),
} as const;
export const ProjectSchemas = {
create: z.object({
name: z.string().min(1).max(100),
}),
update: z.object({
name: z.string().min(1).max(100).optional(),
trackingEnabled: z.boolean().optional(),
}),
} as const;
export const ContactSchemas = {
create: z.object({
email,
subscribed: z.boolean().default(true),
data: jsonSchema.optional(),
}),
};
export const SegmentSchemas = {
filter: z.object({
field: z.string().min(1),
operator: z.enum([
'equals',
'notEquals',
'contains',
'notContains',
'greaterThan',
'lessThan',
'greaterThanOrEqual',
'lessThanOrEqual',
'exists',
'notExists',
'within',
]),
value: z.any().optional(),
unit: z.enum(['days', 'hours', 'minutes']).optional(),
}),
create: z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
filters: z.array(
z.object({
field: z.string().min(1),
operator: z.enum([
'equals',
'notEquals',
'contains',
'notContains',
'greaterThan',
'lessThan',
'greaterThanOrEqual',
'lessThanOrEqual',
'exists',
'notExists',
'within',
]),
value: z.any().optional(),
unit: z.enum(['days', 'hours', 'minutes']).optional(),
}),
),
trackMembership: z.boolean().default(false),
}),
update: z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional(),
filters: z
.array(
z.object({
field: z.string().min(1),
operator: z.enum([
'equals',
'notEquals',
'contains',
'notContains',
'greaterThan',
'lessThan',
'greaterThanOrEqual',
'lessThanOrEqual',
'exists',
'notExists',
'within',
]),
value: z.any().optional(),
unit: z.enum(['days', 'hours', 'minutes']).optional(),
}),
)
.optional(),
trackMembership: z.boolean().optional(),
}),
};
export const TemplateSchemas = {
create: z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
subject: z.string().min(1),
body: z.string().min(1),
from: email,
fromName: z.string().max(100).optional(),
replyTo: email.optional(),
type: z.nativeEnum(TemplateType).default('MARKETING'),
}),
update: z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional(),
subject: z.string().min(1).optional(),
body: z.string().min(1).optional(),
from: email.optional(),
fromName: z.string().max(100).optional(),
replyTo: email.optional(),
type: z.nativeEnum(TemplateType).optional(),
}),
};
export const WorkflowSchemas = {
create: z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
eventName: z.string().min(1),
allowReentry: z.boolean().optional(),
enabled: z.boolean().default(false),
}),
update: z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional(),
triggerType: z.nativeEnum(WorkflowTriggerType).optional(),
triggerConfig: jsonSchema.optional(),
enabled: z.boolean().optional(),
}),
addStep: z.object({
type: z.nativeEnum(WorkflowStepType),
name: z.string().min(1).max(100),
position: jsonSchema,
config: jsonSchema,
templateId: uuid.optional(),
}),
updateStep: z.object({
name: z.string().min(1).max(100).optional(),
position: jsonSchema.optional(),
config: jsonSchema.optional(),
templateId: uuid.optional().nullable(),
}),
createTransition: z.object({
fromStepId: uuid,
toStepId: uuid,
condition: jsonSchema.optional(),
priority: z.number().int().min(0).default(0),
}),
startExecution: z.object({
contactId: uuid,
context: jsonSchema.optional(),
}),
};
export const WorkflowStepConfigSchemas = {
delay: z.object({
amount: z.number().positive(),
unit: z.enum(['minutes', 'hours', 'days']),
}),
waitForEvent: z.object({
eventName: z.string().min(1),
timeout: z.number().positive().optional(),
}),
condition: z.object({
field: z.string().min(1),
operator: z.enum([
'equals',
'notEquals',
'contains',
'notContains',
'greaterThan',
'lessThan',
'greaterThanOrEqual',
'lessThanOrEqual',
'exists',
'notExists',
]),
value: z.any().optional(),
}),
webhook: z.object({
url: z.string().url(),
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']).default('POST'),
headers: z.record(z.string()).optional(),
body: jsonSchema.optional(),
}),
updateContact: z.object({
updates: z.record(z.any()),
}),
};
export const DomainSchemas = {
create: z.object({
projectId: uuid,
domain: z
.string()
.min(3)
.max(253)
.refine(
value => {
// Basic domain validation regex
const domainRegex = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/i;
return domainRegex.test(value);
},
{
message: 'Invalid domain format',
},
),
}),
projectId: z.object({
projectId: uuid,
}),
};
export const CampaignSchemas = {
create: z.object({
name: z.string().min(1),
description: z.string().optional(),
subject: z.string().min(1),
body: z.string().min(1),
from: email,
fromName: z.string().max(100).optional(),
replyTo: email.optional(),
audienceType: z.nativeEnum(CampaignAudienceType),
audienceFilter: jsonSchema.optional(),
segmentId: uuid.optional(),
}),
schedule: z.object({
scheduledFor: z.string(),
}),
update: z.object({
name: z.string().optional(),
description: z.string().optional(),
subject: z.string().optional(),
body: z.string().optional(),
from: z.string().optional(),
fromName: z.string().max(100).optional(),
replyTo: z.string().optional(),
audienceType: z.nativeEnum(CampaignAudienceType).optional(),
segmentId: z.string().optional(),
}),
sendTest: z.object({
email,
}),
} as const;
export const ActionSchemas = {
track: z.object({
event: z.string().min(1),
email,
subscribed: z.boolean().optional().default(true),
data: jsonSchema.optional(),
}),
send: z
.object({
to: z.union([email, z.array(email)]),
subject: z.string().min(1).max(998).optional(),
body: z.string().min(1).optional(),
template: uuid.optional(),
subscribed: z.boolean().optional().default(false),
name: z.string().optional(),
from: email.optional(),
reply: email.optional(),
headers: z.record(z.string().max(998)).optional(),
data: jsonSchema.optional(),
attachments: z
.array(
z.object({
filename: z.string().min(1).max(255),
content: z.string().min(1), // Base64 encoded file content
contentType: z.string().min(1).max(255),
}),
)
.max(10) // Maximum 10 attachments per email
.optional(),
})
.refine(data => data.template ?? (data.subject && data.body), {
message: 'Either template ID or both subject and body are required',
})
.refine(
data => {
// Validate total attachment size (sum of base64 strings should be reasonable)
if (!data.attachments || data.attachments.length === 0) {
return true;
}
// Each base64 char = ~0.75 bytes, so 13.3M base64 chars ≈ 10MB actual data
const totalBase64Length = data.attachments.reduce((sum, att) => sum + att.content.length, 0);
return totalBase64Length <= 13333333; // ~10MB limit
},
{
message: 'Total attachment size must not exceed 10MB',
},
),
} as const;
export const BillingLimitSchemas = {
update: z.object({
workflows: z.coerce.number().int().positive().nullable(),
campaigns: z.coerce.number().int().positive().nullable(),
transactional: z.coerce.number().int().positive().nullable(),
}),
} as const;
+6 -11
View File
@@ -1,13 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"target": "ES2020",
"lib": ["ES2020", "DOM"],
"esModuleInterop": true,
"moduleResolution": "node"
},
"exclude": ["dist", "node_modules"]
"extends": "@plunk/typescript-config/base.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"],
"exclude": ["node_modules"]
}