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,
+238 -225
View File
@@ -1,264 +1,277 @@
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({
id, id,
}), }),
email: z.object({ email: z.object({
email, email,
}), }),
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);
}), }),
), ),
}), }),
}; };
export const UserSchemas = { export const UserSchemas = {
credentials: z.object({ credentials: z.object({
email: email, email: email,
password: password, password: password,
}), }),
}; };
const zodSchema = z.record( const zodSchema = z.record(
z.union( z.union(
[ [
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({
email, email,
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(), }),
}),
}; };
export const CampaignSchemas = { 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([]),
}), }),
}; };
export const ContactSchemas = { export const ContactSchemas = {
create: z.object({ create: z.object({
email, email,
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(),
data: z email: email.optional(),
.object({}) data: z
.catchall(z.union([z.string(), z.array(z.string())])) .object({})
.or(z.string().transform(s => (s === '' ? null : JSON.parse(s)))) .catchall(z.union([z.string(), z.array(z.string()), z.null()]))
.nullish(), .or(z.string().transform((s) => (s === "" ? null : JSON.parse(s))))
subscribed: z.boolean(), .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 = { 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"),
}), }),
}; };
export const MembershipSchemas = { 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,
email, email,
}), }),
}; };
export const ProjectSchemas = { export const ProjectSchemas = {
secret: z.object({ secret: z.object({
secret: z.string(), secret: z.string(),
}), }),
create: z.object({ create: z.object({
name: z.string().min(1, "Name can't be empty"), name: z.string().min(1, "Name can't be empty"),
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,
name: z.string().min(1, "Name can't be empty"), name: z.string().min(1, "Name can't be empty"),
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"),
}), }),
}; };
export const IdentitySchemas = { 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({
id: id, id: id,
from: z.string().min(1, "Name can't be empty"), from: z.string().min(1, "Name can't be empty"),
}), }),
}; };