Initial push of Plunk Next
@@ -1,11 +0,0 @@
|
||||
# ENV
|
||||
JWT_SECRET=mysupersecretJWTsecret
|
||||
REDIS_URL=redis://127.0.0.1:56379
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:55432/postgres
|
||||
DISABLE_SIGNUPS=false
|
||||
|
||||
# AWS
|
||||
AWS_REGION=
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_SES_CONFIGURATION_SET=
|
||||
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"name": "@plunk/api",
|
||||
"version": "1.0.0",
|
||||
"main": "dist/index.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_ENV=development ts-node-dev --transpile-only --exit-child src/app.ts",
|
||||
"start": "node ./dist/app.js",
|
||||
"build": "tsc",
|
||||
"clean": "rimraf node_modules dist .turbo"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/ioredis": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/mjml": "^4.7.4",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/signale": "^1.4.7",
|
||||
"cross-env": "^7.0.3",
|
||||
"prisma": "^5.17.0",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.5.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-cloudfront": "^3.616.0",
|
||||
"@aws-sdk/client-ses": "^3.616.0",
|
||||
"@overnightjs/core": "^1.7.6",
|
||||
"@plunk/shared": "^1.0.0",
|
||||
"@prisma/client": "^5.17.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"body-parser": "^1.20.2",
|
||||
"compression": "^1.7.4",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mjml": "^4.15.3",
|
||||
"morgan": "^1.10.0",
|
||||
"node-cron": "^3.0.3",
|
||||
"signale": "^1.4.0"
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import "dotenv/config";
|
||||
import "express-async-errors";
|
||||
|
||||
import { STATUS_CODES } from "node:http";
|
||||
import { Server } from "@overnightjs/core";
|
||||
import compression from "compression";
|
||||
import cookies from "cookie-parser";
|
||||
import cors from "cors";
|
||||
import { type NextFunction, type Request, type Response, json } from "express";
|
||||
import helmet from "helmet";
|
||||
import morgan from "morgan";
|
||||
import signale from "signale";
|
||||
import { APP_URI, NODE_ENV } from "./app/constants";
|
||||
import { task } from "./app/cron";
|
||||
import { Auth } from "./controllers/Auth";
|
||||
import { Identities } from "./controllers/Identities";
|
||||
import { Memberships } from "./controllers/Memberships";
|
||||
import { Projects } from "./controllers/Projects";
|
||||
import { Tasks } from "./controllers/Tasks";
|
||||
import { Users } from "./controllers/Users";
|
||||
import { Webhooks } from "./controllers/Webhooks";
|
||||
import { V1 } from "./controllers/v1";
|
||||
import { prisma } from "./database/prisma";
|
||||
import { HttpException } from "./exceptions";
|
||||
import { Health } from "./controllers/Health";
|
||||
|
||||
const server = new (class extends Server {
|
||||
public constructor() {
|
||||
super();
|
||||
|
||||
// Set the content-type to JSON for any request coming from AWS SNS
|
||||
this.app.use((req, res, next) => {
|
||||
if (req.get("x-amz-sns-message-type")) {
|
||||
req.headers["content-type"] = "application/json";
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
this.app.use(
|
||||
compression({
|
||||
threshold: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
// Parse the rest of our application as json
|
||||
this.app.use(json({ limit: "50mb" }));
|
||||
this.app.use(cookies());
|
||||
this.app.use(helmet());
|
||||
|
||||
this.app.use(["/v1", "/v1/track", "/v1/send"], (req, res, next) => {
|
||||
res.set({ "Access-Control-Allow-Origin": "*" });
|
||||
next();
|
||||
});
|
||||
|
||||
this.app.use(
|
||||
cors({
|
||||
origin: [APP_URI],
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
this.app.use(morgan(NODE_ENV === "development" ? "dev" : "short"));
|
||||
|
||||
this.addControllers([
|
||||
new Auth(),
|
||||
new Users(),
|
||||
new Projects(),
|
||||
new Memberships(),
|
||||
new Webhooks(),
|
||||
new Identities(),
|
||||
new Tasks(),
|
||||
new V1(),
|
||||
new Health(),
|
||||
]);
|
||||
|
||||
this.app.use("*", () => {
|
||||
throw new HttpException(404, "Unknown route");
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
server.app.use((req, res, next) => {
|
||||
console.log(`Incoming request: ${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
server.app.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
|
||||
const code = error instanceof HttpException ? error.code : 500;
|
||||
|
||||
if (NODE_ENV !== "development") {
|
||||
signale.error(error);
|
||||
}
|
||||
|
||||
res.status(code).json({
|
||||
code,
|
||||
error: STATUS_CODES[code],
|
||||
message: error.message,
|
||||
time: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
void prisma.$connect().then(() => {
|
||||
server.app.listen(4000, () => {
|
||||
task.start();
|
||||
|
||||
signale.success("[HTTPS] Ready on", 4000);
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* Safely parse environment variables
|
||||
* @param key The key
|
||||
* @param defaultValue An optional default value if the environment variable does not exist
|
||||
*/
|
||||
export function validateEnv<T extends string = string>(key: keyof NodeJS.ProcessEnv, defaultValue?: T): T {
|
||||
const value = process.env[key] as T | undefined;
|
||||
|
||||
if (!value) {
|
||||
if (typeof defaultValue !== "undefined") {
|
||||
return defaultValue;
|
||||
}
|
||||
throw new Error(`${key} is not defined in environment variables`);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// ENV
|
||||
export const JWT_SECRET = validateEnv("JWT_SECRET");
|
||||
export const NODE_ENV = validateEnv<"development" | "production">("NODE_ENV", "production");
|
||||
|
||||
export const REDIS_URL = validateEnv("REDIS_URL");
|
||||
export const DISABLE_SIGNUPS = validateEnv("DISABLE_SIGNUPS", "false").toLowerCase() === "true";
|
||||
|
||||
// URLs
|
||||
export const API_URI = validateEnv("API_URI", "http://localhost:4000");
|
||||
export const APP_URI = validateEnv("APP_URI", "http://localhost:3000");
|
||||
|
||||
if (!API_URI.startsWith("http")) {
|
||||
throw new Error("API_URI must start with 'http'");
|
||||
}
|
||||
|
||||
if (!APP_URI.startsWith("http")) {
|
||||
throw new Error("APP_URI must start with 'http'");
|
||||
}
|
||||
|
||||
// AWS
|
||||
export const AWS_REGION = validateEnv("AWS_REGION");
|
||||
export const AWS_ACCESS_KEY_ID = validateEnv("AWS_ACCESS_KEY_ID");
|
||||
export const AWS_SECRET_ACCESS_KEY = validateEnv("AWS_SECRET_ACCESS_KEY");
|
||||
export const AWS_SES_CONFIGURATION_SET = validateEnv("AWS_SES_CONFIGURATION_SET");
|
||||
@@ -1,23 +0,0 @@
|
||||
import cron from "node-cron";
|
||||
import signale from "signale";
|
||||
|
||||
import { Identities } from "../controllers/Identities";
|
||||
import { Tasks } from "../controllers/Tasks";
|
||||
|
||||
export const task = cron.schedule("* * * * *", async () => {
|
||||
signale.info("Running scheduled tasks");
|
||||
try {
|
||||
await (new Tasks().handleTasks());
|
||||
} catch (e) {
|
||||
signale.error("Failed to run scheduled tasks. Please check the error below");
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
signale.info("Updating verified identities");
|
||||
try {
|
||||
await (new Identities().updateIdentities());
|
||||
} catch (e) {
|
||||
signale.error("Failed to update verified identities. Please check the error below");
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { Controller, Get, Post } from "@overnightjs/core";
|
||||
import { UserSchemas, UtilitySchemas } from "@plunk/shared";
|
||||
import type { Request, Response } from "express";
|
||||
import { DISABLE_SIGNUPS } from "../app/constants";
|
||||
import { prisma } from "../database/prisma";
|
||||
import { NotAllowed, NotFound } from "../exceptions";
|
||||
import { jwt } from "../middleware/auth";
|
||||
import { AuthService } from "../services/AuthService";
|
||||
import { UserService } from "../services/UserService";
|
||||
import { Keys } from "../services/keys";
|
||||
import { REDIS_ONE_MINUTE, redis } from "../services/redis";
|
||||
import { createHash } from "../util/hash";
|
||||
|
||||
@Controller("auth")
|
||||
export class Auth {
|
||||
@Post("login")
|
||||
public async login(req: Request, res: Response) {
|
||||
const { email, password } = UserSchemas.credentials.parse(req.body);
|
||||
|
||||
const user = await UserService.email(email);
|
||||
|
||||
if (!user) {
|
||||
return res.json({ success: false, data: "Incorrect email or password" });
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
return res.json({
|
||||
success: "redirect",
|
||||
redirect: `/auth/reset?id=${user.id}`,
|
||||
});
|
||||
}
|
||||
|
||||
const verified = await AuthService.verifyCredentials(email, password);
|
||||
|
||||
if (!verified) {
|
||||
return res.json({ success: false, data: "Incorrect email or password" });
|
||||
}
|
||||
|
||||
await redis.set(Keys.User.id(user.id), JSON.stringify(user), "EX", REDIS_ONE_MINUTE * 60);
|
||||
|
||||
const token = jwt.sign(user.id);
|
||||
const cookie = UserService.cookieOptions();
|
||||
|
||||
return res
|
||||
.cookie(UserService.COOKIE_NAME, token, cookie)
|
||||
.json({ success: true, data: { id: user.id, email: user.email } });
|
||||
}
|
||||
|
||||
@Post("signup")
|
||||
public async signup(req: Request, res: Response) {
|
||||
if (DISABLE_SIGNUPS) {
|
||||
return res.json({
|
||||
success: false,
|
||||
data: "Signups are currently disabled",
|
||||
});
|
||||
}
|
||||
|
||||
const { email, password } = UserSchemas.credentials.parse(req.body);
|
||||
|
||||
const user = await UserService.email(email);
|
||||
|
||||
if (user) {
|
||||
return res.json({
|
||||
success: false,
|
||||
data: "That email is already associated with another user",
|
||||
});
|
||||
}
|
||||
|
||||
const created_user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
password: await createHash(password),
|
||||
},
|
||||
});
|
||||
|
||||
await redis.set(Keys.User.id(created_user.id), JSON.stringify(created_user), "EX", REDIS_ONE_MINUTE * 60);
|
||||
|
||||
const token = jwt.sign(created_user.id);
|
||||
const cookie = UserService.cookieOptions();
|
||||
|
||||
return res.cookie(UserService.COOKIE_NAME, token, cookie).json({
|
||||
success: true,
|
||||
data: { id: created_user.id, email: created_user.email },
|
||||
});
|
||||
}
|
||||
|
||||
@Post("reset")
|
||||
public async reset(req: Request, res: Response) {
|
||||
const { id, password } = UtilitySchemas.id.merge(UserSchemas.credentials.pick({ password: true })).parse(req.body);
|
||||
|
||||
const user = await UserService.id(id);
|
||||
|
||||
if (!user) {
|
||||
throw new NotFound("user");
|
||||
}
|
||||
|
||||
if (user.password) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id },
|
||||
data: { password: await createHash(password) },
|
||||
});
|
||||
|
||||
await redis.del(Keys.User.id(user.id));
|
||||
await redis.del(Keys.User.email(user.email));
|
||||
|
||||
return res.json({ success: true });
|
||||
}
|
||||
|
||||
@Get("logout")
|
||||
public logout(req: Request, res: Response) {
|
||||
res.cookie(UserService.COOKIE_NAME, "", UserService.cookieOptions(new Date()));
|
||||
return res.json(true);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Controller, Get } from "@overnightjs/core";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
@Controller("health")
|
||||
export class Health {
|
||||
@Get("")
|
||||
public async health(req: Request, res: Response) {
|
||||
return res.json({ success: true });
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { Controller, Get, Middleware, Post } from "@overnightjs/core";
|
||||
import { IdentitySchemas, UtilitySchemas } from "@plunk/shared";
|
||||
import type { Request, Response } from "express";
|
||||
import signale from "signale";
|
||||
import { prisma } from "../database/prisma";
|
||||
import { NotFound } from "../exceptions";
|
||||
import { type IJwt, isAuthenticated } from "../middleware/auth";
|
||||
import { ProjectService } from "../services/ProjectService";
|
||||
import { Keys } from "../services/keys";
|
||||
import { redis } from "../services/redis";
|
||||
import { getIdentities, getIdentityVerificationAttributes, ses, verifyIdentity } from "../util/ses";
|
||||
|
||||
@Controller("identities")
|
||||
export class Identities {
|
||||
@Get("id/:id")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getVerification(req: Request, res: Response) {
|
||||
const { id } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const project = await ProjectService.id(id);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
if (!project.email) {
|
||||
return res.status(200).json({ success: false });
|
||||
}
|
||||
|
||||
const attributes = await getIdentityVerificationAttributes(project.email);
|
||||
|
||||
if (attributes.status === "Success" && !project.verified) {
|
||||
await prisma.project.update({ where: { id }, data: { verified: true } });
|
||||
|
||||
await redis.del(Keys.Project.id(project.id));
|
||||
await redis.del(Keys.Project.secret(project.secret));
|
||||
await redis.del(Keys.Project.public(project.public));
|
||||
}
|
||||
|
||||
return res.status(200).json({ tokens: attributes.tokens });
|
||||
}
|
||||
|
||||
@Middleware([isAuthenticated])
|
||||
@Post("create")
|
||||
public async addIdentity(req: Request, res: Response) {
|
||||
const { id, email } = IdentitySchemas.create.parse(req.body);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(id);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const domain = email.split('@')[1];
|
||||
const existingProject = await prisma.project.findFirst({where: {email: {endsWith: `@${domain}`}}});
|
||||
|
||||
if (existingProject) {
|
||||
throw new Error("Domain already attached to another project");
|
||||
}
|
||||
|
||||
const tokens = await verifyIdentity(email);
|
||||
|
||||
await prisma.project.update({
|
||||
where: { id },
|
||||
data: { email, verified: false },
|
||||
});
|
||||
|
||||
await redis.del(Keys.User.projects(userId));
|
||||
await redis.del(Keys.Project.id(project.id));
|
||||
|
||||
return res.status(200).json({ success: true, tokens });
|
||||
}
|
||||
|
||||
@Middleware([isAuthenticated])
|
||||
@Post("reset")
|
||||
public async resetIdentity(req: Request, res: Response) {
|
||||
const { id } = UtilitySchemas.id.parse(req.body);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(id);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
await prisma.project.update({
|
||||
where: { id },
|
||||
data: { email: null, verified: false },
|
||||
});
|
||||
|
||||
await redis.del(Keys.User.projects(userId));
|
||||
await redis.del(Keys.Project.id(project.id));
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
|
||||
@Post("update")
|
||||
public async updateIdentitiesApi(req: Request, res: Response) {
|
||||
await (new Identities().updateIdentities());
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
|
||||
public async updateIdentities() {
|
||||
const count = await prisma.project.count({
|
||||
where: { email: { not: null } },
|
||||
});
|
||||
|
||||
for (let i = 0; i < count; i += 99) {
|
||||
const dbIdentities = await prisma.project.findMany({
|
||||
where: { email: { not: null } },
|
||||
select: { id: true, email: true },
|
||||
skip: i,
|
||||
take: 99,
|
||||
});
|
||||
|
||||
const awsIdentities = await getIdentities(dbIdentities.map((i) => i.email as string));
|
||||
|
||||
for (const identity of awsIdentities) {
|
||||
const projectId = dbIdentities.find((i) => i.email?.endsWith(identity.email));
|
||||
|
||||
const project = await ProjectService.id(projectId?.id as string);
|
||||
|
||||
if (identity.status === "Failed") {
|
||||
signale.info(`Restarting verification for ${identity.email}`);
|
||||
try {
|
||||
void verifyIdentity(identity.email);
|
||||
} catch (e) {
|
||||
// @ts-ignore
|
||||
if (e.Code === "Throttling") {
|
||||
signale.warn("Throttling detected, waiting 5 seconds");
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.project.update({
|
||||
where: { id: projectId?.id as string },
|
||||
data: { verified: identity.status === "Success" },
|
||||
});
|
||||
|
||||
if (project && !project.verified && identity.status === "Success") {
|
||||
signale.success(`Successfully verified ${identity.email}`);
|
||||
void ses.setIdentityFeedbackForwardingEnabled({
|
||||
Identity: identity.email,
|
||||
ForwardingEnabled: false,
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.id(project.id));
|
||||
await redis.del(Keys.Project.secret(project.secret));
|
||||
await redis.del(Keys.Project.public(project.public));
|
||||
}
|
||||
|
||||
if (project?.verified && identity.status !== "Success") {
|
||||
await redis.del(Keys.Project.id(project.id));
|
||||
await redis.del(Keys.Project.secret(project.secret));
|
||||
await redis.del(Keys.Project.public(project.public));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { Controller, Middleware, Post } from "@overnightjs/core";
|
||||
import { MembershipSchemas, UtilitySchemas } from "@plunk/shared";
|
||||
import type { Request, Response } from "express";
|
||||
import { HttpException, NotAllowed, NotAuthenticated, NotFound } from "../exceptions";
|
||||
import { type IJwt, isAuthenticated } from "../middleware/auth";
|
||||
import { MembershipService } from "../services/MembershipService";
|
||||
import { ProjectService } from "../services/ProjectService";
|
||||
import { UserService } from "../services/UserService";
|
||||
import { Keys } from "../services/keys";
|
||||
import { redis } from "../services/redis";
|
||||
|
||||
@Controller("memberships")
|
||||
export class Memberships {
|
||||
@Middleware([isAuthenticated])
|
||||
@Post("invite")
|
||||
public async inviteMember(req: Request, res: Response) {
|
||||
const { id: projectId, email } = MembershipSchemas.invite.parse(req.body);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isAdmin = await MembershipService.isAdmin(projectId, userId);
|
||||
|
||||
if (!isAdmin) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const invitedUser = await UserService.email(email);
|
||||
|
||||
if (!invitedUser) {
|
||||
throw new HttpException(404, "We could not find that user, please ask them to sign up first.");
|
||||
}
|
||||
|
||||
const alreadyMember = await MembershipService.isMember(project.id, invitedUser.id);
|
||||
|
||||
if (alreadyMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
await MembershipService.invite(projectId, invitedUser.id, "ADMIN");
|
||||
|
||||
const memberships = await ProjectService.memberships(projectId);
|
||||
|
||||
return res.status(200).json({ success: true, memberships });
|
||||
}
|
||||
|
||||
@Middleware([isAuthenticated])
|
||||
@Post("kick")
|
||||
public async kickMember(req: Request, res: Response) {
|
||||
const { id: projectId, email } = MembershipSchemas.kick.parse(req.body);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const user = await UserService.id(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isAdmin = await MembershipService.isAdmin(projectId, userId);
|
||||
|
||||
if (!isAdmin) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const kickedUser = await UserService.email(email);
|
||||
|
||||
if (!kickedUser) {
|
||||
throw new NotFound("user");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(project.id, kickedUser.id);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
if (userId === kickedUser.id) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
await MembershipService.kick(projectId, kickedUser.id);
|
||||
|
||||
const memberships = await ProjectService.memberships(projectId);
|
||||
|
||||
return res.status(200).json({ success: true, memberships });
|
||||
}
|
||||
|
||||
@Middleware([isAuthenticated])
|
||||
@Post("leave")
|
||||
public async leaveProject(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.body);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const user = await UserService.id(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
await MembershipService.kick(projectId, userId);
|
||||
|
||||
await redis.del(Keys.User.projects(userId));
|
||||
|
||||
const memberships = await UserService.projects(userId);
|
||||
|
||||
return res.status(200).json({ success: true, memberships });
|
||||
}
|
||||
}
|
||||
@@ -1,614 +0,0 @@
|
||||
import { Controller, Delete, Get, Middleware, Post, Put } from "@overnightjs/core";
|
||||
import { IdentitySchemas, ProjectSchemas, UtilitySchemas } from "@plunk/shared";
|
||||
import type { Request, Response } from "express";
|
||||
import z from "zod";
|
||||
import { prisma } from "../database/prisma";
|
||||
import { NotAllowed, NotAuthenticated, NotFound } from "../exceptions";
|
||||
import { type IJwt, isAuthenticated } from "../middleware/auth";
|
||||
import { MembershipService } from "../services/MembershipService";
|
||||
import { ProjectService } from "../services/ProjectService";
|
||||
import { UserService } from "../services/UserService";
|
||||
import { Keys } from "../services/keys";
|
||||
import { redis } from "../services/redis";
|
||||
import { generateToken } from "../util/tokens";
|
||||
|
||||
@Controller("projects")
|
||||
export class Projects {
|
||||
@Get("id/:id")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
return res.json(project);
|
||||
}
|
||||
|
||||
@Get("id/:id/memberships")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectMembershipsByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const memberships = await ProjectService.memberships(projectId);
|
||||
|
||||
return res.status(200).json(memberships);
|
||||
}
|
||||
|
||||
@Get("id/:id/usage")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectUsageByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const usage = await ProjectService.usage(projectId);
|
||||
|
||||
return res.status(200).json(usage);
|
||||
}
|
||||
|
||||
@Get("id/:id/events")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectEventsByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { triggers } = z
|
||||
.object({
|
||||
triggers: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.or(z.string().transform((str) => str.toLowerCase() === "true")),
|
||||
})
|
||||
.parse(req.query);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const events = await ProjectService.events(projectId, triggers);
|
||||
|
||||
return res.status(200).json(events);
|
||||
}
|
||||
|
||||
@Get("id/:id/actions")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectActionsByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const actions = await ProjectService.actions(projectId);
|
||||
|
||||
return res.status(200).json(actions);
|
||||
}
|
||||
|
||||
@Get("id/:id/templates")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectTemplatesByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const templates = await ProjectService.templates(projectId);
|
||||
|
||||
return res.status(200).json(templates);
|
||||
}
|
||||
|
||||
@Get("id/:id/contacts/search")
|
||||
@Middleware([isAuthenticated])
|
||||
public async searchContacts(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const { query } = z.object({ query: z.string().min(1) }).parse(req.query);
|
||||
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
OR: [{ email: { contains: query, mode: "insensitive" } }, { data: { contains: query, mode: "insensitive" } }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
subscribed: true,
|
||||
createdAt: true,
|
||||
triggers: { select: { createdAt: true } },
|
||||
emails: { select: { createdAt: true } },
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
contacts,
|
||||
count: contacts.length,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("id/:id/contacts/count")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectContactCountByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const count = await ProjectService.contacts.count(projectId);
|
||||
|
||||
return res.status(200).json(count);
|
||||
}
|
||||
|
||||
@Get("id/:id/contacts/metadata")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectMetadataByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const metadata = await ProjectService.metadata(projectId);
|
||||
|
||||
return res.status(200).json(metadata);
|
||||
}
|
||||
|
||||
@Get("id/:id/contacts")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectContactsByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
const { page } = UtilitySchemas.pagination.parse(req.query);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
if (page === 0) {
|
||||
const contacts = await ProjectService.contacts.get(projectId);
|
||||
|
||||
return res.status(200).json({ contacts, count: contacts?.length });
|
||||
}
|
||||
const contacts = await ProjectService.contacts.paginated(projectId, page);
|
||||
const count = await ProjectService.contacts.count(projectId);
|
||||
|
||||
return res.status(200).json({ contacts, count });
|
||||
}
|
||||
|
||||
@Get("id/:id/feed")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectFeedByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
const { page } = UtilitySchemas.pagination.parse(req.query);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const feed = await ProjectService.feed(projectId, page);
|
||||
|
||||
return res.status(200).json(feed);
|
||||
}
|
||||
|
||||
@Get("id/:id/campaigns")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectCampaignsByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const campaigns = await ProjectService.campaigns(projectId);
|
||||
|
||||
return res.status(200).json(campaigns);
|
||||
}
|
||||
|
||||
@Get("id/:id/emails/count")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectEmailCountByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const count = await ProjectService.emails.count(projectId);
|
||||
|
||||
return res.status(200).json(count);
|
||||
}
|
||||
|
||||
@Get("id/:id/emails")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectEmailsByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const emails = await ProjectService.emails.get(projectId);
|
||||
|
||||
return res.status(200).json(emails);
|
||||
}
|
||||
|
||||
@Get("id/:id/analytics")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getProjectAnalyticsByID(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.params);
|
||||
const { method } = ProjectSchemas.analytics.parse(req.query);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
const analytics = await ProjectService.analytics({ id: projectId, method });
|
||||
|
||||
return res.status(200).json(analytics);
|
||||
}
|
||||
|
||||
@Post("create")
|
||||
@Middleware([isAuthenticated])
|
||||
public async createProject(req: Request, res: Response) {
|
||||
const { name, url } = ProjectSchemas.create.parse(req.body);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const user = await UserService.id(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
let secretKey = "";
|
||||
let secretIsAvailable = false;
|
||||
|
||||
let publicKey = "";
|
||||
let publicIsAvailable = false;
|
||||
|
||||
while (!secretIsAvailable) {
|
||||
secretKey = generateToken("secret");
|
||||
|
||||
secretIsAvailable = await ProjectService.secretIsAvailable(secretKey);
|
||||
}
|
||||
|
||||
while (!publicIsAvailable) {
|
||||
publicKey = generateToken("public");
|
||||
|
||||
publicIsAvailable = await ProjectService.publicIsAvailable(publicKey);
|
||||
}
|
||||
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name,
|
||||
url,
|
||||
secret: secretKey,
|
||||
public: publicKey,
|
||||
memberships: {
|
||||
create: [{ userId, role: "OWNER" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await redis.del(Keys.User.projects(userId));
|
||||
await redis.del(Keys.Project.secret(project.secret));
|
||||
await redis.del(Keys.Project.public(project.public));
|
||||
await redis.del(Keys.Project.id(project.id));
|
||||
|
||||
return res.status(200).json({ success: true, data: project });
|
||||
}
|
||||
|
||||
@Post("id/:id/regenerate")
|
||||
@Middleware([isAuthenticated])
|
||||
public async regenerateAPIkey(req: Request, res: Response) {
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
let project = await ProjectService.id(req.params.id);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const user = await UserService.id(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
const isAdmin = await MembershipService.isAdmin(project.id, userId);
|
||||
|
||||
if (!isAdmin) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
let secretKey = "";
|
||||
let secretIsAvailable = false;
|
||||
|
||||
let publicKey = "";
|
||||
let publicIsAvailable = false;
|
||||
|
||||
while (!secretIsAvailable) {
|
||||
secretKey = generateToken("secret");
|
||||
|
||||
secretIsAvailable = await ProjectService.secretIsAvailable(secretKey);
|
||||
}
|
||||
|
||||
while (!publicIsAvailable) {
|
||||
publicKey = generateToken("public");
|
||||
|
||||
publicIsAvailable = await ProjectService.secretIsAvailable(publicKey);
|
||||
}
|
||||
|
||||
project = await prisma.project.update({
|
||||
where: { id: project.id },
|
||||
data: { secret: secretKey, public: publicKey },
|
||||
});
|
||||
|
||||
await redis.del(Keys.User.projects(userId));
|
||||
|
||||
return res.status(200).json({ success: true, project });
|
||||
}
|
||||
|
||||
@Put("update")
|
||||
@Middleware([isAuthenticated])
|
||||
public async updateProject(req: Request, res: Response) {
|
||||
const { id: projectId, name, url } = ProjectSchemas.update.parse(req.body);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
let project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isAdmin = await MembershipService.isAdmin(projectId, userId);
|
||||
|
||||
if (!isAdmin) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
project = await prisma.project.update({
|
||||
where: { id: projectId },
|
||||
data: {
|
||||
name,
|
||||
url,
|
||||
},
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.id(project.id));
|
||||
await redis.del(Keys.Project.secret(project.secret));
|
||||
await redis.del(Keys.User.projects(userId));
|
||||
|
||||
return res.status(200).json({ success: true, data: project });
|
||||
}
|
||||
|
||||
@Put("update/identity")
|
||||
@Middleware([isAuthenticated])
|
||||
public async updateIdentity(req: Request, res: Response) {
|
||||
const { id: projectId, from } = IdentitySchemas.update.parse(req.body);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
let project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const isAdmin = await MembershipService.isAdmin(projectId, userId);
|
||||
|
||||
if (!isAdmin) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
project = await prisma.project.update({
|
||||
where: { id: projectId },
|
||||
data: {
|
||||
from,
|
||||
},
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.id(project.id));
|
||||
await redis.del(Keys.Project.secret(project.secret));
|
||||
await redis.del(Keys.User.projects(userId));
|
||||
|
||||
return res.status(200).json({ success: true, data: project });
|
||||
}
|
||||
|
||||
@Delete("delete")
|
||||
@Middleware([isAuthenticated])
|
||||
public async deleteProject(req: Request, res: Response) {
|
||||
const { id: projectId } = UtilitySchemas.id.parse(req.body);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const project = await ProjectService.id(projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const user = await UserService.id(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
const isOwner = await MembershipService.isOwner(projectId, userId);
|
||||
|
||||
if (!isOwner) {
|
||||
throw new NotAllowed();
|
||||
}
|
||||
|
||||
await prisma.project.delete({ where: { id: project.id } });
|
||||
|
||||
await redis.del(Keys.User.projects(userId));
|
||||
await redis.del(Keys.Project.id(projectId));
|
||||
|
||||
return res.status(200).json({ success: true, data: project });
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { Controller, Post } from "@overnightjs/core";
|
||||
import type { Request, Response } from "express";
|
||||
import signale from "signale";
|
||||
import { prisma } from "../database/prisma";
|
||||
import { ContactService } from "../services/ContactService";
|
||||
import { EmailService } from "../services/EmailService";
|
||||
import { ProjectService } from "../services/ProjectService";
|
||||
|
||||
@Controller("tasks")
|
||||
export class Tasks {
|
||||
@Post()
|
||||
public async handleTasksApi(req: Request, res: Response) {
|
||||
await (new Tasks().handleTasks());
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
|
||||
public async handleTasks() {
|
||||
// Get all tasks with a runBy data in the past
|
||||
const tasks = await prisma.task.findMany({
|
||||
where: { runBy: { lte: new Date() } },
|
||||
orderBy: { runBy: "asc" },
|
||||
include: {
|
||||
action: { include: { template: true, notevents: true } },
|
||||
campaign: true,
|
||||
contact: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const task of tasks) {
|
||||
const { action, campaign, contact } = task;
|
||||
|
||||
const project = await ProjectService.id(contact.projectId);
|
||||
|
||||
// If the project does not exist or is disabled, delete all tasks
|
||||
if (!project) {
|
||||
await prisma.task.deleteMany({
|
||||
where: {
|
||||
contact: {
|
||||
projectId: contact.projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let subject = "";
|
||||
let body = "";
|
||||
|
||||
let email = "";
|
||||
let name = "";
|
||||
|
||||
if (action) {
|
||||
const { template, notevents } = action;
|
||||
|
||||
if (notevents.length > 0) {
|
||||
const triggers = await ContactService.triggers(contact.id);
|
||||
if (notevents.some((e) => triggers.some((t) => t.contactId === contact.id && t.eventId === e.id))) {
|
||||
await prisma.task.delete({ where: { id: task.id } });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
email = project.verified && project.email ? template.email ?? project.email : "[email protected]";
|
||||
name = template.from ?? project.from ?? project.name;
|
||||
|
||||
({ subject, body } = EmailService.format({
|
||||
subject: template.subject,
|
||||
body: template.body,
|
||||
data: {
|
||||
plunk_id: contact.id,
|
||||
plunk_email: contact.email,
|
||||
...JSON.parse(contact.data ?? "{}"),
|
||||
},
|
||||
}));
|
||||
} else if (campaign) {
|
||||
email = project.verified && project.email ? campaign.email ?? project.email : "[email protected]";
|
||||
name = campaign.from ?? project.from ?? project.name;
|
||||
|
||||
({ subject, body } = EmailService.format({
|
||||
subject: campaign.subject,
|
||||
body: campaign.body,
|
||||
data: {
|
||||
plunk_id: contact.id,
|
||||
plunk_email: contact.email,
|
||||
...JSON.parse(contact.data ?? "{}"),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const { messageId } = await EmailService.send({
|
||||
from: {
|
||||
name,
|
||||
email,
|
||||
},
|
||||
to: [contact.email],
|
||||
content: {
|
||||
subject,
|
||||
html: EmailService.compile({
|
||||
content: body,
|
||||
footer: {
|
||||
unsubscribe: campaign ? true : !!action && action.template.type === "MARKETING",
|
||||
},
|
||||
contact: {
|
||||
id: contact.id,
|
||||
},
|
||||
project: {
|
||||
name: project.name,
|
||||
},
|
||||
isHtml: (campaign && campaign.style === "HTML") ?? (!!action && action.template.style === "HTML"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const emailData: {
|
||||
messageId: string;
|
||||
contactId: string;
|
||||
actionId?: string;
|
||||
campaignId?: string;
|
||||
} = {
|
||||
messageId,
|
||||
contactId: contact.id,
|
||||
};
|
||||
|
||||
if (action) {
|
||||
emailData.actionId = action.id;
|
||||
} else if (campaign) {
|
||||
emailData.campaignId = campaign.id;
|
||||
}
|
||||
|
||||
await prisma.email.create({ data: emailData });
|
||||
|
||||
await prisma.task.delete({ where: { id: task.id } });
|
||||
|
||||
signale.success(`Task completed for ${contact.email} from ${project.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Controller, Get, Middleware } from "@overnightjs/core";
|
||||
import type { Request, Response } from "express";
|
||||
import { NotAuthenticated } from "../exceptions";
|
||||
import { type IJwt, isAuthenticated } from "../middleware/auth";
|
||||
import { UserService } from "../services/UserService";
|
||||
|
||||
@Controller("users")
|
||||
export class Users {
|
||||
@Get("@me")
|
||||
@Middleware([isAuthenticated])
|
||||
public async me(req: Request, res: Response) {
|
||||
const auth = res.locals.auth as IJwt;
|
||||
|
||||
const me = await UserService.id(auth.userId);
|
||||
|
||||
if (!me) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
return res.status(200).json({ id: me.id, email: me.email });
|
||||
}
|
||||
|
||||
@Get("@me/projects")
|
||||
@Middleware([isAuthenticated])
|
||||
public async meProjects(req: Request, res: Response) {
|
||||
const auth = res.locals.auth as IJwt;
|
||||
|
||||
const projects = await UserService.projects(auth.userId);
|
||||
|
||||
return res.status(200).json(projects);
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { Controller, Post } from "@overnightjs/core";
|
||||
import type { Event } from "@prisma/client";
|
||||
import type { Request, Response } from "express";
|
||||
import signale from "signale";
|
||||
import { prisma } from "../../../database/prisma";
|
||||
import { ActionService } from "../../../services/ActionService";
|
||||
import { ProjectService } from "../../../services/ProjectService";
|
||||
|
||||
const eventMap = {
|
||||
Bounce: "BOUNCED",
|
||||
Delivery: "DELIVERED",
|
||||
Open: "OPENED",
|
||||
Complaint: "COMPLAINT",
|
||||
Click: "CLICKED",
|
||||
} as const;
|
||||
|
||||
@Controller("sns")
|
||||
export class SNSWebhook {
|
||||
@Post()
|
||||
public async receiveSNSWebhook(req: Request, res: Response) {
|
||||
try {
|
||||
const body = JSON.parse(req.body.Message);
|
||||
|
||||
const email = await prisma.email.findUnique({
|
||||
where: { messageId: body.mail.messageId },
|
||||
include: {
|
||||
contact: true,
|
||||
action: { include: { template: { include: { events: true } } } },
|
||||
campaign: { include: { events: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!email) {
|
||||
return res.status(200).json({});
|
||||
}
|
||||
|
||||
const project = await ProjectService.id(email.contact.projectId);
|
||||
|
||||
if (!project) {
|
||||
return res.status(200).json({ success: false });
|
||||
}
|
||||
|
||||
// The email was a transactional email
|
||||
if (email.projectId) {
|
||||
if (body.eventType === "Click") {
|
||||
signale.success(`Click received for ${email.contact.email} from ${project.name}`);
|
||||
|
||||
await prisma.click.create({
|
||||
data: { emailId: email.id, link: body.click.link },
|
||||
});
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
|
||||
if (body.eventType === "Complaint") {
|
||||
signale.warn(`Complaint received for ${email.contact.email} from ${project.name}`);
|
||||
}
|
||||
|
||||
if (body.eventType === "Bounce") {
|
||||
signale.warn(`Bounce received for ${email.contact.email} from ${project.name}`);
|
||||
}
|
||||
|
||||
await prisma.email.update({
|
||||
where: { messageId: body.mail.messageId },
|
||||
data: {
|
||||
status: eventMap[body.eventType as "Bounce" | "Delivery" | "Open" | "Complaint"],
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
|
||||
if (body.eventType === "Complaint" || body.eventType === "Bounce") {
|
||||
signale.warn(
|
||||
`${body.eventType === "Complaint" ? "Complaint" : "Bounce"} received for ${email.contact.email} from ${project.name}`,
|
||||
);
|
||||
|
||||
await prisma.email.update({
|
||||
where: { messageId: body.mail.messageId },
|
||||
data: { status: eventMap[body.eventType as "Bounce" | "Complaint"] },
|
||||
});
|
||||
|
||||
await prisma.contact.update({
|
||||
where: { id: email.contactId },
|
||||
data: { subscribed: false },
|
||||
});
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
|
||||
if (body.eventType === "Click") {
|
||||
signale.success(`Click received for ${email.contact.email} from ${project.name}`);
|
||||
|
||||
await prisma.click.create({
|
||||
data: { emailId: email.id, link: body.click.link },
|
||||
});
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
|
||||
let event: Event | undefined;
|
||||
|
||||
if (email.action) {
|
||||
event = email.action.template.events.find((e) =>
|
||||
e.name.includes(
|
||||
(body.eventType as "Bounce" | "Delivery" | "Open" | "Complaint" | "Click") === "Delivery"
|
||||
? "delivered"
|
||||
: "opened",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (email.campaign) {
|
||||
event = email.campaign.events.find((e) =>
|
||||
e.name.includes(
|
||||
(body.eventType as "Bounce" | "Delivery" | "Open" | "Complaint" | "Click") === "Delivery"
|
||||
? "delivered"
|
||||
: "opened",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return res.status(200).json({ success: false });
|
||||
}
|
||||
|
||||
switch (body.eventType as "Delivery" | "Open") {
|
||||
case "Delivery":
|
||||
signale.success(`Delivery received for ${email.contact.email} from ${project.name}`);
|
||||
await prisma.email.update({
|
||||
where: { messageId: body.mail.messageId },
|
||||
data: { status: "DELIVERED" },
|
||||
});
|
||||
|
||||
await prisma.trigger.create({
|
||||
data: { contactId: email.contactId, eventId: event.id },
|
||||
});
|
||||
|
||||
break;
|
||||
case "Open":
|
||||
signale.success(`Open received for ${email.contact.email} from ${project.name}`);
|
||||
await prisma.email.update({
|
||||
where: { messageId: body.mail.messageId },
|
||||
data: { status: "OPENED" },
|
||||
});
|
||||
await prisma.trigger.create({
|
||||
data: { contactId: email.contactId, eventId: event.id },
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (email.action) {
|
||||
void ActionService.trigger({ event, contact: email.contact, project });
|
||||
}
|
||||
} catch (e) {
|
||||
if (req.body.SubscribeURL) {
|
||||
signale.info("--------------");
|
||||
signale.info("SNS Topic Confirmation URL:");
|
||||
signale.info(req.body.SubscribeURL);
|
||||
signale.info("--------------");
|
||||
} else {
|
||||
signale.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import {ChildControllers, Controller} from '@overnightjs/core';
|
||||
import {SNSWebhook} from './SNS';
|
||||
|
||||
@Controller('incoming')
|
||||
@ChildControllers([new SNSWebhook()])
|
||||
export class IncomingWebhooks {}
|
||||
@@ -1,6 +0,0 @@
|
||||
import {ChildControllers, Controller} from '@overnightjs/core';
|
||||
import {IncomingWebhooks} from './Incoming';
|
||||
|
||||
@Controller('webhooks')
|
||||
@ChildControllers([new IncomingWebhooks()])
|
||||
export class Webhooks {}
|
||||
@@ -1,243 +0,0 @@
|
||||
import { Controller, Delete, Get, Middleware, Post, Put } from "@overnightjs/core";
|
||||
import { ActionSchemas, UtilitySchemas } from "@plunk/shared";
|
||||
import type { Request, Response } from "express";
|
||||
import { prisma } from "../../database/prisma";
|
||||
import { NotFound } from "../../exceptions";
|
||||
import { type IJwt, type ISecret, isAuthenticated, isValidSecretKey } from "../../middleware/auth";
|
||||
import { ActionService } from "../../services/ActionService";
|
||||
import { EventService } from "../../services/EventService";
|
||||
import { MembershipService } from "../../services/MembershipService";
|
||||
import { ProjectService } from "../../services/ProjectService";
|
||||
import { TemplateService } from "../../services/TemplateService";
|
||||
import { Keys } from "../../services/keys";
|
||||
import { redis } from "../../services/redis";
|
||||
|
||||
@Controller("actions")
|
||||
export class Actions {
|
||||
@Get(":id")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getActionById(req: Request, res: Response) {
|
||||
const { id } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const action = await ActionService.id(id);
|
||||
|
||||
if (!action) {
|
||||
throw new NotFound("action");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(action.projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotFound("action");
|
||||
}
|
||||
|
||||
return res.status(200).json(action);
|
||||
}
|
||||
|
||||
@Get(":id/related")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getRelatedActionsById(req: Request, res: Response) {
|
||||
const { id } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const action = await ActionService.id(id);
|
||||
|
||||
if (!action) {
|
||||
throw new NotFound("action");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(action.projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotFound("action");
|
||||
}
|
||||
|
||||
const related = await ActionService.related(id);
|
||||
|
||||
return res.status(200).json(related);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async createAction(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { name, runOnce, delay, template: templateId, events, notevents } = ActionSchemas.create.parse(req.body);
|
||||
|
||||
const template = await TemplateService.id(templateId);
|
||||
|
||||
if (!template) {
|
||||
throw new NotFound("template");
|
||||
}
|
||||
|
||||
const action = await prisma.action.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
name,
|
||||
runOnce,
|
||||
delay,
|
||||
templateId: template.id,
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
events.map(async (e: string) => {
|
||||
const event = await EventService.id(e);
|
||||
|
||||
if (!event) {
|
||||
throw new NotFound("event");
|
||||
}
|
||||
|
||||
if (event.projectId !== project.id) {
|
||||
throw new NotFound("event");
|
||||
}
|
||||
|
||||
await prisma.action.update({
|
||||
where: { id: action.id },
|
||||
data: { events: { connect: { id: event.id } } },
|
||||
});
|
||||
}),
|
||||
notevents.map(async (e: string) => {
|
||||
const event = await EventService.id(e);
|
||||
|
||||
if (!event) {
|
||||
throw new NotFound("event");
|
||||
}
|
||||
|
||||
if (event.projectId !== project.id) {
|
||||
throw new NotFound("event");
|
||||
}
|
||||
|
||||
await prisma.action.update({
|
||||
where: { id: action.id },
|
||||
data: { notevents: { connect: { id: event.id } } },
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
await redis.del(Keys.Action.id(action.id));
|
||||
await redis.del(Keys.Project.actions(project.id));
|
||||
|
||||
return res.status(200).json(action);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async updateAction(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { id, template: templateId, events, notevents, name, runOnce, delay } = ActionSchemas.update.parse(req.body);
|
||||
|
||||
let action = await ActionService.id(id);
|
||||
|
||||
if (!action || action.projectId !== project.id) {
|
||||
throw new NotFound("action");
|
||||
}
|
||||
|
||||
const template = await TemplateService.id(templateId);
|
||||
|
||||
if (!template || template.projectId !== project.id) {
|
||||
throw new NotFound("template");
|
||||
}
|
||||
|
||||
const actionEvents = await prisma.action.findUnique({
|
||||
where: { id },
|
||||
include: { events: true, notevents: true },
|
||||
});
|
||||
|
||||
action = await prisma.action.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
runOnce,
|
||||
delay,
|
||||
templateId,
|
||||
events: { disconnect: actionEvents?.events.map((e) => ({ id: e.id })) },
|
||||
notevents: {
|
||||
disconnect: actionEvents?.notevents.map((e) => ({ id: e.id })),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
events: true,
|
||||
notevents: true,
|
||||
triggers: true,
|
||||
emails: true,
|
||||
template: true,
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
events.map(async (e: string) => {
|
||||
const event = await EventService.id(e);
|
||||
|
||||
if (!event || event.projectId !== project.id) {
|
||||
throw new NotFound("event");
|
||||
}
|
||||
|
||||
await prisma.action.update({
|
||||
where: { id },
|
||||
data: { events: { connect: { id: event.id } } },
|
||||
});
|
||||
}),
|
||||
notevents.map(async (e: string) => {
|
||||
const event = await EventService.id(e);
|
||||
|
||||
if (!event || event.projectId !== project.id) {
|
||||
throw new NotFound("event");
|
||||
}
|
||||
|
||||
await prisma.action.update({
|
||||
where: { id },
|
||||
data: { notevents: { connect: { id: event.id } } },
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
await redis.del(Keys.Action.id(action.id));
|
||||
await redis.del(Keys.Project.actions(project.id));
|
||||
|
||||
return res.status(200).json(action);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async deleteAction(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { id } = UtilitySchemas.id.parse(req.body);
|
||||
|
||||
const action = await ActionService.id(id);
|
||||
|
||||
if (!action || action.projectId !== project.id) {
|
||||
throw new NotFound("action");
|
||||
}
|
||||
|
||||
await prisma.action.delete({ where: { id } });
|
||||
|
||||
await redis.del(Keys.Action.id(action.id));
|
||||
await redis.del(Keys.Project.actions(project.id));
|
||||
|
||||
return res.status(200).json(action);
|
||||
}
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
import { Controller, Delete, Get, Middleware, Post, Put } from "@overnightjs/core";
|
||||
import { CampaignSchemas, UtilitySchemas } from "@plunk/shared";
|
||||
import dayjs from "dayjs";
|
||||
import type { Request, Response } from "express";
|
||||
import { prisma } from "../../database/prisma";
|
||||
import { HttpException, NotAllowed, NotFound } from "../../exceptions";
|
||||
import { type IJwt, type ISecret, isAuthenticated, isValidSecretKey } from "../../middleware/auth";
|
||||
import { CampaignService } from "../../services/CampaignService";
|
||||
import { ContactService } from "../../services/ContactService";
|
||||
import { EmailService } from "../../services/EmailService";
|
||||
import { MembershipService } from "../../services/MembershipService";
|
||||
import { ProjectService } from "../../services/ProjectService";
|
||||
import { Keys } from "../../services/keys";
|
||||
import { redis } from "../../services/redis";
|
||||
|
||||
@Controller("campaigns")
|
||||
export class Campaigns {
|
||||
@Get(":id")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getCampaignById(req: Request, res: Response) {
|
||||
const { id } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const campaign = await CampaignService.id(id);
|
||||
|
||||
if (!campaign) {
|
||||
throw new NotFound("campaign");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(campaign.projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotFound("campaign");
|
||||
}
|
||||
|
||||
return res.status(200).json(campaign);
|
||||
}
|
||||
|
||||
@Post("send")
|
||||
@Middleware([isValidSecretKey])
|
||||
public async sendCampaign(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { id, live, delay: userDelay } = CampaignSchemas.send.parse(req.body);
|
||||
|
||||
const campaign = await CampaignService.id(id);
|
||||
|
||||
if (!campaign || campaign.projectId !== project.id) {
|
||||
throw new NotFound("campaign");
|
||||
}
|
||||
|
||||
if (live) {
|
||||
if (campaign.recipients.length === 0) {
|
||||
throw new HttpException(400, "No recipients found");
|
||||
}
|
||||
|
||||
await prisma.campaign.update({
|
||||
where: { id: campaign.id },
|
||||
data: { status: "DELIVERED", delivered: new Date() },
|
||||
});
|
||||
|
||||
await prisma.event.createMany({
|
||||
data: [
|
||||
{
|
||||
projectId: project.id,
|
||||
name: `${campaign.subject
|
||||
.toLowerCase()
|
||||
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
|
||||
.replace(/ /g, "-")}-campaign-delivered`,
|
||||
campaignId: campaign.id,
|
||||
},
|
||||
{
|
||||
projectId: project.id,
|
||||
name: `${campaign.subject
|
||||
.toLowerCase()
|
||||
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
|
||||
.replace(/ /g, "-")}-campaign-opened`,
|
||||
campaignId: campaign.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let delay = userDelay ?? 0;
|
||||
|
||||
const tasks = campaign.recipients.map((r, index) => {
|
||||
if (index % 80 === 0) {
|
||||
delay += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
campaignId: campaign.id,
|
||||
contactId: r.id,
|
||||
runBy: dayjs().add(delay, "minutes").toDate(),
|
||||
};
|
||||
});
|
||||
|
||||
await prisma.task.createMany({ data: tasks });
|
||||
} else {
|
||||
const members = await ProjectService.memberships(project.id);
|
||||
|
||||
await EmailService.send({
|
||||
from: {
|
||||
name: project.from ?? project.name,
|
||||
email: project.verified && project.email ? project.email : "[email protected]",
|
||||
},
|
||||
to: members.map((m) => m.email),
|
||||
content: {
|
||||
subject: `[Plunk Campaign Test] ${campaign.subject}`,
|
||||
html: EmailService.compile({
|
||||
content: campaign.body,
|
||||
footer: {
|
||||
unsubscribe: false,
|
||||
},
|
||||
contact: {
|
||||
id: "",
|
||||
},
|
||||
project: {
|
||||
name: project.name,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await redis.del(Keys.Campaign.id(campaign.id));
|
||||
await redis.del(Keys.Project.campaigns(project.id));
|
||||
|
||||
return res.status(200).json({});
|
||||
}
|
||||
|
||||
@Post("duplicate")
|
||||
@Middleware([isValidSecretKey])
|
||||
public async duplicateCampaign(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { id } = UtilitySchemas.id.parse(req.body);
|
||||
|
||||
const campaign = await CampaignService.id(id);
|
||||
|
||||
if (!campaign) {
|
||||
throw new NotFound("campaign");
|
||||
}
|
||||
|
||||
const duplicatedCampaign = await prisma.campaign.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
subject: campaign.subject,
|
||||
body: campaign.body,
|
||||
style: campaign.style,
|
||||
email: campaign.email,
|
||||
from: campaign.from,
|
||||
},
|
||||
});
|
||||
|
||||
await redis.del(Keys.Campaign.id(campaign.id));
|
||||
await redis.del(Keys.Project.campaigns(project.id));
|
||||
|
||||
return res.status(200).json(duplicatedCampaign);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async createCampaign(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
let { subject, body, recipients, style, email, from } = CampaignSchemas.create.parse(req.body);
|
||||
|
||||
if (email && !project.verified) {
|
||||
throw new NotAllowed("You need to attach a domain to your project to customize the sender address");
|
||||
}
|
||||
|
||||
if (email && email.split("@")[1] !== project.email?.split("@")[1]) {
|
||||
throw new NotAllowed("The sender address must be the same domain as the project's email address");
|
||||
}
|
||||
|
||||
if (recipients.length === 1 && recipients[0] === "all") {
|
||||
const projectContacts = await prisma.contact.findMany({
|
||||
where: { projectId: project.id, subscribed: true },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
recipients = projectContacts.map((c) => c.id);
|
||||
}
|
||||
|
||||
const campaign = await prisma.campaign.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
subject,
|
||||
body,
|
||||
style,
|
||||
from: from === "" ? null : from,
|
||||
email: email === "" ? null : email,
|
||||
},
|
||||
});
|
||||
|
||||
const chunkSize = 500;
|
||||
for (let i = 0; i < recipients.length; i += chunkSize) {
|
||||
const chunk = recipients.slice(i, i + chunkSize);
|
||||
|
||||
const recipientIds = await Promise.all(
|
||||
chunk.map(async (recipient) => {
|
||||
if (recipient.includes("@")) {
|
||||
let contact = await ContactService.email(recipient, project.id);
|
||||
|
||||
if (!contact) {
|
||||
contact = await prisma.contact.create({
|
||||
data: {
|
||||
email: recipient,
|
||||
projectId: project.id,
|
||||
subscribed: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return contact.id;
|
||||
}
|
||||
|
||||
return recipient;
|
||||
}),
|
||||
);
|
||||
|
||||
await prisma.campaign.update({
|
||||
where: { id: campaign.id },
|
||||
data: {
|
||||
recipients: {
|
||||
connect: recipientIds.map((id) => ({ id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await redis.del(Keys.Campaign.id(campaign.id));
|
||||
await redis.del(Keys.Project.campaigns(project.id));
|
||||
|
||||
return res.status(200).json(campaign);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async updateCampaign(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
let { id, subject, body, recipients, style, email, from } = CampaignSchemas.update.parse(req.body);
|
||||
|
||||
if (email && !project.verified) {
|
||||
throw new NotAllowed("You need to attach a domain to your project to customize the sender address");
|
||||
}
|
||||
|
||||
if (email && email.split("@")[1] !== project.email?.split("@")[1]) {
|
||||
throw new NotAllowed("The sender address must be the same domain as the project's email address");
|
||||
}
|
||||
|
||||
if (recipients.length === 1 && recipients[0] === "all") {
|
||||
const projectContacts = await prisma.contact.findMany({
|
||||
where: { projectId: project.id, subscribed: true },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
recipients = projectContacts.map((c) => c.id);
|
||||
}
|
||||
|
||||
let campaign = await CampaignService.id(id);
|
||||
|
||||
if (!campaign || campaign.projectId !== project.id) {
|
||||
throw new NotFound("campaign");
|
||||
}
|
||||
|
||||
campaign = await prisma.campaign.update({
|
||||
where: { id },
|
||||
data: {
|
||||
subject,
|
||||
body,
|
||||
style,
|
||||
from: from === "" ? null : from,
|
||||
email: email === "" ? null : email,
|
||||
},
|
||||
include: {
|
||||
recipients: { select: { id: true } },
|
||||
emails: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
contact: { select: { id: true, email: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.campaign.update({
|
||||
where: { id },
|
||||
data: {
|
||||
recipients: {
|
||||
set: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const chunkSize = 500;
|
||||
|
||||
for (let i = 0; i < campaign.recipients.length; i += chunkSize) {
|
||||
const chunk = campaign.recipients.slice(i, i + chunkSize);
|
||||
|
||||
await prisma.$executeRaw`DELETE FROM "_CampaignToContact" WHERE "A" = ${campaign.id} AND "B" = ANY(${chunk.map((c) => c.id)})`;
|
||||
}
|
||||
|
||||
for (let i = 0; i < recipients.length; i += chunkSize) {
|
||||
const chunk = recipients.slice(i, i + chunkSize);
|
||||
|
||||
const recipientIds = await Promise.all(
|
||||
chunk.map(async (recipient) => {
|
||||
if (recipient.includes("@")) {
|
||||
let contact = await ContactService.email(recipient, project.id);
|
||||
|
||||
if (!contact) {
|
||||
contact = await prisma.contact.create({
|
||||
data: {
|
||||
email: recipient,
|
||||
projectId: project.id,
|
||||
subscribed: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return contact.id;
|
||||
}
|
||||
|
||||
return recipient;
|
||||
}),
|
||||
);
|
||||
|
||||
await prisma.campaign.update({
|
||||
where: { id: campaign.id },
|
||||
data: {
|
||||
recipients: {
|
||||
connect: recipientIds.map((id) => ({ id })),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await redis.del(Keys.Campaign.id(campaign.id));
|
||||
await redis.del(Keys.Project.campaigns(project.id));
|
||||
|
||||
return res.status(200).json(campaign);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async deleteCampaign(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { id } = UtilitySchemas.id.parse(req.body);
|
||||
|
||||
const campaign = await CampaignService.id(id);
|
||||
|
||||
if (!campaign || campaign.projectId !== project.id) {
|
||||
throw new NotFound("campaign");
|
||||
}
|
||||
|
||||
await prisma.campaign.delete({ where: { id } });
|
||||
|
||||
await redis.del(Keys.Campaign.id(campaign.id));
|
||||
await redis.del(Keys.Project.campaigns(project.id));
|
||||
|
||||
return res.status(200).json(campaign);
|
||||
}
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
import { Controller, Delete, Get, Middleware, Post, Put } from "@overnightjs/core";
|
||||
import { ContactSchemas, UtilitySchemas } from "@plunk/shared";
|
||||
import type { Request, Response } from "express";
|
||||
import z from "zod";
|
||||
import { prisma } from "../../database/prisma";
|
||||
import { HttpException, NotFound } from "../../exceptions";
|
||||
import { type IKey, type ISecret, isValidKey, isValidSecretKey } from "../../middleware/auth";
|
||||
import { ActionService } from "../../services/ActionService";
|
||||
import { ContactService } from "../../services/ContactService";
|
||||
import { EventService } from "../../services/EventService";
|
||||
import { ProjectService } from "../../services/ProjectService";
|
||||
import { Keys } from "../../services/keys";
|
||||
import { redis } from "../../services/redis";
|
||||
|
||||
@Controller("contacts")
|
||||
export class Contacts {
|
||||
@Get("count")
|
||||
@Middleware([isValidKey])
|
||||
public async getContactCount(req: Request, res: Response) {
|
||||
const { key } = res.locals.auth as IKey;
|
||||
|
||||
const project = await ProjectService.key(key);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const count = await ProjectService.contacts.count(project.id);
|
||||
|
||||
return res.status(200).json({ count });
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
public async getContactById(req: Request, res: Response) {
|
||||
const { id } = UtilitySchemas.id.parse(req.params);
|
||||
const { withProject } = z
|
||||
.object({
|
||||
withProject: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.or(z.string().transform((s) => s === "true")),
|
||||
})
|
||||
.parse(req.query);
|
||||
|
||||
const contact = await ContactService.id(id);
|
||||
|
||||
if (!contact) {
|
||||
throw new NotFound("contact");
|
||||
}
|
||||
|
||||
if (withProject) {
|
||||
const project = await ProjectService.id(contact.projectId);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
...contact,
|
||||
project: { name: project.name, public: project.public },
|
||||
});
|
||||
}
|
||||
return res.status(200).json(contact);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async getContacts(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const contacts = await ProjectService.contacts.get(project.id);
|
||||
|
||||
return res.status(200).json(
|
||||
contacts?.map((c) => {
|
||||
return {
|
||||
id: c.id,
|
||||
email: c.email,
|
||||
subscribed: c.subscribed,
|
||||
data: c.data,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@Post("unsubscribe")
|
||||
@Middleware([isValidKey])
|
||||
public async unsubscribe(req: Request, res: Response) {
|
||||
const { key } = res.locals.auth as IKey;
|
||||
|
||||
const project = await ProjectService.key(key);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { id, email } = ContactSchemas.manage.parse(req.body);
|
||||
|
||||
let 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: contact.id },
|
||||
data: { subscribed: false },
|
||||
});
|
||||
|
||||
let event = await EventService.event(project.id, "unsubscribe");
|
||||
|
||||
if (!event) {
|
||||
event = await prisma.event.create({
|
||||
data: { name: "unsubscribe", projectId: project.id },
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.events(project.id, true));
|
||||
await redis.del(Keys.Project.events(project.id, false));
|
||||
await redis.del(Keys.Event.event(project.id, event.name));
|
||||
await redis.del(Keys.Event.id(event.id));
|
||||
}
|
||||
|
||||
await prisma.trigger.create({
|
||||
data: { eventId: event.id, contactId: contact.id },
|
||||
});
|
||||
await redis.del(Keys.Contact.id(contact.id));
|
||||
|
||||
await ActionService.trigger({ event, contact, project });
|
||||
|
||||
await redis.del(Keys.Project.contacts(project.id));
|
||||
await redis.del(Keys.Contact.id(contact.id));
|
||||
await redis.del(Keys.Contact.email(project.id, contact.email));
|
||||
|
||||
return res.status(200).json({ success: true, contact: contact.id, subscribed: false });
|
||||
}
|
||||
|
||||
@Post("subscribe")
|
||||
@Middleware([isValidKey])
|
||||
public async subscribe(req: Request, res: Response) {
|
||||
const { key } = res.locals.auth as IKey;
|
||||
|
||||
const project = await ProjectService.key(key);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { id, email } = ContactSchemas.manage.parse(req.body);
|
||||
|
||||
let 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: contact.id },
|
||||
data: { subscribed: true },
|
||||
});
|
||||
|
||||
let event = await EventService.event(project.id, "subscribe");
|
||||
|
||||
if (!event) {
|
||||
event = await prisma.event.create({
|
||||
data: { name: "subscribe", projectId: project.id },
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.events(project.id, true));
|
||||
await redis.del(Keys.Project.events(project.id, false));
|
||||
await redis.del(Keys.Event.event(project.id, event.name));
|
||||
await redis.del(Keys.Event.id(event.id));
|
||||
}
|
||||
|
||||
await prisma.trigger.create({
|
||||
data: { eventId: event.id, contactId: contact.id },
|
||||
});
|
||||
await redis.del(Keys.Contact.id(contact.id));
|
||||
|
||||
await ActionService.trigger({ event, contact, project });
|
||||
|
||||
await redis.del(Keys.Project.contacts(project.id));
|
||||
await redis.del(Keys.Contact.id(contact.id));
|
||||
await redis.del(Keys.Contact.email(project.id, contact.email));
|
||||
|
||||
return res.status(200).json({ success: true, contact: contact.id, subscribed: true });
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async createContact(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { email, subscribed, data } = ContactSchemas.create.parse(req.body);
|
||||
|
||||
let contact = await ContactService.email(project.id, email);
|
||||
|
||||
if (contact) {
|
||||
throw new HttpException(409, "Contact already exists");
|
||||
}
|
||||
|
||||
contact = await prisma.contact.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
email,
|
||||
subscribed,
|
||||
data: data ? JSON.stringify(data) : null,
|
||||
},
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
subscribed: contact.subscribed,
|
||||
data: contact.data,
|
||||
createdAt: contact.createdAt,
|
||||
updatedAt: contact.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@Put()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async updateContact(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { id, email, subscribed, data } = ContactSchemas.manage.parse(req.body);
|
||||
|
||||
let contact = id ? await ContactService.id(id) : await ContactService.email(project.id, email as string);
|
||||
|
||||
if (!contact || contact.projectId !== project.id) {
|
||||
throw new NotFound("contact");
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
email,
|
||||
subscribed: subscribed ?? contact.subscribed,
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
updateData.data = JSON.stringify(dataToUpdate);
|
||||
}
|
||||
|
||||
contact = await prisma.contact.update({
|
||||
where: { id: contact.id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.contacts(project.id));
|
||||
await redis.del(Keys.Contact.id(contact.id));
|
||||
await redis.del(Keys.Contact.email(project.id, contact.email));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
subscribed: contact.subscribed,
|
||||
data: contact.data,
|
||||
createdAt: contact.createdAt,
|
||||
updatedAt: contact.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async deleteContact(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { id } = UtilitySchemas.id.parse(req.body);
|
||||
|
||||
const contact = await ContactService.id(id);
|
||||
|
||||
if (!contact || contact.projectId !== project.id) {
|
||||
throw new NotFound("contact");
|
||||
}
|
||||
|
||||
await prisma.contact.delete({ where: { id } });
|
||||
|
||||
await redis.del(Keys.Project.contacts(project.id));
|
||||
await redis.del(Keys.Contact.id(contact.id));
|
||||
await redis.del(Keys.Contact.email(project.id, contact.email));
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
subscribed: contact.subscribed,
|
||||
data: contact.data,
|
||||
createdAt: contact.createdAt,
|
||||
updatedAt: contact.updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Controller, Delete, Middleware } from "@overnightjs/core";
|
||||
import { UtilitySchemas } from "@plunk/shared";
|
||||
import type { Request, Response } from "express";
|
||||
import { prisma } from "../../database/prisma";
|
||||
import { NotFound } from "../../exceptions";
|
||||
import { type ISecret, isValidSecretKey } from "../../middleware/auth";
|
||||
import { EventService } from "../../services/EventService";
|
||||
import { ProjectService } from "../../services/ProjectService";
|
||||
import { Keys } from "../../services/keys";
|
||||
import { redis } from "../../services/redis";
|
||||
|
||||
@Controller("events")
|
||||
export class Events {
|
||||
@Delete()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async deleteEvent(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { id } = UtilitySchemas.id.parse(req.body);
|
||||
|
||||
const event = await EventService.id(id);
|
||||
|
||||
if (!event || event.projectId !== project.id) {
|
||||
throw new NotFound("event");
|
||||
}
|
||||
|
||||
await prisma.event.delete({ where: { id } });
|
||||
|
||||
await redis.del(Keys.Event.id(id));
|
||||
await redis.del(Keys.Event.event(project.id, event.name));
|
||||
|
||||
return res.status(200).json(event);
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
import { Controller, Delete, Get, Middleware, Post, Put } from "@overnightjs/core";
|
||||
import { TemplateSchemas, UtilitySchemas } from "@plunk/shared";
|
||||
import type { Request, Response } from "express";
|
||||
import { prisma } from "../../database/prisma";
|
||||
import { NotAllowed, NotFound } from "../../exceptions";
|
||||
import { type IJwt, type ISecret, isAuthenticated, isValidSecretKey } from "../../middleware/auth";
|
||||
import { MembershipService } from "../../services/MembershipService";
|
||||
import { ProjectService } from "../../services/ProjectService";
|
||||
import { TemplateService } from "../../services/TemplateService";
|
||||
import { Keys } from "../../services/keys";
|
||||
import { redis } from "../../services/redis";
|
||||
|
||||
@Controller("templates")
|
||||
export class Templates {
|
||||
@Get(":id")
|
||||
@Middleware([isAuthenticated])
|
||||
public async getTemplateById(req: Request, res: Response) {
|
||||
const { id } = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const { userId } = res.locals.auth as IJwt;
|
||||
|
||||
const template = await TemplateService.id(id);
|
||||
|
||||
if (!template) {
|
||||
throw new NotFound("template");
|
||||
}
|
||||
|
||||
const isMember = await MembershipService.isMember(template.projectId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
throw new NotFound("template");
|
||||
}
|
||||
|
||||
return res.status(200).json(template);
|
||||
}
|
||||
|
||||
@Post("duplicate")
|
||||
@Middleware([isValidSecretKey])
|
||||
public async duplicateTemplate(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { id } = UtilitySchemas.id.parse(req.body);
|
||||
|
||||
const template = await TemplateService.id(id);
|
||||
|
||||
if (!template || template.projectId !== project.id) {
|
||||
throw new NotFound("template");
|
||||
}
|
||||
|
||||
const duplicatedTemplate = await prisma.template.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
subject: template.subject,
|
||||
body: template.body,
|
||||
type: template.type,
|
||||
style: template.style,
|
||||
email: template.email,
|
||||
from: template.from,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.event.createMany({
|
||||
data: [
|
||||
{
|
||||
projectId: project.id,
|
||||
name: `${duplicatedTemplate.subject
|
||||
.toLowerCase()
|
||||
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
|
||||
.replace(/ /g, "-")}-template-delivered`,
|
||||
templateId: template.id,
|
||||
},
|
||||
{
|
||||
projectId: project.id,
|
||||
name: `${duplicatedTemplate.subject
|
||||
.toLowerCase()
|
||||
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
|
||||
.replace(/ /g, "-")}-template-opened`,
|
||||
templateId: template.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.templates(project.id));
|
||||
await redis.del(Keys.Template.id(template.id));
|
||||
|
||||
return res.status(200).json(template);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async createTemplate(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { subject, body, type, style, email, from } = TemplateSchemas.create.parse(req.body);
|
||||
|
||||
if (email && !project.verified) {
|
||||
throw new NotAllowed("You need to attach a domain to your project to customize the sender address");
|
||||
}
|
||||
|
||||
if (email && email.split("@")[1] !== project.email?.split("@")[1]) {
|
||||
throw new NotAllowed("The sender address must be the same domain as the project's email address");
|
||||
}
|
||||
|
||||
const template = await prisma.template.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
subject,
|
||||
body,
|
||||
type,
|
||||
style,
|
||||
from: from === "" ? null : from,
|
||||
email: email === "" ? null : email,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.event.createMany({
|
||||
data: [
|
||||
{
|
||||
projectId: project.id,
|
||||
name: `${subject
|
||||
.toLowerCase()
|
||||
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
|
||||
.replace(/ /g, "-")}-template-delivered`,
|
||||
templateId: template.id,
|
||||
},
|
||||
{
|
||||
projectId: project.id,
|
||||
name: `${subject
|
||||
.toLowerCase()
|
||||
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
|
||||
.replace(/ /g, "-")}-template-opened`,
|
||||
templateId: template.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.templates(project.id));
|
||||
await redis.del(Keys.Template.id(template.id));
|
||||
|
||||
return res.status(200).json(template);
|
||||
}
|
||||
|
||||
@Put()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async updateTemplate(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { id, subject, body, type, style, email, from } = TemplateSchemas.update.parse(req.body);
|
||||
|
||||
let template = await TemplateService.id(id);
|
||||
|
||||
if (!template || template.projectId !== project.id) {
|
||||
throw new NotFound("template");
|
||||
}
|
||||
|
||||
if (email && !project.verified) {
|
||||
throw new NotAllowed("You need to attach a domain to your project to customize the sender address");
|
||||
}
|
||||
|
||||
if (email && email.split("@")[1] !== project.email?.split("@")[1]) {
|
||||
throw new NotAllowed("The sender address must be the same domain as the project's email address");
|
||||
}
|
||||
|
||||
template = await prisma.template.update({
|
||||
where: { id },
|
||||
data: {
|
||||
subject,
|
||||
body,
|
||||
type,
|
||||
style,
|
||||
from: from === "" ? null : from,
|
||||
email: email === "" ? null : email,
|
||||
},
|
||||
include: {
|
||||
actions: true,
|
||||
},
|
||||
});
|
||||
|
||||
const events = await prisma.event.findMany({
|
||||
where: { templateId: template.id },
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
events.map(async (e) => {
|
||||
await prisma.event.update({
|
||||
where: { id: e.id },
|
||||
data: {
|
||||
name: e.name.includes("delivered")
|
||||
? `${subject
|
||||
.toLowerCase()
|
||||
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
|
||||
.replace(/ /g, "-")}-template-delivered`
|
||||
: `${subject
|
||||
.toLowerCase()
|
||||
.replace(/[.,/#!$%^&*;:{}=\-_`~()]/g, "")
|
||||
.replace(/ /g, "-")}-template-opened`,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
await redis.del(Keys.Project.templates(project.id));
|
||||
await redis.del(Keys.Template.id(template.id));
|
||||
|
||||
return res.status(200).json(template);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@Middleware([isValidSecretKey])
|
||||
public async deleteTemplate(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound("project");
|
||||
}
|
||||
|
||||
const { id } = UtilitySchemas.id.parse(req.body);
|
||||
|
||||
const template = await TemplateService.id(id);
|
||||
|
||||
if (!template || template.projectId !== project.id) {
|
||||
throw new NotFound("template");
|
||||
}
|
||||
|
||||
const actions = await TemplateService.actions(id);
|
||||
|
||||
if (actions && actions.length > 0) {
|
||||
throw new NotAllowed("This template is being used by an action. Unlink the action before deleting the template.");
|
||||
}
|
||||
|
||||
await prisma.template.delete({ where: { id } });
|
||||
|
||||
await redis.del(Keys.Project.templates(project.id));
|
||||
await redis.del(Keys.Template.id(template.id));
|
||||
|
||||
return res.status(200).json(template);
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
import { ChildControllers, Controller, Middleware, Post } from "@overnightjs/core";
|
||||
import { EventSchemas } from "@plunk/shared";
|
||||
import dayjs from "dayjs";
|
||||
import type { Request, Response } from "express";
|
||||
import signale from "signale";
|
||||
import { prisma } from "../../database/prisma";
|
||||
import { HttpException, NotAllowed } from "../../exceptions";
|
||||
import { type IKey, type ISecret, isValidKey, isValidSecretKey } from "../../middleware/auth";
|
||||
import { ActionService } from "../../services/ActionService";
|
||||
import { ContactService } from "../../services/ContactService";
|
||||
import { EmailService } from "../../services/EmailService";
|
||||
import { EventService } from "../../services/EventService";
|
||||
import { ProjectService } from "../../services/ProjectService";
|
||||
import { Keys } from "../../services/keys";
|
||||
import { redis } from "../../services/redis";
|
||||
import { Actions } from "./Actions";
|
||||
import { Campaigns } from "./Campaigns";
|
||||
import { Contacts } from "./Contacts";
|
||||
import { Events } from "./Events";
|
||||
import { Templates } from "./Templates";
|
||||
|
||||
@Controller("v1")
|
||||
@ChildControllers([new Actions(), new Templates(), new Campaigns(), new Contacts(), new Events()])
|
||||
export class V1 {
|
||||
@Post()
|
||||
@Post("track")
|
||||
@Middleware([isValidKey])
|
||||
public async postEvent(req: Request, res: Response) {
|
||||
const { key } = res.locals.auth as IKey;
|
||||
|
||||
const project = await ProjectService.key(key);
|
||||
|
||||
if (!project) {
|
||||
throw new HttpException(401, "Incorrect Bearer token specified");
|
||||
}
|
||||
|
||||
const result = EventSchemas.post.safeParse(req.body);
|
||||
|
||||
if (!result.success) {
|
||||
signale.warn(`${project.name} tried tracking an event with invalid data: ${JSON.stringify(req.body)}`);
|
||||
if ("unionErrors" in result.error.issues[0]) {
|
||||
throw new HttpException(400, result.error.issues[0].unionErrors[0].errors[0].message);
|
||||
}
|
||||
|
||||
throw new HttpException(400, result.error.issues[0].message);
|
||||
}
|
||||
|
||||
const { event: name, email, data, subscribed } = result.data;
|
||||
|
||||
if (name === "subscribe" || name === "unsubscribe") {
|
||||
throw new NotAllowed("subscribe & unsubscribe are reserved event names.");
|
||||
}
|
||||
|
||||
let event = await EventService.event(project.id, name);
|
||||
|
||||
if (!event) {
|
||||
event = await prisma.event.create({
|
||||
data: { name, projectId: project.id },
|
||||
});
|
||||
redis.set(Keys.Event.event(project.id, event.name), JSON.stringify(event));
|
||||
redis.set(Keys.Event.id(event.id), JSON.stringify(event));
|
||||
|
||||
redis.del(Keys.Project.events(project.id, true));
|
||||
redis.del(Keys.Project.events(project.id, false));
|
||||
}
|
||||
|
||||
let contact = await ContactService.email(project.id, email);
|
||||
|
||||
if (!contact) {
|
||||
contact = await prisma.contact.create({
|
||||
data: {
|
||||
email,
|
||||
subscribed: subscribed ?? true,
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
redis.del(Keys.Contact.id(contact.id));
|
||||
redis.del(Keys.Contact.email(project.id, contact.email));
|
||||
} else {
|
||||
if (subscribed !== null && contact.subscribed !== subscribed) {
|
||||
contact = await prisma.contact.update({where: {id: contact.id}, data: {subscribed}});
|
||||
|
||||
redis.del(Keys.Contact.id(contact.id));
|
||||
redis.del(Keys.Contact.email(project.id, contact.email));
|
||||
}
|
||||
}
|
||||
|
||||
if (data) {
|
||||
const givenUserData = Object.entries(data);
|
||||
const userData = JSON.parse(contact.data ?? "{}");
|
||||
const dataToUpdate = JSON.parse(contact.data ?? "{}");
|
||||
|
||||
givenUserData.forEach(([key, value]) => {
|
||||
userData[key] = value.value;
|
||||
if (value.persistent) {
|
||||
dataToUpdate[key] = value.value;
|
||||
}
|
||||
});
|
||||
|
||||
contact.data = JSON.stringify(userData);
|
||||
|
||||
await prisma.contact.update({
|
||||
where: { id: contact.id },
|
||||
data: { data: JSON.stringify(dataToUpdate) },
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.trigger.create({
|
||||
data: { eventId: event.id, contactId: contact.id },
|
||||
});
|
||||
|
||||
void ActionService.trigger({ event, contact, project });
|
||||
|
||||
signale.success(`${project.name} triggered ${event.name} for ${contact.email}`);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
contact: contact.id,
|
||||
event: event.id,
|
||||
timestamp: dayjs().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
@Post("send")
|
||||
@Middleware([isValidSecretKey])
|
||||
public async send(req: Request, res: Response) {
|
||||
const { sk } = res.locals.auth as ISecret;
|
||||
|
||||
const project = await ProjectService.secret(sk);
|
||||
|
||||
if (!project) {
|
||||
throw new HttpException(401, "Incorrect Bearer token specified");
|
||||
}
|
||||
|
||||
const result = EventSchemas.send.safeParse(req.body);
|
||||
|
||||
if (!result.success) {
|
||||
if ("unionErrors" in result.error.issues[0]) {
|
||||
throw new HttpException(400, result.error.issues[0].unionErrors[0].errors[0].message);
|
||||
}
|
||||
|
||||
throw new HttpException(400, result.error.issues[0].message);
|
||||
}
|
||||
|
||||
const { from, name, reply, to, subject, body, subscribed, headers, attachments } = result.data;
|
||||
|
||||
if (!project.email || !project.verified) {
|
||||
throw new HttpException(401, "Verify your domain before you start sending");
|
||||
}
|
||||
|
||||
if (from && from.split("@")[1] !== project.email?.split("@")[1]) {
|
||||
throw new HttpException(401, "Custom from address must be from a verified domain");
|
||||
}
|
||||
|
||||
const emails: {
|
||||
contact: {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
email: string;
|
||||
}[] = [];
|
||||
|
||||
for (const email of to) {
|
||||
let contact = await ContactService.email(project.id, email);
|
||||
|
||||
if (!contact) {
|
||||
contact = await prisma.contact.create({
|
||||
data: {
|
||||
email,
|
||||
subscribed: subscribed ?? false,
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
redis.del(Keys.Contact.id(contact.id));
|
||||
redis.del(Keys.Contact.email(project.id, contact.email));
|
||||
} else {
|
||||
if (subscribed && contact.subscribed !== subscribed) {
|
||||
await prisma.contact.update({
|
||||
where: { id: contact.id },
|
||||
data: { subscribed },
|
||||
});
|
||||
redis.set(
|
||||
Keys.Contact.email(project.id, contact.email),
|
||||
JSON.stringify({
|
||||
...contact,
|
||||
subscribed,
|
||||
}),
|
||||
);
|
||||
redis.del(Keys.Contact.id(contact.id));
|
||||
}
|
||||
}
|
||||
|
||||
const { subject: enrichedSubject, body: enrichedBody } = EmailService.format({
|
||||
subject,
|
||||
body,
|
||||
data: {
|
||||
plunk_id: contact.id,
|
||||
plunk_email: contact.email,
|
||||
...JSON.parse(contact.data ?? "{}"),
|
||||
},
|
||||
});
|
||||
|
||||
const { messageId } = await EmailService.send({
|
||||
from: {
|
||||
name: name ?? project.from ?? project.name,
|
||||
email: from ?? project.email,
|
||||
},
|
||||
reply: reply ?? from ?? project.email,
|
||||
to: [email],
|
||||
headers,
|
||||
attachments,
|
||||
content: {
|
||||
subject: enrichedSubject,
|
||||
html: EmailService.compile({
|
||||
isHtml: true,
|
||||
content: enrichedBody,
|
||||
footer: {
|
||||
unsubscribe: false,
|
||||
},
|
||||
contact: {
|
||||
id: contact.id,
|
||||
},
|
||||
project: {
|
||||
name: project.name,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const createdEmail = await prisma.email.create({
|
||||
data: {
|
||||
messageId,
|
||||
subject,
|
||||
body: enrichedBody,
|
||||
contactId: contact.id,
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
emails.push({
|
||||
contact: { id: contact.id, email: contact.email },
|
||||
email: createdEmail.id,
|
||||
});
|
||||
}
|
||||
|
||||
redis.del(Keys.Project.emails(project.id));
|
||||
redis.del(Keys.Project.emails(project.id, { count: true }));
|
||||
|
||||
signale.success(`${project.name} sent a transactional email to ${to.join(", ")}`);
|
||||
|
||||
return res.status(200).json({ success: true, emails, timestamp: dayjs().toISOString() });
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import {PrismaClient} from '@prisma/client';
|
||||
import signale from 'signale';
|
||||
|
||||
let prisma: PrismaClient;
|
||||
try {
|
||||
prisma = new PrismaClient();
|
||||
signale.info('Prisma initialized');
|
||||
} catch (error) {
|
||||
signale.error('Failed to initialize Prisma: ', error);
|
||||
}
|
||||
export {prisma};
|
||||
@@ -1,34 +0,0 @@
|
||||
export class HttpException extends Error {
|
||||
public constructor(
|
||||
public readonly code: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFound extends HttpException {
|
||||
/**
|
||||
* Construct a new NotFound exception
|
||||
* @param resource The type of resource that was not found
|
||||
*/
|
||||
public constructor(resource: string) {
|
||||
super(404, `That ${resource.toLowerCase()} was not found`);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotAllowed extends HttpException {
|
||||
/**
|
||||
* Construct a new NotAllowed exception
|
||||
* @param msg
|
||||
*/
|
||||
public constructor(msg = 'You are not allowed to perform this action') {
|
||||
super(403, msg);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotAuthenticated extends HttpException {
|
||||
public constructor() {
|
||||
super(401, 'You need to be authenticated to do this');
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import jsonwebtoken from "jsonwebtoken";
|
||||
import { JWT_SECRET } from "../app/constants";
|
||||
import { HttpException, NotAuthenticated } from "../exceptions";
|
||||
|
||||
export interface IJwt {
|
||||
type: "jwt";
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface ISecret {
|
||||
type: "secret";
|
||||
sk: string;
|
||||
}
|
||||
|
||||
export interface IKey {
|
||||
type: "key";
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to check if this unsubscribe is authenticated on the dashboard
|
||||
* @param req
|
||||
* @param res
|
||||
* @param next
|
||||
*/
|
||||
export const isAuthenticated = (req: Request, res: Response, next: NextFunction) => {
|
||||
res.locals.auth = { type: "jwt", userId: parseJwt(req) };
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to check if this request is signed with an API secret key
|
||||
* @param req
|
||||
* @param res
|
||||
* @param next
|
||||
*/
|
||||
export const isValidSecretKey = (req: Request, res: Response, next: NextFunction) => {
|
||||
res.locals.auth = { type: "secret", sk: parseBearer(req, "secret") };
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export const isValidKey = (req: Request, res: Response, next: NextFunction) => {
|
||||
res.locals.auth = { type: "key", key: parseBearer(req) };
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
export const jwt = {
|
||||
/**
|
||||
* Extracts a unsubscribe id from a jwt
|
||||
* @param token The JWT token
|
||||
*/
|
||||
verify(token: string): string | null {
|
||||
try {
|
||||
const verified = jsonwebtoken.verify(token, JWT_SECRET) as {
|
||||
id: string;
|
||||
};
|
||||
return verified.id;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Signs a JWT token
|
||||
* @param id The unsubscribe's ID to sign into a jwt token
|
||||
*/
|
||||
sign(id: string): string {
|
||||
return jsonwebtoken.sign({ id }, JWT_SECRET, {
|
||||
expiresIn: "168h",
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Find out when a JWT expires
|
||||
* @param token The unsubscribe's jwt token
|
||||
*/
|
||||
expires(token: string): dayjs.Dayjs {
|
||||
const { exp } = jsonwebtoken.verify(token, JWT_SECRET) as {
|
||||
exp?: number;
|
||||
};
|
||||
return dayjs(exp);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a unsubscribe's ID from the request JWT token
|
||||
* @param request The express request object
|
||||
*/
|
||||
export function parseJwt(request: Request): string {
|
||||
const token: string | undefined = request.cookies.token;
|
||||
|
||||
if (!token) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
const id = jwt.verify(token);
|
||||
|
||||
if (!id) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a bearer token from the request headers
|
||||
* @param request The express request object
|
||||
* @param type
|
||||
*/
|
||||
export function parseBearer(request: Request, type?: "secret" | "public"): string {
|
||||
const bearer: string | undefined = request.headers.authorization;
|
||||
|
||||
if (!bearer) {
|
||||
throw new HttpException(401, "No authorization header passed");
|
||||
}
|
||||
|
||||
if (!bearer.includes("Bearer")) {
|
||||
throw new HttpException(401, "Please add Bearer in front of your API key");
|
||||
}
|
||||
|
||||
const split = bearer.split(" ");
|
||||
|
||||
if (!(split[0] === "Bearer") || split.length > 2) {
|
||||
throw new HttpException(401, "Your authorization header is malformed. Please pass your API key as Bearer sk_...");
|
||||
}
|
||||
|
||||
if (!type && !split[1].startsWith("sk_") && !split[1].startsWith("pk_")) {
|
||||
throw new HttpException(401, "Your API key could not be parsed. API keys start with sk_ or pk_");
|
||||
}
|
||||
|
||||
if (!type) {
|
||||
return split[1];
|
||||
}
|
||||
|
||||
if (type === "secret" && split[1].startsWith("pk_")) {
|
||||
throw new HttpException(401, "You attached a public key but this route may only be accessed with a secret key");
|
||||
}
|
||||
|
||||
if (type === "secret" && !split[1].startsWith("sk_")) {
|
||||
throw new HttpException(
|
||||
401,
|
||||
"Your secret key could not be parsed. Secret keys start with sk_ and should be passed in the authorization header as Bearer sk_...",
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "public" && !split[1].startsWith("pk_")) {
|
||||
throw new HttpException(
|
||||
401,
|
||||
"Your public key could not be parsed. Public keys start with pk_ and should be passed in the authorization header as Bearer sk_...",
|
||||
);
|
||||
}
|
||||
|
||||
return split[1];
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import type { Contact, Event, Project } from "@prisma/client";
|
||||
import dayjs from "dayjs";
|
||||
import { prisma } from "../database/prisma";
|
||||
import { ContactService } from "./ContactService";
|
||||
import { EmailService } from "./EmailService";
|
||||
import { Keys } from "./keys";
|
||||
import { wrapRedis } from "./redis";
|
||||
|
||||
export class ActionService {
|
||||
/**
|
||||
* Gets an action by its ID
|
||||
* @param id
|
||||
*/
|
||||
public static id(id: string) {
|
||||
return wrapRedis(Keys.Action.id(id), async () => {
|
||||
return prisma.action.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
events: true,
|
||||
notevents: true,
|
||||
triggers: true,
|
||||
emails: true,
|
||||
template: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all actions that share an event with the action with the given ID
|
||||
* @param id
|
||||
*/
|
||||
public static related(id: string) {
|
||||
return wrapRedis(Keys.Action.related(id), async () => {
|
||||
const action = await ActionService.id(id);
|
||||
|
||||
if (!action) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return prisma.action.findMany({
|
||||
where: {
|
||||
events: { some: { id: { in: action.events.map((e) => e.id) } } },
|
||||
id: { not: action.id },
|
||||
},
|
||||
include: { events: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all actions that have an event as a trigger
|
||||
* @param eventId
|
||||
*/
|
||||
public static event(eventId: string) {
|
||||
return wrapRedis(Keys.Action.event(eventId), async () => {
|
||||
return prisma.event.findUniqueOrThrow({ where: { id: eventId } }).actions({
|
||||
include: { events: true, template: true, notevents: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a contact and an event and triggers all required actions
|
||||
* @param contact
|
||||
* @param event
|
||||
* @param project
|
||||
*/
|
||||
public static async trigger({ event, contact, project }: { event: Event; contact: Contact; project: Project }) {
|
||||
const actions = await ActionService.event(event.id);
|
||||
|
||||
const triggers = await ContactService.triggers(contact.id);
|
||||
|
||||
for (const action of actions) {
|
||||
const hasTriggeredAction = !!triggers.find((t) => t.actionId === action.id);
|
||||
|
||||
if (action.runOnce && hasTriggeredAction) {
|
||||
// User has already triggered this run once action
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action.notevents.length > 0 && action.notevents.some((e) => triggers.some((t) => t.eventId === e.id))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let triggeredEvents = triggers.filter((t) => t.eventId === event.id);
|
||||
|
||||
if (hasTriggeredAction) {
|
||||
const lastActionTrigger = triggers.filter((t) => t.contactId === contact.id && t.actionId === action.id)[0];
|
||||
|
||||
triggeredEvents = triggeredEvents.filter((e) => e.createdAt > lastActionTrigger.createdAt);
|
||||
}
|
||||
|
||||
const updatedTriggers = [...new Set(triggeredEvents.map((t) => t.eventId))];
|
||||
const requiredTriggers = action.events.map((e) => e.id);
|
||||
|
||||
if (updatedTriggers.sort().join(",") !== requiredTriggers.sort().join(",")) {
|
||||
// Not all required events have been triggered
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.trigger.create({
|
||||
data: { actionId: action.id, contactId: contact.id },
|
||||
});
|
||||
|
||||
if (!contact.subscribed && action.template.type === "MARKETING") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action.delay === 0) {
|
||||
const { subject, body } = EmailService.format({
|
||||
subject: action.template.subject,
|
||||
body: action.template.body,
|
||||
data: {
|
||||
plunk_id: contact.id,
|
||||
plunk_email: contact.email,
|
||||
...JSON.parse(contact.data ?? "{}"),
|
||||
},
|
||||
});
|
||||
|
||||
const { messageId } = await EmailService.send({
|
||||
from: {
|
||||
name: action.template.from ?? project.from ?? project.name,
|
||||
email: project.verified && project.email ? action.template.email ?? project.email : "[email protected]",
|
||||
},
|
||||
to: [contact.email],
|
||||
content: {
|
||||
subject,
|
||||
html: EmailService.compile({
|
||||
content: body,
|
||||
footer: {
|
||||
unsubscribe: action.template.type === "MARKETING",
|
||||
},
|
||||
contact: {
|
||||
id: contact.id,
|
||||
},
|
||||
project: {
|
||||
name: project.name,
|
||||
},
|
||||
isHtml: action.template.style === "HTML",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.email.create({
|
||||
data: {
|
||||
messageId,
|
||||
actionId: action.id,
|
||||
contactId: contact.id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.task.create({
|
||||
data: {
|
||||
actionId: action.id,
|
||||
contactId: contact.id,
|
||||
runBy: dayjs().add(action.delay, "minutes").toDate(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import {prisma} from '../database/prisma';
|
||||
import {verifyHash} from '../util/hash';
|
||||
|
||||
export class AuthService {
|
||||
public static async verifyCredentials(email: string, password: string) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await verifyHash(password, user.password);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import {Keys} from './keys';
|
||||
import {wrapRedis} from './redis';
|
||||
import {prisma} from '../database/prisma';
|
||||
|
||||
export class CampaignService {
|
||||
public static id(id: string) {
|
||||
return wrapRedis(Keys.Campaign.id(id), async () => {
|
||||
return prisma.campaign.findUnique({
|
||||
where: {id},
|
||||
include: {
|
||||
recipients: {select: {id: true}},
|
||||
emails: {select: {id: true, status: true, contact: {select: {id: true, email: true}}}},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import {Keys} from './keys';
|
||||
import {wrapRedis} from './redis';
|
||||
import {prisma} from '../database/prisma';
|
||||
|
||||
export class ContactService {
|
||||
public static id(id: string) {
|
||||
return wrapRedis(Keys.Contact.id(id), async () => {
|
||||
return prisma.contact.findUnique({
|
||||
where: {id},
|
||||
include: {triggers: {include: {event: true, action: true}}, emails: {where: {subject: {not: null}}}},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static email(projectId: string, email: string) {
|
||||
return wrapRedis(Keys.Contact.email(projectId, email), () => {
|
||||
return prisma.contact.findFirst({where: {projectId, email}});
|
||||
});
|
||||
}
|
||||
|
||||
public static async triggers(id: string) {
|
||||
return prisma.contact.findUniqueOrThrow({where: {id}}).triggers();
|
||||
}
|
||||
}
|
||||
@@ -1,562 +0,0 @@
|
||||
import mjml2html from "mjml";
|
||||
import { APP_URI, AWS_SES_CONFIGURATION_SET } from "../app/constants";
|
||||
import { ses } from "../util/ses";
|
||||
|
||||
export class EmailService {
|
||||
public static async send({
|
||||
from,
|
||||
to,
|
||||
content,
|
||||
reply,
|
||||
headers,
|
||||
attachments,
|
||||
}: {
|
||||
from: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
reply?: string;
|
||||
to: string[];
|
||||
content: {
|
||||
subject: string;
|
||||
html: string;
|
||||
};
|
||||
headers?: {
|
||||
[key: string]: string;
|
||||
} | null;
|
||||
attachments?: Array<{
|
||||
filename: string;
|
||||
content: string;
|
||||
contentType: string;
|
||||
}> | null;
|
||||
}) {
|
||||
// Check if the body contains an unsubscribe link
|
||||
const regex = /unsubscribe\/([a-f\d-]+)"/;
|
||||
const containsUnsubscribeLink = content.html.match(regex);
|
||||
|
||||
let unsubscribeLink = "";
|
||||
if (containsUnsubscribeLink?.[1]) {
|
||||
const unsubscribeId = containsUnsubscribeLink[1];
|
||||
unsubscribeLink = `List-Unsubscribe: <https://${APP_URI}/unsubscribe/${unsubscribeId}>`;
|
||||
}
|
||||
|
||||
// Generate a unique boundary for multipart messages
|
||||
const boundary = `----=_NextPart_${Math.random().toString(36).substring(2)}`;
|
||||
const mixedBoundary = attachments?.length ? `----=_MixedPart_${Math.random().toString(36).substring(2)}` : null;
|
||||
|
||||
const rawMessage = `From: ${from.name} <${from.email}>
|
||||
To: ${to.join(", ")}
|
||||
Reply-To: ${reply || from.email}
|
||||
Subject: ${content.subject}
|
||||
MIME-Version: 1.0
|
||||
${
|
||||
mixedBoundary
|
||||
? `Content-Type: multipart/mixed; boundary="${mixedBoundary}"`
|
||||
: `Content-Type: multipart/alternative; boundary="${boundary}"`
|
||||
}
|
||||
${
|
||||
headers
|
||||
? Object.entries(headers)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join("\n")
|
||||
: ""
|
||||
}
|
||||
${unsubscribeLink}
|
||||
|
||||
${mixedBoundary ? `--${mixedBoundary}\n` : ""}${
|
||||
mixedBoundary
|
||||
? `Content-Type: multipart/alternative; boundary="${boundary}"\n\n`
|
||||
: ""
|
||||
}--${boundary}
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
${EmailService.breakLongLines(content.html, 500)}
|
||||
--${boundary}--
|
||||
${
|
||||
attachments?.length
|
||||
? attachments.map(attachment => `
|
||||
--${mixedBoundary}
|
||||
Content-Type: ${attachment.contentType}
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-Disposition: attachment; filename="${attachment.filename}"
|
||||
|
||||
${EmailService.breakLongLines(attachment.content, 76, true)}
|
||||
`).join('\n')
|
||||
: ""
|
||||
}${mixedBoundary ? `\n--${mixedBoundary}--` : ""}`;
|
||||
|
||||
const response = await ses.sendRawEmail({
|
||||
Destinations: to,
|
||||
ConfigurationSetName: AWS_SES_CONFIGURATION_SET,
|
||||
RawMessage: {
|
||||
Data: new TextEncoder().encode(rawMessage),
|
||||
},
|
||||
Source: `${from.name} <${from.email}>`,
|
||||
});
|
||||
|
||||
if (!response.MessageId) {
|
||||
throw new Error("Could not send email");
|
||||
}
|
||||
|
||||
return { messageId: response.MessageId };
|
||||
}
|
||||
|
||||
public static compile({
|
||||
content,
|
||||
footer,
|
||||
contact,
|
||||
project,
|
||||
isHtml,
|
||||
}: {
|
||||
content: string;
|
||||
|
||||
project: {
|
||||
name: string;
|
||||
};
|
||||
contact: {
|
||||
id: string;
|
||||
};
|
||||
footer: {
|
||||
unsubscribe?: boolean;
|
||||
};
|
||||
isHtml?: boolean;
|
||||
}) {
|
||||
const html = content.replace(/<img/g, "<img");
|
||||
|
||||
if (isHtml) {
|
||||
return `${html}
|
||||
|
||||
${
|
||||
footer.unsubscribe
|
||||
? ` <table align="center" width="100%" style="max-width: 480px; width: 100%; margin-left: auto; margin-right: auto; font-family: Inter, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; border: 0; cellpadding: 0; cellspacing: 0;" role="presentation">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<hr style="border: none; border-top: 1px solid #eaeaea; width: 100%; margin-top: 12px; margin-bottom: 12px;">
|
||||
<p style="font-size: 12px; line-height: 24px; margin: 16px 0; text-align: center; color: rgb(64, 64, 64);">
|
||||
You received this email because you agreed to receive emails from ${project.name}. If you no longer wish to receive emails like this, please
|
||||
<a href="${APP_URI.startsWith("https://") ? APP_URI : `https://${APP_URI}`}/unsubscribe/${contact.id}">update your preferences</a>.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>`
|
||||
: ""
|
||||
}`;
|
||||
}
|
||||
return mjml2html(
|
||||
`<mjml>
|
||||
<mj-head>
|
||||
<mj-font name="Inter" href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap" />
|
||||
<mj-style inline="inline">
|
||||
.prose {
|
||||
color: #4a5568;
|
||||
max-width: 600px;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
|
||||
}
|
||||
|
||||
.prose [class~="lead"] {
|
||||
color: #4a5568;
|
||||
font-size: 20px;
|
||||
line-height: 32px;
|
||||
margin-top: 19px;
|
||||
margin-bottom: 19px;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
color: #1a202c;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.prose strong {
|
||||
color: #1a202c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose ol {
|
||||
counter-reset: list-counter;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.prose ol > li {
|
||||
position: relative;
|
||||
counter-increment: list-counter;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.prose ol > li::before {
|
||||
content: counter(list-counter) ".";
|
||||
position: absolute;
|
||||
font-weight: 400;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.prose ul > li {
|
||||
position: relative;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.prose ul > li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: #cbd5e0;
|
||||
border-radius: 50%;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
top: 11px;
|
||||
left: 4px;
|
||||
}
|
||||
|
||||
.prose hr {
|
||||
border-color: #e2e8f0;
|
||||
border-top-width: 1px;
|
||||
margin-top: 42px;
|
||||
margin-bottom: 42px;
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
color: #1a202c;
|
||||
border-left: 4px solid #e2e8f0;
|
||||
quotes: initial;
|
||||
margin-top: 25px;
|
||||
margin-bottom: 25px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
color: #1a202c;
|
||||
font-weight: 800;
|
||||
font-size: 36px;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 14px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
color: #1a202c;
|
||||
font-weight: 700;
|
||||
font-size: 24px;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 16px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.prose h3 {
|
||||
color: #1a202c;
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
margin-top: 25px;
|
||||
margin-bottom: 9.6px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.prose h4 {
|
||||
color: #1a202c;
|
||||
font-weight: 600;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.prose figure figcaption {
|
||||
color: #718096;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
color: #1a202c;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.prose code::before {
|
||||
content: "\`";
|
||||
}
|
||||
|
||||
.prose code::after {
|
||||
content: "\`";
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
color: #e2e8f0;
|
||||
background-color: #2d3748;
|
||||
overflow-x: auto;
|
||||
font-size: 14px;
|
||||
line-height: 1.7142857;
|
||||
margin-top: 27px;
|
||||
margin-bottom: 27px;
|
||||
border-radius: 6px;
|
||||
padding-top: 13px;
|
||||
padding-right: 18px;
|
||||
padding-bottom: 13px;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.prose pre code {
|
||||
background-color: transparent;
|
||||
border-width: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
font-weight: 400;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.prose pre code::before {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.prose pre code::after {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.prose table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
font-size: 11px;
|
||||
line-height: 1.7142857;
|
||||
}
|
||||
|
||||
.prose thead {
|
||||
color: #1a202c;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #cbd5e0;
|
||||
}
|
||||
|
||||
.prose thead th {
|
||||
vertical-align: bottom;
|
||||
padding-right: 9px;
|
||||
padding-bottom: 9px;
|
||||
padding-left: 9px;
|
||||
}
|
||||
|
||||
.prose tbody tr {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.prose tbody tr:last-child {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
|
||||
.prose tbody td {
|
||||
vertical-align: top;
|
||||
padding-top: 9px;
|
||||
padding-right: 9px;
|
||||
padding-bottom: 9px;
|
||||
padding-left: 9px;
|
||||
}
|
||||
|
||||
.prose {
|
||||
font-size: 16px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.prose video {
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.prose figure {
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.prose figure > * {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose h2 code {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.prose h3 code {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.prose ul {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.prose ol > li:before {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.prose > ul > li p {
|
||||
margin-top: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.prose > ul > li > *:first-child {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.prose > ul > li > *:last-child {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.prose > ol > li > *:first-child {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.prose > ol > li > *:last-child {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.prose ul ul,
|
||||
.prose ul ol,
|
||||
.prose ol ul,
|
||||
.prose ol ol {
|
||||
margin-top: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.prose hr + * {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose h2 + * {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose h3 + * {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose h4 + * {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose thead th:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.prose thead th:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.prose tbody td:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.prose tbody td:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.prose > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.prose > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</mj-style>
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
<mj-raw>
|
||||
<tr class="prose prose-neutral">
|
||||
<td style="padding:10px 25px;word-break:break-word">
|
||||
${html}
|
||||
</td>
|
||||
</tr>
|
||||
</mj-raw>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section>
|
||||
<mj-column>
|
||||
${
|
||||
footer.unsubscribe
|
||||
? `
|
||||
<mj-divider border-width="2px" border-color="#f5f5f5"></mj-divider>
|
||||
<mj-text align="center">
|
||||
<p style="color: #a3a3a3; text-decoration: none; font-size: 12px; line-height: 1.7142857;">
|
||||
You received this email because you agreed to receive emails from ${project.name}. If you no longer wish to receive emails like this, please <a style="text-decoration: underline" href="${APP_URI.startsWith("https://") ? APP_URI : `https://${APP_URI}`}/unsubscribe/${contact.id}" target="_blank">update your preferences</a>.
|
||||
</p>
|
||||
</mj-text>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>`,
|
||||
).html.replace(/^\s+|\s+$/g, "");
|
||||
}
|
||||
|
||||
public static format({ subject, body, data }: { subject: string; body: string; data: Record<string, string> }) {
|
||||
return {
|
||||
subject: subject.replace(/\{\{(.*?)}}/g, (match, key) => {
|
||||
const [mainKey, defaultValue] = key.split("??").map((s: string) => s.trim());
|
||||
return data[mainKey] ?? defaultValue ?? "";
|
||||
}),
|
||||
body: body.replace(/\{\{(.*?)}}/g, (match, key) => {
|
||||
const [mainKey, defaultValue] = key.split("??").map((s: string) => s.trim());
|
||||
if (Array.isArray(data[mainKey])) {
|
||||
return data[mainKey].map((e: string) => `<li>${e}</li>`).join("\n");
|
||||
}
|
||||
return data[mainKey] ?? defaultValue ?? "";
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private static breakLongLines(input: string, maxLineLength: number, isBase64: boolean = false): string {
|
||||
if (isBase64) {
|
||||
// For base64 content, break at exact intervals without looking for spaces
|
||||
const result = [];
|
||||
for (let i = 0; i < input.length; i += maxLineLength) {
|
||||
result.push(input.substring(i, i + maxLineLength));
|
||||
}
|
||||
return result.join("\n");
|
||||
} else {
|
||||
// Original implementation for text content
|
||||
const lines = input.split("\n");
|
||||
const result = [];
|
||||
for (let line of lines) {
|
||||
while (line.length > maxLineLength) {
|
||||
let pos = maxLineLength;
|
||||
while (pos > 0 && line[pos] !== " ") {
|
||||
pos--;
|
||||
}
|
||||
if (pos === 0) {
|
||||
pos = maxLineLength;
|
||||
}
|
||||
result.push(line.substring(0, pos));
|
||||
line = line.substring(pos).trim();
|
||||
}
|
||||
result.push(line);
|
||||
}
|
||||
return result.join("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import {prisma} from '../database/prisma';
|
||||
import {REDIS_ONE_MINUTE, wrapRedis} from './redis';
|
||||
import {Keys} from './keys';
|
||||
|
||||
export class EventService {
|
||||
public static id(id: string) {
|
||||
return wrapRedis(
|
||||
Keys.Event.id(id),
|
||||
() => {
|
||||
return prisma.event.findUnique({where: {id}});
|
||||
},
|
||||
REDIS_ONE_MINUTE * 1440,
|
||||
);
|
||||
}
|
||||
|
||||
public static event(projectId: string, name: string) {
|
||||
return wrapRedis(
|
||||
Keys.Event.event(projectId, name),
|
||||
() => {
|
||||
return prisma.event.findFirst({where: {projectId, name}});
|
||||
},
|
||||
REDIS_ONE_MINUTE * 1440,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { Role } from "@prisma/client";
|
||||
import { NODE_ENV } from "../app/constants";
|
||||
import { prisma } from "../database/prisma";
|
||||
import { Keys } from "./keys";
|
||||
import { redis, wrapRedis } from "./redis";
|
||||
|
||||
export class MembershipService {
|
||||
public static async isMember(projectId: string, userId: string) {
|
||||
return wrapRedis(Keys.ProjectMembership.isMember(projectId, userId), async () => {
|
||||
if (NODE_ENV === "development") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const membership = await prisma.projectMembership.findFirst({
|
||||
where: { projectId, userId },
|
||||
});
|
||||
|
||||
return !!membership;
|
||||
});
|
||||
}
|
||||
|
||||
public static async isAdmin(projectId: string, userId: string) {
|
||||
return wrapRedis(Keys.ProjectMembership.isAdmin(projectId, userId), async () => {
|
||||
if (NODE_ENV === "development") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const membership = await prisma.projectMembership.findFirst({
|
||||
where: { projectId, userId, role: { in: ["ADMIN", "OWNER"] } },
|
||||
});
|
||||
|
||||
return !!membership;
|
||||
});
|
||||
}
|
||||
|
||||
public static async isOwner(projectId: string, userId: string) {
|
||||
return wrapRedis(Keys.ProjectMembership.isOwner(projectId, userId), async () => {
|
||||
if (NODE_ENV === "development") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const membership = await prisma.projectMembership.findFirst({
|
||||
where: { projectId, userId, role: "OWNER" },
|
||||
});
|
||||
|
||||
return !!membership;
|
||||
});
|
||||
}
|
||||
|
||||
public static async kick(projectId: string, userId: string) {
|
||||
await prisma.projectMembership.delete({
|
||||
where: { userId_projectId: { projectId, userId } },
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.memberships(projectId));
|
||||
await redis.del(Keys.User.projects(userId));
|
||||
}
|
||||
|
||||
public static async invite(projectId: string, userId: string, role: Role) {
|
||||
await prisma.projectMembership.create({
|
||||
data: { projectId, userId, role },
|
||||
});
|
||||
|
||||
await redis.del(Keys.Project.memberships(projectId));
|
||||
await redis.del(Keys.User.projects(userId));
|
||||
}
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import { prisma } from "../database/prisma";
|
||||
import { Keys } from "./keys";
|
||||
import { wrapRedis } from "./redis";
|
||||
|
||||
export class ProjectService {
|
||||
public static contacts = {
|
||||
get: (id: string) => {
|
||||
return wrapRedis(Keys.Project.contacts(id), async () => {
|
||||
return prisma.project.findUnique({ where: { id } }).contacts({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
subscribed: true,
|
||||
createdAt: true,
|
||||
data: true,
|
||||
updatedAt: true,
|
||||
triggers: { select: { createdAt: true, eventId: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
paginated: (id: string, page: number) => {
|
||||
return wrapRedis(Keys.Project.contacts(id, { page }), async () => {
|
||||
return prisma.project.findUnique({ where: { id } }).contacts({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
subscribed: true,
|
||||
createdAt: true,
|
||||
triggers: { select: { createdAt: true } },
|
||||
emails: { select: { createdAt: true } },
|
||||
},
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
take: 100,
|
||||
skip: (page - 1) * 100,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
count: (id: string) => {
|
||||
return wrapRedis(Keys.Project.contacts(id, { count: true }), async () => {
|
||||
return prisma.contact.count({ where: { projectId: id } });
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
public static emails = {
|
||||
get: (id: string) => {
|
||||
return wrapRedis(Keys.Project.emails(id), async () => {
|
||||
return prisma.email.findMany({
|
||||
where: {
|
||||
OR: [{ action: { projectId: id } }, { campaign: { projectId: id } }, { projectId: id }],
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
},
|
||||
count: (id: string) => {
|
||||
return wrapRedis(Keys.Project.emails(id, { count: true }), async () => {
|
||||
return prisma.email.count({ where: { contact: { projectId: id } } });
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
public static id(id: string) {
|
||||
return wrapRedis(Keys.Project.id(id), async () => {
|
||||
return prisma.project.findUnique({ where: { id } });
|
||||
});
|
||||
}
|
||||
|
||||
public static key(key: string) {
|
||||
if (key.startsWith("sk_")) {
|
||||
return ProjectService.secret(key);
|
||||
}
|
||||
return ProjectService.public(key);
|
||||
}
|
||||
|
||||
public static secret(secretKey: string) {
|
||||
return wrapRedis(Keys.Project.secret(secretKey), () => {
|
||||
return prisma.project.findUnique({ where: { secret: secretKey } });
|
||||
});
|
||||
}
|
||||
|
||||
public static public(publicKey: string) {
|
||||
return wrapRedis(Keys.Project.public(publicKey), () => {
|
||||
return prisma.project.findUnique({ where: { public: publicKey } });
|
||||
});
|
||||
}
|
||||
|
||||
public static async secretIsAvailable(secretKey: string) {
|
||||
const project = await ProjectService.secret(secretKey);
|
||||
|
||||
return !project;
|
||||
}
|
||||
|
||||
public static async publicIsAvailable(publicKey: string) {
|
||||
const project = await ProjectService.public(publicKey);
|
||||
|
||||
return !project;
|
||||
}
|
||||
|
||||
public static memberships(id: string) {
|
||||
return wrapRedis(Keys.Project.memberships(id), async () => {
|
||||
const memberships = await prisma.project.findUnique({ where: { id } }).memberships({ include: { user: true } });
|
||||
|
||||
if (!memberships) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return memberships.map((membership) => {
|
||||
return {
|
||||
userId: membership.userId,
|
||||
email: membership.user.email,
|
||||
role: membership.role,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static metadata(id: string) {
|
||||
return wrapRedis(Keys.Project.metadata(id), async () => {
|
||||
const contacts = await prisma.project.findUnique({ where: { id } }).contacts({
|
||||
where: {
|
||||
data: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
distinct: ["data"],
|
||||
select: {
|
||||
data: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!contacts) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...new Set(contacts.filter((c) => c.data).flatMap((c) => Object.keys(JSON.parse(c.data as string))))];
|
||||
});
|
||||
}
|
||||
|
||||
public static async feed(id: string, page: number) {
|
||||
const itemsPerPage = 10;
|
||||
const skip = (page - 1) * itemsPerPage;
|
||||
|
||||
const [triggers, emails] = await Promise.all([
|
||||
prisma.trigger.findMany({
|
||||
where: {
|
||||
contact: {
|
||||
projectId: id
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
event: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
take: itemsPerPage,
|
||||
skip,
|
||||
}),
|
||||
prisma.email.findMany({
|
||||
where: {
|
||||
contact: {
|
||||
projectId: id
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
messageId: true,
|
||||
status: true,
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
take: itemsPerPage,
|
||||
skip,
|
||||
}),
|
||||
]);
|
||||
|
||||
const combined = [...triggers, ...emails];
|
||||
combined.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
return combined.slice(0, itemsPerPage);
|
||||
}
|
||||
|
||||
public static usage(id: string) {
|
||||
return wrapRedis(Keys.Project.usage(id), async () => {
|
||||
const transactional = await prisma.email.count({
|
||||
where: {
|
||||
projectId: id,
|
||||
createdAt: {
|
||||
gte: new Date(dayjs().startOf("month").toISOString()),
|
||||
lte: new Date(dayjs().endOf("month").toISOString()),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const automation = await prisma.email.count({
|
||||
where: {
|
||||
action: { projectId: id },
|
||||
createdAt: {
|
||||
gte: new Date(dayjs().startOf("month").toISOString()),
|
||||
lte: new Date(dayjs().endOf("month").toISOString()),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const campaign = await prisma.email.count({
|
||||
where: {
|
||||
campaign: { projectId: id },
|
||||
createdAt: {
|
||||
gte: new Date(dayjs().startOf("month").toISOString()),
|
||||
lte: new Date(dayjs().endOf("month").toISOString()),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
transactional,
|
||||
automation,
|
||||
campaign,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public static events(id: string, triggers: boolean) {
|
||||
return wrapRedis(Keys.Project.events(id, triggers), async () => {
|
||||
if (triggers) {
|
||||
return prisma.project.findUnique({ where: { id } }).events({
|
||||
include: {
|
||||
triggers: {
|
||||
select: { id: true, createdAt: true, contactId: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
return prisma.project.findUnique({ where: { id } }).events({
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static actions(id: string) {
|
||||
return wrapRedis(Keys.Project.actions(id), async () => {
|
||||
return prisma.project.findUnique({ where: { id } }).actions({
|
||||
include: {
|
||||
triggers: { select: { id: true } },
|
||||
template: true,
|
||||
emails: { select: { id: true, status: true } },
|
||||
tasks: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static templates(id: string) {
|
||||
return wrapRedis(Keys.Project.templates(id), async () => {
|
||||
return prisma.project.findUnique({ where: { id } }).templates({
|
||||
include: { actions: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static campaigns(id: string) {
|
||||
return wrapRedis(Keys.Project.campaigns(id), async () => {
|
||||
return prisma.project.findUnique({ where: { id } }).campaigns({
|
||||
include: {
|
||||
emails: { select: { id: true, status: true } },
|
||||
tasks: { select: { id: true } },
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static analytics(params: {
|
||||
id: string;
|
||||
method?: "week" | "month" | "year";
|
||||
}) {
|
||||
return wrapRedis(Keys.Project.analytics(params.id), async () => {
|
||||
const methods = {
|
||||
week: {
|
||||
daysBack: 7,
|
||||
method: "week",
|
||||
},
|
||||
month: {
|
||||
daysBack: 30,
|
||||
method: "day",
|
||||
},
|
||||
year: {
|
||||
daysBack: 365,
|
||||
method: "month",
|
||||
},
|
||||
};
|
||||
|
||||
const end = dayjs().toDate();
|
||||
const start = dayjs()
|
||||
.subtract(methods[params.method ?? "week"].daysBack, "days")
|
||||
.toDate();
|
||||
|
||||
const contacts = await prisma.$queryRaw`
|
||||
WITH date_range AS (
|
||||
SELECT generate_series(
|
||||
(SELECT DATE_TRUNC('day', MIN("createdAt")) FROM contacts),
|
||||
DATE_TRUNC('day', NOW()) + INTERVAL '1 day',
|
||||
INTERVAL '1 day'
|
||||
) AS day
|
||||
)
|
||||
|
||||
SELECT
|
||||
dr.day,
|
||||
SUM(COALESCE(ct.count, 0)) OVER (ORDER BY dr.day) as count
|
||||
FROM date_range dr
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
DATE_TRUNC('day', c."createdAt") AS day,
|
||||
COUNT(c.id) as count
|
||||
FROM contacts c
|
||||
WHERE "projectId" = ${params.id}
|
||||
GROUP BY DATE_TRUNC('day', c."createdAt")
|
||||
) ct ON dr.day = ct.day
|
||||
WHERE dr.day < DATE_TRUNC('day', NOW())
|
||||
ORDER BY dr.day DESC
|
||||
LIMIT 30;
|
||||
|
||||
`;
|
||||
|
||||
const rawActionClicks = await prisma.$queryRaw`
|
||||
SELECT clicks."link", a."name", count(clicks.id)::int FROM clicks
|
||||
JOIN emails e on clicks."emailId" = e.id
|
||||
JOIN actions a on e."actionId" = a.id
|
||||
WHERE clicks."link" NOT LIKE '%unsubscribe%' AND DATE(clicks."createdAt") BETWEEN DATE(${start}) AND DATE(${end}) AND a."projectId" = ${params.id}
|
||||
GROUP BY a."name", clicks."link"
|
||||
`;
|
||||
|
||||
const combinedRoutes = {};
|
||||
|
||||
// @ts-expect-error
|
||||
rawActionClicks.forEach((item) => {
|
||||
const url = new URL(item.link);
|
||||
const route = url.pathname;
|
||||
// @ts-expect-error
|
||||
if (combinedRoutes[route]) {
|
||||
// @ts-expect-error
|
||||
combinedRoutes[route].count += item.count;
|
||||
} else {
|
||||
// @ts-expect-error
|
||||
combinedRoutes[route] = {
|
||||
link: url.hostname + route,
|
||||
name: item.name,
|
||||
count: item.count,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const formattedActionClicks = Object.values(combinedRoutes).sort(
|
||||
// @ts-expect-error
|
||||
(a, b) => b.count - a.count,
|
||||
);
|
||||
|
||||
const subscribed = await prisma.contact.count({
|
||||
where: { subscribed: true, projectId: params.id },
|
||||
});
|
||||
const unsubscribed = await prisma.contact.count({
|
||||
where: { subscribed: false, projectId: params.id },
|
||||
});
|
||||
|
||||
const opened = await prisma.email.count({
|
||||
where: {
|
||||
contact: { projectId: params.id },
|
||||
status: "OPENED",
|
||||
},
|
||||
});
|
||||
|
||||
const openedPrev = await prisma.email.count({
|
||||
where: {
|
||||
contact: { projectId: params.id },
|
||||
status: "OPENED",
|
||||
createdAt: {
|
||||
lte: start,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const bounced = await prisma.email.count({
|
||||
where: {
|
||||
contact: { projectId: params.id },
|
||||
status: "BOUNCED",
|
||||
},
|
||||
});
|
||||
|
||||
const bouncedPrev = await prisma.email.count({
|
||||
where: {
|
||||
contact: { projectId: params.id },
|
||||
status: "BOUNCED",
|
||||
createdAt: {
|
||||
lte: start,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const complaint = await prisma.email.count({
|
||||
where: {
|
||||
contact: { projectId: params.id },
|
||||
status: "COMPLAINT",
|
||||
},
|
||||
});
|
||||
|
||||
const complaintPrev = await prisma.email.count({
|
||||
where: {
|
||||
contact: { projectId: params.id },
|
||||
status: "COMPLAINT",
|
||||
createdAt: {
|
||||
lte: start,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const total = await prisma.email.count({
|
||||
where: {
|
||||
contact: { projectId: params.id },
|
||||
},
|
||||
});
|
||||
|
||||
const totalPrev = await prisma.email.count({
|
||||
where: {
|
||||
contact: { projectId: params.id },
|
||||
createdAt: {
|
||||
lte: start,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
contacts: { timeseries: contacts, subscribed, unsubscribed },
|
||||
emails: {
|
||||
total,
|
||||
opened,
|
||||
bounced,
|
||||
complaint,
|
||||
totalPrev,
|
||||
bouncedPrev,
|
||||
complaintPrev,
|
||||
openedPrev,
|
||||
},
|
||||
clicks: {
|
||||
actions: formattedActionClicks,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import {wrapRedis} from './redis';
|
||||
import {prisma} from '../database/prisma';
|
||||
import {Keys} from './keys';
|
||||
|
||||
export class TemplateService {
|
||||
public static id(id: string) {
|
||||
return wrapRedis(Keys.Template.id(id), async () => {
|
||||
return prisma.template.findUnique({where: {id}, include: {actions: true}});
|
||||
});
|
||||
}
|
||||
|
||||
public static actions(templateId: string) {
|
||||
return wrapRedis(Keys.Template.actions(templateId), async () => {
|
||||
return prisma.template.findUnique({where: {id: templateId}}).actions();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import { NODE_ENV } from "../app/constants";
|
||||
import { prisma } from "../database/prisma";
|
||||
import { Keys } from "./keys";
|
||||
import { wrapRedis } from "./redis";
|
||||
|
||||
export class UserService {
|
||||
public static readonly COOKIE_NAME = "token";
|
||||
|
||||
public static id(id: string) {
|
||||
return wrapRedis(Keys.User.id(id), () => {
|
||||
return prisma.user.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static email(email: string) {
|
||||
return wrapRedis(Keys.User.email(email), () => {
|
||||
return prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static async projects(id: string) {
|
||||
return wrapRedis(Keys.User.projects(id), async () => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: { memberships: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return prisma.project.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: user.memberships.map((project) => project.projectId),
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates cookie options
|
||||
* @param expires An optional expiry for this cookie (useful for a logout)
|
||||
*/
|
||||
public static cookieOptions(expires?: Date) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
expires: expires ?? dayjs().add(168, "hours").toDate(),
|
||||
secure: NODE_ENV !== "development",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
export const Keys = {
|
||||
User: {
|
||||
id(id: string): string {
|
||||
return `account:id:${id}`;
|
||||
},
|
||||
email(email: string): string {
|
||||
return `account:${email}`;
|
||||
},
|
||||
projects(id: string): string {
|
||||
return `account:${id}:projects`;
|
||||
},
|
||||
},
|
||||
Project: {
|
||||
id(id: string): string {
|
||||
return `project:id:${id}`;
|
||||
},
|
||||
secret(secretKey: string): string {
|
||||
return `project:secret:${secretKey}`;
|
||||
},
|
||||
public(publicKey: string): string {
|
||||
return `project:public:${publicKey}`;
|
||||
},
|
||||
memberships(id: string): string {
|
||||
return `project:${id}:memberships`;
|
||||
},
|
||||
usage(id: string): string {
|
||||
return `project:${id}:usage`;
|
||||
},
|
||||
events(id: string, triggers: boolean): string {
|
||||
if (triggers) {
|
||||
return `project:${id}:events:triggers`;
|
||||
}
|
||||
|
||||
return `project:${id}:events`;
|
||||
},
|
||||
metadata(id: string): string {
|
||||
return `project:${id}:metadata`;
|
||||
},
|
||||
actions(id: string): string {
|
||||
return `project:${id}:actions`;
|
||||
},
|
||||
templates(id: string): string {
|
||||
return `project:${id}:templates`;
|
||||
},
|
||||
feed(id: string): string {
|
||||
return `project:${id}:feed`;
|
||||
},
|
||||
contacts(
|
||||
id: string,
|
||||
options?: {
|
||||
page?: number;
|
||||
count?: boolean;
|
||||
},
|
||||
): string {
|
||||
if (options?.count) {
|
||||
return `project:${id}:contacts:count`;
|
||||
}
|
||||
|
||||
if (options?.page) {
|
||||
return `project:${id}:contacts:page:${options.page}`;
|
||||
}
|
||||
|
||||
return `project:${id}:contacts`;
|
||||
},
|
||||
campaigns(id: string): string {
|
||||
return `project:${id}:campaigns`;
|
||||
},
|
||||
analytics(id: string): string {
|
||||
return `project:${id}:analytics`;
|
||||
},
|
||||
emails(
|
||||
id: string,
|
||||
options?: {
|
||||
count?: boolean;
|
||||
},
|
||||
): string {
|
||||
if (options?.count) {
|
||||
return `project:${id}:emails:count`;
|
||||
}
|
||||
|
||||
return `project:${id}:emails`;
|
||||
},
|
||||
},
|
||||
ProjectMembership: {
|
||||
isMember(projectId: string, accountId: string) {
|
||||
return `project:id:${projectId}:ismember:${accountId}`;
|
||||
},
|
||||
isAdmin(projectId: string, accountId: string) {
|
||||
return `project:id:${projectId}:isadmin:${accountId}`;
|
||||
},
|
||||
isOwner(projectId: string, accountId: string) {
|
||||
return `project:id:${projectId}:isowner:${accountId}`;
|
||||
},
|
||||
},
|
||||
Campaign: {
|
||||
id(id: string): string {
|
||||
return `campaign:id:${id}`;
|
||||
},
|
||||
},
|
||||
Template: {
|
||||
id(id: string): string {
|
||||
return `template:id:${id}`;
|
||||
},
|
||||
actions(templateId: string): string {
|
||||
return `template:id:${templateId}:actions`;
|
||||
},
|
||||
},
|
||||
Webhook: {
|
||||
id(id: string): string {
|
||||
return `webhook:id:${id}`;
|
||||
},
|
||||
},
|
||||
Contact: {
|
||||
id(id: string): string {
|
||||
return `contact:id:${id}`;
|
||||
},
|
||||
email(projectId: string, email: string): string {
|
||||
return `project:id:${projectId}:contact:email:${email}`;
|
||||
},
|
||||
},
|
||||
Action: {
|
||||
id(id: string): string {
|
||||
return `action:id:${id}`;
|
||||
},
|
||||
related(id: string): string {
|
||||
return `action:id:${id}:related`;
|
||||
},
|
||||
event(eventId: string): string {
|
||||
return `action:event:id:${eventId}`;
|
||||
},
|
||||
},
|
||||
Event: {
|
||||
id(id: string): string {
|
||||
return `event:id:${id}`;
|
||||
},
|
||||
event(projectId: string, name: string): string {
|
||||
return `project:id:${projectId}:event:name:${name}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
import Redis from 'ioredis';
|
||||
import {REDIS_URL} from '../app/constants';
|
||||
import signale from "signale";
|
||||
|
||||
let redis: Redis;
|
||||
try {
|
||||
redis = new Redis(REDIS_URL);
|
||||
const infoString = redis.info();
|
||||
signale.info('Redis initialized: ', infoString);
|
||||
} catch (error) {
|
||||
signale.error('Failed to initialize Redis: ', error);
|
||||
}
|
||||
export {redis};
|
||||
|
||||
export const REDIS_ONE_MINUTE = 60;
|
||||
export const REDIS_DEFAULT_EXPIRY = REDIS_ONE_MINUTE / 60;
|
||||
|
||||
/**
|
||||
* @param key The key for redis (use Keys#<type>)
|
||||
* @param fn The function to return a resource. Can be a promise
|
||||
* @param seconds The amount of seconds to hold this resource in redis for. Defaults to 60
|
||||
*/
|
||||
export async function wrapRedis<T>(key: string, fn: () => Promise<T>, seconds = REDIS_DEFAULT_EXPIRY): Promise<T> {
|
||||
const cached = await redis.get(key);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
const recent = await fn();
|
||||
|
||||
if (recent) {
|
||||
await redis.set(key, JSON.stringify(recent), 'EX', seconds);
|
||||
}
|
||||
|
||||
return recent;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import bcrypt from "bcrypt";
|
||||
|
||||
/**
|
||||
* Verifies a hash against a password
|
||||
* @param {string} pass The password
|
||||
* @param {string} hash The hash
|
||||
*/
|
||||
export const verifyHash = (pass: string, hash: string) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
void bcrypt.compare(pass, hash, (err, res) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
return resolve(res);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a hash from plain text
|
||||
* @param {string} pass The password
|
||||
* @returns {Promise<string>} Password hash
|
||||
*/
|
||||
export const createHash = (pass: string): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
void bcrypt.hash(pass, 10, (err, res) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve(res);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
import { SES } from "@aws-sdk/client-ses";
|
||||
import { AWS_ACCESS_KEY_ID, AWS_REGION, AWS_SECRET_ACCESS_KEY } from "../app/constants";
|
||||
|
||||
export const ses = new SES({
|
||||
apiVersion: "2010-12-01",
|
||||
region: AWS_REGION,
|
||||
credentials: {
|
||||
accessKeyId: AWS_ACCESS_KEY_ID,
|
||||
secretAccessKey: AWS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
export const getIdentities = async (identities: string[]) => {
|
||||
const res = await ses.getIdentityVerificationAttributes({
|
||||
Identities: identities.flatMap((identity) => [identity.split("@")[1]]),
|
||||
});
|
||||
|
||||
const parsedResult = Object.entries(res.VerificationAttributes ?? {});
|
||||
return parsedResult.map((obj) => {
|
||||
return { email: obj[0], status: obj[1].VerificationStatus };
|
||||
});
|
||||
};
|
||||
|
||||
export const verifyIdentity = async (email: string) => {
|
||||
const DKIM = await ses.verifyDomainDkim({
|
||||
Domain: email.includes("@") ? email.split("@")[1] : email,
|
||||
});
|
||||
|
||||
await ses.setIdentityMailFromDomain({
|
||||
Identity: email.includes("@") ? email.split("@")[1] : email,
|
||||
MailFromDomain: `plunk.${email.includes("@") ? email.split("@")[1] : email}`,
|
||||
});
|
||||
|
||||
return DKIM.DkimTokens;
|
||||
};
|
||||
|
||||
export const getIdentityVerificationAttributes = async (email: string) => {
|
||||
const attributes = await ses.getIdentityDkimAttributes({
|
||||
Identities: [email, email.split("@")[1]],
|
||||
});
|
||||
|
||||
const parsedAttributes = Object.entries(attributes.DkimAttributes ?? {});
|
||||
|
||||
return {
|
||||
email: parsedAttributes[0][0],
|
||||
tokens: parsedAttributes[0][1].DkimTokens,
|
||||
status: parsedAttributes[0][1].DkimVerificationStatus,
|
||||
};
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
/**
|
||||
* A function that generates a random 24 byte API secret
|
||||
* @param type
|
||||
*/
|
||||
export function generateToken(type: "secret" | "public") {
|
||||
return `${type === "secret" ? "sk" : "pk"}_${randomBytes(24).toString("hex")}`;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
NEXT_PUBLIC_API_URI=http://localhost:4000
|
||||
NEXT_PUBLIC_AWS_REGION=eu-west-3
|
||||
@@ -1,5 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
@@ -1,18 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
module.exports = {
|
||||
reactStrictMode: true,
|
||||
swcMinify: true,
|
||||
webpack(config) {
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
use: ["@svgr/webpack"],
|
||||
});
|
||||
|
||||
config.module.rules.push({
|
||||
test: [/src\/(components|layouts)\/index.ts/i],
|
||||
sideEffects: false,
|
||||
});
|
||||
|
||||
return config;
|
||||
},
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
{
|
||||
"name": "@plunk/dashboard",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"clean": "rimraf .next node_modules .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@plunk/shared": "1.0.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@tiptap/core": "^2.5.4",
|
||||
"@tiptap/extension-color": "^2.5.4",
|
||||
"@tiptap/extension-dropcursor": "^2.5.4",
|
||||
"@tiptap/extension-font-family": "^2.5.4",
|
||||
"@tiptap/extension-image": "^2.5.4",
|
||||
"@tiptap/extension-link": "^2.5.4",
|
||||
"@tiptap/extension-placeholder": "^2.5.4",
|
||||
"@tiptap/extension-text-align": "^2.5.4",
|
||||
"@tiptap/extension-text-style": "^2.5.4",
|
||||
"@tiptap/extension-typography": "^2.5.4",
|
||||
"@tiptap/pm": "^2.5.4",
|
||||
"@tiptap/react": "^2.5.4",
|
||||
"@tiptap/starter-kit": "^2.5.4",
|
||||
"@tiptap/suggestion": "^2.5.4",
|
||||
"@uiball/loaders": "^1.3.1",
|
||||
"classnames": "^2.5.1",
|
||||
"dayjs": "^1.11.12",
|
||||
"dompurify": "^3.2.6",
|
||||
"framer-motion": "^11.3.7",
|
||||
"jotai": "2.9.0",
|
||||
"lucide-react": "^0.408.0",
|
||||
"next": "14.2.5",
|
||||
"next-seo": "^6.5.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"prosemirror-commands": "^1.5.2",
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-schema-list": "^1.4.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"react-window": "^1.8.10",
|
||||
"recharts": "^2.12.7",
|
||||
"sharp": "^0.33.4",
|
||||
"sonner": "^1.5.0",
|
||||
"swr": "2.2.5",
|
||||
"tailwind-scrollbar": "^3.1.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.13",
|
||||
"@types/node": "20.14.11",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/react": "18.3.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-window": "^1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"typescript": "5.5.3"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/favicon/mstile-150x150.png"/>
|
||||
<TileColor>#ffffff</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
|
Before Width: | Height: | Size: 658 B |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
@@ -1,73 +0,0 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="1080.000000pt" height="1080.000000pt" viewBox="0 0 1080.000000 1080.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<metadata>
|
||||
Created by potrace 1.14, written by Peter Selinger 2001-2017
|
||||
</metadata>
|
||||
<g transform="translate(0.000000,1080.000000) scale(0.100000,-0.100000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M6218 10755 c-1 -1 -52 -6 -113 -9 -109 -6 -147 -10 -235 -21 -25 -3
|
||||
-65 -8 -90 -11 -38 -5 -115 -17 -255 -40 -16 -3 -68 -14 -115 -24 -47 -10 -96
|
||||
-21 -110 -23 -23 -5 -66 -16 -239 -64 -136 -37 -411 -134 -546 -193 -252 -109
|
||||
-535 -257 -735 -384 -59 -38 -184 -121 -195 -129 -5 -4 -59 -44 -120 -88 -266
|
||||
-197 -581 -493 -785 -739 -19 -23 -43 -52 -54 -64 -10 -11 -39 -48 -64 -81
|
||||
-25 -33 -49 -62 -52 -65 -18 -14 -260 -357 -260 -367 0 -3 -10 -20 -23 -37
|
||||
-69 -93 -387 -656 -392 -696 -1 -3 -11 -24 -23 -47 -108 -206 -293 -687 -382
|
||||
-993 -12 -41 -26 -86 -30 -100 -5 -14 -19 -63 -30 -110 -12 -47 -28 -105 -35
|
||||
-130 -8 -25 -16 -58 -19 -75 -3 -16 -14 -68 -26 -115 -11 -47 -25 -107 -30
|
||||
-135 -6 -27 -12 -61 -15 -75 -3 -14 -7 -36 -10 -50 -2 -14 -9 -50 -15 -80 -6
|
||||
-30 -13 -66 -16 -80 -2 -14 -7 -41 -10 -60 -7 -36 -9 -47 -18 -115 -3 -22 -8
|
||||
-49 -10 -61 -2 -12 -7 -45 -11 -75 -3 -30 -8 -61 -10 -69 -2 -8 -6 -37 -10
|
||||
-65 -3 -27 -7 -63 -9 -80 -2 -16 -7 -59 -10 -95 -4 -36 -9 -76 -11 -90 -2 -14
|
||||
-7 -61 -11 -105 -4 -44 -8 -93 -10 -110 -3 -38 -14 -184 -19 -255 -27 -407
|
||||
-27 -1218 0 -1455 2 -16 6 -64 10 -105 4 -41 9 -84 11 -95 2 -11 6 -46 9 -77
|
||||
3 -32 10 -83 16 -115 5 -32 12 -76 15 -98 10 -68 73 -355 100 -450 237 -845
|
||||
667 -1476 1235 -1810 46 -26 238 -121 299 -147 69 -30 263 -91 323 -103 15 -3
|
||||
39 -7 55 -10 15 -3 32 -7 39 -10 6 -2 38 -7 70 -11 32 -3 62 -8 66 -11 15 -9
|
||||
391 -12 452 -3 144 20 160 22 166 26 3 2 19 5 36 8 148 27 377 140 510 253
|
||||
203 172 308 322 457 656 18 39 94 267 102 302 2 11 13 58 25 105 11 47 22 96
|
||||
24 110 2 14 17 97 34 185 17 88 33 180 36 204 3 23 7 46 9 50 2 3 7 28 10 55
|
||||
4 27 9 52 11 56 2 3 6 24 9 45 2 22 23 141 46 265 23 124 43 236 45 250 4 27
|
||||
4 28 40 230 25 134 33 182 41 230 3 14 7 36 9 50 3 14 8 37 10 53 3 15 7 39
|
||||
10 55 3 15 12 68 20 117 9 50 20 110 25 135 5 25 11 59 14 75 3 26 14 88 30
|
||||
170 2 11 25 142 51 290 26 149 50 273 54 276 3 4 50 10 104 13 93 6 137 11
|
||||
217 22 19 3 58 7 85 10 28 2 64 6 80 9 17 3 48 7 70 10 142 21 400 73 575 116
|
||||
560 139 1036 346 1478 640 111 74 294 208 312 228 3 3 25 22 50 42 82 67 183
|
||||
163 304 290 399 418 702 991 821 1549 11 55 36 196 44 255 40 283 33 767 -14
|
||||
995 -2 14 -12 60 -20 102 -115 560 -455 1118 -898 1470 -328 261 -755 473
|
||||
-1157 573 -81 20 -249 56 -305 66 -72 12 -78 12 -155 23 -25 4 -56 8 -70 11
|
||||
-14 2 -61 7 -105 11 -44 3 -93 8 -110 11 -36 5 -672 13 -677 8z m142 -1239
|
||||
c84 -6 217 -21 275 -31 11 -1 36 -6 55 -9 72 -12 87 -16 165 -35 367 -91 649
|
||||
-262 871 -529 77 -92 74 -88 126 -172 84 -135 144 -280 187 -455 19 -77 24
|
||||
-108 38 -225 14 -112 6 -513 -11 -608 -2 -9 -7 -42 -11 -72 -8 -63 -7 -54 -34
|
||||
-185 -12 -55 -35 -145 -53 -200 -17 -55 -32 -102 -32 -105 -3 -17 -59 -142
|
||||
-109 -240 -51 -100 -148 -252 -204 -321 -300 -366 -742 -625 -1309 -767 -142
|
||||
-36 -267 -62 -359 -75 -16 -3 -46 -7 -65 -11 -106 -18 -102 -19 -96 22 14 88
|
||||
18 112 32 192 9 47 17 97 19 112 4 27 21 122 30 168 3 14 8 43 11 65 2 22 18
|
||||
119 35 215 16 96 32 189 35 205 5 33 15 89 20 120 2 11 15 88 29 170 14 83 34
|
||||
200 45 260 11 61 22 126 24 145 3 19 9 60 15 90 11 59 17 92 27 155 3 22 12
|
||||
74 20 115 8 41 17 91 19 110 3 19 9 64 14 100 10 69 7 336 -3 383 -54 239
|
||||
-237 378 -531 404 -232 20 -416 -5 -580 -81 -223 -103 -343 -243 -414 -482
|
||||
-16 -54 -85 -415 -135 -699 -15 -88 -58 -325 -76 -425 -12 -63 -23 -126 -25
|
||||
-140 -2 -14 -13 -77 -25 -140 -11 -63 -23 -130 -26 -149 -3 -18 -7 -43 -10
|
||||
-55 -4 -19 -56 -320 -68 -391 -3 -16 -12 -70 -21 -120 -9 -49 -18 -101 -20
|
||||
-115 -2 -14 -22 -135 -44 -270 -22 -135 -52 -315 -65 -400 -14 -85 -28 -166
|
||||
-30 -180 -16 -87 -69 -389 -71 -407 -2 -12 -6 -34 -9 -50 -3 -15 -8 -44 -11
|
||||
-63 -4 -19 -10 -54 -16 -78 -5 -23 -12 -59 -15 -80 -5 -32 -37 -223 -50 -292
|
||||
-2 -14 -7 -40 -10 -59 -6 -38 -11 -64 -19 -101 -3 -14 -7 -41 -9 -60 -3 -19
|
||||
-19 -114 -36 -210 -17 -96 -33 -188 -36 -205 -3 -16 -7 -37 -9 -45 -2 -8 -6
|
||||
-33 -10 -55 -3 -22 -12 -76 -20 -120 -8 -44 -17 -97 -21 -119 -3 -21 -8 -48
|
||||
-10 -60 -2 -11 -29 -163 -59 -336 -68 -390 -62 -356 -90 -475 -147 -622 -384
|
||||
-760 -719 -420 -129 131 -256 357 -354 632 -42 117 -104 320 -117 380 -2 10
|
||||
-8 38 -14 63 -5 25 -12 59 -15 75 -3 17 -8 40 -10 52 -5 24 -34 226 -41 283
|
||||
-2 19 -6 53 -9 75 -5 40 -10 107 -22 280 -18 274 -2 931 32 1290 13 146 34
|
||||
338 39 375 2 14 9 61 15 105 6 44 13 95 16 114 2 19 6 43 9 55 2 12 7 38 10
|
||||
59 5 35 50 280 59 322 2 11 12 56 21 100 69 335 236 874 359 1160 116 268 243
|
||||
523 340 684 31 51 56 95 56 97 0 8 169 256 228 334 207 274 434 510 672 700
|
||||
221 176 482 331 735 435 154 64 412 142 560 170 147 27 261 41 405 51 46 3 85
|
||||
6 87 7 4 5 335 -1 423 -7z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/favicon/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface AlertProps {
|
||||
type: 'info' | 'danger' | 'warning' | 'success';
|
||||
title: string;
|
||||
children?: string | React.ReactNode;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
info: 'bg-blue-50 text-blue-800 border-blue-300',
|
||||
danger: 'bg-red-50 text-red-800 border-red-300',
|
||||
warning: 'bg-yellow-50 text-yellow-800 border-yellow-300',
|
||||
success: 'bg-green-50 text-green-800 border-green-300',
|
||||
};
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.type
|
||||
* @param root0.title
|
||||
* @param root0.children
|
||||
*/
|
||||
export default function Alert({type = 'info', title, children}: AlertProps) {
|
||||
const classNames = ['w-full px-7 py-5 border rounded-lg'];
|
||||
classNames.push(styles[type]);
|
||||
|
||||
return (
|
||||
<div className={classNames.join(' ')}>
|
||||
<p className={'font-medium'}>{title}</p>
|
||||
<p className={'text-sm'}>{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {default as Alert} from './Alert';
|
||||
@@ -1,26 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface BadgeProps {
|
||||
type: 'info' | 'danger' | 'warning' | 'success' | 'purple';
|
||||
children: string;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
info: 'bg-blue-100 text-blue-800',
|
||||
danger: 'bg-red-100 text-red-800',
|
||||
warning: 'bg-yellow-100 text-yellow-800',
|
||||
success: 'bg-green-100 text-green-800',
|
||||
purple: 'bg-purple-100 text-purple-800',
|
||||
};
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.type
|
||||
* @param root0.children
|
||||
*/
|
||||
export default function Badge({type = 'info', children}: BadgeProps) {
|
||||
const classNames = ['inline-flex items-center px-2 py-0.5 rounded text-xs font-medium'];
|
||||
classNames.push(styles[type]);
|
||||
|
||||
return <span className={classNames.join(' ')}>{children}</span>;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {default as Badge} from './Badge';
|
||||
@@ -1,101 +0,0 @@
|
||||
import React, {MutableRefObject, useEffect, useState} from 'react';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
|
||||
export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
description?: string;
|
||||
actions?: React.ReactNode;
|
||||
options?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.title
|
||||
* @param root0.description
|
||||
* @param root0.children
|
||||
* @param root0.className
|
||||
* @param root0.actions
|
||||
* @param root0.options
|
||||
*/
|
||||
export default function Card({title, description, children, className, actions, options}: CardProps) {
|
||||
const ref = React.createRef<HTMLDivElement>();
|
||||
|
||||
const [optionsOpen, setOptionsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mutableRef = ref as MutableRefObject<HTMLDivElement | null>;
|
||||
|
||||
const handleClickOutside = (event: any) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
if (mutableRef.current && !mutableRef.current.contains(event.target) && optionsOpen) {
|
||||
setOptionsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
return (
|
||||
<div className={`rounded border border-neutral-200 bg-white px-8 py-4 ${className}`}>
|
||||
<div className={'flex items-center'}>
|
||||
<div className={'flex w-full flex-col gap-3 md:flex-row md:items-center'}>
|
||||
<div>
|
||||
<h2 className={'text-xl font-semibold leading-tight text-neutral-800'}>{title}</h2>
|
||||
<p className={'text-sm text-neutral-500'}>{description}</p>
|
||||
</div>
|
||||
<div className={'flex flex-1 gap-x-2.5 md:justify-end'}>{actions}</div>
|
||||
</div>
|
||||
|
||||
{options && (
|
||||
<div className="relative ml-3 inline-block text-left" ref={ref}>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOptionsOpen(!optionsOpen)}
|
||||
className="flex items-center rounded-full text-neutral-500 transition hover:text-neutral-800"
|
||||
id="menu-button"
|
||||
aria-expanded="true"
|
||||
aria-haspopup="true"
|
||||
>
|
||||
<span className="sr-only">Open options</span>
|
||||
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{optionsOpen && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, scale: 0.9}}
|
||||
animate={{opacity: 1, scale: 1}}
|
||||
exit={{opacity: 0, scale: 0.9}}
|
||||
transition={{duration: 0.1}}
|
||||
className="absolute right-0 z-50 mt-2 w-56 origin-top-right rounded-md bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="menu-button"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{options}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={'py-4'}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {default as Card} from './Card';
|
||||
@@ -1,118 +0,0 @@
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter';
|
||||
import React from 'react';
|
||||
|
||||
export interface CodeBlockProps {
|
||||
language: string;
|
||||
code: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param root0
|
||||
* @param root0.code
|
||||
* @param root0.language
|
||||
* @param root0.style
|
||||
*/
|
||||
export default function ({code, language, style}: CodeBlockProps) {
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
customStyle={style}
|
||||
showLineNumbers
|
||||
language={language}
|
||||
style={{
|
||||
'hljs': {
|
||||
display: 'block',
|
||||
overflowX: 'auto',
|
||||
padding: '0.5em',
|
||||
background: '#1e293b',
|
||||
color: '#f8f8f2',
|
||||
},
|
||||
'hljs-keyword': {
|
||||
color: '#8be9fd',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-selector-tag': {
|
||||
color: '#8be9fd',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-literal': {
|
||||
color: '#8be9fd',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-section': {
|
||||
color: '#8be9fd',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-link': {
|
||||
color: '#8be9fd',
|
||||
},
|
||||
'hljs-function .hljs-keyword': {
|
||||
color: '#ff79c6',
|
||||
},
|
||||
'hljs-subst': {
|
||||
color: '#f8f8f2',
|
||||
},
|
||||
'hljs-string': {
|
||||
color: '#d8b4fe',
|
||||
},
|
||||
'hljs-title': {
|
||||
color: '#c3e88d',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-name': {
|
||||
color: '#c3e88d',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-type': {
|
||||
color: '#f1fa8c',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-attribute': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-symbol': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-bullet': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-addition': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-variable': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-template-tag': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-template-variable': {
|
||||
color: '#f1fa8c',
|
||||
},
|
||||
'hljs-comment': {
|
||||
color: '#6272a4',
|
||||
},
|
||||
'hljs-quote': {
|
||||
color: '#6272a4',
|
||||
},
|
||||
'hljs-deletion': {
|
||||
color: '#6272a4',
|
||||
},
|
||||
'hljs-meta': {
|
||||
color: '#6272a4',
|
||||
},
|
||||
'hljs-doctag': {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-strong': {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
'hljs-emphasis': {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {default as CodeBlock} from './CodeBlock';
|
||||
@@ -1,191 +0,0 @@
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import React, {MutableRefObject, useEffect, useState} from 'react';
|
||||
|
||||
export interface Dropdownprops {
|
||||
withSearch?: boolean;
|
||||
inModal?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
values: {
|
||||
name: string;
|
||||
value: string;
|
||||
}[];
|
||||
selectedValue: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.onChange
|
||||
* @param root0.values
|
||||
* @param root0.selectedValue
|
||||
* @param root0.className
|
||||
* @param root0.withSearch
|
||||
* @param root0.inModal
|
||||
* @param root0.disabled
|
||||
*/
|
||||
export default function Dropdown({
|
||||
onChange,
|
||||
values,
|
||||
selectedValue,
|
||||
className,
|
||||
withSearch = false,
|
||||
inModal = false,
|
||||
disabled = false,
|
||||
}: Dropdownprops) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = React.createRef<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
const mutableRef = ref as MutableRefObject<HTMLDivElement | null>;
|
||||
|
||||
const handleClickOutside = (event: any) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
if (mutableRef.current && !mutableRef.current.contains(event.target) && open) {
|
||||
setOpen(false);
|
||||
setQuery('');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className ?? ''}>
|
||||
<div className="relative mt-1 w-full">
|
||||
<button
|
||||
type="button"
|
||||
className={`${
|
||||
disabled ? 'cursor-default bg-neutral-100' : 'cursor-pointer bg-white'
|
||||
} relative w-full rounded border border-neutral-300 py-2 pl-3 pr-10 text-left focus:border-neutral-500 focus:outline-none focus:ring-1 focus:ring-neutral-500 sm:text-sm`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="true"
|
||||
aria-labelledby="listbox-label"
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setOpen(!open);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="block flex items-center truncate">
|
||||
{values.find(v => v.value === selectedValue)
|
||||
? `${values
|
||||
.find(v => v.value === selectedValue)
|
||||
?.name.charAt(0)
|
||||
.toUpperCase()}${values
|
||||
.find(v => v.value === selectedValue)
|
||||
?.name.slice(1)
|
||||
.toLowerCase()}`
|
||||
: 'No value selected'}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<motion.svg
|
||||
initial={{rotate: '90deg'}}
|
||||
animate={open ? {rotate: '0deg'} : {rotate: '90deg'}}
|
||||
className="h-5 w-5 text-neutral-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</motion.svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.ul
|
||||
initial={{opacity: 0, height: 0}}
|
||||
animate={{opacity: 1, height: 'auto'}}
|
||||
exit={{opacity: 0, height: 0}}
|
||||
transition={{duration: 0.2, ease: 'easeInOut'}}
|
||||
className={`${
|
||||
inModal ? 'fixed w-64' : 'absolute w-full'
|
||||
} z-50 mt-1 max-h-72 rounded-md border border-black border-opacity-10 bg-white text-base shadow-lg focus:outline-none sm:text-sm`}
|
||||
tabIndex={-1}
|
||||
role="listbox"
|
||||
>
|
||||
<div className="sticky top-0 z-50 bg-white">
|
||||
{withSearch ? (
|
||||
<>
|
||||
<li className="relative cursor-default select-none px-3 py-2 text-neutral-800">
|
||||
<input
|
||||
type="search"
|
||||
name="search"
|
||||
autoComplete={'off'}
|
||||
className="block w-full rounded border-neutral-300 border-opacity-5 focus:border-neutral-800 sm:text-sm"
|
||||
placeholder={'Search'}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
/>
|
||||
</li>
|
||||
<hr />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'scrollbar-w-2 scrollbar scrollbar-thumb-rounded-full scrollbar-thumb-neutral-400 scrollbar-track-neutral-100 max-h-52 overflow-y-scroll p-1'
|
||||
}
|
||||
>
|
||||
{values.filter(value => value.name.toLowerCase().startsWith(query.toLowerCase())).length === 0 ? (
|
||||
<li className="relative cursor-default select-none py-2.5 pl-3 pr-9 text-neutral-800">
|
||||
No results found
|
||||
</li>
|
||||
) : (
|
||||
values
|
||||
.filter(value => value.name.toLowerCase().startsWith(query.toLowerCase()))
|
||||
.map((value, index) => {
|
||||
return (
|
||||
<li
|
||||
key={`x-${index}`}
|
||||
className="relative flex cursor-default select-none items-center rounded-md py-2.5 pl-2.5 text-neutral-800 transition ease-in-out hover:bg-neutral-100"
|
||||
role="option"
|
||||
onClick={() => {
|
||||
onChange(value.value);
|
||||
setQuery('');
|
||||
setOpen(!open);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">
|
||||
{value.name.charAt(0).toUpperCase() + value.name.slice(1).toLowerCase()}
|
||||
</span>
|
||||
{value.value === selectedValue ? (
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-3 text-neutral-800">
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {default as Dropdown} from './Dropdown';
|
||||
@@ -1,57 +0,0 @@
|
||||
import {FieldError, UseFormRegisterReturn} from 'react-hook-form';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import React from 'react';
|
||||
|
||||
export interface InputProps {
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
type?: 'text' | 'email' | 'password' | 'number';
|
||||
register: UseFormRegisterReturn;
|
||||
error?: FieldError;
|
||||
className?: string;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param props
|
||||
* @param props.label
|
||||
* @param props.type
|
||||
* @param props.register
|
||||
* @param props.error
|
||||
* @param props.placeholder
|
||||
* @param props.className
|
||||
*/
|
||||
export default function Input(props: InputProps) {
|
||||
return (
|
||||
<div className={props.className}>
|
||||
<label className="block text-sm font-medium text-neutral-700">{props.label}</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
autoComplete={'off'}
|
||||
type={props.type}
|
||||
min={props.type === 'number' ? props.min : undefined}
|
||||
max={props.type === 'number' ? props.max : undefined}
|
||||
className={
|
||||
'block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm'
|
||||
}
|
||||
placeholder={props.placeholder}
|
||||
{...props.register}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{props.error && (
|
||||
<motion.p
|
||||
initial={{height: 0}}
|
||||
animate={{height: 'auto'}}
|
||||
exit={{height: 0}}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{props.error.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {default as Input} from './Input';
|
||||
@@ -1,801 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import Typography from "@tiptap/extension-typography";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Modal } from "../../Overlay";
|
||||
import "tippy.js/animations/scale.css";
|
||||
import HTMLEditor from "@monaco-editor/react";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import { Dropcursor } from "@tiptap/extension-dropcursor";
|
||||
import FontFamily from "@tiptap/extension-font-family";
|
||||
import { TextAlign } from "@tiptap/extension-text-align";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import { AlignCenter, AlignLeft, AlignRight, ImageIcon, Inspect, LinkIcon } from "lucide-react";
|
||||
import { Dropdown } from "../Dropdown";
|
||||
import { Button } from "./extensions/Button";
|
||||
import { EditorBubbleMenu } from "./extensions/EditorBubbleMenu";
|
||||
import { Mention } from "./extensions/MetadataSuggestion/MetadataSuggestion";
|
||||
import suggestion from "./extensions/MetadataSuggestion/Suggestions";
|
||||
import { Progress, type colors } from "./extensions/Progress";
|
||||
import Slash from "./extensions/Slash";
|
||||
|
||||
export interface MarkdownEditorProps {
|
||||
value: string;
|
||||
mode: "PLUNK" | "HTML";
|
||||
onChange: (value: string, type: "PLUNK" | "HTML") => void;
|
||||
modeSwitcher?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param root0
|
||||
* @param root0.value
|
||||
* @param root0.onChange
|
||||
*/
|
||||
export default function Editor({ value, onChange, mode, modeSwitcher }: MarkdownEditorProps) {
|
||||
const [imageModal, setImageModal] = useState(false);
|
||||
const [urlModal, setUrlModal] = useState(false);
|
||||
const [barModal, setBarModal] = useState(false);
|
||||
const [buttonModal, setButtonModal] = useState(false);
|
||||
const [confirmModal, setConfirmModal] = useState(false);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
Slash,
|
||||
StarterKit,
|
||||
Typography,
|
||||
TextStyle,
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
// @ts-ignore
|
||||
suggestion,
|
||||
}),
|
||||
Dropcursor.configure({
|
||||
width: 3,
|
||||
color: "#e5e5e5",
|
||||
}),
|
||||
TextAlign.configure({
|
||||
alignments: ["left", "center", "right"],
|
||||
types: ["heading", "paragraph"],
|
||||
defaultAlignment: "left",
|
||||
}),
|
||||
FontFamily.configure({
|
||||
types: ["textStyle"],
|
||||
}),
|
||||
Image.configure({ allowBase64: true }),
|
||||
Placeholder.configure({
|
||||
placeholder: "Start typing or press / to use a slash command",
|
||||
includeChildren: true,
|
||||
}),
|
||||
Progress,
|
||||
Button,
|
||||
Link.configure({
|
||||
autolink: true,
|
||||
protocols: ["http", "https", "mailto"],
|
||||
}).extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Space: ({ editor }) => {
|
||||
if (editor.isActive("link")) {
|
||||
// Toggle the link and add a space
|
||||
editor.commands.toggleMark("link");
|
||||
// Add a space
|
||||
return editor.chain().focus().insertContent(" ").run();
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
Color,
|
||||
],
|
||||
content: value,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "prose font-sans my-5 focus:outline-none",
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
return event.key === "Enter" && !event.shiftKey;
|
||||
},
|
||||
paste: (view, event) => {
|
||||
const text = event.clipboardData?.getData("text");
|
||||
const urlPattern = /^(https?:\/\/)?([\w-]+\.)+[\w-]+(\/[\w- ./?%&=]*({{[\w-]+}})?)*$/;
|
||||
|
||||
if (editor && text && urlPattern.test(text)) {
|
||||
event.preventDefault();
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange("link")
|
||||
.setLink({ href: text.startsWith("http") ? text : `https://${text}`, target: "_blank" })
|
||||
.run();
|
||||
editor.commands.insertContent(text);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange(editor.getHTML(), "PLUNK");
|
||||
},
|
||||
immediatelyRender: false
|
||||
});
|
||||
|
||||
const {
|
||||
register: registerUrl,
|
||||
handleSubmit: handleSubmitUrl,
|
||||
reset: resetUrl,
|
||||
setFocus: setFocusUrl,
|
||||
formState: { errors: errorsUrl },
|
||||
} = useForm<{
|
||||
url: string;
|
||||
}>({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
url: z
|
||||
.string()
|
||||
.regex(/^(?:https?:\/\/)?(?:\{\{[\w-]+}}|(?:[\w-]+\.)+[a-z]{2,})(?:\/\S*)?(?:\?\S*)?$/)
|
||||
.transform((u) => {
|
||||
if (u.startsWith("{{") && u.endsWith("}}")) {
|
||||
return u;
|
||||
}
|
||||
|
||||
return u.startsWith("http") ? u : `https://${u}`;
|
||||
}),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const {
|
||||
register: registerBar,
|
||||
handleSubmit: handleSubmitBar,
|
||||
reset: resetBar,
|
||||
setValue: setValueBar,
|
||||
watch: watchBar,
|
||||
formState: { errors: errorsBar },
|
||||
} = useForm<{
|
||||
percent: number;
|
||||
color: colors;
|
||||
}>({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
percent: z.preprocess((a) => Number.parseInt(z.string().parse(a), 10), z.number().positive().max(100)),
|
||||
color: z.enum(["red", "yellow", "green", "blue", "indigo", "purple", "pink", "orange", "black"]).default("blue"),
|
||||
}),
|
||||
),
|
||||
defaultValues: {
|
||||
color: "blue",
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
register: registerButton,
|
||||
handleSubmit: handleSubmitButton,
|
||||
reset: resetButton,
|
||||
setValue: setValueButton,
|
||||
watch: watchButton,
|
||||
formState: { errors: errorsButton },
|
||||
} = useForm<{
|
||||
link: string;
|
||||
color: colors;
|
||||
}>({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
link: z
|
||||
.string()
|
||||
.regex(/^(?:https?:\/\/)?(?:\{\{[\w-]+}}|(?:[\w-]+\.)+[a-z]{2,})(?:\/\S*)?$/)
|
||||
.transform((u) => {
|
||||
if (u.startsWith("{{") && u.endsWith("}}")) {
|
||||
return u;
|
||||
}
|
||||
|
||||
return u.startsWith("http") ? u : `https://${u}`;
|
||||
}),
|
||||
color: z.enum(["red", "yellow", "green", "blue", "indigo", "purple", "pink", "orange", "black"]).default("blue"),
|
||||
}),
|
||||
),
|
||||
defaultValues: {
|
||||
color: "blue",
|
||||
},
|
||||
});
|
||||
|
||||
const addImage = useCallback(
|
||||
(data: { url: string }) => {
|
||||
editor?.chain().focus().setImage({ src: data.url }).run();
|
||||
setImageModal(false);
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
const addBar = useCallback(
|
||||
(data: { percent: number; color: colors }) => {
|
||||
editor?.chain().focus().setProgress({ percent: data.percent, color: data.color }).run();
|
||||
setBarModal(false);
|
||||
resetBar();
|
||||
},
|
||||
[editor, resetBar],
|
||||
);
|
||||
|
||||
const addButton = useCallback(
|
||||
(data: { link: string; color: colors }) => {
|
||||
editor?.chain().focus().setButton({ href: data.link, color: data.color }).run();
|
||||
setButtonModal(false);
|
||||
resetButton();
|
||||
},
|
||||
[editor, resetButton],
|
||||
);
|
||||
|
||||
const addUrl = useCallback(
|
||||
(data: { url: string }) => {
|
||||
editor?.chain().focus().setLink({ href: data.url, target: "_blank" }).run();
|
||||
setUrlModal(false);
|
||||
resetUrl();
|
||||
},
|
||||
[editor, resetUrl],
|
||||
);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={"Watch out!"}
|
||||
isOpen={confirmModal}
|
||||
onToggle={() => setConfirmModal(!confirmModal)}
|
||||
onAction={() => {
|
||||
if (mode === "PLUNK") {
|
||||
void onChange("", "HTML");
|
||||
} else {
|
||||
void onChange("", "PLUNK");
|
||||
}
|
||||
|
||||
editor.chain().clearContent().run();
|
||||
setConfirmModal(false);
|
||||
}}
|
||||
type={"danger"}
|
||||
>
|
||||
<div className={"flex flex-col gap-3"}>
|
||||
<p className={"text-sm text-neutral-700"}>
|
||||
Are you sure you want to switch to {mode === "PLUNK" ? "HTML" : "the Plunk Editor"}? <br /> This will clear your
|
||||
current content.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
{modeSwitcher && (
|
||||
<div className={"my-3 flex w-full gap-3 rounded-lg bg-neutral-100 p-2"}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setConfirmModal(true);
|
||||
}}
|
||||
className={`w-full flex-1 rounded p-2 text-sm font-medium ${mode === "PLUNK" ? "bg-white" : "hover:bg-neutral-50"
|
||||
} transition ease-in-out`}
|
||||
>
|
||||
Plunk Editor
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setConfirmModal(true);
|
||||
}}
|
||||
className={`w-full flex-1 rounded p-2 text-sm font-medium ${mode === "HTML" ? "bg-white" : "hover:bg-neutral-50"
|
||||
} transition ease-in-out`}
|
||||
>
|
||||
HTML
|
||||
</button>{" "}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title={"Add image"}
|
||||
description={"Enter the URL of the image you want to add."}
|
||||
isOpen={imageModal}
|
||||
onToggle={() => setImageModal(!imageModal)}
|
||||
onAction={handleSubmitUrl(addImage)}
|
||||
type={"info"}
|
||||
action={"Add"}
|
||||
icon={
|
||||
<>
|
||||
<path d="M4.75 16L7.49619 12.5067C8.2749 11.5161 9.76453 11.4837 10.5856 12.4395L13 15.25M10.915 12.823C11.9522 11.5037 13.3973 9.63455 13.4914 9.51294C13.4947 9.50859 13.4979 9.50448 13.5013 9.50017C14.2815 8.51598 15.7663 8.48581 16.5856 9.43947L19 12.25M6.75 19.25H17.25C18.3546 19.25 19.25 18.3546 19.25 17.25V6.75C19.25 5.64543 18.3546 4.75 17.25 4.75H6.75C5.64543 4.75 4.75 5.64543 4.75 6.75V17.25C4.75 18.3546 5.64543 19.25 6.75 19.25Z" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmitUrl(addImage)} className="grid gap-6 sm:grid-cols-2">
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label htmlFor={"url"} className="block text-sm font-medium text-neutral-700">
|
||||
Image URL
|
||||
</label>
|
||||
<div className="mt-1 flex rounded-md shadow-sm">
|
||||
<input
|
||||
type="text"
|
||||
autoComplete={"off"}
|
||||
className={
|
||||
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
placeholder="https://www.example.com/image.png"
|
||||
{...registerUrl("url")}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{errorsUrl.url?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errorsUrl.url.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={"Add URL"}
|
||||
description={"Copy and paste the URL"}
|
||||
isOpen={urlModal}
|
||||
onToggle={() => setUrlModal(!urlModal)}
|
||||
onAction={handleSubmitUrl(addUrl)}
|
||||
type={"info"}
|
||||
action={"Add"}
|
||||
icon={
|
||||
<>
|
||||
<path d="M16.75 13.25L18 12C19.6569 10.3431 19.6569 7.65685 18 6V6C16.3431 4.34315 13.6569 4.34315 12 6L10.75 7.25" />
|
||||
<path d="M7.25 10.75L6 12C4.34315 13.6569 4.34315 16.3431 6 18V18C7.65685 19.6569 10.3431 19.6569 12 18L13.25 16.75" />
|
||||
<path d="M14.25 9.75L9.75 14.25" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmitUrl(addUrl)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleSubmitUrl(addUrl)();
|
||||
}
|
||||
}}
|
||||
className="grid gap-6 sm:grid-cols-2"
|
||||
>
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label htmlFor={"url"} className="block text-sm font-medium text-neutral-700">
|
||||
URL
|
||||
</label>
|
||||
<div className="mt-1 flex rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center rounded-l border border-r-0 border-neutral-300 bg-neutral-50 px-3 text-neutral-500 sm:text-sm">
|
||||
https://
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete={"off"}
|
||||
className={
|
||||
"block w-full rounded-r border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
placeholder="www.example.com"
|
||||
{...registerUrl("url")}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{errorsUrl.url?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errorsUrl.url.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={"Add a progress bar"}
|
||||
description={"Show the percentage of progress with ease"}
|
||||
isOpen={barModal}
|
||||
onToggle={() => setBarModal(!barModal)}
|
||||
onAction={handleSubmitBar(addBar)}
|
||||
type={"info"}
|
||||
action={"Add"}
|
||||
icon={
|
||||
<>
|
||||
<path d="M3 12m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" />
|
||||
<path d="M9 8m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" />
|
||||
<path d="M15 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" />
|
||||
<path d="M4 20l14 0" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmitBar(addBar)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleSubmitUrl(addUrl)();
|
||||
}
|
||||
}}
|
||||
className="grid gap-6 sm:grid-cols-2"
|
||||
>
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label htmlFor={"percentage"} className="block text-sm font-medium text-neutral-700">
|
||||
Percentage
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
autoComplete={"off"}
|
||||
type={"number"}
|
||||
min={0}
|
||||
max={100}
|
||||
className={
|
||||
"block w-full rounded border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
placeholder={"20"}
|
||||
{...registerBar("percent")}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{errorsBar.percent?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errorsBar.percent.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label htmlFor={"style"} className="flex items-center text-sm font-medium text-neutral-700">
|
||||
Color
|
||||
</label>
|
||||
<Dropdown
|
||||
inModal={true}
|
||||
onChange={(t) => setValueBar("color", t as colors)}
|
||||
values={[
|
||||
{ value: "blue", name: "Blue" },
|
||||
{ value: "red", name: "Red" },
|
||||
{ value: "green", name: "Green" },
|
||||
{ value: "yellow", name: "Yellow" },
|
||||
{ value: "orange", name: "Orange" },
|
||||
{ value: "purple", name: "Purple" },
|
||||
{ value: "pink", name: "Pink" },
|
||||
{ value: "indigo", name: "Indigo" },
|
||||
]}
|
||||
selectedValue={watchBar("color")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{errorsBar.color?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errorsBar.color.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={"Add a button"}
|
||||
description={"Create a button with a link"}
|
||||
isOpen={buttonModal}
|
||||
onToggle={() => setButtonModal(!buttonModal)}
|
||||
onAction={handleSubmitButton(addButton)}
|
||||
type={"info"}
|
||||
action={"Add"}
|
||||
icon={
|
||||
<>
|
||||
<path d="M8 13v-8.5a1.5 1.5 0 0 1 3 0v7.5" />
|
||||
<path d="M11 11.5v-2a1.5 1.5 0 0 1 3 0v2.5" />
|
||||
<path d="M14 10.5a1.5 1.5 0 0 1 3 0v1.5" />
|
||||
<path d="M17 11.5a1.5 1.5 0 0 1 3 0v4.5a6 6 0 0 1 -6 6h-2h.208a6 6 0 0 1 -5.012 -2.7l-.196 -.3c-.312 -.479 -1.407 -2.388 -3.286 -5.728a1.5 1.5 0 0 1 .536 -2.022a1.867 1.867 0 0 1 2.28 .28l1.47 1.47" />
|
||||
<path d="M5 3l-1 -1" />
|
||||
<path d="M4 7h-1" />
|
||||
<path d="M14 3l1 -1" />
|
||||
<path d="M15 6h1" />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmitButton(addButton)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void handleSubmitUrl(addUrl)();
|
||||
}
|
||||
}}
|
||||
className="grid gap-6 sm:grid-cols-2"
|
||||
>
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label htmlFor={"percentage"} className="block text-sm font-medium text-neutral-700">
|
||||
Link
|
||||
</label>
|
||||
<div className="mt-1 flex rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center rounded-l border border-r-0 border-neutral-300 bg-neutral-50 px-3 text-neutral-500 sm:text-sm">
|
||||
https://
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete={"off"}
|
||||
className={
|
||||
"block w-full rounded-r border-neutral-300 transition ease-in-out focus:border-neutral-800 focus:ring-neutral-800 sm:text-sm"
|
||||
}
|
||||
placeholder="www.example.com"
|
||||
{...registerButton("link")}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{errorsButton.link?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errorsButton.link.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className={"sm:col-span-2"}>
|
||||
<label htmlFor={"style"} className="flex items-center text-sm font-medium text-neutral-700">
|
||||
Color
|
||||
</label>
|
||||
<Dropdown
|
||||
inModal={true}
|
||||
onChange={(t) => setValueButton("color", t as colors)}
|
||||
values={[
|
||||
{ value: "blue", name: "Blue" },
|
||||
{ value: "red", name: "Red" },
|
||||
{ value: "green", name: "Green" },
|
||||
{ value: "yellow", name: "Yellow" },
|
||||
{ value: "orange", name: "Orange" },
|
||||
{ value: "purple", name: "Purple" },
|
||||
{ value: "pink", name: "Pink" },
|
||||
{ value: "indigo", name: "Indigo" },
|
||||
{ value: "black", name: "Black" },
|
||||
]}
|
||||
selectedValue={watchButton("color")}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{errorsButton.color?.message && (
|
||||
<motion.p
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: "auto" }}
|
||||
exit={{ height: 0 }}
|
||||
className="mt-1 text-xs text-red-500"
|
||||
>
|
||||
{errorsButton.color.message}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
<div>
|
||||
<>
|
||||
{mode === "PLUNK" ? (
|
||||
<>
|
||||
<div
|
||||
onClick={() => {
|
||||
editor.chain().focus().run();
|
||||
}}
|
||||
>
|
||||
<label className="block text-sm font-medium text-neutral-700">Email Body</label>
|
||||
<div className="mt-1 h-full">
|
||||
<div
|
||||
className={
|
||||
"flex h-full max-h-[600px] flex-col items-center overflow-y-auto overflow-x-hidden rounded border border-neutral-300 px-3 py-1"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"sticky top-3 z-10 mt-3 flex flex-col justify-center gap-3 rounded-lg border border-neutral-300 bg-white p-4 shadow-sm"
|
||||
}
|
||||
>
|
||||
<div className={"flex gap-3"}>
|
||||
<div className={"flex"}>
|
||||
<button
|
||||
title={"Align Left"}
|
||||
className={
|
||||
"flex items-center justify-center rounded-l-md border border-neutral-300 bg-white px-3 py-1 text-neutral-800 transition ease-in-out hover:bg-neutral-50"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor.chain().focus().setTextAlign("left").run();
|
||||
}}
|
||||
>
|
||||
<AlignLeft
|
||||
size={24}
|
||||
strokeWidth={
|
||||
editor.isActive("textAlign", {
|
||||
textAlign: "left",
|
||||
})
|
||||
? "2.5"
|
||||
: "1.5"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
title={"Align Center"}
|
||||
className={
|
||||
"flex items-center justify-center border border-neutral-300 bg-white px-3 py-1 text-neutral-800 transition ease-in-out hover:bg-neutral-50"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor.chain().focus().setTextAlign("center").run();
|
||||
}}
|
||||
>
|
||||
<AlignCenter
|
||||
size={24}
|
||||
strokeWidth={
|
||||
editor.isActive("textAlign", {
|
||||
textAlign: "center",
|
||||
})
|
||||
? "2.5"
|
||||
: "1.5"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
title={"Align Right"}
|
||||
className={
|
||||
"flex items-center justify-center rounded-r-md border border-neutral-300 bg-white px-3 py-1 text-neutral-800 transition ease-in-out hover:bg-neutral-50"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
editor.chain().focus().setTextAlign("right").run();
|
||||
}}
|
||||
>
|
||||
<AlignRight
|
||||
size={24}
|
||||
strokeWidth={
|
||||
editor.isActive("textAlign", {
|
||||
textAlign: "right",
|
||||
})
|
||||
? "2.5"
|
||||
: "1.5"
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={"flex"}>
|
||||
<button
|
||||
title={"Image"}
|
||||
className={
|
||||
"flex items-center justify-center rounded-l-md border border-neutral-300 bg-white px-3 py-1 text-neutral-800 transition ease-in-out hover:bg-neutral-50"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setImageModal(true);
|
||||
}}
|
||||
>
|
||||
<ImageIcon size={24} strokeWidth={"1.5"} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
title={"Button"}
|
||||
className={
|
||||
"flex items-center justify-center rounded-r-md border border-neutral-300 bg-white px-3 py-1 text-neutral-800 transition ease-in-out hover:bg-neutral-50"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setButtonModal(true);
|
||||
}}
|
||||
>
|
||||
<Inspect size={24} strokeWidth={"1.5"} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<div className={"prose prose-sm prose-neutral space-y-4 break-words p-4 w-full"}>
|
||||
<div className={"w-full lg:w-[600px]"}>
|
||||
<EditorContent editor={editor} />
|
||||
<EditorBubbleMenu
|
||||
editor={editor}
|
||||
items={[
|
||||
{
|
||||
name: "Link",
|
||||
icon: LinkIcon,
|
||||
command: () => {
|
||||
setUrlModal(true);
|
||||
setTimeout(() => {
|
||||
setFocusUrl("url", { shouldSelect: true });
|
||||
}, 100);
|
||||
},
|
||||
isActive: () => false,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={"mb-3 sm:grid sm:gap-3 md:grid-cols-1"}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700">Email Body</label>
|
||||
<div className="mt-1 h-full">
|
||||
<HTMLEditor
|
||||
height={400}
|
||||
className={"rounded border border-neutral-300"}
|
||||
language="html"
|
||||
theme="vs-light"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e as string, "HTML")}
|
||||
options={{
|
||||
inlineSuggest: true,
|
||||
fontSize: "12px",
|
||||
formatOnType: true,
|
||||
autoClosingBrackets: true,
|
||||
minimap: {
|
||||
enabled: false,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700">Preview</label>
|
||||
|
||||
<div className={"mt-1 h-full rounded border border-neutral-300 p-3"}>
|
||||
{/* injects value into iframe with script to dynamically adjust height based on content */}
|
||||
<iframe
|
||||
className="mb-0 overflow-hidden w-full"
|
||||
srcDoc={`
|
||||
${value}
|
||||
<script>
|
||||
window.addEventListener('load', function() {
|
||||
const height = document.documentElement.offsetHeight;
|
||||
window.parent.postMessage({ type: 'resize-iframe', height: height }, '*');
|
||||
});
|
||||
</script>
|
||||
`}
|
||||
onLoad={(e) => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data?.type === 'resize-iframe') {
|
||||
(e.target as HTMLIFrameElement).style.height = `${event.data.height}px`;
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import {mergeAttributes, Node, wrappingInputRule} from '@tiptap/core';
|
||||
|
||||
export type colors = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'indigo' | 'purple' | 'pink' | 'black';
|
||||
|
||||
// Map each color to a tailwind color hex code for 500
|
||||
const colorMap = {
|
||||
red: '#ef4444',
|
||||
orange: '#f97316',
|
||||
yellow: '#facc15',
|
||||
green: '#22c55e',
|
||||
blue: '#2563eb',
|
||||
indigo: '#6366f1',
|
||||
purple: '#8b5cf6',
|
||||
pink: '#ec4899',
|
||||
black: '#171717',
|
||||
} as const;
|
||||
|
||||
export interface ButtonOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
button: {
|
||||
/**
|
||||
* Set a blockquote node
|
||||
*/
|
||||
setButton: (attributes: {href: string; color: colors}) => ReturnType;
|
||||
/**
|
||||
* Toggle a blockquote node
|
||||
*/
|
||||
toggleButton: (attributes: {href: string; color: colors}) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const inputRegex = /^\s*>\s$/;
|
||||
|
||||
export const Button = Node.create<ButtonOptions>({
|
||||
name: 'button',
|
||||
content: 'text*',
|
||||
marks: '',
|
||||
group: 'block',
|
||||
defining: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {
|
||||
class: 'btn',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
href: {
|
||||
default: null,
|
||||
},
|
||||
color: {
|
||||
default: 'blue' as colors,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'a.btn',
|
||||
priority: 51,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({node, HTMLAttributes}) {
|
||||
return [
|
||||
'a',
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
style: `color: white; background-color: ${
|
||||
colorMap[node.attrs.color as colors]
|
||||
}; text-align: center; text-decoration: none; padding: 12px; border-radius: 8px; display: block; font-size: 15px; line-height: 20px; font-weight: 600; margin: 9px 0 9px 0;`,
|
||||
}),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setButton:
|
||||
attributes =>
|
||||
({commands}) => {
|
||||
return commands.setNode(this.name, attributes);
|
||||
},
|
||||
toggleButton:
|
||||
attributes =>
|
||||
({commands}) => {
|
||||
return commands.toggleNode(this.name, 'paragraph', attributes);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
wrappingInputRule({
|
||||
find: inputRegex,
|
||||
type: this.type,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
import {Editor} from '@tiptap/core';
|
||||
import cx from 'classnames';
|
||||
import {Check, ChevronDown} from 'lucide-react';
|
||||
import {FC} from 'react';
|
||||
|
||||
export interface BubbleColorMenuItem {
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface ColorSelectorProps {
|
||||
editor: Editor;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const ColorSelector: FC<ColorSelectorProps> = ({editor, isOpen, setIsOpen}) => {
|
||||
const items: BubbleColorMenuItem[] = [
|
||||
{
|
||||
name: 'Default',
|
||||
color: '#000000',
|
||||
},
|
||||
{
|
||||
name: 'Purple',
|
||||
color: '#9333EA',
|
||||
},
|
||||
{
|
||||
name: 'Red',
|
||||
color: '#E00000',
|
||||
},
|
||||
{
|
||||
name: 'Blue',
|
||||
color: '#2563EB',
|
||||
},
|
||||
{
|
||||
name: 'Green',
|
||||
color: '#008A00',
|
||||
},
|
||||
{
|
||||
name: 'Orange',
|
||||
color: '#FFA500',
|
||||
},
|
||||
{
|
||||
name: 'Pink',
|
||||
color: '#BA4081',
|
||||
},
|
||||
{
|
||||
name: 'Gray',
|
||||
color: '#A8A29E',
|
||||
},
|
||||
];
|
||||
|
||||
const activeItem = items.find(({color}) => editor.isActive('textStyle', {color}));
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<button
|
||||
className="flex h-full items-center gap-1 p-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 active:bg-neutral-200"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
>
|
||||
<span style={{color: activeItem?.color ?? '#000000'}}>A</span>
|
||||
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<section className="animate-in fade-in slide-in-from-top-1 fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded border border-neutral-200 bg-white p-1 shadow-xl">
|
||||
{items.map(({name, color}, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => {
|
||||
editor.chain().focus().setColor(color).run();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cx(
|
||||
'flex items-center justify-between rounded-sm px-2 py-1 text-sm text-neutral-600 hover:bg-neutral-100',
|
||||
{
|
||||
'text-blue-600': editor.isActive('textStyle', {color}),
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="rounded-sm border border-neutral-200 px-1 py-px font-medium" style={{color}}>
|
||||
A
|
||||
</div>
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
{editor.isActive('textStyle', {color}) && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
import {BubbleMenu, BubbleMenuProps} from '@tiptap/react';
|
||||
import cx from 'classnames';
|
||||
import {FC, useState} from 'react';
|
||||
import {BoldIcon, ItalicIcon, StrikethroughIcon} from 'lucide-react';
|
||||
|
||||
import {NodeSelector} from './NodeSelector';
|
||||
import {ColorSelector} from './ColorSelector';
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
isActive: () => boolean;
|
||||
command: () => void;
|
||||
icon: typeof BoldIcon;
|
||||
}
|
||||
|
||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, 'children'> & {
|
||||
items: BubbleMenuItem[];
|
||||
};
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = props => {
|
||||
const items: BubbleMenuItem[] = [
|
||||
{
|
||||
name: 'bold',
|
||||
isActive: () => props.editor?.isActive('bold') ?? false,
|
||||
command: () => props.editor?.chain().focus().toggleBold().run(),
|
||||
icon: BoldIcon,
|
||||
},
|
||||
{
|
||||
name: 'italic',
|
||||
isActive: () => props.editor?.isActive('italic') ?? false,
|
||||
command: () => props.editor?.chain().focus().toggleItalic().run(),
|
||||
icon: ItalicIcon,
|
||||
},
|
||||
|
||||
{
|
||||
name: 'strike',
|
||||
isActive: () => props.editor?.isActive('strike') ?? false,
|
||||
command: () => props.editor?.chain().focus().toggleStrike().run(),
|
||||
icon: StrikethroughIcon,
|
||||
},
|
||||
...props.items,
|
||||
];
|
||||
|
||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||
...props,
|
||||
shouldShow: ({editor}) => {
|
||||
// don't show if image is selected
|
||||
if (editor.isActive('image')) {
|
||||
return false;
|
||||
}
|
||||
return editor.view.state.selection.content().size > 0;
|
||||
},
|
||||
tippyOptions: {
|
||||
moveTransition: 'transform 0.15s ease-out',
|
||||
onHidden: () => {
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
{...bubbleMenuProps}
|
||||
className="flex overflow-hidden rounded border border-neutral-200 bg-white shadow-xl"
|
||||
>
|
||||
{props.editor && (
|
||||
<NodeSelector editor={props.editor} isOpen={isNodeSelectorOpen} setIsOpen={setIsNodeSelectorOpen} />
|
||||
)}
|
||||
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
item.command();
|
||||
}}
|
||||
className="p-2 text-neutral-600 hover:bg-neutral-100 active:bg-neutral-200"
|
||||
>
|
||||
<item.icon
|
||||
className={cx('h-4 w-4', {
|
||||
'text-blue-500': item.isActive(),
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{props.editor && (
|
||||
<ColorSelector editor={props.editor} isOpen={isColorSelectorOpen} setIsOpen={setIsColorSelectorOpen} />
|
||||
)}
|
||||
</BubbleMenu>
|
||||
);
|
||||
};
|
||||
@@ -1,151 +0,0 @@
|
||||
import {mergeAttributes, Node} from '@tiptap/core';
|
||||
import {Node as ProseMirrorNode} from '@tiptap/pm/model';
|
||||
import {PluginKey} from '@tiptap/pm/state';
|
||||
import Suggestion, {SuggestionOptions} from '@tiptap/suggestion';
|
||||
|
||||
export interface MentionOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
renderLabel: (props: {options: MentionOptions; node: ProseMirrorNode}) => string;
|
||||
suggestion: Omit<SuggestionOptions, 'editor'>;
|
||||
}
|
||||
|
||||
export const MentionPluginKey = new PluginKey('mention');
|
||||
|
||||
export const Mention = Node.create<MentionOptions>({
|
||||
name: 'mention',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
renderLabel({options, node}) {
|
||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||
},
|
||||
suggestion: {
|
||||
char: '{{',
|
||||
pluginKey: MentionPluginKey,
|
||||
command: ({editor, range, props}) => {
|
||||
// increase range.to by one when the next node is of type "text"
|
||||
// and starts with a space character
|
||||
const nodeAfter = editor.view.state.selection.$to.nodeAfter;
|
||||
const overrideSpace = nodeAfter?.text?.startsWith(' ');
|
||||
|
||||
if (overrideSpace) {
|
||||
range.to += 1;
|
||||
}
|
||||
|
||||
editor.chain().focus().insertContent(`${props.id}}}`).run();
|
||||
|
||||
window.getSelection()?.collapseToEnd();
|
||||
},
|
||||
allow: ({state, range}) => {
|
||||
const $from = state.doc.resolve(range.from);
|
||||
const type = state.schema.nodes[this.name];
|
||||
const allow = !!$from.parent.type.contentMatch.matchType(type);
|
||||
|
||||
return allow;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
group: 'inline',
|
||||
|
||||
inline: true,
|
||||
|
||||
selectable: true,
|
||||
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-id'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.id) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
'data-id': attributes.id,
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
label: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-label'),
|
||||
renderHTML: attributes => {
|
||||
if (!attributes.label) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
'data-label': attributes.label,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `span[data-type="${this.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({node, HTMLAttributes}) {
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes({'data-type': this.name}, this.options.HTMLAttributes, HTMLAttributes),
|
||||
this.options.renderLabel({
|
||||
options: this.options,
|
||||
node,
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
renderText({node}) {
|
||||
return this.options.renderLabel({
|
||||
options: this.options,
|
||||
node,
|
||||
});
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Backspace: () =>
|
||||
this.editor.commands.command(({tr, state}) => {
|
||||
let isMention = false;
|
||||
const {selection} = state;
|
||||
const {empty, anchor} = selection;
|
||||
|
||||
if (!empty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
state.doc.nodesBetween(anchor - 1, anchor, (node, pos) => {
|
||||
if (node.type.name === this.name) {
|
||||
isMention = true;
|
||||
tr.insertText(this.options.suggestion.char ?? '', pos, pos + node.nodeSize);
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return isMention;
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import React, { forwardRef, useEffect, useImperativeHandle, useState } from "react";
|
||||
|
||||
export default forwardRef((props, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const selectItem = (index) => {
|
||||
const item = props.items[index];
|
||||
|
||||
if (item) {
|
||||
props.command({ id: item });
|
||||
}
|
||||
};
|
||||
|
||||
const upHandler = () => {
|
||||
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
|
||||
};
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((selectedIndex + 1) % props.items.length);
|
||||
};
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex);
|
||||
};
|
||||
|
||||
useEffect(() => setSelectedIndex(0), [props.items]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
upHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
downHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
enterHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="z-50 mt-2 w-56 origin-top-right rounded-md bg-white p-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
|
||||
{props.items.length ? (
|
||||
props.items.map((item, index) => (
|
||||
<button
|
||||
className={`flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 transition hover:bg-neutral-100 ${
|
||||
index === selectedIndex ? "bg-neutral-50" : ""
|
||||
}`}
|
||||
key={index}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="flex w-full items-center gap-2 px-4 py-2 text-sm text-neutral-700 transition hover:bg-neutral-100">
|
||||
No result
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import { network } from "dashboard/src/lib/network";
|
||||
import type { RefAttributes } from "react";
|
||||
import tippy from "tippy.js";
|
||||
import MentionList from "./SuggestionList";
|
||||
|
||||
export default {
|
||||
items: async ({ query }: { query: string }) => {
|
||||
const activeProject = typeof window !== "undefined" ? window.localStorage.getItem("project") : null;
|
||||
|
||||
if (!activeProject) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const keys = await network.fetch<string[]>("GET", `/projects/id/${activeProject}/contacts/metadata`);
|
||||
|
||||
return keys.filter((key) => key.toLowerCase().includes(query.toLowerCase()));
|
||||
},
|
||||
|
||||
render: () => {
|
||||
let component: ReactRenderer<unknown, RefAttributes<unknown>>;
|
||||
let popup: { destroy: () => void }[];
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: any; clientRect: any }) => {
|
||||
component = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component.updateProps(props);
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return component.ref?.onKeyDown(props);
|
||||
},
|
||||
|
||||
onExit() {
|
||||
if (popup[0]) {
|
||||
popup[0].destroy();
|
||||
}
|
||||
|
||||
component.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
import {Editor} from '@tiptap/core';
|
||||
import cx from 'classnames';
|
||||
import {Check, ChevronDown, Heading1, Heading2, Heading3, ListOrdered, TextIcon} from 'lucide-react';
|
||||
import {FC} from 'react';
|
||||
|
||||
import {BubbleMenuItem} from './EditorBubbleMenu';
|
||||
|
||||
interface NodeSelectorProps {
|
||||
editor: Editor;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const NodeSelector: FC<NodeSelectorProps> = ({editor, isOpen, setIsOpen}) => {
|
||||
const items: BubbleMenuItem[] = [
|
||||
{
|
||||
name: 'Text',
|
||||
icon: TextIcon,
|
||||
command: () => editor.chain().focus().toggleNode('paragraph', 'paragraph').run(),
|
||||
isActive: () => editor.isActive('paragraph') && !editor.isActive('bulletList') && !editor.isActive('orderedList'),
|
||||
},
|
||||
{
|
||||
name: 'Heading 1',
|
||||
icon: Heading1,
|
||||
command: () => editor.chain().focus().toggleHeading({level: 1}).run(),
|
||||
isActive: () => editor.isActive('heading', {level: 1}),
|
||||
},
|
||||
{
|
||||
name: 'Heading 2',
|
||||
icon: Heading2,
|
||||
command: () => editor.chain().focus().toggleHeading({level: 2}).run(),
|
||||
isActive: () => editor.isActive('heading', {level: 2}),
|
||||
},
|
||||
{
|
||||
name: 'Heading 3',
|
||||
icon: Heading3,
|
||||
command: () => editor.chain().focus().toggleHeading({level: 3}).run(),
|
||||
isActive: () => editor.isActive('heading', {level: 3}),
|
||||
},
|
||||
{
|
||||
name: 'Bullet List',
|
||||
icon: ListOrdered,
|
||||
command: () => editor.chain().focus().toggleBulletList().run(),
|
||||
isActive: () => editor.isActive('bulletList'),
|
||||
},
|
||||
{
|
||||
name: 'Numbered List',
|
||||
icon: ListOrdered,
|
||||
command: () => editor.chain().focus().toggleOrderedList().run(),
|
||||
isActive: () => editor.isActive('orderedList'),
|
||||
},
|
||||
];
|
||||
|
||||
const activeItem = items.find(item => item.isActive());
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<button
|
||||
className="flex h-full items-center gap-1 p-2 text-sm font-medium text-neutral-600 hover:bg-neutral-100 active:bg-neutral-200"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
>
|
||||
<span>{activeItem?.name}</span>
|
||||
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<section className="animate-in fade-in slide-in-from-top-1 fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded border border-neutral-200 bg-white p-1 shadow-xl">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
item.command();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className={cx(
|
||||
'flex items-center justify-between rounded-sm px-2 py-1 text-sm text-neutral-600 hover:bg-neutral-100',
|
||||
{
|
||||
'text-blue-600': item.isActive(),
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="rounded-sm border border-neutral-200 p-1">
|
||||
<item.icon className="h-3 w-3" />
|
||||
</div>
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
{item.isActive() && <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,133 +0,0 @@
|
||||
import {Node} from '@tiptap/core';
|
||||
|
||||
export type colors = 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'indigo' | 'purple' | 'pink' | 'black';
|
||||
|
||||
// Map each color to a tailwind color hex code for 500
|
||||
const colorMap = {
|
||||
red: '#ef4444',
|
||||
orange: '#f97316',
|
||||
yellow: '#facc15',
|
||||
green: '#22c55e',
|
||||
blue: '#2563eb',
|
||||
indigo: '#6366f1',
|
||||
purple: '#8b5cf6',
|
||||
pink: '#ec4899',
|
||||
black: '#171717',
|
||||
} as const;
|
||||
|
||||
export interface ProgressOptions {
|
||||
percent: number;
|
||||
color: colors;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
progress: {
|
||||
/**
|
||||
* Set a heading node
|
||||
*/
|
||||
setProgress: (attributes: {percent: number; color: colors}) => ReturnType;
|
||||
/**
|
||||
* Toggle a heading node
|
||||
*/
|
||||
toggleProgress: (attributes: {percent: number; color: colors}) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const Progress = Node.create<ProgressOptions>({
|
||||
name: 'progress',
|
||||
|
||||
content: 'inline*',
|
||||
|
||||
group: 'block',
|
||||
|
||||
defining: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
percent: {
|
||||
default: 100,
|
||||
rendered: false,
|
||||
},
|
||||
color: {
|
||||
default: 'blue' as colors,
|
||||
rendered: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'table',
|
||||
getAttrs: element => {
|
||||
// @ts-ignore
|
||||
const percent = element.querySelector('td')?.style.width;
|
||||
// @ts-ignore
|
||||
const color = element.querySelector('td')?.style.backgroundColor;
|
||||
|
||||
const rgb = color?.slice(4, color.length - 1).split(', ');
|
||||
const hex = rgb?.map((value: any) => {
|
||||
const hex = Number(value).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
});
|
||||
|
||||
return {
|
||||
percent: Number(percent?.slice(0, percent.length - 1)),
|
||||
color: Object.keys(colorMap).find(key => colorMap[key as colors] === `#${hex?.join('')}`) as colors,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({node}) {
|
||||
// Render a progress bar using table elements
|
||||
return [
|
||||
'table',
|
||||
{
|
||||
class: 'progress',
|
||||
style: `width: 100%; border-radius: 10px;height: 28px;`,
|
||||
},
|
||||
[
|
||||
'tr',
|
||||
{
|
||||
style: `width: 100%; border-radius: 8px;`,
|
||||
},
|
||||
// Render two cells, one for the progress bar and one for the percentage
|
||||
[
|
||||
'td',
|
||||
{
|
||||
style: `width: ${node.attrs.percent}%; background-color: ${
|
||||
colorMap[node.attrs.color as colors]
|
||||
}; border-top-left-radius: 8px; border-bottom-left-radius: 8px;`,
|
||||
},
|
||||
],
|
||||
[
|
||||
'td',
|
||||
{
|
||||
style: `width: ${
|
||||
100 - node.attrs.percent
|
||||
}%; background-color: #f5f5f5; border-top-right-radius: 8px; border-bottom-right-radius: 8px;`,
|
||||
},
|
||||
],
|
||||
],
|
||||
];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setProgress:
|
||||
attributes =>
|
||||
({commands}) => {
|
||||
return commands.setNode(this.name, attributes);
|
||||
},
|
||||
toggleProgress:
|
||||
attributes =>
|
||||
({commands}) => {
|
||||
return commands.toggleNode(this.name, 'paragraph', attributes);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,324 +0,0 @@
|
||||
import { type Editor, Extension, type Range } from "@tiptap/core";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import {
|
||||
AlignCenter,
|
||||
AlignLeft,
|
||||
AlignRight,
|
||||
Bold,
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Italic,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Strikethrough,
|
||||
} from "lucide-react";
|
||||
import React, { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import tippy from "tippy.js";
|
||||
|
||||
interface CommandItemProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
interface Command {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
const Command = Extension.create({
|
||||
name: "slash-command",
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: "/",
|
||||
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
|
||||
props.command({ editor, range });
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const getSuggestionItems = ({ query }: { query: string }) => {
|
||||
return [
|
||||
{
|
||||
title: "Heading 1",
|
||||
description: "Big section heading.",
|
||||
icon: <Heading1 size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 2",
|
||||
description: "Medium section heading.",
|
||||
icon: <Heading2 size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Heading 3",
|
||||
description: "Small section heading.",
|
||||
icon: <Heading3 size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Bold",
|
||||
description: "Make text bold.",
|
||||
icon: <Bold size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setMark("bold").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Italic",
|
||||
description: "Make text italic.",
|
||||
icon: <Italic size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setMark("italic").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Strikethrough",
|
||||
description: "Make text strikethrough.",
|
||||
icon: <Strikethrough size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setMark("strike").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Bullet List",
|
||||
description: "Create a bullet list.",
|
||||
icon: <List size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Numbered List",
|
||||
description: "Create a numbered list.",
|
||||
icon: <ListOrdered size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Code Block",
|
||||
description: "Create a code block.",
|
||||
icon: <Code size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Quote",
|
||||
description: "Create a quote.",
|
||||
icon: <Quote size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).toggleBlockquote().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Align Left",
|
||||
description: "Align text to the left.",
|
||||
icon: <AlignLeft size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setTextAlign("left").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Align Center",
|
||||
description: "Align text to the center.",
|
||||
icon: <AlignCenter size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setTextAlign("center").run();
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Align Right",
|
||||
description: "Align text to the right.",
|
||||
icon: <AlignRight size={18} />,
|
||||
command: ({ editor, range }: Command) => {
|
||||
editor.chain().focus().deleteRange(range).setTextAlign("right").run();
|
||||
},
|
||||
},
|
||||
].filter((item) => {
|
||||
if (query.length > 0) {
|
||||
return item.title.toLowerCase().includes(query.toLowerCase());
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// .slice(0, 10);
|
||||
};
|
||||
|
||||
export const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
||||
const containerHeight = container.offsetHeight;
|
||||
const itemHeight = item.offsetHeight;
|
||||
|
||||
const top = item.offsetTop;
|
||||
const bottom = top + itemHeight;
|
||||
|
||||
if (top < container.scrollTop) {
|
||||
container.scrollTop -= container.scrollTop - top + 5;
|
||||
} else if (bottom > containerHeight + container.scrollTop) {
|
||||
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
|
||||
}
|
||||
};
|
||||
|
||||
const CommandList = ({
|
||||
items,
|
||||
command,
|
||||
editor,
|
||||
}: { items: CommandItemProps[]; command: any; editor: any; range: any }) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
|
||||
command(item);
|
||||
},
|
||||
[command, editor, items],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (navigationKeys.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
if (e.key === "ArrowUp") {
|
||||
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "Enter") {
|
||||
selectItem(selectedIndex);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [items, selectedIndex, setSelectedIndex, selectItem]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = commandListContainer.current;
|
||||
|
||||
const item = container?.children[selectedIndex] as HTMLElement;
|
||||
|
||||
if (container) {
|
||||
updateScrollView(container, item);
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
return items.length > 0 ? (
|
||||
<div
|
||||
ref={commandListContainer}
|
||||
className="z-50 h-auto max-h-[330px] w-72 overflow-y-auto scroll-smooth rounded-md border border-neutral-200 bg-white px-1 py-2 shadow-md transition-all"
|
||||
>
|
||||
{items.map((item: CommandItemProps, index: number) => {
|
||||
return (
|
||||
<button
|
||||
className={`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm text-neutral-800 hover:bg-neutral-100 ${
|
||||
index === selectedIndex ? "bg-neutral-100 text-neutral-800" : ""
|
||||
}`}
|
||||
key={index}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-neutral-200 bg-white">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-xs text-neutral-500">{item.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const renderItems = () => {
|
||||
let component: ReactRenderer | null = null;
|
||||
let popup: any;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component = new ReactRenderer(CommandList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
popup?.[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return component?.ref?.onKeyDown(props);
|
||||
},
|
||||
onExit: () => {
|
||||
popup?.[0].destroy();
|
||||
component?.destroy();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const Slash = Command.configure({
|
||||
suggestion: {
|
||||
items: getSuggestionItems,
|
||||
render: renderItems,
|
||||
},
|
||||
});
|
||||
|
||||
export default Slash;
|
||||
@@ -1 +0,0 @@
|
||||
export {default as Editor} from './Editor';
|
||||
@@ -1,172 +0,0 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import React, { type MutableRefObject, useEffect, useState } from "react";
|
||||
import { FixedSizeList as List } from "react-window";
|
||||
|
||||
export interface MultiselectDropdownProps {
|
||||
onChange: (value: string[]) => void;
|
||||
values: readonly {
|
||||
name: string;
|
||||
value: string;
|
||||
tag?: string;
|
||||
}[];
|
||||
selectedValues?: readonly string[];
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function MultiselectDropdown({
|
||||
onChange,
|
||||
values,
|
||||
selectedValues: PropsselectedValues,
|
||||
className,
|
||||
disabled = false,
|
||||
}: MultiselectDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedValues, setSelectedValues] = useState<readonly string[]>([]);
|
||||
|
||||
const ref = React.createRef<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
if (PropsselectedValues) {
|
||||
setSelectedValues(PropsselectedValues);
|
||||
}
|
||||
}, [PropsselectedValues]);
|
||||
|
||||
useEffect(() => {
|
||||
const mutableRef = ref as MutableRefObject<HTMLDivElement | null>;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (mutableRef.current && !mutableRef.current.contains(event.target as Node) && open) {
|
||||
setOpen(!open);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [ref, open]);
|
||||
|
||||
const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||
const value = filteredValues[index];
|
||||
return (
|
||||
<li
|
||||
style={style}
|
||||
key={`multiselect-${index}`}
|
||||
className="relative flex cursor-default select-none items-center rounded-md py-2.5 pl-2.5 text-neutral-800 transition ease-in-out hover:bg-neutral-100"
|
||||
onClick={() => {
|
||||
const isAlreadySelected = selectedValues.find((selection) => value.value === selection);
|
||||
|
||||
const updatedArray = isAlreadySelected
|
||||
? selectedValues.filter((selection) => selection !== value.value)
|
||||
: [...selectedValues, value.value];
|
||||
|
||||
onChange(updatedArray);
|
||||
setSelectedValues(updatedArray);
|
||||
}}
|
||||
>
|
||||
{value.tag && (
|
||||
<span className={"mr-3 whitespace-nowrap rounded bg-blue-100 px-3 py-0.5 text-xs text-blue-900"}>{value.tag}</span>
|
||||
)}
|
||||
<span className="truncate font-normal">
|
||||
{value.name.charAt(0).toUpperCase() + value.name.slice(1).toLowerCase()}
|
||||
</span>
|
||||
{value.value === selectedValues.find((selection) => value.value === selection) ? (
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-3 text-black">
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const filteredValues = values.filter((value) => value.name.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} className={className ?? ""}>
|
||||
<div className="relative mt-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`${
|
||||
disabled ? "cursor-default bg-neutral-100" : "cursor-pointer bg-white"
|
||||
} relative w-full rounded border border-neutral-300 py-2 pl-3 pr-10 text-left sm:text-sm`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="true"
|
||||
aria-labelledby="listbox-label"
|
||||
onClick={() => {
|
||||
if (!disabled) {
|
||||
setOpen(!open);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="block truncate">{selectedValues.length} selected</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<motion.svg
|
||||
initial={{ rotate: "90deg" }}
|
||||
animate={open ? { rotate: "0deg" } : { rotate: "90deg" }}
|
||||
className="h-5 w-5 text-neutral-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</motion.svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className="scrollbar-w-2 scrollbar scrollbar-thumb-rounded-full scrollbar-thumb-neutral-400 scrollbar-track-neutral-100 absolute z-40 mt-1 max-h-72 w-full list-none space-y-3 overflow-y-scroll rounded-md border border-black border-opacity-5 bg-white p-1 pr-1 text-base shadow focus:outline-none sm:text-sm"
|
||||
tabIndex={-1}
|
||||
role="listbox"
|
||||
>
|
||||
<li className="relative cursor-default select-none px-3 py-2 text-neutral-800">
|
||||
<input
|
||||
type="search"
|
||||
name="search"
|
||||
autoComplete={"off"}
|
||||
className="block w-full rounded border-neutral-300 focus:border-black focus:ring-black sm:text-sm"
|
||||
placeholder={"Search"}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</li>
|
||||
|
||||
{filteredValues.length === 0 ? (
|
||||
<li className="relative cursor-default select-none py-2 pl-3 pr-9 text-neutral-800">No results found</li>
|
||||
) : (
|
||||
<List height={500} itemCount={filteredValues.length} itemSize={40} width={"100%"}>
|
||||
{Row}
|
||||
</List>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {default as MultiselectDropdown} from './MultiselectDropdown';
|
||||
@@ -1,54 +0,0 @@
|
||||
export interface ToggleProps {
|
||||
title: string;
|
||||
description: string;
|
||||
toggled: boolean;
|
||||
onToggle: () => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.toggled
|
||||
* @param root0.onToggle
|
||||
* @param root0.title
|
||||
* @param root0.description
|
||||
* @param root0.className
|
||||
* @param root0.disabled
|
||||
*/
|
||||
export default function Toggle({title, description, toggled, onToggle, disabled, className}: ToggleProps) {
|
||||
return (
|
||||
<>
|
||||
<div className={`flex items-center justify-between ${className}`}>
|
||||
<span className="flex flex-grow flex-col">
|
||||
<span
|
||||
className={`${
|
||||
disabled ? 'text-neutral-400' : 'text-neutral-800'
|
||||
} text-sm font-medium transition ease-in-out`}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<span className={`${disabled ? 'text-neutral-300' : 'text-neutral-500'} w-10/12 text-sm`}>{description}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`${
|
||||
disabled ? 'bg-neutral-100' : toggled ? 'bg-neutral-800' : 'bg-neutral-200'
|
||||
} relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-2`}
|
||||
role="switch"
|
||||
aria-checked="false"
|
||||
aria-labelledby="availability-label"
|
||||
aria-describedby="availability-description"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`${
|
||||
disabled ? 'translate-x-0' : toggled ? 'translate-x-5' : 'translate-x-0'
|
||||
} pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {default as Toggle} from './Toggle';
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from './Toggle';
|
||||
export * from './Dropdown';
|
||||
export * from './MultiselectDropdown';
|
||||
export * from './MarkdownEditor';
|
||||
export * from './Input';
|
||||
@@ -1,19 +0,0 @@
|
||||
import {Tabs} from '../Tabs';
|
||||
import React from 'react';
|
||||
import {useRouter} from 'next/router';
|
||||
|
||||
/**
|
||||
*
|
||||
* @param root0
|
||||
* @param root0.onMethodChange
|
||||
*/
|
||||
export default function AnalyticsTabs() {
|
||||
const router = useRouter();
|
||||
|
||||
const links = [
|
||||
{to: '/analytics', text: 'Overview', active: router.route === '/analytics'},
|
||||
{to: '/analytics/clicks', text: 'Clicks', active: router.route === '/analytics/clicks'},
|
||||
];
|
||||
|
||||
return <Tabs links={links} />;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {default as AnalyticsTabs} from './AnalyticsTabs';
|
||||
@@ -1,14 +0,0 @@
|
||||
import {Tabs} from '../Tabs';
|
||||
import React from 'react';
|
||||
import {useRouter} from 'next/router';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function DeveloperTabs() {
|
||||
const router = useRouter();
|
||||
|
||||
const links = [{to: '/developers/webhooks', text: 'Webhooks', active: router.route === '/developers/webhooks'}];
|
||||
|
||||
return <Tabs links={links} />;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {default as DeveloperTabs} from './DeveloperTabs';
|
||||
@@ -1,159 +0,0 @@
|
||||
import React, {MutableRefObject, useEffect} from 'react';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {useActiveProject, useProjects} from '../../../lib/hooks/projects';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useAtom} from 'jotai';
|
||||
import {atomActiveProject} from '../../../lib/atoms/project';
|
||||
|
||||
export interface ProjectSelectorProps {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const ProjectSelector = React.forwardRef<HTMLDivElement, ProjectSelectorProps>(
|
||||
({open, onToggle}: ProjectSelectorProps, ref) => {
|
||||
const router = useRouter();
|
||||
const {data: projects} = useProjects();
|
||||
const activeProject = useActiveProject();
|
||||
const [, setActiveProjectId] = useAtom(atomActiveProject);
|
||||
|
||||
useEffect(() => {
|
||||
const mutableRef = ref as MutableRefObject<HTMLDivElement | null>;
|
||||
|
||||
const handleClickOutside = (event: any) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
if (mutableRef.current && !mutableRef.current.contains(event.target) && open) {
|
||||
onToggle();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
const onChange = (project: string) => {
|
||||
localStorage.setItem('project', project);
|
||||
setActiveProjectId(project);
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={'projects'} className="block select-none text-sm font-semibold text-neutral-600">
|
||||
Projects
|
||||
</label>
|
||||
<div ref={ref}>
|
||||
<div className="relative mt-1">
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
'relative w-full cursor-pointer rounded border border-neutral-300 bg-white py-2 pl-3 pr-10 text-left focus:border-neutral-500 focus:outline-none focus:ring-1 focus:ring-neutral-500 sm:text-sm'
|
||||
}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="true"
|
||||
aria-labelledby="listbox-label"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className="block flex items-center gap-x-1.5 truncate font-medium">
|
||||
{activeProject?.name ?? 'No active project'}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<motion.svg
|
||||
initial={{rotate: '90deg'}}
|
||||
animate={open ? {rotate: '0deg'} : {rotate: '90deg'}}
|
||||
className="h-5 w-5 text-neutral-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</motion.svg>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.ul
|
||||
initial={{opacity: 0, scale: 0.6}}
|
||||
animate={{opacity: 1, scale: 1}}
|
||||
exit={{opacity: 0, scale: 0.6}}
|
||||
transition={{duration: 0.1}}
|
||||
className={`absolute z-50 mt-1 w-full rounded bg-white text-base shadow-md ring-1 ring-neutral-800 ring-opacity-5 focus:outline-none sm:text-sm`}
|
||||
tabIndex={-1}
|
||||
role="listbox"
|
||||
aria-labelledby="listbox-label"
|
||||
aria-activedescendant="listbox-option-3"
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'scrollbar-w-2 scrollbar scrollbar-thumb-rounded-full scrollbar-thumb-neutral-400 scrollbar-track-neutral-100 max-h-72 overflow-y-scroll p-1'
|
||||
}
|
||||
>
|
||||
{projects?.map((project, index) => {
|
||||
return (
|
||||
<li
|
||||
key={`projects-${index}`}
|
||||
className="relative flex cursor-default select-none items-center rounded-md py-2.5 pl-2.5 text-neutral-800 transition ease-in-out hover:bg-neutral-100"
|
||||
role="option"
|
||||
onClick={() => {
|
||||
onChange(project.id);
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`${
|
||||
project.id === activeProject?.id ? 'font-medium' : 'font-normal'
|
||||
} flex items-center truncate`}
|
||||
>
|
||||
{project.name}
|
||||
</span>
|
||||
{project.id === activeProject?.id ? (
|
||||
<span className="absolute inset-y-0 right-0 flex items-center pr-4 text-neutral-800">
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
<hr className={'my-0.5'} />
|
||||
<li
|
||||
className="relative flex cursor-default select-none items-center rounded-md py-2.5 pl-2.5 text-neutral-800 transition ease-in-out hover:bg-neutral-100"
|
||||
role="option"
|
||||
onClick={async () => {
|
||||
await router.push('/new');
|
||||
}}
|
||||
>
|
||||
<span className="flex items-center truncate font-normal">Create new project</span>
|
||||
</li>
|
||||
</div>
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default ProjectSelector;
|
||||
@@ -1 +0,0 @@
|
||||
export {default as ProjectSelector} from './ProjectSelector';
|
||||
@@ -1,19 +0,0 @@
|
||||
import {Tabs} from '../Tabs';
|
||||
import React from 'react';
|
||||
import {useRouter} from 'next/router';
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export default function SettingTabs() {
|
||||
const router = useRouter();
|
||||
|
||||
const links = [
|
||||
{to: '/settings/project', text: 'Project Settings', active: router.route === '/settings/project'},
|
||||
{to: '/settings/api', text: 'API Keys', active: router.route === '/settings/api'},
|
||||
{to: '/settings/identity', text: 'Verified Domain', active: router.route === '/settings/identity'},
|
||||
{to: '/settings/members', text: 'Members', active: router.route === '/settings/members'},
|
||||
];
|
||||
|
||||
return <Tabs links={links} />;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {default as SettingTabs} from './SettingTabs';
|
||||
@@ -1,366 +0,0 @@
|
||||
import React, {ReactElement, useState} from 'react';
|
||||
import {useRouter} from 'next/router';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import {ProjectSelector} from '../../index';
|
||||
import Image from 'next/image';
|
||||
import logo from '../../../../public/assets/logo.png';
|
||||
import {Home, LayoutTemplate, LineChart, LogOut, Send, Settings, TerminalSquare, Users2, Workflow} from 'lucide-react';
|
||||
|
||||
interface SidebarLinkType {
|
||||
to: string;
|
||||
text: string;
|
||||
disabled: boolean;
|
||||
highlight?: boolean;
|
||||
position: 'top' | 'bottom';
|
||||
icon: ReactElement;
|
||||
}
|
||||
|
||||
interface SidebarLinkProps {
|
||||
active?: boolean;
|
||||
to: string;
|
||||
text: string;
|
||||
disabled?: boolean;
|
||||
highlight?: boolean;
|
||||
svgPath: React.ReactElement;
|
||||
}
|
||||
|
||||
export interface SidebarProps {
|
||||
mobileOpen: boolean;
|
||||
onSidebarVisibilityChange: () => void;
|
||||
}
|
||||
|
||||
const links: SidebarLinkType[] = [
|
||||
{
|
||||
to: '/',
|
||||
text: 'Dashboard',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <Home />,
|
||||
},
|
||||
{
|
||||
to: '/contacts',
|
||||
text: 'Contacts',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <Users2 />,
|
||||
},
|
||||
{
|
||||
to: '/analytics',
|
||||
text: 'Analytics',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <LineChart />,
|
||||
},
|
||||
// {
|
||||
// to: '/developers',
|
||||
// text: 'Developers',
|
||||
// disabled: false,
|
||||
// position: 'top',
|
||||
// icon: <TerminalSquare />,
|
||||
// },
|
||||
{
|
||||
to: '/settings/project',
|
||||
text: 'Project Settings',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <Settings />,
|
||||
},
|
||||
{
|
||||
to: '/events',
|
||||
text: 'Events',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <TerminalSquare />,
|
||||
},
|
||||
{
|
||||
to: '/templates',
|
||||
text: 'Templates',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <LayoutTemplate />,
|
||||
},
|
||||
{
|
||||
to: '/actions',
|
||||
text: 'Actions',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <Workflow />,
|
||||
},
|
||||
{
|
||||
to: '/campaigns',
|
||||
text: 'Campaigns',
|
||||
disabled: false,
|
||||
position: 'top',
|
||||
icon: <Send />,
|
||||
},
|
||||
|
||||
// {
|
||||
// to: '/settings/account',
|
||||
// text: 'Account Settings',
|
||||
// disabled: false,
|
||||
// position: 'bottom',
|
||||
// icon: (
|
||||
// <>
|
||||
// <Settings />
|
||||
// </>
|
||||
// ),
|
||||
// },
|
||||
];
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.active
|
||||
* @param root0.to
|
||||
* @param root0.text
|
||||
* @param root0.disabled
|
||||
* @param root0.svgPath
|
||||
* @param root0.highlight
|
||||
*/
|
||||
function SidebarLink({active, to, text, disabled, highlight, svgPath}: SidebarLinkProps) {
|
||||
if (to.startsWith('http')) {
|
||||
return (
|
||||
<a
|
||||
onClick={() => window.open(to, '_blank')?.focus()}
|
||||
className={`${
|
||||
active
|
||||
? 'cursor-default bg-neutral-100 text-neutral-700'
|
||||
: disabled
|
||||
? 'text-neutral-200'
|
||||
: 'cursor-pointer text-neutral-400 hover:bg-neutral-50 hover:text-neutral-700'
|
||||
} flex items-center gap-x-3 rounded p-2 text-sm font-medium transition ease-in-out`}
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center">{svgPath}</div>
|
||||
{text}
|
||||
{highlight && <div className="ml-auto rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-800">New</div>}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={to}
|
||||
className={`${
|
||||
active
|
||||
? 'cursor-default bg-neutral-100 text-neutral-700'
|
||||
: disabled
|
||||
? 'text-neutral-200'
|
||||
: 'cursor-pointer text-neutral-400 hover:bg-neutral-50 hover:text-neutral-700'
|
||||
} flex items-center gap-x-3 rounded p-2 text-sm font-medium transition ease-in-out`}
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center">{svgPath}</div>
|
||||
{text}
|
||||
{highlight && <div className="ml-auto rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-800">New</div>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.mobileOpen
|
||||
* @param root0.onSidebarVisibilityChange
|
||||
*/
|
||||
export default function Sidebar({mobileOpen, onSidebarVisibilityChange}: SidebarProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const projectSelectorRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
const [projectSelectorOpen, setProjectSelectorOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{mobileOpen && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, x: -100}}
|
||||
animate={{opacity: 1, x: 0}}
|
||||
exit={{opacity: 0, x: -100}}
|
||||
transition={{ease: 'easeOut', duration: 0.15}}
|
||||
className="fixed inset-0 z-40 flex w-full md:hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<motion.div
|
||||
animate={{opacity: [0, 1]}}
|
||||
transition={{ease: 'easeOut', duration: 0.15}}
|
||||
className="fixed inset-0 bg-neutral-600 bg-opacity-75"
|
||||
aria-hidden={!mobileOpen}
|
||||
/>
|
||||
|
||||
<div className="relative flex h-full w-full max-w-xs flex-1 flex-col bg-white">
|
||||
<div className="absolute right-0 top-0 -mr-12 pt-2">
|
||||
<button
|
||||
className="ml-1 flex h-10 w-10 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
|
||||
onClick={() => onSidebarVisibilityChange()}
|
||||
>
|
||||
<span className="sr-only">Close sidebar</span>
|
||||
<svg
|
||||
className="h-6 w-6 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-0 flex-1 overflow-y-auto pb-4 pt-5">
|
||||
<div className="flex flex-shrink-0 items-center px-4">
|
||||
<Link href={'/'} passHref>
|
||||
<Image className={'cursor-pointer'} width={40} height={40} quality={40} src={logo} alt="Logo" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={'mt-5 px-2'}>
|
||||
<ProjectSelector
|
||||
open={projectSelectorOpen}
|
||||
onToggle={() => setProjectSelectorOpen(!projectSelectorOpen)}
|
||||
ref={projectSelectorRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav className="mt-5 space-y-1 px-2">
|
||||
{links
|
||||
.filter(l => l.position === 'top')
|
||||
.map((link, index) => {
|
||||
return (
|
||||
<SidebarLink
|
||||
key={`mobile-top-${index}`}
|
||||
active={
|
||||
link.to === '/'
|
||||
? router.pathname === link.to
|
||||
: router.pathname.split('/')[1].includes(link.to.split('/')[1])
|
||||
}
|
||||
to={link.to}
|
||||
text={link.text}
|
||||
disabled={link.disabled}
|
||||
svgPath={link.icon}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex-0 mb-4 space-y-1 bg-white px-2">
|
||||
<nav>
|
||||
{links
|
||||
.filter(l => l.position === 'bottom')
|
||||
.map((link, index) => {
|
||||
return (
|
||||
<SidebarLink
|
||||
key={`mobile-bottom-${index}`}
|
||||
active={router.pathname === link.to}
|
||||
to={link.to}
|
||||
text={link.text}
|
||||
disabled={link.disabled}
|
||||
svgPath={link.icon}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-14 flex-shrink-0" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Static sidebar for desktop */}
|
||||
<div className="hidden md:flex md:flex-shrink-0">
|
||||
<div className="flex w-72 flex-col">
|
||||
<div className="flex h-0 flex-1 flex-col border-r border-neutral-100 bg-white px-6">
|
||||
<div className="flex flex-1 flex-col overflow-y-auto pb-4 pt-5">
|
||||
<div className="flex flex-shrink-0 items-center justify-center px-4">
|
||||
<Link href={'/'} passHref>
|
||||
<Image className={'cursor-pointer'} width={35} height={35} quality={80} src={logo} alt="Logo" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className={'px-2'}>
|
||||
<ProjectSelector
|
||||
open={projectSelectorOpen}
|
||||
onToggle={() => setProjectSelectorOpen(!projectSelectorOpen)}
|
||||
ref={projectSelectorRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav className="mt-5 flex-1 space-y-1 px-2">
|
||||
{links
|
||||
.filter(l => l.position === 'top')
|
||||
.map((link, index) => {
|
||||
if (link.to === '/events') {
|
||||
return (
|
||||
<div className={'pt-3'}>
|
||||
<p className={'pb-1 text-sm font-semibold text-neutral-500'}>Automations</p>
|
||||
<SidebarLink
|
||||
key={`desktop-top-${index}`}
|
||||
active={router.pathname.split('/')[1].includes(link.to.split('/')[1])}
|
||||
to={link.to}
|
||||
text={link.text}
|
||||
disabled={link.disabled}
|
||||
svgPath={link.icon}
|
||||
highlight={link.highlight}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (link.to === '/campaigns') {
|
||||
return (
|
||||
<div className={'py-3'}>
|
||||
<p className={'pb-1 text-sm font-semibold text-neutral-500'}>Campaigns</p>
|
||||
<SidebarLink
|
||||
key={`desktop-top-${index}`}
|
||||
active={router.pathname.split('/')[1].includes(link.to.split('/')[1])}
|
||||
to={link.to}
|
||||
text={link.text}
|
||||
disabled={link.disabled}
|
||||
svgPath={link.icon}
|
||||
highlight={link.highlight}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarLink
|
||||
key={`desktop-top-${index}`}
|
||||
active={
|
||||
link.to === '/'
|
||||
? router.pathname === link.to
|
||||
: router.pathname.split('/')[1].includes(link.to.split('/')[1])
|
||||
}
|
||||
to={link.to}
|
||||
text={link.text}
|
||||
disabled={link.disabled}
|
||||
svgPath={link.icon}
|
||||
highlight={link.highlight}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="flex-0 mb-4 w-full space-y-1 bg-white px-2">
|
||||
<Link
|
||||
href={'/auth/logout'}
|
||||
className={
|
||||
'flex cursor-pointer items-center gap-x-3 rounded p-2 text-sm font-medium text-neutral-400 transition ease-in-out hover:bg-neutral-50 hover:text-neutral-700'
|
||||
}
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center">
|
||||
<LogOut />
|
||||
</div>
|
||||
Sign out
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {default as Sidebar} from './Sidebar';
|
||||
@@ -1,61 +0,0 @@
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
|
||||
export interface TabProps {
|
||||
links: {
|
||||
to: string;
|
||||
text: string;
|
||||
active: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.links
|
||||
*/
|
||||
export default function Tabs({links}: TabProps) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div>
|
||||
<div className="sm:hidden">
|
||||
<label htmlFor="tabs" className="sr-only">
|
||||
Select a tab
|
||||
</label>
|
||||
<select
|
||||
id="tabs"
|
||||
name="tabs"
|
||||
className="focus:ring-mirage-500 focus:border-mirage-500 block w-full rounded border-neutral-300 py-2 pl-3 pr-10 text-base focus:outline-none sm:text-sm"
|
||||
onChange={e => router.push(e.target.value)}
|
||||
>
|
||||
{links.map(link => {
|
||||
return (
|
||||
<option value={link.to} selected={link.active}>
|
||||
{link.text}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<div className="border-b border-neutral-200">
|
||||
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
|
||||
{links.map(link => {
|
||||
return (
|
||||
<Link
|
||||
href={link.to}
|
||||
className={`${
|
||||
link.active
|
||||
? 'border-mirage-500 text-mirage-600'
|
||||
: 'text-neutral-500 hover:border-neutral-300 hover:text-neutral-700'
|
||||
} whitespace-nowrap border-b-2 border-transparent px-1 py-4 text-sm font-medium transition`}
|
||||
>
|
||||
{link.text}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export {default as Tabs} from './Tabs';
|
||||
@@ -1,6 +0,0 @@
|
||||
export * from './Sidebar';
|
||||
export * from './ProjectSelector';
|
||||
export * from './Tabs';
|
||||
export * from './SettingTabs';
|
||||
export * from './AnalyticsTabs';
|
||||
export * from './DeveloperTabs';
|
||||
@@ -1,181 +0,0 @@
|
||||
import React from 'react';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
|
||||
export interface ModalProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
onAction: () => void;
|
||||
children?: React.ReactNode;
|
||||
action?: string;
|
||||
type: 'info' | 'danger';
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.isOpen
|
||||
* @param root0.onToggle
|
||||
* @param root0.onAction
|
||||
* @param root0.children
|
||||
* @param root0.action
|
||||
* @param root0.type
|
||||
* @param root0.title
|
||||
* @param root0.description
|
||||
* @param root0.icon
|
||||
*/
|
||||
export default function Modal({
|
||||
title,
|
||||
description,
|
||||
isOpen,
|
||||
onToggle,
|
||||
onAction,
|
||||
children,
|
||||
action,
|
||||
type,
|
||||
icon,
|
||||
}: ModalProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-20 overflow-y-auto"
|
||||
aria-labelledby="modal-title"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="flex min-h-screen items-end justify-center px-4 pb-20 pt-4 text-center sm:block sm:p-0">
|
||||
<motion.div
|
||||
initial={{opacity: 0}}
|
||||
animate={{opacity: 1}}
|
||||
exit={{opacity: 0}}
|
||||
transition={{ease: 'easeInOut', duration: 0.15}}
|
||||
className="fixed inset-0 z-20 bg-neutral-500 bg-opacity-75 transition ease-in-out"
|
||||
aria-hidden="true"
|
||||
onClick={onToggle}
|
||||
/>
|
||||
|
||||
<span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
|
||||
​
|
||||
</span>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, scale: 0.7}}
|
||||
animate={{opacity: 1, scale: 1}}
|
||||
exit={{opacity: 0, scale: 0.7}}
|
||||
transition={{ease: 'easeInOut', duration: 0.15}}
|
||||
className="relative z-40 inline-block transform overflow-hidden rounded-lg border border-black border-opacity-5 bg-white px-8 py-10 text-left align-bottom shadow-2xl sm:my-8 sm:w-full sm:max-w-xl sm:align-middle"
|
||||
>
|
||||
<div className="absolute right-0 top-0 hidden p-8 sm:block">
|
||||
<button
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
onToggle();
|
||||
}}
|
||||
type="button"
|
||||
className="rounded-md bg-white text-neutral-400 transition hover:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="sm:flex sm:items-start">
|
||||
{type === 'info' ? (
|
||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-neutral-100 p-3 text-neutral-800 sm:mx-0 sm:h-12 sm:w-12">
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon ?? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg bg-red-50 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg
|
||||
className="h-6 w-6 text-red-900"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon ?? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex-1 sm:ml-4 sm:mt-0 sm:text-left">
|
||||
<div className={'mb-3'}>
|
||||
<p className={'text-lg font-semibold text-neutral-800'}>{title}</p>
|
||||
<p className={'text-sm text-neutral-500'}>{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${children ? 'mt-5' : ''} sm:flex sm:flex-row-reverse`}>
|
||||
<motion.button
|
||||
whileHover={{scale: 1.05}}
|
||||
whileTap={{scale: 0.95}}
|
||||
type="button"
|
||||
className={`${
|
||||
type === 'info'
|
||||
? 'bg-neutral-800 focus:ring-neutral-800'
|
||||
: 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
|
||||
} inline-flex w-full justify-center rounded border border-transparent px-6 py-2 text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm`}
|
||||
onClick={onAction}
|
||||
>
|
||||
{action ? action : 'Confirm'}
|
||||
</motion.button>
|
||||
<motion.button
|
||||
whileHover={{scale: 1.05}}
|
||||
whileTap={{scale: 0.95}}
|
||||
type="button"
|
||||
className="mt-3 inline-flex w-full justify-center rounded border border-neutral-300 bg-white px-6 py-2 text-base font-medium text-neutral-700 focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-2 sm:mt-0 sm:w-auto sm:text-sm"
|
||||
onClick={onToggle}
|
||||
>
|
||||
Cancel
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||