diff --git a/package.json b/package.json index 5c04da1..a11998c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plunk", - "version": "1.0.1", + "version": "1.0.2", "private": true, "license": "agpl-3.0", "workspaces": { diff --git a/packages/api/src/controllers/v1/Contacts.ts b/packages/api/src/controllers/v1/Contacts.ts index 2c238f8..76a124a 100644 --- a/packages/api/src/controllers/v1/Contacts.ts +++ b/packages/api/src/controllers/v1/Contacts.ts @@ -101,16 +101,16 @@ export class Contacts { 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) { throw new NotFound("contact"); } await prisma.contact.update({ - where: { id }, + where: { id: contact.id }, data: { subscribed: false }, }); @@ -152,16 +152,16 @@ export class Contacts { 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) { throw new NotFound("contact"); } await prisma.contact.update({ - where: { id }, + where: { id: contact.id }, data: { subscribed: true }, }); @@ -246,26 +246,43 @@ export class Contacts { 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) { throw new NotFound("contact"); } - contact = await prisma.contact.update({ - where: { id }, - data: { email, subscribed, data: data ? JSON.stringify(data) : null }, - include: { - triggers: { include: { event: true, action: true } }, - emails: { where: { subject: { not: null } } }, + if (data) { + const givenUserData = Object.entries(data); + const dataToUpdate = JSON.parse(contact.data ?? "{}"); + + givenUserData.forEach(([key, value]) => { + 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.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({ success: true, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 92e6d0c..885323a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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 - .string({invalid_type_error: 'Email needs to be a string', required_error: 'Email is required'}) - .email({message: 'Invalid email address'}) - .transform(e => e.toLowerCase()); + .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 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'}); + .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); - }), - ), - }), + 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, - }), + 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)'}, + 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(), - }), + 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(), + }), }; 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'), - 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'), - recipients: z.array(z.string()), - style: z.nativeEnum(TemplateStyle).default('PLUNK'), - }), + 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"), + 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"), + 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([]), - }), + 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(), - }), - update: z.object({ - id, - 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(), - }), + 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"), - 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"), - type: z.nativeEnum(TemplateType).default('MARKETING'), - style: z.nativeEnum(TemplateStyle).default('PLUNK'), - }), + 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"), + 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"), + 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, - }), + 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"), + 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}`)), + }), + 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'), - }), + 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"), - }), + 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"), + }), };