Initial Commit
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.yarn/
|
||||
.next/
|
||||
.github/
|
||||
dist/
|
||||
assets/
|
||||
node_modules/
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Build and Push Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: driaug/plunk:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
dist
|
||||
.idea
|
||||
.vscode
|
||||
*.log
|
||||
.env
|
||||
.tscache
|
||||
.next
|
||||
.out
|
||||
build
|
||||
.DS_Store
|
||||
.yarn/install-state.gz
|
||||
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
@@ -0,0 +1,35 @@
|
||||
# Base Stage
|
||||
FROM node:alpine AS base
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG NEXT_PUBLIC_API_URI=PLUNK_API_URI
|
||||
|
||||
RUN yarn install --network-timeout 1000000
|
||||
RUN yarn build:shared
|
||||
RUN yarn workspace @plunk/api build
|
||||
RUN yarn workspace @plunk/dashboard build
|
||||
|
||||
# Final Stage
|
||||
FROM node:alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache bash nginx
|
||||
|
||||
COPY --from=base /app/packages/api/dist /app/packages/api/
|
||||
COPY --from=base /app/packages/dashboard/.next /app/packages/dashboard/.next
|
||||
COPY --from=base /app/packages/dashboard/public /app/packages/dashboard/public
|
||||
COPY --from=base /app/node_modules /app/node_modules
|
||||
COPY --from=base /app/packages/shared /app/packages/shared
|
||||
COPY --from=base /app/prisma /app/prisma
|
||||
COPY deployment/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY deployment/entry.sh deployment/replace-variables.sh /app/
|
||||
|
||||
RUN chmod +x /app/entry.sh /app/replace-variables.sh
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["sh", "/app/entry.sh"]
|
||||
@@ -0,0 +1,28 @@
|
||||

|
||||
|
||||
<h1 align="center">Plunk</h1>
|
||||
|
||||
<p align="center">
|
||||
The Open-Source Email Platform for AWS
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/github/contributors/useplunk/plunk"/>
|
||||
<img src="https://img.shields.io/github/actions/workflow/status/driaug/plunk-whitelabel/docker-build.yml"/>
|
||||
<img src="https://img.shields.io/docker/pulls/driaug/plunk"/>
|
||||
</p>
|
||||
|
||||
## Introduction
|
||||
Plunk is an open-source email platform built on top of AWS SES. It allows you to easily send emails from your applications.
|
||||
It can be considered as a self-hosted alternative to services like [SendGrid](https://sendgrid.com/), [Resend](https://resend.com) or [Mailgun](https://www.mailgun.com/).
|
||||
|
||||
## Features
|
||||
- **Transactional Emails**: Send emails straight from your API
|
||||
- **Automations**: Create automations based on user actions
|
||||
- **Broadcasts**: Send newsletters and product updates to big audiences
|
||||
|
||||
## Self-hosting Plunk
|
||||
The easiest way to self-host Plunk is by using the `driaug/plunk` Docker image.
|
||||
You can pull the latest image from [Docker Hub](https://hub.docker.com/r/driaug/plunk/).
|
||||
|
||||
A complete guide on how to deploy Plunk can be found in the [documentation](https://docs.useplunk.com/getting-started/self-hosting).
|
||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.8.3/schema.json",
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"a11y": {
|
||||
"useKeyWithClickEvents": "off",
|
||||
"noSvgWithoutTitle": "off",
|
||||
"useButtonType": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "off",
|
||||
"noStaticOnlyClass": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
|
||||
echo "Starting Prisma migrations..."
|
||||
npx prisma migrate deploy
|
||||
echo "Prisma migrations completed."
|
||||
|
||||
sh replace-variables.sh &&
|
||||
|
||||
nginx &
|
||||
|
||||
echo "Starting the API server..."
|
||||
node packages/api/app.js &
|
||||
echo "API server started in the background."
|
||||
|
||||
echo "Starting the Dashboard..."
|
||||
cd packages/dashboard
|
||||
npx next start -p 5000 -H 0.0.0.0
|
||||
echo "Dashboard started."
|
||||
@@ -0,0 +1,25 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
server {
|
||||
listen 3000;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://plunk:4000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://plunk:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Baking Environment Variables..."
|
||||
|
||||
if [ -z "${API_URI}" ]; then
|
||||
echo "API_URI is not set. Exiting..."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find and replace baked values with real values for the API_URI
|
||||
find /app/packages/dashboard/public /app/packages/dashboard/.next -type f -name "*.js" |
|
||||
while read file; do
|
||||
sed -i "s|PLUNK_API_URI|${API_URI}|g" "$file"
|
||||
done
|
||||
|
||||
echo "Environment Variables Baked."
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "plunk",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/*"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"npm": ">=6.14.x",
|
||||
"yarn": "1.22.x",
|
||||
"node": ">=18.x"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.8.3",
|
||||
"lerna": "^8.1.6",
|
||||
"prisma": "^5.17.0",
|
||||
"rimraf": "^5.0.9"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.17.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev:api": "yarn workspace @plunk/api dev",
|
||||
"dev:dashboard": "yarn workspace @plunk/dashboard dev",
|
||||
"dev:shared": "yarn workspace @plunk/shared dev",
|
||||
"build:api": "yarn build:shared && yarn workspace @plunk/api build",
|
||||
"build:dashboard": "yarn build:shared && yarn workspace @plunk/dashboard build",
|
||||
"build:shared": "yarn generate && yarn workspace @plunk/shared build",
|
||||
"clean": "rimraf node_modules yarn.lock && yarn add lerna -DW && lerna run clean",
|
||||
"preinstall": "node tools/preinstall.js",
|
||||
"migrate": "prisma migrate dev",
|
||||
"migrate:deploy": "prisma migrate deploy",
|
||||
"generate": "prisma generate"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# ENV
|
||||
JWT_SECRET=mysupersecretJWTsecret
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
|
||||
NODE_ENV=development
|
||||
|
||||
# AWS
|
||||
AWS_REGION=
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_SES_CONFIGURATION_SET=
|
||||
AWS_CLOUDFRONT_DISTRIBUTION_ID=
|
||||
AWS_S3_BUCKET=
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
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 { API_URI, NODE_ENV, PORT } 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";
|
||||
|
||||
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: [API_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(),
|
||||
]);
|
||||
|
||||
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(PORT, () => {
|
||||
task.start();
|
||||
|
||||
signale.success("[HTTPS] Ready on", PORT);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 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 PORT = validateEnv<`${number}`>("PORT", "4000");
|
||||
export const NODE_ENV = validateEnv<"development" | "production">(
|
||||
"NODE_ENV",
|
||||
"production",
|
||||
);
|
||||
|
||||
export const REDIS_URL = validateEnv("REDIS_URL");
|
||||
|
||||
// URLs
|
||||
export const API_URI = validateEnv("API_URI", "http://localhost:8080");
|
||||
export const APP_URI = validateEnv("APP_URI", "http://localhost:3000");
|
||||
|
||||
// AWS
|
||||
export const AWS_REGION = validateEnv("AWS_REGION");
|
||||
export const AWS_ACCESS_KEY_ID = validateEnv("AWS_SES_ACCESS_KEY_ID");
|
||||
export const AWS_SECRET_ACCESS_KEY = validateEnv("AWS_SES_SECRET_ACCESS_KEY");
|
||||
export const AWS_SES_CONFIGURATION_SET = validateEnv(
|
||||
"AWS_SES_CONFIGURATION_SET",
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
import cron from 'node-cron';
|
||||
import {API_URI} from './constants';
|
||||
import signale from 'signale';
|
||||
|
||||
export const task = cron.schedule('* * * * *', () => {
|
||||
signale.info('Running scheduled tasks');
|
||||
void fetch(`${API_URI}/tasks`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
signale.info('Updating verified identities');
|
||||
void fetch(`${API_URI}/identities/update`, {
|
||||
method: 'POST',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Controller, Get, Post } from "@overnightjs/core";
|
||||
import { UserSchemas, UtilitySchemas } from "@plunk/shared";
|
||||
import type { Request, Response } from "express";
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
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 existingProject = await prisma.project.findFirst({
|
||||
where: { email: { endsWith: email.split("@")[1] } },
|
||||
});
|
||||
|
||||
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 updateIdentities(req: Request, res: Response) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,624 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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 handleTasks(req: Request, res: Response) {
|
||||
// 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 = "";
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
({ 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) {
|
||||
({ 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: project.from ?? project.name,
|
||||
email:
|
||||
project.verified && project.email
|
||||
? project.email
|
||||
: "no-reply@useplunk.dev",
|
||||
},
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {ChildControllers, Controller} from '@overnightjs/core';
|
||||
import {SNSWebhook} from './SNS';
|
||||
|
||||
@Controller('incoming')
|
||||
@ChildControllers([new SNSWebhook()])
|
||||
export class IncomingWebhooks {}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {ChildControllers, Controller} from '@overnightjs/core';
|
||||
import {IncomingWebhooks} from './Incoming';
|
||||
|
||||
@Controller('webhooks')
|
||||
@ChildControllers([new IncomingWebhooks()])
|
||||
export class Webhooks {}
|
||||
@@ -0,0 +1,270 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
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, NotFound } from "../../exceptions";
|
||||
import {
|
||||
type IJwt,
|
||||
type ISecret,
|
||||
isAuthenticated,
|
||||
isValidSecretKey,
|
||||
} from "../../middleware/auth";
|
||||
import { CampaignService } from "../../services/CampaignService";
|
||||
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
|
||||
: "no-reply@useplunk.dev",
|
||||
},
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
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 } = CampaignSchemas.create.parse(
|
||||
req.body,
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
const chunkSize = 500;
|
||||
for (let i = 0; i < recipients.length; i += chunkSize) {
|
||||
const chunk = recipients.slice(i, i + chunkSize);
|
||||
|
||||
await prisma.campaign.update({
|
||||
where: { id: campaign.id },
|
||||
data: {
|
||||
recipients: {
|
||||
connect: chunk.map((r: string) => ({ id: r })),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
let { id, subject, body, recipients, style } = CampaignSchemas.update.parse(
|
||||
req.body,
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
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 < recipients.length; i += chunkSize) {
|
||||
const chunk = recipients.slice(i, i + chunkSize);
|
||||
|
||||
await prisma.campaign.update({
|
||||
where: { id },
|
||||
data: {
|
||||
recipients: {
|
||||
connect: chunk.map((r: string) => ({ id: r })),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
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 } = UtilitySchemas.id.parse(req.body);
|
||||
|
||||
const contact = await ContactService.id(id);
|
||||
|
||||
if (!contact || contact.projectId !== project.id) {
|
||||
throw new NotFound("contact");
|
||||
}
|
||||
|
||||
await prisma.contact.update({
|
||||
where: { 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 } = UtilitySchemas.id.parse(req.body);
|
||||
|
||||
const contact = await ContactService.id(id);
|
||||
|
||||
if (!contact || contact.projectId !== project.id) {
|
||||
throw new NotFound("contact");
|
||||
}
|
||||
|
||||
await prisma.contact.update({
|
||||
where: { 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.update.parse(
|
||||
req.body,
|
||||
);
|
||||
|
||||
let contact = await ContactService.id(id);
|
||||
|
||||
if (!contact || contact.projectId !== project.id) {
|
||||
throw new NotFound("contact");
|
||||
}
|
||||
|
||||
contact = await prisma.contact.update({
|
||||
where: { id },
|
||||
data: { email, subscribed, data: data ? JSON.stringify(data) : null },
|
||||
include: {
|
||||
triggers: { include: { event: true, action: true } },
|
||||
emails: { where: { subject: { not: null } } },
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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));
|
||||
|
||||
return res.status(200).json(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
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 } = TemplateSchemas.create.parse(
|
||||
req.body,
|
||||
);
|
||||
|
||||
const template = await prisma.template.create({
|
||||
data: {
|
||||
projectId: project.id,
|
||||
subject,
|
||||
body,
|
||||
type,
|
||||
style,
|
||||
},
|
||||
});
|
||||
|
||||
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 } = TemplateSchemas.update.parse(
|
||||
req.body,
|
||||
);
|
||||
|
||||
let template = await TemplateService.id(id);
|
||||
|
||||
if (!template || template.projectId !== project.id) {
|
||||
throw new NotFound("template");
|
||||
}
|
||||
|
||||
template = await prisma.template.update({
|
||||
where: { id },
|
||||
data: { subject, body, type, style },
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
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 && 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 } =
|
||||
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,
|
||||
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() });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import {PrismaClient} from '@prisma/client';
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
@@ -0,0 +1,34 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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];
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
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: project.from ?? project.name,
|
||||
email:
|
||||
project.verified && project.email
|
||||
? project.email
|
||||
: "no-reply@useplunk.dev",
|
||||
},
|
||||
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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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}}}},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
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,
|
||||
}: {
|
||||
from: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
reply?: string;
|
||||
to: string[];
|
||||
content: {
|
||||
subject: string;
|
||||
html: string;
|
||||
};
|
||||
headers?: {
|
||||
[key: string]: 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}>`;
|
||||
}
|
||||
|
||||
const rawMessage = `From: ${from.name} <${from.email}>
|
||||
To: ${to.join(", ")}
|
||||
Reply-To: ${reply || from.email}
|
||||
Subject: ${content.subject}
|
||||
MIME-Version: 1.0
|
||||
Content-Type: multipart/alternative; boundary="NextPart"
|
||||
${
|
||||
headers
|
||||
? Object.entries(headers)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join("\n")
|
||||
: ""
|
||||
}
|
||||
${unsubscribeLink}
|
||||
|
||||
--NextPart
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
${EmailService.breakLongLines(content.html, 500)}
|
||||
--NextPart--
|
||||
`;
|
||||
|
||||
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="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="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): string {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
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: 20,
|
||||
skip: (page - 1) * 20,
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
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 = await prisma.trigger.findMany({
|
||||
where: { contact: { projectId: id } },
|
||||
include: {
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
event: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
const emails = await prisma.email.findMany({
|
||||
where: { contact: { projectId: id } },
|
||||
include: {
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
const combined = [...triggers, ...emails];
|
||||
combined.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
|
||||
return combined.slice(skip, skip + 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: {
|
||||
recipients: { select: { id: true } },
|
||||
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,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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}`;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import Redis from 'ioredis';
|
||||
import {REDIS_URL} from '../app/constants';
|
||||
|
||||
export const redis = new Redis(REDIS_URL);
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
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")}`;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
NEXT_PUBLIC_API_URI=http://localhost:8080
|
||||
NEXT_PUBLIC_AWS_REGION=eu-west-3
|
||||
@@ -0,0 +1,5 @@
|
||||
/// <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.
|
||||
@@ -0,0 +1,18 @@
|
||||
/** @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;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"typescript": "5.5.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/favicon/mstile-150x150.png"/>
|
||||
<TileColor>#ffffff</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
|
After Width: | Height: | Size: 658 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,73 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Alert} from './Alert';
|
||||
@@ -0,0 +1,26 @@
|
||||
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>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Badge} from './Badge';
|
||||
@@ -0,0 +1,101 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Card} from './Card';
|
||||
@@ -0,0 +1,118 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as CodeBlock} from './CodeBlock';
|
||||
@@ -0,0 +1,191 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Dropdown} from './Dropdown';
|
||||
@@ -0,0 +1,57 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Input} from './Input';
|
||||
@@ -0,0 +1,873 @@
|
||||
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, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { API_URI } from "../../../lib/constants";
|
||||
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 { toast } from "sonner";
|
||||
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 fileInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
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;
|
||||
},
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange(editor.getHTML(), "PLUNK");
|
||||
},
|
||||
});
|
||||
|
||||
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"
|
||||
}
|
||||
>
|
||||
<div className={"w-full"} style={{ width: "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 grid 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"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={"revert-tailwind"}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: value,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
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,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
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,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
// @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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
// @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();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
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);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
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;
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Editor} from './Editor';
|
||||
@@ -0,0 +1,181 @@
|
||||
import React, {MutableRefObject, useEffect, useState} from 'react';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
|
||||
export interface MultiselectDropdownProps {
|
||||
onChange: (value: string[]) => void;
|
||||
values: readonly {
|
||||
name: string;
|
||||
value: string;
|
||||
tag?: string;
|
||||
}[];
|
||||
selectedValues?: readonly string[];
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param root0
|
||||
* @param root0.onChange
|
||||
* @param root0.values
|
||||
* @param root0.selectedValues
|
||||
* @param root0.className
|
||||
* @param root0.disabled
|
||||
*/
|
||||
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: any) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
if (mutableRef.current && !mutableRef.current.contains(event.target) && open) {
|
||||
setOpen(!open);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
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.ul
|
||||
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 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>
|
||||
|
||||
{values.filter(value => value.name.toLowerCase().includes(query.toLowerCase())).length === 0 ? (
|
||||
<li className="relative cursor-default select-none py-2 pl-3 pr-9 text-neutral-800">
|
||||
No results found
|
||||
</li>
|
||||
) : (
|
||||
values
|
||||
.filter(value => value.name.toLowerCase().includes(query.toLowerCase()))
|
||||
.map((value, index) => {
|
||||
return (
|
||||
<li
|
||||
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"
|
||||
role="option"
|
||||
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>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</motion.ul>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as MultiselectDropdown} from './MultiselectDropdown';
|
||||
@@ -0,0 +1,54 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as Toggle} from './Toggle';
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './Toggle';
|
||||
export * from './Dropdown';
|
||||
export * from './MultiselectDropdown';
|
||||
export * from './MarkdownEditor';
|
||||
export * from './Input';
|
||||
@@ -0,0 +1,19 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export {default as AnalyticsTabs} from './AnalyticsTabs';
|
||||