Merge pull request #52 from useplunk/dev-driaug-contact-update

Improved DX for contact update, subscribe, and unsubscribe
This commit is contained in:
Dries Augustyns
2024-08-13 20:03:28 +02:00
committed by GitHub
3 changed files with 271 additions and 241 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "plunk", "name": "plunk",
"version": "1.0.1", "version": "1.0.2",
"private": true, "private": true,
"license": "agpl-3.0", "license": "agpl-3.0",
"workspaces": { "workspaces": {
+32 -15
View File
@@ -101,16 +101,16 @@ export class Contacts {
throw new NotFound("project"); throw new NotFound("project");
} }
const { id } = UtilitySchemas.id.parse(req.body); const { id, email } = ContactSchemas.manage.parse(req.body);
const contact = await ContactService.id(id); const contact = id ? await ContactService.id(id) : await ContactService.email(project.id, email as string);
if (!contact || contact.projectId !== project.id) { if (!contact || contact.projectId !== project.id) {
throw new NotFound("contact"); throw new NotFound("contact");
} }
await prisma.contact.update({ await prisma.contact.update({
where: { id }, where: { id: contact.id },
data: { subscribed: false }, data: { subscribed: false },
}); });
@@ -152,16 +152,16 @@ export class Contacts {
throw new NotFound("project"); throw new NotFound("project");
} }
const { id } = UtilitySchemas.id.parse(req.body); const { id, email } = ContactSchemas.manage.parse(req.body);
const contact = await ContactService.id(id); const contact = id ? await ContactService.id(id) : await ContactService.email(project.id, email as string);
if (!contact || contact.projectId !== project.id) { if (!contact || contact.projectId !== project.id) {
throw new NotFound("contact"); throw new NotFound("contact");
} }
await prisma.contact.update({ await prisma.contact.update({
where: { id }, where: { id: contact.id },
data: { subscribed: true }, data: { subscribed: true },
}); });
@@ -246,26 +246,43 @@ export class Contacts {
throw new NotFound("project"); throw new NotFound("project");
} }
const { id, email, subscribed, data } = ContactSchemas.update.parse(req.body); const { id, email, subscribed, data } = ContactSchemas.manage.parse(req.body);
let contact = await ContactService.id(id); const contact = id ? await ContactService.id(id) : await ContactService.email(project.id, email as string);
if (!contact || contact.projectId !== project.id) { if (!contact || contact.projectId !== project.id) {
throw new NotFound("contact"); throw new NotFound("contact");
} }
contact = await prisma.contact.update({ if (data) {
where: { id }, const givenUserData = Object.entries(data);
data: { email, subscribed, data: data ? JSON.stringify(data) : null }, const dataToUpdate = JSON.parse(contact.data ?? "{}");
include: {
triggers: { include: { event: true, action: true } }, givenUserData.forEach(([key, value]) => {
emails: { where: { subject: { not: null } } }, if (!value) {
delete dataToUpdate[key];
} else {
dataToUpdate[key] = value;
}
});
await prisma.contact.update({
where: { id: contact.id },
data: { data: JSON.stringify(dataToUpdate) },
});
}
await prisma.contact.update({
where: { id: contact.id },
data: {
email,
subscribed: subscribed ?? contact.subscribed,
}, },
}); });
await redis.del(Keys.Project.contacts(project.id)); await redis.del(Keys.Project.contacts(project.id));
await redis.del(Keys.Contact.id(contact.id)); await redis.del(Keys.Contact.id(contact.id));
await redis.del(Keys.Contact.email(project.id, email)); await redis.del(Keys.Contact.email(project.id, contact.email));
return res.status(200).json({ return res.status(200).json({
success: true, success: true,
+81 -68
View File
@@ -1,16 +1,16 @@
import {z} from 'zod'; import { TemplateStyle, TemplateType } from "@prisma/client";
import {TemplateStyle, TemplateType} from '@prisma/client'; import { z } from "zod";
const email = z const email = z
.string({invalid_type_error: 'Email needs to be a string', required_error: 'Email is required'}) .string({ invalid_type_error: "Email needs to be a string", required_error: "Email is required" })
.email({message: 'Invalid email address'}) .email({ message: "Invalid email address" })
.transform(e => e.toLowerCase()); .transform((e) => e.toLowerCase());
const password = z.string().min(6, 'Password needs to be at least 6 characters long'); const password = z.string().min(6, "Password needs to be at least 6 characters long");
const id = z const id = z
.string({invalid_type_error: 'ID needs to be a string', required_error: 'ID is required'}) .string({ invalid_type_error: "ID needs to be a string", required_error: "ID is required" })
.uuid({message: 'Id needs to be a valid UUID'}); .uuid({ message: "Id needs to be a valid UUID" });
export const UtilitySchemas = { export const UtilitySchemas = {
id: z.object({ id: z.object({
@@ -22,13 +22,13 @@ export const UtilitySchemas = {
pagination: z.object({ pagination: z.object({
page: z page: z
.number({ .number({
invalid_type_error: 'Page needs to be a number', invalid_type_error: "Page needs to be a number",
required_error: 'Page is required', required_error: "Page is required",
}) })
.min(1, 'Page needs to be at least 1') .min(1, "Page needs to be at least 1")
.default(1) .default(1)
.or( .or(
z.string().transform(s => { z.string().transform((s) => {
return Number(s); return Number(s);
}), }),
), ),
@@ -48,41 +48,41 @@ const zodSchema = z.record(
z z
.string({ .string({
invalid_type_error: 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)', "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 => { .transform((s) => {
return { persistent: true, value: s }; return { persistent: true, value: s };
}), }),
z z
.array( .array(
z.string({ z.string({
invalid_type_error: 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)', "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 => { .transform((s) => {
return { persistent: true, value: s }; return { persistent: true, value: s };
}), }),
z.object( z.object(
{ {
persistent: z.boolean({invalid_type_error: 'Persistent should be a boolean'}).optional().default(true), persistent: z.boolean({ invalid_type_error: "Persistent should be a boolean" }).optional().default(true),
value: z.union([z.string(), z.array(z.string())], { value: z.union([z.string(), z.array(z.string())], {
invalid_type_error: 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)', "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: 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)', "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: 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)', "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)'}, { invalid_type_error: "Metadata should be an object (https://docs.useplunk.com/working-with-contacts/metadata)" },
); );
export const EventSchemas = { export const EventSchemas = {
post: z.object({ post: z.object({
@@ -90,33 +90,32 @@ export const EventSchemas = {
subscribed: z subscribed: z
.boolean({ .boolean({
invalid_type_error: invalid_type_error:
'Subscribed should be a boolean. Read more: https://docs.useplunk.com/api-reference/actions/track', "Subscribed should be a boolean. Read more: https://docs.useplunk.com/api-reference/actions/track",
}) })
.nullish(), .nullish(),
event: z event: z
.string({ .string({
required_error: 'Event is required. Read more: https://docs.useplunk.com/api-reference/actions/track', required_error: "Event is required. Read more: https://docs.useplunk.com/api-reference/actions/track",
invalid_type_error: invalid_type_error: "Event can only be a string. Read more: https://docs.useplunk.com/api-reference/actions/track",
'Event can only be a string. Read more: https://docs.useplunk.com/api-reference/actions/track',
}) })
.transform(n => n.toLowerCase()) .transform((n) => n.toLowerCase())
.transform(n => n.replace(/ /g, '-')), .transform((n) => n.replace(/ /g, "-")),
data: zodSchema.nullish(), data: zodSchema.nullish(),
}), }),
send: z.object({ send: z.object({
subscribed: z.boolean({invalid_type_error: 'Subscribed should be a boolean'}).nullish(), subscribed: z.boolean({ invalid_type_error: "Subscribed should be a boolean" }).nullish(),
from: email.nullish(), from: email.nullish(),
name: z.string().nullish(), name: z.string().nullish(),
reply: email.nullish(), reply: email.nullish(),
to: z to: z
.array(email) .array(email)
.max(5, 'You can only send transactional emails to 5 people at a time') .max(5, "You can only send transactional emails to 5 people at a time")
.or(email.transform(e => [e])), .or(email.transform((e) => [e])),
subject: z.string({ subject: z.string({
required_error: 'Subject is required. Read more: https://docs.useplunk.com/api-reference/transactional/send', required_error: "Subject is required. Read more: https://docs.useplunk.com/api-reference/transactional/send",
}), }),
body: z.string({ body: z.string({
required_error: 'Body is required. Read more: https://docs.useplunk.com/api-reference/transactional/send', required_error: "Body is required. Read more: https://docs.useplunk.com/api-reference/transactional/send",
}), }),
headers: z.record(z.string()).nullish(), headers: z.record(z.string()).nullish(),
}), }),
@@ -126,43 +125,43 @@ export const CampaignSchemas = {
send: z.object({ send: z.object({
id, id,
live: z.boolean().default(false), live: z.boolean().default(false),
delay: z.number().int('Delay needs to be a whole number').nonnegative('Delay needs to be a positive number'), delay: z.number().int("Delay needs to be a whole number").nonnegative("Delay needs to be a positive number"),
}), }),
create: z.object({ create: z.object({
subject: z subject: z
.string() .string()
.min(1, 'Subject needs to be at least 1 character long') .min(1, "Subject needs to be at least 1 character long")
.max(70, 'Subject needs to be less than 70 characters 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'), body: z.string().min(1, "Body needs to be at least 1 character long"),
recipients: z.array(z.string()), recipients: z.array(z.string()),
style: z.nativeEnum(TemplateStyle).default('PLUNK'), style: z.nativeEnum(TemplateStyle).default("PLUNK"),
}), }),
update: z.object({ update: z.object({
id, id,
subject: z subject: z
.string() .string()
.min(1, 'Subject needs to be at least 1 character long') .min(1, "Subject needs to be at least 1 character long")
.max(70, 'Subject needs to be less than 70 characters 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'), body: z.string().min(1, "Body needs to be at least 1 character long"),
recipients: z.array(z.string()), recipients: z.array(z.string()),
style: z.nativeEnum(TemplateStyle).default('PLUNK'), style: z.nativeEnum(TemplateStyle).default("PLUNK"),
}), }),
}; };
export const ActionSchemas = { export const ActionSchemas = {
create: z.object({ create: z.object({
name: z.string().min(1, 'Name needs to be at least 1 character long'), name: z.string().min(1, "Name needs to be at least 1 character long"),
runOnce: z.boolean().default(false), runOnce: z.boolean().default(false),
delay: z.number().int('Delay needs to be a whole number').nonnegative('Delay needs to be a positive number'), delay: z.number().int("Delay needs to be a whole number").nonnegative("Delay needs to be a positive number"),
template: id, template: id,
events: z.array(id).min(1, 'Select at least one event'), events: z.array(id).min(1, "Select at least one event"),
notevents: z.array(id).optional().default([]), notevents: z.array(id).optional().default([]),
}), }),
update: z.object({ update: z.object({
id, id,
name: z.string().min(1, 'Name needs to be at least 1 character long'), name: z.string().min(1, "Name needs to be at least 1 character long"),
runOnce: z.boolean().default(false), runOnce: z.boolean().default(false),
delay: z.number().int('Delay needs to be a whole number').nonnegative('Delay needs to be a positive number'), delay: z.number().int("Delay needs to be a whole number").nonnegative("Delay needs to be a positive number"),
template: id, template: id,
events: z.array(id).default([]), events: z.array(id).default([]),
notevents: z.array(id).optional().default([]), notevents: z.array(id).optional().default([]),
@@ -175,35 +174,49 @@ export const ContactSchemas = {
data: z data: z
.object({}) .object({})
.catchall(z.union([z.string(), z.array(z.string())])) .catchall(z.union([z.string(), z.array(z.string())]))
.or(z.string().transform(s => (s === '' ? null : JSON.parse(s)))) .or(z.string().transform((s) => (s === "" ? null : JSON.parse(s))))
.nullish(), .nullish(),
subscribed: z.boolean(), subscribed: z.boolean(),
}), }),
update: z.object({ manage: z
id, .object({
email, id: id.optional(),
email: email.optional(),
data: z data: z
.object({}) .object({})
.catchall(z.union([z.string(), z.array(z.string())])) .catchall(z.union([z.string(), z.array(z.string()), z.null()]))
.or(z.string().transform(s => (s === '' ? null : JSON.parse(s)))) .or(z.string().transform((s) => (s === "" ? null : JSON.parse(s))))
.nullish(), .nullish(),
subscribed: z.boolean(), 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 = { export const TemplateSchemas = {
create: z.object({ create: z.object({
subject: z.string().min(1, "Subject can't be empty").max(70, 'Subject needs to be less than 70 characters long'), 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"), body: z.string().min(1, "Body can't be empty"),
type: z.nativeEnum(TemplateType).default('MARKETING'), type: z.nativeEnum(TemplateType).default("MARKETING"),
style: z.nativeEnum(TemplateStyle).default('PLUNK'), style: z.nativeEnum(TemplateStyle).default("PLUNK"),
}), }),
update: z.object({ update: z.object({
id, id,
subject: z.string().min(1, "Subject can't be empty").max(70, 'Subject needs to be less than 70 characters long'), 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"), body: z.string().min(1, "Body can't be empty"),
type: z.nativeEnum(TemplateType).default('MARKETING'), type: z.nativeEnum(TemplateType).default("MARKETING"),
style: z.nativeEnum(TemplateStyle).default('PLUNK'), style: z.nativeEnum(TemplateStyle).default("PLUNK"),
}), }),
}; };
@@ -211,7 +224,7 @@ export const MembershipSchemas = {
invite: z.object({ invite: z.object({
id, id,
email, email,
role: z.enum(['MEMBER', 'ADMIN']).default('MEMBER'), role: z.enum(["MEMBER", "ADMIN"]).default("MEMBER"),
}), }),
kick: z.object({ kick: z.object({
id, id,
@@ -229,7 +242,7 @@ export const ProjectSchemas = {
url: z url: z
.string() .string()
.regex(/^(?:(?:https?):\/\/)?(?:[\w-]+\.)+[a-z]{2,}(?:\/[^\s]*)?$/) .regex(/^(?:(?:https?):\/\/)?(?:[\w-]+\.)+[a-z]{2,}(?:\/[^\s]*)?$/)
.transform(u => (u.startsWith('http') ? u : `https://${u}`)), .transform((u) => (u.startsWith("http") ? u : `https://${u}`)),
}), }),
update: z.object({ update: z.object({
id: id, id: id,
@@ -238,10 +251,10 @@ export const ProjectSchemas = {
url: z url: z
.string() .string()
.regex(/^(?:(?:https?):\/\/)?(?:[\w-]+\.)+[a-z]{2,}(?:\/[^\s]*)?$/) .regex(/^(?:(?:https?):\/\/)?(?:[\w-]+\.)+[a-z]{2,}(?:\/[^\s]*)?$/)
.transform(u => (u.startsWith('http') ? u : `https://${u}`)), .transform((u) => (u.startsWith("http") ? u : `https://${u}`)),
}), }),
analytics: z.object({ analytics: z.object({
method: z.enum(['week', 'month', 'year']).default('week'), method: z.enum(["week", "month", "year"]).default("week"),
}), }),
}; };
@@ -249,12 +262,12 @@ export const IdentitySchemas = {
create: z.object({ create: z.object({
id: id, id: id,
email: email.refine( email: email.refine(
e => { (e) => {
return !['gmail.com', 'outlook.com', 'hotmail.com', 'yahoo.com', 'useplunk.com', 'useplunk.dev'].includes( return !["gmail.com", "outlook.com", "hotmail.com", "yahoo.com", "useplunk.com", "useplunk.dev"].includes(
e.split('@')[1], e.split("@")[1],
); );
}, },
{message: 'Please use your own domain'}, { message: "Please use your own domain" },
), ),
}), }),
update: z.object({ update: z.object({