Initial push of Plunk Next
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'
|
||||
*/
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user