Compare commits
407
Commits
main
..
wcatbb/next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbde3cce3f | ||
|
|
df18979e5e | ||
|
|
a981a8bba4 | ||
|
|
7834b9e7ef | ||
|
|
9e2400c6de | ||
|
|
fb5aa8796a | ||
|
|
284838279d | ||
|
|
2c5a71518d | ||
|
|
a8014cf7cf | ||
|
|
3214f6c42d | ||
|
|
d24259e8d2 | ||
|
|
3343e891bd | ||
|
|
6f99d4190c | ||
|
|
5f990573c1 | ||
|
|
b50b5af763 | ||
|
|
35c9e4c34e | ||
|
|
7b6540a748 | ||
|
|
4b290fd0b7 | ||
|
|
f9d7cf2453 | ||
|
|
ac8aeebfbe | ||
|
|
1675b07694 | ||
|
|
d4f41892cd | ||
|
|
5046c21045 | ||
|
|
9c50e1a7b4 | ||
|
|
33df5632a3 | ||
|
|
3e3117e4a7 | ||
|
|
3b39d847cd | ||
|
|
dff25c62d9 | ||
|
|
200dfb1dbf | ||
|
|
a99cde8839 | ||
|
|
0e8b6f813d | ||
|
|
7b6872c4b7 | ||
|
|
c17f7065d9 | ||
|
|
f44d987bc9 | ||
|
|
dc2ce02d1a | ||
|
|
b9d692cf5b | ||
|
|
c19cf531b8 | ||
|
|
f0ef34646e | ||
|
|
6a340e9554 | ||
|
|
38565bbce2 | ||
|
|
c32b54f588 | ||
|
|
44987d2b5e | ||
|
|
2c3e8970cb | ||
|
|
395af0080c | ||
|
|
e3ea5fb6dc | ||
|
|
4451e921ca | ||
|
|
92949b7596 | ||
|
|
57bfbf874b | ||
|
|
1dc423a1c4 | ||
|
|
de8aaa5c45 | ||
|
|
1a1e3204ee | ||
|
|
6c4cc295b8 | ||
|
|
9f212ddcb8 | ||
|
|
5f82278282 | ||
|
|
f6a32ce610 | ||
|
|
0839975083 | ||
|
|
c90f3d0f01 | ||
|
|
5f3daf689b | ||
|
|
cc7fcdb4ef | ||
|
|
d75aed81e1 | ||
|
|
0cbaa8fc66 | ||
|
|
0686b3a3f2 | ||
|
|
9b15b93b96 | ||
|
|
8f2510f5ec | ||
|
|
e196d798c5 | ||
|
|
777a12ec89 | ||
|
|
6c6af775bc | ||
|
|
4f8c9f029d | ||
|
|
8f72994b46 | ||
|
|
d162f255c2 | ||
|
|
c8e252fefd | ||
|
|
b8f1ad9ab5 | ||
|
|
708ab506f1 | ||
|
|
ec1f4c9374 | ||
|
|
5528288c7b | ||
|
|
7bd098bdd0 | ||
|
|
cbea263a91 | ||
|
|
e8a247fe12 | ||
|
|
4b51e386e3 | ||
|
|
59aa7845ba | ||
|
|
5600c49bb6 | ||
|
|
21af8fe05e | ||
|
|
2c4d95e604 | ||
|
|
64bd094b47 | ||
|
|
53b33bbd36 | ||
|
|
5fa9b9dda5 | ||
|
|
4bc5424815 | ||
|
|
573e2e8449 | ||
|
|
00ae942c3c | ||
|
|
9c69c7a36e | ||
|
|
9568b7d345 | ||
|
|
6bba9b6199 | ||
|
|
2f55d32485 | ||
|
|
eec37ecb31 | ||
|
|
f947ee6f22 | ||
|
|
3330d83d08 | ||
|
|
de7ed84fcf | ||
|
|
e639c72e8b | ||
|
|
43ea660d52 | ||
|
|
1f85d448e2 | ||
|
|
e7f168c373 | ||
|
|
b42698616a | ||
|
|
a00473314f | ||
|
|
2dabcb2419 | ||
|
|
d9ae171cc7 | ||
|
|
e4e334c77a | ||
|
|
78d3d224af | ||
|
|
f9b1354460 | ||
|
|
95e5ec8485 | ||
|
|
d050b55baa | ||
|
|
7964563b62 | ||
|
|
e01dc4066c | ||
|
|
c286f49097 | ||
|
|
4c81d9ec04 | ||
|
|
a928666dfc | ||
|
|
64ba19e589 | ||
|
|
3a42012ac7 | ||
|
|
08e5c0d930 | ||
|
|
18788ac1ca | ||
|
|
d55dda3127 | ||
|
|
d637147f25 | ||
|
|
c1ed23c967 | ||
|
|
46001619e8 | ||
|
|
7a6df7760b | ||
|
|
d2d779c68f | ||
|
|
bbc12e0029 | ||
|
|
125718bb94 | ||
|
|
35d67c6b42 | ||
|
|
14c471e4ca | ||
|
|
42ceb6edc0 | ||
|
|
5bce1d74ff | ||
|
|
b4404f698e | ||
|
|
e4625894c9 | ||
|
|
af87d80baa | ||
|
|
1f84c9113c | ||
|
|
0cf0a26a97 | ||
|
|
0d4b694208 | ||
|
|
c40394ffd6 | ||
|
|
20ffb2b8bc | ||
|
|
0af79eb84b | ||
|
|
e2e98149cb | ||
|
|
f55ab31ae5 | ||
|
|
61a65d01c7 | ||
|
|
7aa3b74c6f | ||
|
|
7647109fb6 | ||
|
|
ad0c17da56 | ||
|
|
93ca6b8d94 | ||
|
|
c85df75d53 | ||
|
|
e16093a264 | ||
|
|
a54c8139b1 | ||
|
|
8de79e37e9 | ||
|
|
c662ecdbb2 | ||
|
|
8f6f63e572 | ||
|
|
22c22aba1b | ||
|
|
de106f9d91 | ||
|
|
8a38766c02 | ||
|
|
3a16ad3221 | ||
|
|
ec402cb9b3 | ||
|
|
663bc2be8d | ||
|
|
df28e2dc7d | ||
|
|
79fe9e94c7 | ||
|
|
c2afb2dfab | ||
|
|
0d1ed0ec67 | ||
|
|
99526ebfc2 | ||
|
|
e6baace00d | ||
|
|
743155b06a | ||
|
|
0a67a8278f | ||
|
|
26800a8553 | ||
|
|
93165644af | ||
|
|
4db1ccc3fc | ||
|
|
06c4d644b7 | ||
|
|
b5fa21a57c | ||
|
|
ddbe5237fe | ||
|
|
18e17cf198 | ||
|
|
6a6772d5ed | ||
|
|
50042cea27 | ||
|
|
3ccf890a21 | ||
|
|
d57f6c81cb | ||
|
|
c07816c2a1 | ||
|
|
73da0a5136 | ||
|
|
d12b797ebe | ||
|
|
7df43d8553 | ||
|
|
b1047590e6 | ||
|
|
e3cf0fe0c1 | ||
|
|
940c8938f1 | ||
|
|
e75e07f73f | ||
|
|
940a4d225b | ||
|
|
7386441e61 | ||
|
|
718251c67c | ||
|
|
b6c5471d27 | ||
|
|
fa22b8220a | ||
|
|
a5c575444b | ||
|
|
4dce71a1fe | ||
|
|
ee00eb3481 | ||
|
|
5d44b1606d | ||
|
|
e732c76490 | ||
|
|
fb02051538 | ||
|
|
6f3853de59 | ||
|
|
8790c45edc | ||
|
|
49824aff93 | ||
|
|
492beb095f | ||
|
|
d11c4af4d1 | ||
|
|
82a7c8490e | ||
|
|
457c829b2d | ||
|
|
da7f3e5718 | ||
|
|
76786b2eae | ||
|
|
3a299cae98 | ||
|
|
dd4737cfe9 | ||
|
|
02416654b6 | ||
|
|
85c992a9f9 | ||
|
|
38da58e5e9 | ||
|
|
519b131792 | ||
|
|
be2eb57369 | ||
|
|
19554e6e8f | ||
|
|
68b7e7c082 | ||
|
|
7648f6e1eb | ||
|
|
289c633189 | ||
|
|
857bb8ac9e | ||
|
|
cf6ad0b8e0 | ||
|
|
782dc5754d | ||
|
|
cf88680a13 | ||
|
|
6293259225 | ||
|
|
a8678b8842 | ||
|
|
5993b842a0 | ||
|
|
78cef418a1 | ||
|
|
d56ba0cf9b | ||
|
|
72dffe12e5 | ||
|
|
2d353ef839 | ||
|
|
f26ef53c8c | ||
|
|
98b95b2ab2 | ||
|
|
bc8611a662 | ||
|
|
2ddfceca36 | ||
|
|
862babb8f5 | ||
|
|
61cea95697 | ||
|
|
1fc1e23fce | ||
|
|
76155232a3 | ||
|
|
fa9e9de032 | ||
|
|
a6bd2e7dba | ||
|
|
e58dc1d056 | ||
|
|
ddc14ae853 | ||
|
|
0d54b1a631 | ||
|
|
afc405ec02 | ||
|
|
6a9f6aa65a | ||
|
|
8f725c7c84 | ||
|
|
97ab0a2c2c | ||
|
|
ddd58c20aa | ||
|
|
489c465599 | ||
|
|
451dd0327f | ||
|
|
e1f826357d | ||
|
|
8a136dde55 | ||
|
|
cc9b0f8a73 | ||
|
|
3cada63f15 | ||
|
|
7fdcb22515 | ||
|
|
d7b5d3f60e | ||
|
|
24f82b1be5 | ||
|
|
708dc81b88 | ||
|
|
35f5275d88 | ||
|
|
9567144390 | ||
|
|
1a5607f278 | ||
|
|
7e25c148cc | ||
|
|
122713c39a | ||
|
|
070fd812b2 | ||
|
|
4156cd436d | ||
|
|
996852a506 | ||
|
|
2fb588357a | ||
|
|
c3a276cde3 | ||
|
|
661bf2a9e7 | ||
|
|
007a908e83 | ||
|
|
23b4ec992d | ||
|
|
726f66762b | ||
|
|
3b77ca5570 | ||
|
|
8c0304273c | ||
|
|
dc9b88dedb | ||
|
|
409d1b83eb | ||
|
|
c3bd5d1d5f | ||
|
|
3336fa1e38 | ||
|
|
769e3748a0 | ||
|
|
91eb0f3a69 | ||
|
|
863e784e1c | ||
|
|
780741e37f | ||
|
|
e50c33ae4b | ||
|
|
f34052ed73 | ||
|
|
a555a12736 | ||
|
|
f37bfccbc2 | ||
|
|
db1496e525 | ||
|
|
443c6271f1 | ||
|
|
9cf5222c8c | ||
|
|
2485d2ff1d | ||
|
|
cb40669385 | ||
|
|
d9e7f9f5cf | ||
|
|
dafa90f0dd | ||
|
|
9737c2b67d | ||
|
|
1cd89d1375 | ||
|
|
11ef47b576 | ||
|
|
fbd303801f | ||
|
|
0ecd82d62e | ||
|
|
1452cbd591 | ||
|
|
07f875c18c | ||
|
|
5e4adb71ad | ||
|
|
1019ba0d82 | ||
|
|
6b25bbe2f8 | ||
|
|
b9758149f5 | ||
|
|
d438abf1a5 | ||
|
|
9375c598ee | ||
|
|
8f636cc5b9 | ||
|
|
1430f31e2d | ||
|
|
3225c5005b | ||
|
|
f36b213f0a | ||
|
|
cb15fad7da | ||
|
|
a3bbb1ed64 | ||
|
|
04275e3cf5 | ||
|
|
badb035585 | ||
|
|
c12d0c0898 | ||
|
|
6e9f11f6a7 | ||
|
|
1713b2398d | ||
|
|
68f99798f8 | ||
|
|
5a0a41c6bb | ||
|
|
129a496206 | ||
|
|
d0191be31d | ||
|
|
85b9d7afe7 | ||
|
|
ff5c79cfcf | ||
|
|
672f1e6657 | ||
|
|
433e796c04 | ||
|
|
77089fcdd6 | ||
|
|
a372e55b78 | ||
|
|
8ec0f50f68 | ||
|
|
dbf58046e7 | ||
|
|
d238965c2c | ||
|
|
53ecda9f7a | ||
|
|
fc535a558f | ||
|
|
f7ac25f91f | ||
|
|
9d8b4a59fb | ||
|
|
60804c12b0 | ||
|
|
90f8170efb | ||
|
|
1a10448af3 | ||
|
|
e10662a1a1 | ||
|
|
6746d9e843 | ||
|
|
82e846e993 | ||
|
|
4d7c68db3a | ||
|
|
16572b8f49 | ||
|
|
caf10dce41 | ||
|
|
895e0dc1a0 | ||
|
|
fd5acb9b78 | ||
|
|
c189175658 | ||
|
|
683356c17c | ||
|
|
0003e44db8 | ||
|
|
b2ecf60a13 | ||
|
|
903ea73ca2 | ||
|
|
d392d69fd2 | ||
|
|
19258477d2 | ||
|
|
e00fc1952b | ||
|
|
1a6fbbdc9a | ||
|
|
caaa58a0ff | ||
|
|
eac3815668 | ||
|
|
18b7d7fe98 | ||
|
|
9d7e8a1d1a | ||
|
|
ce552682c0 | ||
|
|
7d90bef8f4 | ||
|
|
f88e6f439d | ||
|
|
04a64ef83a | ||
|
|
8c348d3580 | ||
|
|
b4b7cc78f1 | ||
|
|
ec70d50b39 | ||
|
|
2b65940e73 | ||
|
|
af54ab4c4e | ||
|
|
5fc5bc68fd | ||
|
|
6cdbc43c8e | ||
|
|
c114feb32e | ||
|
|
726493a1ed | ||
|
|
d08471edc2 | ||
|
|
a7c214f9ea | ||
|
|
73918f5211 | ||
|
|
04634a447b | ||
|
|
73a1cdfefa | ||
|
|
fde00ebda3 | ||
|
|
6fe3779fcb | ||
|
|
736672007b | ||
|
|
3b73273629 | ||
|
|
40e35735dc | ||
|
|
e4a43dfb57 | ||
|
|
a28be9adc9 | ||
|
|
382e97e5b2 | ||
|
|
113b94cf2c | ||
|
|
c1f823e2f1 | ||
|
|
9fadd01a6e | ||
|
|
b3e6d03962 | ||
|
|
8defb9f8ab | ||
|
|
d96bce75d7 | ||
|
|
de34b8c76b | ||
|
|
e506659736 | ||
|
|
2745c1bf5d | ||
|
|
2ef463ce5e | ||
|
|
2e6b485532 | ||
|
|
61cb93d04c | ||
|
|
fe2e038037 | ||
|
|
b521a405bb | ||
|
|
b1f0043940 | ||
|
|
5158756650 | ||
|
|
62d7a61834 | ||
|
|
54fde9675c | ||
|
|
e893960a85 | ||
|
|
361ff6fede | ||
|
|
1f3978e89c | ||
|
|
2dd6af8ff7 | ||
|
|
84caef5c45 | ||
|
|
5b430fba0d | ||
|
|
ff1876d580 |
+67
-5
@@ -1,6 +1,68 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
.next
|
||||
.turbo
|
||||
out
|
||||
|
||||
# Development
|
||||
.env
|
||||
.next/
|
||||
.github/
|
||||
dist/
|
||||
assets/
|
||||
node_modules/
|
||||
.env*.local
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
.nyc_output
|
||||
**/__tests__
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
**/*.test.js
|
||||
**/*.test.jsx
|
||||
**/*.spec.ts
|
||||
**/*.spec.tsx
|
||||
**/*.spec.js
|
||||
**/*.spec.jsx
|
||||
test/
|
||||
vitest.config.ts
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
.gitlab-ci.yml
|
||||
|
||||
# Documentation (not needed in image)
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Docker
|
||||
Dockerfile*
|
||||
docker
|
||||
!docker/nginx
|
||||
!docker/*.sh
|
||||
.dockerignore
|
||||
|
||||
# Misc
|
||||
.cursor
|
||||
.eslintcache
|
||||
.cache
|
||||
tmp
|
||||
temp
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
# ========================================
|
||||
# Plunk Self-Hosting Configuration
|
||||
# ========================================
|
||||
|
||||
# ========================================
|
||||
# REQUIRED: Security & Database
|
||||
# ========================================
|
||||
DB_PASSWORD=changeme123
|
||||
JWT_SECRET=
|
||||
|
||||
# ========================================
|
||||
# REQUIRED: Domains
|
||||
# ========================================
|
||||
# Replace example.com with your domain
|
||||
# Or use *.localhost for local testing
|
||||
API_DOMAIN=api.example.com
|
||||
DASHBOARD_DOMAIN=app.example.com
|
||||
LANDING_DOMAIN=www.example.com
|
||||
WIKI_DOMAIN=docs.example.com
|
||||
|
||||
# For local development: URIs for running services locally
|
||||
# These are used by the applications at runtime
|
||||
API_URI=http://localhost:8080
|
||||
DASHBOARD_URI=http://localhost:3000
|
||||
LANDING_URI=http://localhost:4000
|
||||
WIKI_URI=http://localhost:1000
|
||||
|
||||
# NEXT_PUBLIC_* variables are used for client-side code and sitemap generation
|
||||
# Use placeholder URLs that will be replaced at Docker container runtime
|
||||
NEXT_PUBLIC_API_URI=https://next-api.useplunk.com
|
||||
NEXT_PUBLIC_DASHBOARD_URI=https://next-app.useplunk.com
|
||||
NEXT_PUBLIC_LANDING_URI=https://www.useplunk.com
|
||||
NEXT_PUBLIC_WIKI_URI=https://next-wiki.useplunk.com
|
||||
|
||||
# Set to 'true' if using HTTPS in production (behind a reverse proxy/load balancer)
|
||||
# This affects how application URIs are auto-generated from domain names
|
||||
USE_HTTPS=false
|
||||
|
||||
# ========================================
|
||||
# REQUIRED: AWS SES (Email Sending)
|
||||
# ========================================
|
||||
AWS_SES_REGION=us-east-1
|
||||
AWS_SES_ACCESS_KEY_ID=
|
||||
AWS_SES_SECRET_ACCESS_KEY=
|
||||
|
||||
# Configuration sets for email tracking
|
||||
# SES_CONFIGURATION_SET: Default configuration with open/click tracking enabled
|
||||
SES_CONFIGURATION_SET=plunk-configuration-set
|
||||
|
||||
# SES_CONFIGURATION_SET_NO_TRACKING: Optional configuration without tracking
|
||||
# If not set, the tracking toggle will be hidden in project settings
|
||||
# When set, projects can choose to disable email tracking
|
||||
SES_CONFIGURATION_SET_NO_TRACKING=plunk-no-tracking-configuration-set
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: OAuth Login
|
||||
# ========================================
|
||||
GITHUB_OAUTH_CLIENT=
|
||||
GITHUB_OAUTH_SECRET=
|
||||
GOOGLE_OAUTH_CLIENT=
|
||||
GOOGLE_OAUTH_SECRET=
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: Stripe Billing
|
||||
# ========================================
|
||||
STRIPE_SK=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PRICE_ONBOARDING=
|
||||
STRIPE_PRICE_EMAIL_USAGE=
|
||||
STRIPE_METER_EVENT_NAME=emails
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: File Storage (Minio)
|
||||
# ========================================
|
||||
# Minio is included by default in Docker Compose
|
||||
# These credentials match the Minio service configuration
|
||||
# Leave defaults unless you're using external storage
|
||||
MINIO_ROOT_USER=plunk
|
||||
MINIO_ROOT_PASSWORD=plunkminiopass
|
||||
MINIO_API_PORT=9000
|
||||
MINIO_CONSOLE_PORT=9001
|
||||
|
||||
# S3-compatible storage configuration
|
||||
# For self-hosted: uses internal Minio service (defaults work out of the box)
|
||||
S3_ENDPOINT=http://minio:9000
|
||||
S3_ACCESS_KEY_ID=plunk
|
||||
S3_ACCESS_KEY_SECRET=plunkminiopass
|
||||
S3_BUCKET=uploads
|
||||
S3_PUBLIC_URL=http://localhost:9000/uploads
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: Notifications (ntfy.sh)
|
||||
# ========================================
|
||||
# Plunk includes a self-hosted ntfy.sh server for system notifications
|
||||
# You can access the ntfy web UI at http://localhost:8080 (or your configured NTFY_PORT)
|
||||
# Subscribe to the topic 'plunk-notifications' to receive notifications
|
||||
|
||||
# Port for ntfy web UI and API (default: 8080)
|
||||
NTFY_PORT=8080
|
||||
|
||||
# Ntfy topic URL - Uses internal ntfy service by default
|
||||
# To use external ntfy.sh or your own server, change this URL
|
||||
# Examples:
|
||||
# - Self-hosted (default): http://ntfy/plunk-notifications
|
||||
# - Public ntfy.sh: https://ntfy.sh/your-unique-topic-name
|
||||
# - Custom server: https://your-ntfy-server.com/your-topic
|
||||
NTFY_URL=http://ntfy/plunk-notifications
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: SMTP Server
|
||||
# ========================================
|
||||
# The SMTP relay server allows sending emails via SMTP protocol
|
||||
# TLS certificates can be mounted via:
|
||||
# 1. Traefik acme.json (requires SMTP_DOMAIN to select the right cert)
|
||||
# 2. PEM files (privkey.pem and fullchain.pem)
|
||||
|
||||
# SMTP domain - Required if using Traefik acme.json with multiple certificates
|
||||
# Optional if using PEM files
|
||||
SMTP_DOMAIN=smtp.example.com
|
||||
|
||||
# SMTP Ports (defaults work for most setups)
|
||||
# PORT_SECURE=465 # SMTPS (implicit TLS)
|
||||
# PORT_SUBMISSION=587 # SMTP Submission (STARTTLS)
|
||||
|
||||
# Maximum recipients per email (default: 5)
|
||||
# MAX_RECIPIENTS=5
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: Platform emails
|
||||
# ========================================
|
||||
# Your Plunk instance will send emails if you provide a Plunk API key and a from address
|
||||
# These emails include system notifications, for example when your project hits billing limits
|
||||
|
||||
# PLUNK_API_KEY=
|
||||
# PLUNK_FROM_ADDRESS=
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: Security Settings
|
||||
# ========================================
|
||||
# Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
|
||||
# When enabled (default), projects exceeding security limits will be automatically suspended
|
||||
# When disabled, violations will be logged and notifications sent, but projects won't be auto-disabled
|
||||
# Recommended for self-hosters: false (manage project status manually)
|
||||
# Default: true
|
||||
# AUTO_PROJECT_DISABLE=false
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: Self-Hosting User Management
|
||||
# ========================================
|
||||
# Controls whether new user signups are allowed
|
||||
# When enabled (true), the signup endpoint will reject new user registration attempts
|
||||
# Useful for private instances or when you want to manually manage users
|
||||
# Default: false
|
||||
# DISABLE_SIGNUPS=false
|
||||
|
||||
# Controls whether email validation checks are performed on signup
|
||||
# When enabled (true), validates emails for disposable domains, plus-addressing, domain existence, and MX records
|
||||
# When disabled (false), skips these validation checks and allows any email format
|
||||
# Default: false
|
||||
# VERIFY_EMAIL_ON_SIGNUP=false
|
||||
|
||||
# ========================================
|
||||
# ADVANCED (rarely needed)
|
||||
# ========================================
|
||||
# NGINX_PORT=80
|
||||
@@ -0,0 +1,45 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: true,
|
||||
tsconfigRootDir: __dirname,
|
||||
ecmaVersion: 2024,
|
||||
sourceType: 'module',
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended-type-checked',
|
||||
'plugin:@typescript-eslint/stylistic-type-checked',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:jsx-a11y/recommended',
|
||||
'next/core-web-vitals',
|
||||
'prettier',
|
||||
],
|
||||
plugins: ['@typescript-eslint', 'react', 'jsx-a11y', 'import'],
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{argsIgnorePattern: '^_', varsIgnorePattern: '^_', destructuredArrayIgnorePattern: '^_'},
|
||||
],
|
||||
'@typescript-eslint/consistent-type-imports': ['warn', {prefer: 'type-imports'}],
|
||||
'import/order': [
|
||||
'warn',
|
||||
{
|
||||
'groups': ['builtin', 'external', 'internal', 'parent', 'sibling'],
|
||||
'newlines-between': 'always',
|
||||
'alphabetize': {order: 'asc'},
|
||||
},
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
ignorePatterns: ['node_modules/', 'dist/', '.next/', '*.config.js', '*.config.ts'],
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: Feature requests
|
||||
about: Use this template to suggest new features for Plunk
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Is your feature request related to a problem?
|
||||
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
## Describe the solution you'd like
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
## Alternatives or workarounds
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
## Additional context
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: Product Issues
|
||||
about: Use this template for bugs and issues on Plunk
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
|
||||
## To Reproduce
|
||||
|
||||
## Expected behavior
|
||||
|
||||
## Environment
|
||||
|
||||
Please select the option that applies:
|
||||
|
||||
* Deployment type:
|
||||
|
||||
* [ ] Hosted
|
||||
* [ ] Self-hosted
|
||||
|
||||
If self-hosted, please provide relevant details (Docker, Kubernetes, bare metal, etc.):
|
||||
|
||||
|
||||
## Version verification
|
||||
|
||||
> ⚠️ **Important:**
|
||||
> Self-hosted issues **must** be reproduced on the latest commit.
|
||||
> Issues without a confirmed commit SHA may be closed without investigation.
|
||||
|
||||
* [ ] I am using the hosted version
|
||||
|
||||
**If self-hosted:**
|
||||
|
||||
* [ ] I have confirmed this issue still exists on the **latest commit** (not the `latest` tag)
|
||||
|
||||
* Commit SHA tested: `__________`
|
||||
|
||||
## Logs / Error output
|
||||
|
||||
If applicable, add logs, stack traces, or error messages here.
|
||||
|
||||
```
|
||||
PASTE LOGS HERE
|
||||
```
|
||||
|
||||
## Screenshots / Recordings / Additional context
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
Please confirm the following before submitting:
|
||||
|
||||
* [ ] I have searched existing issues to ensure this bug has not already been reported
|
||||
* [ ] I have provided clear reproduction steps
|
||||
* [ ] I have included all relevant environment details
|
||||
@@ -0,0 +1,45 @@
|
||||
## Description
|
||||
|
||||
<!-- Describe your changes in detail -->
|
||||
|
||||
## Type of Change
|
||||
|
||||
<!-- Mark the appropriate option with an 'x' -->
|
||||
|
||||
- [ ] `feat:` New feature (MINOR version bump)
|
||||
- [ ] `fix:` Bug fix (PATCH version bump)
|
||||
- [ ] `feat!:` Breaking change - new feature (MAJOR version bump)
|
||||
- [ ] `fix!:` Breaking change - bug fix (MAJOR version bump)
|
||||
- [ ] `docs:` Documentation update (no version bump)
|
||||
- [ ] `chore:` Maintenance/dependencies (no version bump)
|
||||
- [ ] `refactor:` Code refactoring (no version bump)
|
||||
- [ ] `test:` Adding tests (no version bump)
|
||||
- [ ] `perf:` Performance improvement (PATCH version bump)
|
||||
|
||||
## PR Title Format
|
||||
|
||||
<!--
|
||||
Your PR title should follow conventional commits format:
|
||||
✅ feat: add email template editor
|
||||
✅ fix: resolve memory leak in worker
|
||||
✅ feat!: redesign API authentication
|
||||
❌ Added new feature (missing type prefix)
|
||||
|
||||
See VERSIONING.md for more details
|
||||
-->
|
||||
|
||||
## Testing
|
||||
|
||||
<!-- Describe how you tested your changes -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] PR title follows conventional commits format
|
||||
- [ ] Code builds successfully
|
||||
- [ ] Tests pass locally
|
||||
- [ ] Documentation updated (if needed)
|
||||
|
||||
## Related Issues
|
||||
|
||||
<!-- Link any related issues here -->
|
||||
Closes #
|
||||
@@ -0,0 +1,197 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- next
|
||||
pull_request:
|
||||
branches:
|
||||
- next
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test Suite
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Service containers for database, Redis, and Minio
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: plunk_test
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
minio:
|
||||
image: minio/minio:edge-cicd
|
||||
env:
|
||||
MINIO_ROOT_USER: plunk
|
||||
MINIO_ROOT_PASSWORD: plunkminiopass
|
||||
ports:
|
||||
- 9000:9000
|
||||
options: >-
|
||||
--health-cmd "curl -f http://localhost:9000/minio/health/live || exit 1"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Setup environment variables
|
||||
run: |
|
||||
cat > .env << EOF
|
||||
NODE_ENV=test
|
||||
|
||||
# Database
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/plunk_test
|
||||
DIRECT_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/plunk_test
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Security
|
||||
JWT_SECRET=test-jwt-secret-for-ci-only
|
||||
|
||||
# S3 (Minio)
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_ACCESS_KEY_ID=plunk
|
||||
S3_ACCESS_KEY_SECRET=plunkminiopass
|
||||
S3_BUCKET=uploads
|
||||
S3_PUBLIC_URL=http://localhost:9000/uploads
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# Application URLs (not needed for tests but required by schema)
|
||||
API_URI=http://localhost:8080
|
||||
DASHBOARD_URI=http://localhost:3000
|
||||
LANDING_URI=http://localhost:4000
|
||||
WIKI_URI=http://localhost:1000
|
||||
|
||||
# AWS SES (mock values for tests)
|
||||
AWS_SES_REGION=us-east-1
|
||||
AWS_SES_ACCESS_KEY_ID=mock
|
||||
AWS_SES_SECRET_ACCESS_KEY=mock
|
||||
SES_CONFIGURATION_SET=test
|
||||
SES_CONFIGURATION_SET_NO_TRACKING=test-no-tracking
|
||||
EOF
|
||||
|
||||
- name: Build shared packages
|
||||
run: yarn build --filter="@plunk/db" --filter="@plunk/types" --filter="@plunk/shared"
|
||||
|
||||
- name: Generate Prisma Client
|
||||
run: yarn workspace @plunk/db db:generate
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/plunk_test
|
||||
DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/plunk_test
|
||||
|
||||
- name: Run database migrations
|
||||
run: yarn workspace @plunk/db migrate:prod
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/plunk_test
|
||||
DIRECT_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/plunk_test
|
||||
|
||||
- name: Create MinIO bucket
|
||||
run: |
|
||||
docker run --rm --network host \
|
||||
--entrypoint /bin/sh minio/mc:latest \
|
||||
-c "mc alias set local http://localhost:9000 plunk plunkminiopass && \
|
||||
mc mb local/uploads --ignore-existing"
|
||||
|
||||
- name: Run tests
|
||||
run: yarn test:run
|
||||
env:
|
||||
NODE_ENV: test
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results
|
||||
path: |
|
||||
coverage/
|
||||
**/*.test.ts.log
|
||||
retention-days: 7
|
||||
|
||||
lint:
|
||||
name: Lint & Type Check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Setup minimal env (for build)
|
||||
run: |
|
||||
cat > .env << EOF
|
||||
NODE_ENV=development
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/plunk
|
||||
DIRECT_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/plunk
|
||||
REDIS_URL=redis://localhost:6379
|
||||
JWT_SECRET=test
|
||||
|
||||
# S3 (minimal - not needed but schema may require)
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_ACCESS_KEY_ID=mock
|
||||
S3_ACCESS_KEY_SECRET=mock
|
||||
S3_BUCKET=uploads
|
||||
S3_PUBLIC_URL=http://localhost:9000/uploads
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# Application URLs
|
||||
API_URI=http://localhost:8080
|
||||
DASHBOARD_URI=http://localhost:3000
|
||||
LANDING_URI=http://localhost:4000
|
||||
WIKI_URI=http://localhost:1000
|
||||
|
||||
# AWS SES (mock)
|
||||
AWS_SES_REGION=us-east-1
|
||||
AWS_SES_ACCESS_KEY_ID=mock
|
||||
AWS_SES_SECRET_ACCESS_KEY=mock
|
||||
SES_CONFIGURATION_SET=test
|
||||
SES_CONFIGURATION_SET_NO_TRACKING=test
|
||||
EOF
|
||||
|
||||
- name: Build shared packages
|
||||
run: yarn build --filter="@plunk/db" --filter="@plunk/types" --filter="@plunk/shared"
|
||||
|
||||
- name: Run linter
|
||||
run: yarn lint
|
||||
|
||||
- name: Type check
|
||||
run: yarn build --filter="api" --filter="web" --filter="landing" --filter="wiki" || true
|
||||
@@ -1,32 +0,0 @@
|
||||
name: Build and Push Docker image (Canary)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- canary
|
||||
|
||||
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:canary
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -1,37 +0,0 @@
|
||||
name: Build and Push Docker image (Production)
|
||||
|
||||
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: Get version from package.json
|
||||
id: get_version
|
||||
run: echo "VERSION=$(jq -r .version package.json)" >> $GITHUB_ENV
|
||||
|
||||
- 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:${{ env.VERSION }}
|
||||
driaug/plunk:latest
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -0,0 +1,225 @@
|
||||
name: Docker Build and Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- next
|
||||
|
||||
jobs:
|
||||
# Prepare metadata and tags for all builds
|
||||
prepare:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
is_release: ${{ steps.check-release.outputs.is_release }}
|
||||
release_tag: ${{ steps.check-release.outputs.release_tag }}
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
image: ${{ steps.tags.outputs.image }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check if this commit is a release
|
||||
id: check-release
|
||||
run: |
|
||||
git fetch --tags
|
||||
|
||||
# Check if this is a release-please commit
|
||||
if git log -1 --pretty=%B | grep -q "release-please--branches--next"; then
|
||||
echo "This is a release commit, waiting for tag..."
|
||||
# Wait up to 60 seconds for release-please to create the tag
|
||||
for i in {1..12}; do
|
||||
sleep 5
|
||||
git fetch --tags
|
||||
TAG=$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || echo "")
|
||||
if [[ -n "$TAG" ]]; then
|
||||
echo "is_release=true" >> $GITHUB_OUTPUT
|
||||
echo "release_tag=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "Found release tag: $TAG"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for tag... ($i/12)"
|
||||
done
|
||||
echo "ERROR: Release commit but no tag found after 60s"
|
||||
exit 1
|
||||
else
|
||||
# Regular commit, check for tag immediately
|
||||
TAG=$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || echo "")
|
||||
if [[ -n "$TAG" ]]; then
|
||||
echo "is_release=true" >> $GITHUB_OUTPUT
|
||||
echo "release_tag=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "This is a release: $TAG"
|
||||
else
|
||||
echo "is_release=false" >> $GITHUB_OUTPUT
|
||||
echo "This is a regular commit"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Compute tags
|
||||
id: tags
|
||||
run: |
|
||||
IMAGE="ghcr.io/${{ github.repository }}"
|
||||
|
||||
if [[ "${{ steps.check-release.outputs.is_release }}" == "true" ]]; then
|
||||
# This is a release: use semver tags + latest
|
||||
TAG="${{ steps.check-release.outputs.release_tag }}"
|
||||
VERSION="${TAG#v}"
|
||||
MAJOR=$(echo "$VERSION" | cut -d. -f1)
|
||||
MINOR=$(echo "$VERSION" | cut -d. -f2)
|
||||
TAGS="${VERSION},${MAJOR}.${MINOR},${MAJOR},latest"
|
||||
echo "Building RELEASE with tags: $TAGS"
|
||||
else
|
||||
# Regular commit: use SHA tags only (no latest)
|
||||
SHORT_SHA="${GITHUB_SHA:0:7}"
|
||||
TAGS="sha-${SHORT_SHA}"
|
||||
echo "Building COMMIT with tags: $TAGS"
|
||||
fi
|
||||
echo "tags=$TAGS" >> $GITHUB_OUTPUT
|
||||
echo "image=$IMAGE" >> $GITHUB_OUTPUT
|
||||
|
||||
# Build natively on each platform (faster, no QEMU emulation)
|
||||
build:
|
||||
needs: prepare
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Turborepo cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .turbo
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-turbo-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-turbo-
|
||||
|
||||
- name: Free disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL
|
||||
docker system prune -af --volumes
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ needs.prepare.outputs.image }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: true
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ needs.prepare.outputs.image }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
build-args: |
|
||||
BUILDTIME=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
|
||||
VERSION=${{ github.ref_name }}
|
||||
REVISION=${{ github.sha }}
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ strategy.job-index }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Merge platform-specific images into multi-arch manifest
|
||||
merge:
|
||||
needs: [ prepare, build ]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
IMAGE="${{ needs.prepare.outputs.image }}"
|
||||
TAGS="${{ needs.prepare.outputs.tags }}"
|
||||
|
||||
# Convert comma-separated tags to array and create docker tag arguments
|
||||
TAG_ARGS=""
|
||||
IFS=',' read -ra TAG_ARRAY <<< "$TAGS"
|
||||
for tag in "${TAG_ARRAY[@]}"; do
|
||||
TAG_ARGS="$TAG_ARGS -t ${IMAGE}:${tag}"
|
||||
done
|
||||
|
||||
# Create and push manifest using all digests
|
||||
docker buildx imagetools create $TAG_ARGS \
|
||||
$(printf '${{ needs.prepare.outputs.image }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
IMAGE="${{ needs.prepare.outputs.image }}"
|
||||
TAGS="${{ needs.prepare.outputs.tags }}"
|
||||
FIRST_TAG=$(echo "$TAGS" | cut -d',' -f1)
|
||||
docker buildx imagetools inspect ${IMAGE}:${FIRST_TAG}
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
IMAGE="${{ needs.prepare.outputs.image }}"
|
||||
TAGS="${{ needs.prepare.outputs.tags }}"
|
||||
echo "## Docker Image Published 🚀" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [[ "${{ needs.prepare.outputs.is_release }}" == "true" ]]; then
|
||||
echo "**Type:** Release (${{ needs.prepare.outputs.release_tag }})" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**Type:** Commit (sha-${GITHUB_SHA:0:7})" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Tags:**" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
echo "$TAGS" | tr ',' '\n' | sed "s|^|${IMAGE}:|" >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Release Please
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- next
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Release Please
|
||||
uses: googleapis/release-please-action@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
target-branch: next
|
||||
+47
-8
@@ -1,12 +1,51 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
dist
|
||||
.idea
|
||||
.vscode
|
||||
*.log
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.tscache
|
||||
.next
|
||||
.out
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
|
||||
# Turbo
|
||||
.turbo
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# Build Outputs
|
||||
.next/
|
||||
out/
|
||||
build
|
||||
dist
|
||||
.source/
|
||||
|
||||
# Debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.yarn/install-state.gz
|
||||
*.pem
|
||||
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
robots.txt
|
||||
*sitemap*.xml
|
||||
|
||||
.contentlayer
|
||||
.content-collections
|
||||
.source
|
||||
|
||||
install-state.gz
|
||||
|
||||
tsconfig.tsbuildinfo
|
||||
next-env.d.ts
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"quoteProps": "consistent",
|
||||
"arrowParens": "avoid",
|
||||
"singleQuote": true,
|
||||
"bracketSpacing": false,
|
||||
"useTabs": false,
|
||||
"importOrderSeparation": true,
|
||||
"importOrderSortSpecifiers": true
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "0.8.0"
|
||||
}
|
||||
Vendored
-894
File diff suppressed because one or more lines are too long
+948
File diff suppressed because one or more lines are too long
+7
-1
@@ -1,3 +1,9 @@
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.3.0.cjs
|
||||
# Support multiple architectures - download pre-built binaries instead of compiling
|
||||
supportedArchitectures:
|
||||
os: [ "linux", "darwin", "win32" ]
|
||||
cpu: [ "x64", "arm64" ]
|
||||
libc: [ "glibc" ]
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.9.1.cjs
|
||||
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
# Changelog
|
||||
|
||||
## [0.8.0](https://github.com/useplunk/plunk/compare/v0.7.1...v0.8.0) (2026-03-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add multi-branch workflow conditions (switch/case) ([92949b7](https://github.com/useplunk/plunk/commit/92949b7596740b73d04c30e0e2bcc6305bbdf4a7))
|
||||
* **i18n:** add Chinese translations (zh-TW, zh-HK, zh-CN) ([33df563](https://github.com/useplunk/plunk/commit/33df5632a3d4068e641687c8d404a6eef4aa4d0e))
|
||||
* **i18n:** add Italian translation ([7b6872c](https://github.com/useplunk/plunk/commit/7b6872c4b79b0fde4491f02851a2049c846c83d6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Adapt SNS Webhook validation regex pattern to also support AWS eusc partition ([7b6540a](https://github.com/useplunk/plunk/commit/7b6540a748a90c10f41b931450ac809e7ae23c01))
|
||||
* add alt text to email badge image ([3b39d84](https://github.com/useplunk/plunk/commit/3b39d847cd278317723b4b226434c26c49becebe))
|
||||
* broken links to next-wiki.useplunk.com ([e3ea5fb](https://github.com/useplunk/plunk/commit/e3ea5fb6dcea95b5028f140b77e54d5938497223))
|
||||
* Connect branches smoothly to original node ([2c3e897](https://github.com/useplunk/plunk/commit/2c3e8970cbc4ad2eb39fd505a4292345c6cae1c0))
|
||||
* Enhance email step configuration validation and recipient handling ([b50b5af](https://github.com/useplunk/plunk/commit/b50b5af763d8a44b09c7022e1d9135bfca7907f6))
|
||||
* Enhance email step configuration validation and recipient handling ([a99cde8](https://github.com/useplunk/plunk/commit/a99cde88392450bf719112cb94cc1b9e7c7c1478))
|
||||
* normalize field references and handle undefined values in JSON ([b9d692c](https://github.com/useplunk/plunk/commit/b9d692cf5b491ab22a40ec69e77ff7bdc62eec63))
|
||||
* Prevent switching if nodes are attached to multi-branch ([c32b54f](https://github.com/useplunk/plunk/commit/c32b54f588db79a501db456e6075a337dee8ed26))
|
||||
* update old references to next.useplunk.com ([44987d2](https://github.com/useplunk/plunk/commit/44987d2b5e8028c6f8f26c39e9dec386e1ccf6e1))
|
||||
* visual editor preview ([4451e92](https://github.com/useplunk/plunk/commit/4451e921caff45dab126006ba103d91816bed696))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Fix broken links on api overview page ([dc2ce02](https://github.com/useplunk/plunk/commit/dc2ce02d1a4e4d1641728b720ca856cedba719a7))
|
||||
* Improve self-hosting env variable documentation ([f0ef346](https://github.com/useplunk/plunk/commit/f0ef34646e7593f07a780141e14d6d2b735c1101))
|
||||
|
||||
## [0.7.1](https://github.com/useplunk/plunk/compare/v0.7.0...v0.7.1) (2026-03-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Align preview and actual email for templates and campaigns ([cc7fcdb](https://github.com/useplunk/plunk/commit/cc7fcdb4ef31e9dfb4f7403aa93479b6df56719d))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Add inbound to docs ([6c4cc29](https://github.com/useplunk/plunk/commit/6c4cc295b88db6da8d9edcf23064a3fc8e9f5c36))
|
||||
* Improve webhook documentation ([f6a32ce](https://github.com/useplunk/plunk/commit/f6a32ce610559050c1ca9978607bd57ac51e906f))
|
||||
|
||||
## [0.7.0](https://github.com/useplunk/plunk/compare/v0.6.0...v0.7.0) (2026-03-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** support inline images in emails using Content-ID ([9b15b93](https://github.com/useplunk/plunk/commit/9b15b93b96344f811d869d103b3b6d344b531811))
|
||||
* Sort projects alphabetically in the dashboard and fix layout ([64bd094](https://github.com/useplunk/plunk/commit/64bd094b47abbc4feaeb93d97915df57763b3907))
|
||||
* Static segments ([4b51e38](https://github.com/useplunk/plunk/commit/4b51e386e39ae38d6ea52eb87858de36fa45ab46))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add support for STATIC segment type in CampaignService ([7bd098b](https://github.com/useplunk/plunk/commit/7bd098bdd01a8af0dd41ec515880289b1795e5af))
|
||||
* correct cookie domain for .local TLD hostnames ([59aa784](https://github.com/useplunk/plunk/commit/59aa7845bad69a1768bb88f83cfcea607c447538))
|
||||
* Correctly set domain status on manual verify ([21af8fe](https://github.com/useplunk/plunk/commit/21af8fe05e0450e8d826a3a01224511a337ca072))
|
||||
* Do not unsubscribe existing contacts ([2c4d95e](https://github.com/useplunk/plunk/commit/2c4d95e604cbed9189f7ee07c24b1f236fce7990))
|
||||
* Support any locale on creation ([ec1f4c9](https://github.com/useplunk/plunk/commit/ec1f4c9374e06e3defed3c728be603c10e4e7baa))
|
||||
* Verify SNS URL before sending fetch request ([b8f1ad9](https://github.com/useplunk/plunk/commit/b8f1ad9ab53c78f8ef063fdc125f397c8bfc7652))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Static segments ([e8a247f](https://github.com/useplunk/plunk/commit/e8a247fe12b79ae8a25a74485179e93081fe2002))
|
||||
|
||||
## [0.6.0](https://github.com/useplunk/plunk/compare/v0.5.0...v0.6.0) (2026-02-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Ability to change workflow trigger ([eec37ec](https://github.com/useplunk/plunk/commit/eec37ecb31c63888879a1933dba4fd0c6243018b))
|
||||
* Add billing for inbound ([3330d83](https://github.com/useplunk/plunk/commit/3330d83d08904bc547740bfc9fdcbc5ff214d977))
|
||||
* Add billing for inbound ([de7ed84](https://github.com/useplunk/plunk/commit/de7ed84fcfddde16a9297c947048e9876712f6fa))
|
||||
* Add dedicated received type ([f947ee6](https://github.com/useplunk/plunk/commit/f947ee6f22371055801fa9cfc62e15df7851986c))
|
||||
* **i18n:** Add Spanish language ([9c69c7a](https://github.com/useplunk/plunk/commit/9c69c7a36edd86fdc02b8d0d3e3f8d64ed8ecfa6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Enhance email bounce notification with latest bounce details ([e639c72](https://github.com/useplunk/plunk/commit/e639c72e8b940ba1da472d355f930359da7c868b))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Add receiving emails functionality and update DNS records documentation ([b426986](https://github.com/useplunk/plunk/commit/b42698616a3f7bb079a092375ac886f2a9d7405d))
|
||||
|
||||
## [0.5.0](https://github.com/useplunk/plunk/compare/v0.4.0...v0.5.0) (2026-02-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Ability to disable signups and disable email verification for self-hosters ([9316564](https://github.com/useplunk/plunk/commit/93165644af1ebcd7d5eb1900b13e5e38c1af0262))
|
||||
* add "olderThan" segment filter operator ([b5fa21a](https://github.com/useplunk/plunk/commit/b5fa21a57cbf491e0438eb29fc9419063fa2aea7))
|
||||
* Add additional checks for website, NS records and personal emails ([0a67a82](https://github.com/useplunk/plunk/commit/0a67a8278f45d89b1abdf55be1cf251a470595cf))
|
||||
* Add advanced DNS configuration ([7964563](https://github.com/useplunk/plunk/commit/7964563b620868279f5fb6baab0d7499c41f51ed))
|
||||
* Add bounce and complaint filter to activity feed ([c85df75](https://github.com/useplunk/plunk/commit/c85df75d539f7e5500a9a109966b0e6d654d4022))
|
||||
* add documentation link and redirect to WIKI_URI ([4c81d9e](https://github.com/useplunk/plunk/commit/4c81d9ec04003550b140d884f37ebbf13137166d))
|
||||
* Add inbound handling ([d050b55](https://github.com/useplunk/plunk/commit/d050b55baaf46306be3fb6e5ad683205e158bd65))
|
||||
* Add initial handling in webhook for inbound ([e01dc40](https://github.com/useplunk/plunk/commit/e01dc4066c5e5eb5e0e69ef92f8a5bb7786e5a91))
|
||||
* Add initial handling in webhook for inbound ([c286f49](https://github.com/useplunk/plunk/commit/c286f490974d8dfe9c94695c63e6b48216b8052d))
|
||||
* add minimum thresholds for bounce and complaint rates ([d55dda3](https://github.com/useplunk/plunk/commit/d55dda312718890231d1a428448e607792799c84))
|
||||
* Add support for custom email recipients in workflow steps ([78d3d22](https://github.com/useplunk/plunk/commit/78d3d224af60cf5b58c6b18fdd0921320912fb09))
|
||||
* **i18n:** Add Bulgarian translations ([663bc2b](https://github.com/useplunk/plunk/commit/663bc2be8da69733d2bbc53c6b1bfcc36ff55cf8))
|
||||
* **i18n:** add Czech locale translations ([14c471e](https://github.com/useplunk/plunk/commit/14c471e4cadbe1bbf90e53522134fad3d81ff107))
|
||||
* **i18n:** add Polish locale translations ([7a6df77](https://github.com/useplunk/plunk/commit/7a6df7760bc73dfe80dcf2d37612e320f6de1d30))
|
||||
* **i18n:** add Polish locale translations ([d2d779c](https://github.com/useplunk/plunk/commit/d2d779c68f958a6ac0e77b7588e92a5c705a8fd4)), closes [#246](https://github.com/useplunk/plunk/issues/246)
|
||||
* **i18n:** Add Portuguese translations ([c2afb2d](https://github.com/useplunk/plunk/commit/c2afb2dfab977ae74f3b694601a1f2d0353660a8))
|
||||
* Integrate NuqsAdapter for improved state management and query handling ([0cf0a26](https://github.com/useplunk/plunk/commit/0cf0a26a97e608f654ed3046c1a46d414557e3ee))
|
||||
* Remove domain from AWS if no longer in use by other projects ([e6baace](https://github.com/useplunk/plunk/commit/e6baace00d5f7a109e717b8a996bc2cda52cc9fa))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* added missing services:down script, added "win32" to supportedArchitectures for yarn package installation, added missing required WIKI_URI to .env.example of api ([ddbe523](https://github.com/useplunk/plunk/commit/ddbe5237fe7012e197e3b398aebc8e2921a4328a))
|
||||
* Center "Add Step" nodes below parent nodes and update positions on drag ([e4e334c](https://github.com/useplunk/plunk/commit/e4e334c77a7ba41e5a60b69b9c64fa3bb72f1e74))
|
||||
* Enhance email activity filtering by adding date range checks ([ad0c17d](https://github.com/useplunk/plunk/commit/ad0c17da566a26a296229c05d9b4e18cf7d401ad))
|
||||
* Ensure template type is loaded before rendering Select ([8a38766](https://github.com/useplunk/plunk/commit/8a38766c025afd5ef15fee4b7c5baea97a45ec48))
|
||||
* Implement merging for activity updates to preserve component state ([0d4b694](https://github.com/useplunk/plunk/commit/0d4b694208fd8e516e1f50f7384d1ac5bd6561e0))
|
||||
* Improve bounce handling logic to differentiate between permanent and transient bounces ([7df43d8](https://github.com/useplunk/plunk/commit/7df43d8553eca93b601915ea4deaf59233e848c7))
|
||||
* Improve email verification logic by prioritizing MX record checks and clarifying domain existence validation ([26800a8](https://github.com/useplunk/plunk/commit/26800a85538fc90aa05fc43b1cc33cef95d8fb32))
|
||||
* Refactor Redis keys to prevent multiple messages on concurrent requests ([4db1ccc](https://github.com/useplunk/plunk/commit/4db1ccc3fc59edb0187d8e2223f45bb0bce6deb5))
|
||||
* Remove 'Optional' label from MAIL FROM Domain and Inbound Email headings ([f9b1354](https://github.com/useplunk/plunk/commit/f9b135446040de099484db66139560f43bbf027e))
|
||||
* Update contact subscription logic for upsert operations ([a928666](https://github.com/useplunk/plunk/commit/a928666dfcdc65c90602051c79b3c008282674aa))
|
||||
* Update URL replacement logic to handle runtime paths and add warnings for missing files ([c07816c](https://github.com/useplunk/plunk/commit/c07816c2a1b029d83400128f4720263e975214ad))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove unnecessary logging for segment processing ([42ceb6e](https://github.com/useplunk/plunk/commit/42ceb6edc03ebc6f14c6d399e859914ac09f1ab6))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add webhooks documentation for real-time event handling ([5bce1d7](https://github.com/useplunk/plunk/commit/5bce1d74fffb927bcbb2c7df624fde089101efc8))
|
||||
* update contacts documentation to include subscription state and email delivery rules ([b4404f6](https://github.com/useplunk/plunk/commit/b4404f698ec28a59f32d728c15c59c8b7765d377))
|
||||
|
||||
## [0.4.0](https://github.com/useplunk/plunk/compare/v0.3.0...v0.4.0) (2026-01-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add cooldown to resend verification email ([457c829](https://github.com/useplunk/plunk/commit/457c829b2d59debc41ac69f907f758dd5ded1c1a))
|
||||
* Add email verification on signup ([fb02051](https://github.com/useplunk/plunk/commit/fb02051538029d8a6b806ce69b25fb9e75622693))
|
||||
* Add forwarding domains as verification check ([e732c76](https://github.com/useplunk/plunk/commit/e732c76490e015b87a9165a98f1f1b5084552f84))
|
||||
* Add id as reserved field in templates, campaigns and workflows ([7386441](https://github.com/useplunk/plunk/commit/7386441e6137ac9f059a3818895f1b1059e2d99d))
|
||||
* Add platform emails for domain verification and expiration ([19554e6](https://github.com/useplunk/plunk/commit/19554e6e8f94fbcf74006017454aa83a707617ea))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add better validation for sender email ([e75e07f](https://github.com/useplunk/plunk/commit/e75e07f73f5928ded281d2704b0fd06fedeb9077))
|
||||
* Catch unknown content-type headers ([a5c5754](https://github.com/useplunk/plunk/commit/a5c575444ba698624b3932b4d6414c5ad9df282a))
|
||||
* Check email volume for 7-day window ([492beb0](https://github.com/useplunk/plunk/commit/492beb095fd7be0cfd3de9761420d7ce1170d56f))
|
||||
* Copy types build files ([49824af](https://github.com/useplunk/plunk/commit/49824aff93c7ce09caa0cb57cfef68ae296f6626))
|
||||
* Enhance CORS handling to allow requests with rejection logging ([718251c](https://github.com/useplunk/plunk/commit/718251c67c876352a5dfca7592613e33f6713061))
|
||||
* Migrate over to new pagination format in dashboard ([8790c45](https://github.com/useplunk/plunk/commit/8790c45edc1374f9649b8438563fc8844a645367))
|
||||
* Reentry into segment not working after exit ([4dce71a](https://github.com/useplunk/plunk/commit/4dce71a1fe22774391bf0d0e87f1564c3b93b496))
|
||||
* Refactor CORS handling to allow unrestricted access for public API endpoints ([940c893](https://github.com/useplunk/plunk/commit/940c8938f163879da5be205bcc8bb82ecd69279a))
|
||||
* Update sentCount on campaign sent for correct overview stats ([ee00eb3](https://github.com/useplunk/plunk/commit/ee00eb34811270473d1f79729853eaada883a477))
|
||||
* Update template fetching to use Template type and simplify body access ([b6c5471](https://github.com/useplunk/plunk/commit/b6c5471d272e8ba835282691946a418385896c98))
|
||||
* Update templates data fetching to use PaginatedResponse type ([fa22b82](https://github.com/useplunk/plunk/commit/fa22b8220a909e7234948aeeb2a6734ae51aeec9))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Add more details about personalisation ([940a4d2](https://github.com/useplunk/plunk/commit/940a4d225b86ba5af5377851ab758a43b3aa71ff))
|
||||
|
||||
## [0.3.0](https://github.com/useplunk/plunk/compare/v0.2.0...v0.3.0) (2025-12-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Ability to overwrite locale on contact level with locale key on data ([7615523](https://github.com/useplunk/plunk/commit/76155232a3383e75e8c7b44a498454b07472852a))
|
||||
* Add additional banner and information about security metrics ([bc8611a](https://github.com/useplunk/plunk/commit/bc8611a66250eb7747e7acd6e882a740c0028ba1))
|
||||
* Add bulk actions to contact overview ([726f667](https://github.com/useplunk/plunk/commit/726f66762b890c73041139432524d6c85d6bd709))
|
||||
* Add email verification and password reset ([1a5607f](https://github.com/useplunk/plunk/commit/1a5607f2780d5a4692492032dd0cd2e7521362d9))
|
||||
* Add email verification endpoint at /v1/verify ([6a9f6aa](https://github.com/useplunk/plunk/commit/6a9f6aa65a3219c5d4d6f33253cdcf145c3ff20b))
|
||||
* Add plus address check to /v1/verify ([afc405e](https://github.com/useplunk/plunk/commit/afc405ec028ac9d7333a7817c49f1232278fc28b))
|
||||
* Add project-scoped language for unsubscribe footer and contact-facing pages ([e1f8263](https://github.com/useplunk/plunk/commit/e1f826357d1e8cff7bd3c2811698734f578836f5))
|
||||
* Allow to pick currency when starting subscription ([8a136dd](https://github.com/useplunk/plunk/commit/8a136dde55fd1fae1f2a2e285019beb35fc75977))
|
||||
* Email preview in contact and activity feed ([72dffe1](https://github.com/useplunk/plunk/commit/72dffe12e53ff9d74ef43da2cf653d31e7a4df25))
|
||||
* **i18n:** add German translations and update supported languages ([a6bd2e7](https://github.com/useplunk/plunk/commit/a6bd2e7dba261a858eab6ab1f782ddc9efb136a5))
|
||||
* **i18n:** add Hindi translations for contact-facing pages ([ddc14ae](https://github.com/useplunk/plunk/commit/ddc14ae8534eda2e1368f148e0434388008bb7b5)), closes [#246](https://github.com/useplunk/plunk/issues/246)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add styling for visual editor emails in preview ([5993b84](https://github.com/useplunk/plunk/commit/5993b842a0f17d66644aaa3057602ae8f3daebc2))
|
||||
* Correctly reserve fields from being set on contact ([61cea95](https://github.com/useplunk/plunk/commit/61cea95697ccb56c525002b08f76bf57da896837))
|
||||
* Date filtering not working properly for custom contact data ([97ab0a2](https://github.com/useplunk/plunk/commit/97ab0a2c2c811ac1b8a2b9039ab835e837a3f3be))
|
||||
* Do not check verification if platform emails are not enabled ([9567144](https://github.com/useplunk/plunk/commit/9567144390512173f7f615db71368c1cd26d9f4d))
|
||||
* Import no longer case-sensitive about email column ([451dd03](https://github.com/useplunk/plunk/commit/451dd0327f4866fd27343407a84a6c979cfcd70d))
|
||||
* Pass through email verification if auth type is apiKey ([d7b5d3f](https://github.com/useplunk/plunk/commit/d7b5d3f60ed1af6ca9bf8e2a659204a01ca3acb0))
|
||||
* Persistence of subscription state for existing contacts ([007a908](https://github.com/useplunk/plunk/commit/007a908e833cdd1b229f485c34c17c6510a55f9c))
|
||||
* Properly tag events in SegmentFilterBuilder.tsx ([8f725c7](https://github.com/useplunk/plunk/commit/8f725c7c84749eca5647fdbd19d41260f6998d6e))
|
||||
* Redirect verification link to dashboard instead of landing ([35f5275](https://github.com/useplunk/plunk/commit/35f5275d889b167e0fe75246b29a4ffa632bad46))
|
||||
* Set auth type before disable check ([862babb](https://github.com/useplunk/plunk/commit/862babb8f5ab47599ce6a841fc988fafa1ec0bbe))
|
||||
* Variable substitution in transactional emails ([8c03042](https://github.com/useplunk/plunk/commit/8c0304273c2bd1a64718ec56838632aa63447ef8))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* Add locale overwrite to project documentation ([1fc1e23](https://github.com/useplunk/plunk/commit/1fc1e23fce69a870ccf95faf2fff644733de7145))
|
||||
* Add plus address check to /v1/verify ([0d54b1a](https://github.com/useplunk/plunk/commit/0d54b1a631415a15e504ff5bd4573ddb12cc421a))
|
||||
* Improve docs with core-concept and guides ([2ddfcec](https://github.com/useplunk/plunk/commit/2ddfceca3606b0d4d832f83fce14c7277b5eaa35))
|
||||
* Update openapi.json to match actual API outputs ([dc9b88d](https://github.com/useplunk/plunk/commit/dc9b88dedb75535814239b4bc73f62375570998b))
|
||||
|
||||
## [0.2.0](https://github.com/useplunk/plunk/compare/v0.1.1...v0.2.0) (2025-12-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* ability to create new campaigns based on templates or previous campaigns ([6b25bbe](https://github.com/useplunk/plunk/commit/6b25bbe2f86e5dd6946ea38a9f34e9c7bdb5fb0f))
|
||||
* Add improved html editor using CodeMirror ([672f1e6](https://github.com/useplunk/plunk/commit/672f1e6657293860554be1f86167cf4f4403b0ff))
|
||||
* Add security center and warning for exceeding bounce/complaint rates ([fbd3038](https://github.com/useplunk/plunk/commit/fbd303801f9f1c260c5f8201bf218c9f277fe4d1))
|
||||
* Added platform emails for billing limits and disabled projects ([2485d2f](https://github.com/useplunk/plunk/commit/2485d2ff1db652e87f8f1307c8ef770edd19bbd1))
|
||||
* Automatically detect rate limit from AWS with ability to override in .env ([3225c50](https://github.com/useplunk/plunk/commit/3225c5005be42f1443fdca0f8233193ce029c890))
|
||||
* Improved createdAt and updatedAt visualisation ([1019ba0](https://github.com/useplunk/plunk/commit/1019ba0d82c7cb6a0d4cef5989841ccc28dae776))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ability to clear reply-to and from name from templates and campaigns ([badb035](https://github.com/useplunk/plunk/commit/badb035585561b276b6e82a363693918810c8214))
|
||||
* Add additional checks for disabled projects ([863e784](https://github.com/useplunk/plunk/commit/863e784e1c806118204acb3ba489322fe51498ad))
|
||||
* Add additional checks for disabled projects ([780741e](https://github.com/useplunk/plunk/commit/780741e37f7fec5b822b91c329ebe98428077ba0))
|
||||
* add additional indexes on event model ([1cd89d1](https://github.com/useplunk/plunk/commit/1cd89d137511ed4a8ae932a10f4f21487fbcee7c))
|
||||
* Allow changing audience type after creation of campaign ([85b9d7a](https://github.com/useplunk/plunk/commit/85b9d7afe718c803be147475d5fbe13dafd677bf))
|
||||
* Better highlight warnings in SecurityWarningBanner.tsx ([91eb0f3](https://github.com/useplunk/plunk/commit/91eb0f3a699eb7a51e87addfa122107ce74b4a39))
|
||||
* Clear notification cache keys when changing billing limits ([e50c33a](https://github.com/useplunk/plunk/commit/e50c33ae4b2d5144a1bfce72ae97d8ede0187d17))
|
||||
* Correctly show recipient count during creation and edit ([d0191be](https://github.com/useplunk/plunk/commit/d0191be31da8fc894269851a247746ce39a958b2))
|
||||
* custom relative time to shorten strings for better UI fit ([5e4adb7](https://github.com/useplunk/plunk/commit/5e4adb71ade38cf53e67776ae3524a381641fcfd))
|
||||
* display email progress instead of scheduling progress for campaigns ([07f875c](https://github.com/useplunk/plunk/commit/07f875c18c03a8d1766040bc40034c1023715fcd))
|
||||
* Hide upsell banner if billing is not configured ([433e796](https://github.com/useplunk/plunk/commit/433e796c041e8d65068084d5c7729c397956098e))
|
||||
* Increase z-index of color picker ([769e374](https://github.com/useplunk/plunk/commit/769e3748a0bbbcafdb1f79c9aea22d282e39b513))
|
||||
* Move react and react-dom to dependencies instead of peerDependency for API ([a555a12](https://github.com/useplunk/plunk/commit/a555a127366f753130c765f9eb4700dc44a8cb7c))
|
||||
* Move react and react-dom to dependencies instead of peerDependency for API ([f37bfcc](https://github.com/useplunk/plunk/commit/f37bfccbc22a0c2672971ff0e174f49f31301876))
|
||||
* Only check free tier limits if billing is enabled ([1713b23](https://github.com/useplunk/plunk/commit/1713b2398d5c238058639ac5a0d5cef916a7a1e8))
|
||||
* Only fetch project members if project Id is defined ([5a0a41c](https://github.com/useplunk/plunk/commit/5a0a41c6bbbb9bd4eefd5ed62e02c0a7c00c1022))
|
||||
* Overflow of inputs in email editor ([3336fa1](https://github.com/useplunk/plunk/commit/3336fa1e38a29d9c1f69c62259662b1ff2ba6e61))
|
||||
* Prevent manual tracking of internal events that are automatically tracked ([f34052e](https://github.com/useplunk/plunk/commit/f34052ed73cd78db4e4bf39cab8740ce0b78e93c))
|
||||
* prevent scheduling of campaign if billing limit reached ([a3bbb1e](https://github.com/useplunk/plunk/commit/a3bbb1ed64ba461cb2f7170c1929f68f22b5bb4d))
|
||||
* show correct default value for placeholder ([04275e3](https://github.com/useplunk/plunk/commit/04275e3cf59f902d50acb84e756f1c7425171726))
|
||||
* Unauthenticated users are redirected to login on subscribe/unsubscribe/manage pages ([11ef47b](https://github.com/useplunk/plunk/commit/11ef47b576ec39209f2b9726c1027d06f8bbc03a))
|
||||
* Update recipient count on create/update of campaign ([ff5c79c](https://github.com/useplunk/plunk/commit/ff5c79cfcf0ac6b351af20345ec639110f206f4b))
|
||||
* Verify if sending without tracking is possible in SESService ([68f9979](https://github.com/useplunk/plunk/commit/68f99798f8c5944c18b438329b961fdec955ecef))
|
||||
|
||||
## [0.1.1](https://github.com/useplunk/plunk/compare/v0.1.0...v0.1.1) (2025-12-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add additional verification in Oauth controllers ([53ecda9](https://github.com/useplunk/plunk/commit/53ecda9f7a34111785ac3ea3af18cb33460a44af))
|
||||
* add clear cache button on full-screen loader ([60804c1](https://github.com/useplunk/plunk/commit/60804c12b0b4c4c3ef97e730e4857d41fa1fd021))
|
||||
* Dedicated token name for next version ([fc535a5](https://github.com/useplunk/plunk/commit/fc535a558fab28045dae9ce978f483e58c72a24f))
|
||||
* only mark releases as latest ([9d8b4a5](https://github.com/useplunk/plunk/commit/9d8b4a59fbd42420bb595d2a44df93a29214121a))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* dynamic link to Docker Compose for self-hosting ([90f8170](https://github.com/useplunk/plunk/commit/90f8170efbad0cec981a24e7831840aac65d8d70))
|
||||
* Update AWS setup docs ([f7ac25f](https://github.com/useplunk/plunk/commit/f7ac25f91f4e1dcce301fb325801a67f2a9dee07))
|
||||
|
||||
## [0.1.0](https://github.com/useplunk/plunk/compare/v0.0.1...v0.1.0) (2025-12-08)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Added ability to overwrite sender mail and name for templates and campaigns ([f1d4d50](https://github.com/useplunk/plunk/commit/f1d4d5073ccd7ddceabfdbd268c5ff142480fb75))
|
||||
* basic health-checks ([2e69173](https://github.com/useplunk/plunk/commit/2e69173d1378bec8296ce556499f71990ee92375))
|
||||
* **dashboard:** add status filter for contacts ([45697cf](https://github.com/useplunk/plunk/commit/45697cf6b122266ec3ab35e51e064db48517448d))
|
||||
* Email attachment support ([0e718d4](https://github.com/useplunk/plunk/commit/0e718d4dba1c8a258eb949cc0590ee14046c080a))
|
||||
* Increase pagination limits to improve data display and update version to 1.2.1 ([4fe90ad](https://github.com/useplunk/plunk/commit/4fe90adf1e97c85afddeae0ad9e1ecc11dd0609b))
|
||||
* Optimize data retrieval and add new indexes for performance improvements ([51b40cc](https://github.com/useplunk/plunk/commit/51b40cc06962ad98333a50ac7b4dbd82d8a3fe10))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add fixed yarn version ([daa8285](https://github.com/useplunk/plunk/commit/daa8285af59c54c302b805e1d5afa0106567bbf4))
|
||||
* Add fixed yarn version to Docker build ([ff5c6af](https://github.com/useplunk/plunk/commit/ff5c6af9d730a80d037a84454074dedda5709862))
|
||||
* Add ref ([4c25a28](https://github.com/useplunk/plunk/commit/4c25a28d69f7f3719b1180924ef1c26958757ba5))
|
||||
* Add target ([8ca5aea](https://github.com/useplunk/plunk/commit/8ca5aea0ea34f530595c7ec4e1f9b7514ba4e1a9))
|
||||
* Added Migration ([e2d0d0e](https://github.com/useplunk/plunk/commit/e2d0d0eb0d2989e2668506c48fe55c0e41b29da3))
|
||||
* Added try/catch to CRON ([c96a007](https://github.com/useplunk/plunk/commit/c96a0074f87f084801289cc9852f27221e4720e9))
|
||||
* Check if APP_URI has https ([bad18ae](https://github.com/useplunk/plunk/commit/bad18ae559d582181d8c298f3c537c50b4b08851))
|
||||
* clear Redis key on event deletion ([0ef39ce](https://github.com/useplunk/plunk/commit/0ef39ce3d2ad90be8e83ab97d5076f90642f3cc6))
|
||||
* Correctly duplicate from ([111e054](https://github.com/useplunk/plunk/commit/111e054fe88997201ebf012602223c92f65d21a6))
|
||||
* Force node-20 ([0b79bef](https://github.com/useplunk/plunk/commit/0b79bef2cb79cd3f8073884c9b84df733a696bad))
|
||||
* incorrect domain verification records ([4bb38b9](https://github.com/useplunk/plunk/commit/4bb38b9f43cf495cf08ec6d95466e1b8cea409ce))
|
||||
* incorrect schema in dashboard ([d69a57d](https://github.com/useplunk/plunk/commit/d69a57d71150e52e12cd1ed475dd16353d5ff9c1))
|
||||
* move updating the contact to contacts controller ([7c8d3bb](https://github.com/useplunk/plunk/commit/7c8d3bb050fb0c35e0296eeb71d516f0c37329a2))
|
||||
* Proper reset of contact ([7879e66](https://github.com/useplunk/plunk/commit/7879e6631e8a06020753d7a78583d4a1404b7684))
|
||||
* refetch contact after awaiting triggers ([7202606](https://github.com/useplunk/plunk/commit/7202606aef0081a1e68f91df3b2e94e67e44ca39))
|
||||
* refetch contact after awaiting triggers ([3f70be9](https://github.com/useplunk/plunk/commit/3f70be95dde80c04611089f66b1fcaa8eb30655e))
|
||||
* Remove openssl install ([605a5a0](https://github.com/useplunk/plunk/commit/605a5a0abfbf0d11076440afc07d2f9b2d1ee071))
|
||||
* return updated contact info ([115ac5f](https://github.com/useplunk/plunk/commit/115ac5fe6c87181fe33de367d88d01ef872ed2af))
|
||||
* server crash in self-hosted instances ([ccb832d](https://github.com/useplunk/plunk/commit/ccb832d095de7d7b65ee07743ba8cf8025b86948))
|
||||
* Show project `from` and `name` as backup ([d4bdcbf](https://github.com/useplunk/plunk/commit/d4bdcbf78ecfe7913128f8c5c90e36ee60eb6dca))
|
||||
* Unlink domain properly executes ([f58149a](https://github.com/useplunk/plunk/commit/f58149a1b168cf931c2a2768345f2a8213fe27a1))
|
||||
* Update yarn.lock ([ee1651e](https://github.com/useplunk/plunk/commit/ee1651e3902d879047082864a459ff63f7bee57d))
|
||||
* Version ([5ca4567](https://github.com/useplunk/plunk/commit/5ca45677ffa181b9d288e42f69dc90a2498342af))
|
||||
* Version ([3a5d05a](https://github.com/useplunk/plunk/commit/3a5d05a65ba38d4abe5f70be4fe1bd195290e055))
|
||||
* Version ([42e865c](https://github.com/useplunk/plunk/commit/42e865ccbab7799c535a3c18ade4caddffd88f72))
|
||||
* Version ([b742ab1](https://github.com/useplunk/plunk/commit/b742ab10eb34c52fbd9b9af32e36e3784c2d6fc8))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove recipients from campaign data structure ([c40189e](https://github.com/useplunk/plunk/commit/c40189ebbdb80c7e24881191cb1b750d9faffbc1))
|
||||
@@ -0,0 +1,172 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Plunk is a Turborepo monorepo containing multiple applications and shared packages for a platform service. The project
|
||||
uses Yarn workspaces with Node.js 20+ requirement.
|
||||
|
||||
## Scale & Performance Requirements
|
||||
|
||||
**CRITICAL**: This service operates at high scale with a large amount of contacts being added every day. All code
|
||||
changes must consider:
|
||||
|
||||
- **Database Performance**: Queries must be optimized for large datasets (1M+ rows). Avoid N+1 queries, use proper
|
||||
indexes, and prefer cursor-based pagination over offset-based.
|
||||
- **Memory Efficiency**: Never load large datasets into memory. Always use streaming or batch processing with reasonable
|
||||
limits.
|
||||
- **Asynchronous Operations**: Heavy computations (counts, aggregations, bulk updates) should be offloaded to background
|
||||
jobs via BullMQ, not executed synchronously in API requests.
|
||||
- **Caching Strategy**: Frequently accessed computed values should be cached or stored as materialized data to avoid
|
||||
repeated expensive queries.
|
||||
- **Query Optimization**: Be mindful of JSON field queries (Contact.data) - these require GIN indexes. Test query plans
|
||||
with EXPLAIN ANALYZE.
|
||||
- **API Response Times**: Target < 200ms for read operations, < 500ms for write operations. Use timeouts and circuit
|
||||
breakers.
|
||||
|
||||
When implementing features that query or process contacts, segments, or campaigns:
|
||||
|
||||
1. Always consider performance with millions of contacts
|
||||
2. Use pagination with reasonable defaults (20-100 items)
|
||||
3. Implement background jobs for bulk operations
|
||||
4. Add database indexes for new query patterns
|
||||
5. Cache computed values that don't need real-time accuracy
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Environment Setup
|
||||
|
||||
- **Start services**: `yarn services:up` - Starts PostgreSQL, Redis, Minio, and Browserless via Docker Compose
|
||||
- **Build shared packages**: `yarn build --filter="@plunk/shared"` - Required before running apps
|
||||
|
||||
### Development
|
||||
|
||||
- **Start all apps**: `yarn dev` - Starts all apps including API server and worker process
|
||||
- **Start specific app**: `yarn dev --filter="<app-name>"` (e.g., `yarn dev --filter="web"`)
|
||||
- **Start API only (server)**: `yarn workspace api dev:server` - API server without worker
|
||||
- **Start API only (worker)**: `yarn workspace api dev:worker` - Worker process only
|
||||
- **Build all**: `yarn build`
|
||||
- **Lint all**: `yarn lint`
|
||||
- **Clean all**: `yarn clean` - Removes node_modules, .turbo, and build artifacts
|
||||
|
||||
**Note**: The API's `dev` script automatically runs both the server and worker process using `concurrently`. If you need
|
||||
to run them separately (e.g., for debugging), use `dev:server` and `dev:worker` individually.
|
||||
|
||||
### Database (Prisma)
|
||||
|
||||
- **Generate client**: `yarn workspace @plunk/db db:generate`
|
||||
- **Run migrations (dev)**: `yarn workspace @plunk/db migrate:dev`
|
||||
- **Deploy migrations (prod)**: `yarn workspace @plunk/db migrate:prod`
|
||||
|
||||
## Architecture
|
||||
|
||||
### Applications (`apps/`)
|
||||
|
||||
- **api**: Express.js API server with TypeScript (ESM), uses @overnightjs/core
|
||||
- HTTP API endpoints for the platform
|
||||
- Background cron jobs (workflow processor, domain verification)
|
||||
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
|
||||
- **web**: Next.js app (Pages Router) - Main platform (next-app.useplunk.com)
|
||||
- **landing**: Next.js app (Pages Router) - Marketing site (www.useplunk.com)
|
||||
- **wiki**: Next.js app - Documentation site (docs.useplunk.com)
|
||||
|
||||
### Background Job Architecture
|
||||
|
||||
The API uses BullMQ (backed by Redis) for asynchronous job processing:
|
||||
|
||||
- **API Server** creates jobs and adds them to queues (email, campaign, workflow)
|
||||
- **Worker Process** (`apps/api/src/jobs/worker.ts`) consumes jobs from queues
|
||||
- Jobs are processed with retry logic, rate limiting, and concurrency control
|
||||
- Worker runs separately for scalability and fault isolation (can scale workers independently)
|
||||
|
||||
### Shared Packages (`packages/`)
|
||||
|
||||
- **@plunk/db**: Prisma schema and client
|
||||
- **@plunk/ui**: ShadCN-based UI library with Radix UI + Tailwind
|
||||
- **@plunk/shared**: Common utilities and business logic
|
||||
- **@plunk/types**: TypeScript type definitions
|
||||
- **@plunk/email**: React-email templates
|
||||
- **@plunk/notifications**: Notification system
|
||||
|
||||
## Key Technologies
|
||||
|
||||
- **Frontend**: React 19, Next.js 15.3, Tailwind CSS, Framer Motion
|
||||
- **Backend**: Express.js, Prisma, Redis (ioredis), Stripe
|
||||
- **UI Library**: Radix UI primitives, ShadCN components
|
||||
- **Authentication**: JWT with bcrypt
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Import Organization
|
||||
|
||||
ESLint enforces import order: builtin → external → internal → parent → sibling with alphabetical sorting and newlines
|
||||
between groups.
|
||||
|
||||
### TypeScript
|
||||
|
||||
- Consistent type imports preferred: `import type { ... }`
|
||||
- Unused vars allowed with `_` prefix
|
||||
- Strict type checking enabled across all packages
|
||||
- Try to avoid inline types in favor of shared types in `@plunk/types`
|
||||
|
||||
### Component Structure
|
||||
|
||||
- UI components in `packages/ui/src/components/`
|
||||
- App-specific components in `apps/<app>/src/components/`
|
||||
- Atomic design pattern: atoms → molecules hierarchy
|
||||
|
||||
## Environment Variables
|
||||
|
||||
**Configuration File Setup:**
|
||||
|
||||
- **Development**: Copy `.env.example` to `.env` at the repository root and fill in your values
|
||||
- **All apps** (API, web, landing, wiki) load environment variables from the root `.env` file
|
||||
- **Production**: Environment variables are injected by Docker/orchestration systems (no .env file needed)
|
||||
|
||||
Required for builds and deployment (see turbo.json and .env.example):
|
||||
|
||||
**Build Time:**
|
||||
|
||||
- Database: `DATABASE_URL`, `DIRECT_DATABASE_URL` (for Prisma client generation)
|
||||
- Standard: `NODE_ENV`
|
||||
|
||||
**Runtime:**
|
||||
|
||||
- Security: `JWT_SECRET`
|
||||
- Database: `DATABASE_URL`, `DIRECT_DATABASE_URL`
|
||||
- Infrastructure: `REDIS_URL`
|
||||
- **Application URLs** (injected at runtime into Next.js apps): `API_URI`, `DASHBOARD_URI`, `LANDING_URI`, `WIKI_URI` (
|
||||
optional)
|
||||
- S3-compatible Storage (Minio): `S3_ENDPOINT`, `S3_ACCESS_KEY_ID`, `S3_ACCESS_KEY_SECRET`, `S3_BUCKET`,
|
||||
`S3_PUBLIC_URL`, `S3_FORCE_PATH_STYLE`
|
||||
- AWS SES: `AWS_SES_REGION`, `AWS_SES_ACCESS_KEY_ID`, `AWS_SES_SECRET_ACCESS_KEY`, `SES_CONFIGURATION_SET`,
|
||||
`SES_CONFIGURATION_SET_NO_TRACKING`
|
||||
- OAuth (optional): `GITHUB_OAUTH_CLIENT`, `GITHUB_OAUTH_SECRET`, `GOOGLE_OAUTH_CLIENT`, `GOOGLE_OAUTH_SECRET`
|
||||
- Stripe (optional): `STRIPE_SK`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ONBOARDING`, `STRIPE_PRICE_EMAIL_USAGE`,
|
||||
`STRIPE_METER_EVENT_NAME`
|
||||
- Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications)
|
||||
- Platform Email Notifications (optional): `PLUNK_API_KEY` (enables email notifications to users for critical events like
|
||||
project disabled, billing limits, etc. If not set, only ntfy notifications are sent)
|
||||
- Self-hosting User Management (optional):
|
||||
- `DISABLE_SIGNUPS` (default: false) - When set to true, prevents new user signups via the API
|
||||
- `VERIFY_EMAIL_ON_SIGNUP` (default: false) - When set to true, validates emails on signup for disposable domains,
|
||||
plus-addressing, domain existence, and MX records
|
||||
- Security (optional): `AUTO_PROJECT_DISABLE` (default: true) - Controls whether projects are automatically disabled when
|
||||
bounce/complaint rate thresholds are exceeded
|
||||
|
||||
**Important Notes:**
|
||||
|
||||
- **Development**: All environment variables are loaded from the root `.env` file (monorepo-wide)
|
||||
- **Production**: The application URLs (`API_URI`, `DASHBOARD_URI`, etc.) are injected at Docker container startup. This
|
||||
allows the same Docker image to be used across different environments by simply changing environment variables at
|
||||
runtime
|
||||
- **Frontend Variables**: Next.js apps use `NEXT_PUBLIC_*` prefixed variables that are embedded at build time for
|
||||
client-side access
|
||||
|
||||
## Plugins
|
||||
|
||||
There are two plugins installed for you to use.
|
||||
|
||||
- frontend-design: This plugin can help you to create polished user interfaces. Use it when working on design-related tasks.
|
||||
- superpowers: This plugin can help you with advanced tasks such as refactorings, new features or architectural changes. Use it when you need extra assistance beyond basic coding.
|
||||
+131
-16
@@ -1,29 +1,144 @@
|
||||
# Contributing
|
||||
# Contributing to Plunk
|
||||
|
||||
You can greatly support Plunk by contributing to this repository.
|
||||
Thank you for your interest in contributing to Plunk! This guide will help you get started with development.
|
||||
|
||||
Support can be asked in the `#contributions` channel of the [Plunk Discord server](https://useplunk.com/discord)
|
||||
## Architecture
|
||||
|
||||
### 1. Requirements
|
||||
Plunk V2 is built as a modern Turborepo monorepo with the following structure:
|
||||
|
||||
- Docker needs to be [installed](https://docs.docker.com/engine/install/) on your system.
|
||||
### Applications (`apps/`)
|
||||
|
||||
### 2. Install dependencies
|
||||
- **api**: Express.js API server with background worker process (BullMQ)
|
||||
- **web**: Next.js dashboard application (app.useplunk.com)
|
||||
- **landing**: Next.js marketing site (www.useplunk.com)
|
||||
- **wiki**: Next.js documentation site (docs.useplunk.com)
|
||||
|
||||
- Run `yarn install` to install the dependencies.
|
||||
### Shared Packages (`packages/`)
|
||||
|
||||
### 3. Set your environment variables
|
||||
- **@plunk/db**: Prisma database schema and client
|
||||
- **@plunk/ui**: Shared UI components (ShadCN + Radix UI)
|
||||
- **@plunk/shared**: Common utilities and business logic
|
||||
- **@plunk/types**: TypeScript type definitions
|
||||
- **@plunk/email**: React Email templates
|
||||
|
||||
- Copy the `.env.example` files in the `api`, `dashboard` and `prisma` folder to `.env` in their respective folders.
|
||||
- Set AWS credentials in the `api` `.env` file.
|
||||
### Technology Stack
|
||||
|
||||
### 4. Start resources
|
||||
- **Frontend**: React 19, Next.js 15, Tailwind CSS, Framer Motion
|
||||
- **Backend**: Express.js, TypeScript (ESM), Prisma ORM
|
||||
- **Database**: PostgreSQL with connection pooling
|
||||
- **Cache/Queue**: Redis, BullMQ for background jobs
|
||||
- **Email Delivery**: AWS SES with bounce/complaint handling
|
||||
- **Storage**: S3-compatible (MinIO, AWS S3)
|
||||
- **Payments**: Stripe with usage-based billing
|
||||
|
||||
- Run `yarn services:up` to start a local database and a local redis server.
|
||||
- Run `yarn migrate` to apply the migrations to the database.
|
||||
- Run `yarn build:shared` to build the shared package.
|
||||
## Development Setup
|
||||
|
||||
For local development without Docker:
|
||||
|
||||
- Run `yarn dev:api` to start the API server.
|
||||
- Run `yarn dev:dashboard` to start the dashboard server.
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- Yarn 4.9+
|
||||
- Docker & Docker Compose (for services)
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
# Clone and install
|
||||
git clone <repo-url>
|
||||
cd app
|
||||
yarn install
|
||||
|
||||
# Start infrastructure services (PostgreSQL, Redis, MinIO)
|
||||
yarn services:up
|
||||
|
||||
# Set up environment variables
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
|
||||
# Run database migrations
|
||||
yarn workspace @plunk/db migrate:dev
|
||||
|
||||
# Start all development servers
|
||||
yarn dev
|
||||
```
|
||||
|
||||
**Note**: The `dev` command starts the API server, worker process, and web apps. For production-like setup or debugging,
|
||||
you can run components separately:
|
||||
|
||||
```bash
|
||||
# Terminal 1: API Server
|
||||
yarn workspace api dev:server
|
||||
|
||||
# Terminal 2: Background Worker (required for emails)
|
||||
yarn workspace api dev:worker
|
||||
|
||||
# Terminal 3: Web Dashboard
|
||||
yarn workspace web dev
|
||||
```
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Environment Setup
|
||||
|
||||
- **Start services**: `yarn services:up` - Starts PostgreSQL, Redis, MinIO via Docker Compose
|
||||
- **Stop services**: `yarn services:down` - Stops all infrastructure services
|
||||
|
||||
### Development
|
||||
|
||||
- **Start all apps**: `yarn dev` - Starts all apps including API server and worker process
|
||||
- **Start specific app**: `yarn dev --filter="<app-name>"` (e.g., `yarn dev --filter="web"`)
|
||||
- **Start API only (server)**: `yarn workspace api dev:server` - API server without worker
|
||||
- **Start API only (worker)**: `yarn workspace api dev:worker` - Worker process only
|
||||
- **Build all**: `yarn build`
|
||||
- **Lint all**: `yarn lint`
|
||||
- **Clean all**: `yarn clean` - Removes node_modules, .turbo, and build artifacts
|
||||
|
||||
### Database (Prisma)
|
||||
|
||||
- **Generate client**: `yarn workspace @plunk/db db:generate`
|
||||
- **Run migrations (dev)**: `yarn workspace @plunk/db migrate:dev`
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Import Organization
|
||||
|
||||
ESLint enforces import order: builtin → external → internal → parent → sibling with alphabetical sorting and newlines
|
||||
between groups.
|
||||
|
||||
### TypeScript
|
||||
|
||||
- Use TypeScript for all new code
|
||||
- Prefer type imports: `import type { ... }`
|
||||
- Enable strict type checking
|
||||
|
||||
### Component Structure
|
||||
|
||||
- UI components go in `packages/ui/src/components/`
|
||||
- App-specific components in `apps/<app>/src/components/`
|
||||
- Follow atomic design pattern where applicable
|
||||
|
||||
## Making Changes
|
||||
|
||||
1. **Fork the repository** and create a new branch from `main`
|
||||
2. **Make your changes** following the code standards above
|
||||
3. **Test your changes** thoroughly
|
||||
4. **Commit your changes** with clear, descriptive commit messages
|
||||
5. **Push to your fork** and submit a pull request
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
- Provide a clear description of the changes
|
||||
- Reference any related issues
|
||||
- Ensure all tests pass and linting is clean
|
||||
- Keep PRs focused on a single feature or fix
|
||||
|
||||
## Need Help?
|
||||
|
||||
- Check the [documentation](https://docs.useplunk.com)
|
||||
- Open an issue for bugs or feature requests
|
||||
- Join our community discussions
|
||||
|
||||
## License
|
||||
|
||||
By contributing to Plunk, you agree that your contributions will be licensed under the AGPL-3.0 License.
|
||||
|
||||
+347
-23
@@ -1,35 +1,359 @@
|
||||
# Base Stage
|
||||
FROM node:20-alpine3.20 AS base
|
||||
# Multi-stage Dockerfile for Plunk
|
||||
# Creates a single image containing all applications (API, Worker, Web, Landing, Wiki)
|
||||
# Use SERVICE environment variable to specify which service to run
|
||||
|
||||
# ============================================
|
||||
# Stage 1: Dependencies (All dependencies for building)
|
||||
# ============================================
|
||||
# Use build platform (AMD64) to install dependencies, avoiding QEMU issues
|
||||
FROM --platform=$BUILDPLATFORM node:20-slim AS deps
|
||||
ARG TARGETPLATFORM
|
||||
ARG BUILDPLATFORM
|
||||
WORKDIR /app
|
||||
|
||||
# Install curl for health checks
|
||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Enable Corepack and set Yarn version
|
||||
RUN corepack enable && corepack prepare yarn@4.9.1 --activate
|
||||
|
||||
# Copy Yarn configuration and release
|
||||
COPY .yarnrc.yml ./
|
||||
COPY .yarn/releases ./.yarn/releases
|
||||
|
||||
# Copy package files for dependency installation
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# Copy workspace package.json files
|
||||
COPY apps/api/package.json ./apps/api/
|
||||
COPY apps/smtp/package.json ./apps/smtp/
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY apps/landing/package.json ./apps/landing/
|
||||
COPY apps/wiki/package.json ./apps/wiki/
|
||||
COPY packages/db/package.json ./packages/db/
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
COPY packages/types/package.json ./packages/types/
|
||||
COPY packages/email/package.json ./packages/email/
|
||||
COPY packages/typescript-config/package.json ./packages/typescript-config/
|
||||
COPY packages/eslint-config/package.json ./packages/eslint-config/
|
||||
|
||||
# Copy wiki source files for postinstall script (fumadocs-mdx)
|
||||
COPY apps/wiki/content ./apps/wiki/content
|
||||
COPY apps/wiki/lib ./apps/wiki/lib
|
||||
COPY apps/wiki/source.config.ts ./apps/wiki/source.config.ts
|
||||
COPY apps/wiki/mdx-components.tsx ./apps/wiki/mdx-components.tsx
|
||||
COPY apps/wiki/next.config.mjs ./apps/wiki/next.config.mjs
|
||||
COPY apps/wiki/tsconfig.json ./apps/wiki/tsconfig.json
|
||||
|
||||
# Install dependencies (runs on build platform, fetches binaries for target platform)
|
||||
# Use cache mounts for Yarn cache to speed up dependency installation
|
||||
RUN --mount=type=cache,target=/root/.yarn/berry/cache,sharing=locked \
|
||||
--mount=type=cache,target=/root/.cache/yarn,sharing=locked \
|
||||
echo "Building on $BUILDPLATFORM for $TARGETPLATFORM" && \
|
||||
yarn install --immutable
|
||||
|
||||
# ============================================
|
||||
# Stage 1b: Production Dependencies for API/SMTP
|
||||
# ============================================
|
||||
# Install only production dependencies needed for API and SMTP services
|
||||
FROM --platform=$BUILDPLATFORM node:20-slim AS prod-deps
|
||||
ARG TARGETPLATFORM
|
||||
ARG BUILDPLATFORM
|
||||
WORKDIR /app
|
||||
|
||||
# Enable Corepack and set Yarn version
|
||||
RUN corepack enable && corepack prepare yarn@4.9.1 --activate
|
||||
|
||||
# Copy Yarn configuration
|
||||
COPY .yarnrc.yml ./
|
||||
COPY .yarn/releases ./.yarn/releases
|
||||
|
||||
# Copy all package.json files (needed for workspace resolution)
|
||||
COPY package.json yarn.lock ./
|
||||
COPY apps/api/package.json ./apps/api/
|
||||
COPY apps/smtp/package.json ./apps/smtp/
|
||||
COPY packages/db/package.json ./packages/db/
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
COPY packages/types/package.json ./packages/types/
|
||||
COPY packages/email/package.json ./packages/email/
|
||||
|
||||
# Install ONLY production dependencies for api, smtp, and their workspace dependencies
|
||||
# This excludes devDependencies and unneeded workspaces (web, landing, wiki, ui)
|
||||
RUN --mount=type=cache,target=/root/.yarn/berry/cache,sharing=locked \
|
||||
--mount=type=cache,target=/root/.cache/yarn,sharing=locked \
|
||||
echo "Installing production dependencies for API/SMTP on $BUILDPLATFORM for $TARGETPLATFORM" && \
|
||||
yarn workspaces focus api smtp --production
|
||||
|
||||
# ============================================
|
||||
# Stage 2: Builder
|
||||
# ============================================
|
||||
# Builder runs on target platform to generate platform-specific artifacts
|
||||
FROM node:20-slim AS builder
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
# Build-time arguments for URL configuration
|
||||
# These are only used during the build process (for wiki OpenAPI generation and static assets)
|
||||
# Runtime URLs are configured via *_DOMAIN and USE_HTTPS environment variables at container startup
|
||||
ARG API_URI=https://next-api.useplunk.com
|
||||
ARG DASHBOARD_URI=https://next-app.useplunk.com
|
||||
ARG LANDING_URI=https://www.useplunk.com
|
||||
ARG WIKI_URI=https://docs.useplunk.com
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install OpenSSL for Prisma
|
||||
RUN apt-get update && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Enable Corepack and set Yarn version
|
||||
RUN corepack enable && corepack prepare yarn@4.9.1 --activate
|
||||
|
||||
# Copy dependencies from deps stage
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/.yarn ./.yarn
|
||||
COPY --from=deps /app/.yarnrc.yml ./
|
||||
COPY --from=deps /app/package.json ./
|
||||
COPY --from=deps /app/yarn.lock ./
|
||||
|
||||
# ============================================
|
||||
# OPTIMIZATION: Copy and build in layers for better Docker cache utilization
|
||||
# Docker will only rebuild from the first changed layer, not everything
|
||||
# ============================================
|
||||
|
||||
# Copy root config files needed for Turbo
|
||||
COPY turbo.json ./
|
||||
|
||||
# Copy manifest generation script
|
||||
COPY docker/generate-url-manifest.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/generate-url-manifest.sh
|
||||
|
||||
# Step 1: Copy and build shared packages (these change less frequently)
|
||||
# Shared packages are dependencies for apps, so build them first
|
||||
COPY packages ./packages
|
||||
RUN yarn workspace @plunk/db db:generate
|
||||
RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
|
||||
API_URI=${API_URI} \
|
||||
DASHBOARD_URI=${DASHBOARD_URI} \
|
||||
LANDING_URI=${LANDING_URI} \
|
||||
WIKI_URI=${WIKI_URI} \
|
||||
NEXT_PUBLIC_API_URI=${API_URI} \
|
||||
NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} \
|
||||
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
|
||||
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
|
||||
yarn turbo build --filter="@plunk/*"
|
||||
|
||||
# Step 2: Copy and build API (backend services)
|
||||
COPY apps/api ./apps/api
|
||||
COPY apps/smtp ./apps/smtp
|
||||
RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
|
||||
API_URI=${API_URI} \
|
||||
DASHBOARD_URI=${DASHBOARD_URI} \
|
||||
LANDING_URI=${LANDING_URI} \
|
||||
WIKI_URI=${WIKI_URI} \
|
||||
NEXT_PUBLIC_API_URI=${API_URI} \
|
||||
NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} \
|
||||
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
|
||||
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
|
||||
yarn turbo build --filter=api --filter=smtp
|
||||
|
||||
# Step 3: Copy and build Wiki (includes API doc generation)
|
||||
COPY apps/wiki ./apps/wiki
|
||||
# Generate OpenAPI spec and docs (URLs will be placeholder values for runtime replacement)
|
||||
RUN cd apps/wiki && \
|
||||
node scripts/generate-openapi.js && \
|
||||
npx tsx scripts/generate-docs.mts && \
|
||||
npx fumadocs-mdx && \
|
||||
cd ../..
|
||||
# Build wiki with placeholder URLs (replaced at container startup)
|
||||
RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
|
||||
API_URI=${API_URI} \
|
||||
DASHBOARD_URI=${DASHBOARD_URI} \
|
||||
LANDING_URI=${LANDING_URI} \
|
||||
WIKI_URI=${WIKI_URI} \
|
||||
NEXT_PUBLIC_API_URI=${API_URI} \
|
||||
NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} \
|
||||
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
|
||||
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
|
||||
yarn turbo build --filter=wiki
|
||||
# Generate sitemap for wiki
|
||||
RUN NEXT_PUBLIC_WIKI_URI=${WIKI_URI} yarn workspace wiki sitemap
|
||||
# Generate URL replacement manifest for wiki (build-time optimization)
|
||||
RUN generate-url-manifest.sh wiki /app/apps/wiki
|
||||
|
||||
# Step 4: Copy and build Web dashboard
|
||||
COPY apps/web ./apps/web
|
||||
RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
|
||||
API_URI=${API_URI} \
|
||||
DASHBOARD_URI=${DASHBOARD_URI} \
|
||||
LANDING_URI=${LANDING_URI} \
|
||||
WIKI_URI=${WIKI_URI} \
|
||||
NEXT_PUBLIC_API_URI=${API_URI} \
|
||||
NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} \
|
||||
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
|
||||
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
|
||||
yarn turbo build --filter=web
|
||||
# Generate sitemap for web
|
||||
RUN NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} yarn workspace web sitemap
|
||||
# Generate URL replacement manifest for web (build-time optimization)
|
||||
RUN generate-url-manifest.sh web /app/apps/web
|
||||
|
||||
# Step 5: Copy and build Landing page
|
||||
COPY apps/landing ./apps/landing
|
||||
RUN --mount=type=cache,target=/app/.turbo,sharing=locked \
|
||||
API_URI=${API_URI} \
|
||||
DASHBOARD_URI=${DASHBOARD_URI} \
|
||||
LANDING_URI=${LANDING_URI} \
|
||||
WIKI_URI=${WIKI_URI} \
|
||||
NEXT_PUBLIC_API_URI=${API_URI} \
|
||||
NEXT_PUBLIC_DASHBOARD_URI=${DASHBOARD_URI} \
|
||||
NEXT_PUBLIC_LANDING_URI=${LANDING_URI} \
|
||||
NEXT_PUBLIC_WIKI_URI=${WIKI_URI} \
|
||||
yarn turbo build --filter=landing
|
||||
# Generate sitemap for landing
|
||||
RUN NEXT_PUBLIC_LANDING_URI=${LANDING_URI} yarn workspace landing sitemap
|
||||
# Generate URL replacement manifest for landing (build-time optimization)
|
||||
RUN generate-url-manifest.sh landing /app/apps/landing
|
||||
|
||||
# Copy any remaining root files (if needed)
|
||||
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:20-alpine3.20
|
||||
# Ensure directories exist (create empty ones if build didn't generate them)
|
||||
RUN mkdir -p \
|
||||
apps/web/public \
|
||||
apps/landing/public \
|
||||
apps/wiki/public \
|
||||
apps/web/.next/standalone \
|
||||
apps/landing/.next/standalone \
|
||||
apps/wiki/.next/standalone
|
||||
|
||||
# ============================================
|
||||
# Stage 3: Production Runtime
|
||||
# ============================================
|
||||
FROM node:20-alpine AS runner
|
||||
ARG TARGETPLATFORM
|
||||
ARG BUILDPLATFORM
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache bash nginx
|
||||
# Install OpenSSL for Prisma, curl for health checks, nginx, and gettext (for envsubst)
|
||||
RUN apk add --no-cache openssl curl nginx gettext
|
||||
|
||||
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/
|
||||
# Install PM2 globally for process management
|
||||
# Use cache mount and specific version to prevent hangs
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install -g pm2@5.4.2 --prefer-offline --no-audit
|
||||
|
||||
RUN chmod +x /app/entry.sh /app/replace-variables.sh
|
||||
# Create non-root user for security
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 plunk
|
||||
|
||||
EXPOSE 3000 4000 5000
|
||||
# Create nginx directories and set permissions
|
||||
RUN mkdir -p /var/log/nginx /var/lib/nginx /run/nginx && \
|
||||
chown -R plunk:nodejs /var/log/nginx /var/lib/nginx /run/nginx /etc/nginx
|
||||
|
||||
CMD ["sh", "/app/entry.sh"]
|
||||
# ============================================
|
||||
# Copy API and SMTP services with minimal dependencies
|
||||
# ============================================
|
||||
|
||||
# Copy built API and SMTP services
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/api/dist ./apps/api/dist
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/smtp/dist ./apps/smtp/dist
|
||||
|
||||
# Copy ONLY production dependencies for API/SMTP (excludes dev deps and frontend packages)
|
||||
COPY --from=prod-deps --chown=plunk:nodejs /app/node_modules ./node_modules
|
||||
|
||||
# Copy Prisma client from builder (includes generated client with correct platform binaries)
|
||||
COPY --from=builder --chown=plunk:nodejs /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder --chown=plunk:nodejs /app/node_modules/@prisma ./node_modules/@prisma
|
||||
|
||||
# Copy only the shared packages that are built (not source files)
|
||||
# These are needed by API/SMTP at runtime
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/db/dist ./packages/db/dist
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/db/package.json ./packages/db/package.json
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/shared/dist ./packages/shared/dist
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/shared/package.json ./packages/shared/package.json
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/email/dist ./packages/email/dist
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/email/package.json ./packages/email/package.json
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/types/dist ./packages/types/dist
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/types/package.json ./packages/types/package.json
|
||||
|
||||
# Copy Prisma schema (needed for migrations at runtime)
|
||||
COPY --from=builder --chown=plunk:nodejs /app/packages/db/prisma ./packages/db/prisma
|
||||
|
||||
# Copy root package.json and workspace config (needed for yarn workspace commands in entrypoint)
|
||||
COPY --from=builder --chown=plunk:nodejs /app/package.json ./
|
||||
COPY --from=prod-deps --chown=plunk:nodejs /app/.yarnrc.yml ./
|
||||
COPY --from=prod-deps --chown=plunk:nodejs /app/.yarn ./.yarn
|
||||
COPY --from=prod-deps --chown=plunk:nodejs /app/yarn.lock ./
|
||||
|
||||
# Copy API/SMTP package.json files
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/api/package.json ./apps/api/
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/smtp/package.json ./apps/smtp/
|
||||
|
||||
# ============================================
|
||||
# Copy Next.js apps with their standalone builds
|
||||
# ============================================
|
||||
# Next.js standalone mode bundles all dependencies internally, so we don't need
|
||||
# to copy node_modules for these apps - they're completely self-contained
|
||||
|
||||
# Web app - standalone build with static assets
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/standalone ./apps/web/.next/standalone
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/web/public ./apps/web/.next/standalone/apps/web/public
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/static ./apps/web/.next/standalone/apps/web/.next/static
|
||||
# Copy URL replacement manifests to standalone directory
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/url-manifest.txt ./apps/web/.next/standalone/apps/web/.next/url-manifest.txt
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next/sitemap-manifest.txt ./apps/web/.next/standalone/apps/web/.next/sitemap-manifest.txt
|
||||
|
||||
# Landing app - standalone build with static assets
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/standalone ./apps/landing/.next/standalone
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/public ./apps/landing/.next/standalone/apps/landing/public
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/static ./apps/landing/.next/standalone/apps/landing/.next/static
|
||||
# Copy URL replacement manifests to standalone directory
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/url-manifest.txt ./apps/landing/.next/standalone/apps/landing/.next/url-manifest.txt
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next/sitemap-manifest.txt ./apps/landing/.next/standalone/apps/landing/.next/sitemap-manifest.txt
|
||||
|
||||
# Wiki app - standalone build with static assets and OpenAPI spec
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/standalone ./apps/wiki/.next/standalone
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/public ./apps/wiki/.next/standalone/apps/wiki/public
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/static ./apps/wiki/.next/standalone/apps/wiki/.next/static
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/openapi.local.json ./apps/wiki/.next/standalone/apps/wiki/openapi.local.json
|
||||
# Copy URL replacement manifests to standalone directory
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/url-manifest.txt ./apps/wiki/.next/standalone/apps/wiki/.next/url-manifest.txt
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next/sitemap-manifest.txt ./apps/wiki/.next/standalone/apps/wiki/.next/sitemap-manifest.txt
|
||||
|
||||
# Copy full .next directories for the entrypoint script (URL replacement via find command)
|
||||
# These are much smaller than node_modules and needed for runtime URL replacement
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/web/.next ./apps/web/.next
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/landing/.next ./apps/landing/.next
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/.next ./apps/wiki/.next
|
||||
COPY --from=builder --chown=plunk:nodejs /app/apps/wiki/openapi.local.json ./apps/wiki/openapi.local.json
|
||||
|
||||
# ============================================
|
||||
# Copy runtime configuration
|
||||
# ============================================
|
||||
|
||||
# Copy nginx configuration templates and setup script
|
||||
COPY --chown=plunk:nodejs docker/nginx/ /app/docker/nginx/
|
||||
RUN chmod +x /app/docker/nginx/setup-nginx.sh
|
||||
|
||||
# Copy optimized URL replacement script
|
||||
COPY --chown=plunk:nodejs docker/replace-urls-optimized.sh /app/docker/
|
||||
RUN chmod +x /app/docker/replace-urls-optimized.sh
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY --chown=plunk:nodejs docker-entrypoint-nginx.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint-nginx.sh
|
||||
|
||||
USER plunk
|
||||
|
||||
# Expose nginx port (default 80), SMTP ports (465, 587)
|
||||
# Port 80 is also used for ACME HTTP-01 challenges
|
||||
EXPOSE 80 465 587
|
||||
|
||||
# Health check through nginx
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD curl -f http://localhost:80/ || exit 1
|
||||
|
||||
# Default to running all services via entrypoint
|
||||
ENV SERVICE=all
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint-nginx.sh"]
|
||||
|
||||
@@ -7,11 +7,14 @@
|
||||
</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-prod.yml"/>
|
||||
<img src="https://img.shields.io/docker/pulls/driaug/plunk"/>
|
||||
<img src="https://img.shields.io/github/license/useplunk/plunk"/>
|
||||
<img src="https://img.shields.io/github/stars/useplunk/plunk"/>
|
||||
<a href="https://github.com/useplunk/plunk/graphs/contributors"><img src="https://img.shields.io/github/contributors/useplunk/plunk" alt="Contributors"/></a>
|
||||
<a href="https://github.com/useplunk/plunk/actions"><img src="https://img.shields.io/github/actions/workflow/status/useplunk/plunk/docker-publish.yml" alt="Build Status"/></a>
|
||||
<a href="https://github.com/useplunk/plunk/blob/next/LICENSE"><img src="https://img.shields.io/github/license/useplunk/plunk" alt="License"/></a>
|
||||
<a href="https://github.com/useplunk/plunk/stargazers"><img src="https://img.shields.io/github/stars/useplunk/plunk" alt="Stars"/></a>
|
||||
<a href="https://github.com/useplunk/plunk/issues"><img src="https://img.shields.io/github/issues/useplunk/plunk" alt="Issues"/></a>
|
||||
<a href="https://github.com/useplunk/plunk/network/members"><img src="https://img.shields.io/github/forks/useplunk/plunk" alt="Forks"/></a>
|
||||
<a href="https://github.com/useplunk/plunk/pkgs/container/plunk"><img src="https://img.shields.io/badge/docker-available-blue?logo=docker" alt="Docker"/></a>
|
||||
<a href="https://github.com/sponsors/driaug"><img src="https://img.shields.io/badge/sponsor-❤-ff69b4" alt="Sponsor"/></a>
|
||||
</p>
|
||||
|
||||
## Introduction
|
||||
@@ -23,36 +26,34 @@ like [SendGrid](https://sendgrid.com/), [Resend](https://resend.com) or [Mailgun
|
||||
|
||||
## 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
|
||||
- **Transactional Emails**: Send emails straight from your API with template support and variable substitution
|
||||
- **Campaigns**: Send newsletters and product updates to large audiences with segmentation
|
||||
- **Workflows**: Create advanced automations with triggers, delays, and conditional logic
|
||||
- **Contact Management**: Organize contacts with custom fields and dynamic segmentation
|
||||
- **Analytics**: Track opens, clicks, bounces, and engagement metrics in real-time
|
||||
- **Custom Domains**: Verify and send from your own domains with DKIM/SPF support
|
||||
|
||||
## Sponsors
|
||||
Plunk is made possible by the support of our sponsors. If you self-host Plunk, consider supporting via [GitHub Sponsors](https://github.com/sponsors/driaug).
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="120">
|
||||
<img src="https://avatars.githubusercontent.com/u/206509599?s=200&v=4" style="width:80px; height:80px; object-fit:contain;"/><br/>
|
||||
<a href="https://every.news/?ref=useplunk.com">Everynews</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
Plunk is made possible by the support of our sponsors. If you self-host Plunk, consider supporting
|
||||
via [GitHub Sponsors](https://github.com/sponsors/driaug).
|
||||
|
||||
## 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/).
|
||||
The easiest way to self-host Plunk is by using the `plunk` Docker image.
|
||||
You can pull the latest image from [Github](https://github.com/useplunk/plunk/pkgs/container/plunk).
|
||||
|
||||
A complete guide on how to deploy Plunk can be found in
|
||||
the [documentation](https://docs.useplunk.com/getting-started/self-hosting).
|
||||
the [documentation](https://docs.useplunk.com/self-hosting/introduction).
|
||||
|
||||
## Contributing
|
||||
|
||||
You are welcome to contribute to Plunk. You can find a guide on how to contribute in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
<a href="https://github.com/useplunk/plunk/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=useplunk/plunk" />
|
||||
</a>
|
||||
<img src="https://contrib.rocks/image?repo=useplunk/plunk" alt="Contributors" />
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0 License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# ==============================================================================
|
||||
# Development Environment Configuration
|
||||
# ==============================================================================
|
||||
# This is for local development. For production/self-hosting, see .env.self-host.example
|
||||
|
||||
# ==============================================================================
|
||||
# Environment & Security
|
||||
# ==============================================================================
|
||||
NODE_ENV=development
|
||||
JWT_SECRET=hBx9Xh8J6KOMAGAsSjvcZJBT5TWyIkFX
|
||||
|
||||
# ==============================================================================
|
||||
# Application URLs
|
||||
# ==============================================================================
|
||||
# Set to 'true' for HTTPS in production (only used when auto-generating URIs from domains)
|
||||
USE_HTTPS=false
|
||||
|
||||
API_URI=http://localhost:8080
|
||||
WIKI_URI=http://localhost:1000
|
||||
DASHBOARD_URI=http://localhost:3000
|
||||
LANDING_URI=http://localhost:4000
|
||||
|
||||
# ==============================================================================
|
||||
# Plunk API (Optional - for sending emails via Plunk API)
|
||||
# ==============================================================================
|
||||
# API key for authenticating with the Plunk API (obtained from dashboard)
|
||||
PLUNK_API_KEY=
|
||||
|
||||
# ==============================================================================
|
||||
# Database & Redis
|
||||
# ==============================================================================
|
||||
REDIS_URL=redis://127.0.0.1:56379
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:55432/postgres
|
||||
DIRECT_DATABASE_URL=postgresql://postgres:postgres@localhost:55432/postgres
|
||||
|
||||
# ==============================================================================
|
||||
# S3-compatible Storage (Minio - for file uploads)
|
||||
# ==============================================================================
|
||||
# For local development, make sure Minio is running via docker compose
|
||||
S3_ENDPOINT=http://localhost:9000
|
||||
S3_ACCESS_KEY_ID=plunk
|
||||
S3_ACCESS_KEY_SECRET=plunkminiopass
|
||||
S3_BUCKET=uploads
|
||||
S3_PUBLIC_URL=http://localhost:9000/uploads
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# ==============================================================================
|
||||
# AWS SES (Required - for sending emails)
|
||||
# ==============================================================================
|
||||
AWS_SES_REGION=eu-north-1
|
||||
AWS_SES_ACCESS_KEY_ID=
|
||||
AWS_SES_SECRET_ACCESS_KEY=
|
||||
|
||||
# Configuration sets for email tracking
|
||||
SES_CONFIGURATION_SET=plunk-configuration-set # Default: with open/click tracking
|
||||
SES_CONFIGURATION_SET_NO_TRACKING=plunk-configuration-set-no-tracking # Optional: without tracking (enables toggle in UI)
|
||||
|
||||
# Email sending rate limit (emails per second)
|
||||
# If not set, automatically fetches from AWS SES account quota (recommended)
|
||||
# Set this to override AWS quota (useful for setting lower limits or testing)
|
||||
# Default: Fetched from AWS (typically 14 for sandbox, higher for production accounts)
|
||||
# EMAIL_RATE_LIMIT_PER_SECOND=14
|
||||
|
||||
# ==============================================================================
|
||||
# OAuth (Optional - for social login)
|
||||
# ==============================================================================
|
||||
GITHUB_OAUTH_CLIENT=
|
||||
GITHUB_OAUTH_SECRET=
|
||||
GOOGLE_OAUTH_CLIENT=
|
||||
GOOGLE_OAUTH_SECRET=
|
||||
|
||||
# ==============================================================================
|
||||
# Stripe (Optional - for billing)
|
||||
# ==============================================================================
|
||||
STRIPE_SK=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PRICE_ONBOARDING= # Optional: One-time onboarding fee price ID (e.g., price_xxxxx)
|
||||
STRIPE_PRICE_EMAIL_USAGE= # Required: Metered price ID for pay-per-email billing
|
||||
STRIPE_METER_EVENT_NAME=emails # Meter event name (API key from your Stripe meter, default: emails)
|
||||
|
||||
# ==============================================================================
|
||||
# Security (Optional)
|
||||
# ==============================================================================
|
||||
# Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
|
||||
# Set to 'false' to disable automatic project suspension (useful for self-hosters who manage manually)
|
||||
# Default: true (automatic project disabling enabled)
|
||||
# AUTO_PROJECT_DISABLE=true
|
||||
@@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default tseslint.config(
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
ignores: ['node_modules/**', 'dist/**', '.turbo/**'],
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n \"api,worker\" -c \"blue,magenta\" \"yarn dev:server\" \"yarn dev:worker\"",
|
||||
"dev:server": "tsx watch src/app.ts",
|
||||
"dev:worker": "tsx watch src/jobs/worker.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/app.js",
|
||||
"start:worker": "node dist/jobs/worker.js",
|
||||
"lint": "eslint .",
|
||||
"clean": "rimraf node_modules dist .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.937.0",
|
||||
"@aws-sdk/client-ses": "^3.937.0",
|
||||
"@overnightjs/core": "^1.7.6",
|
||||
"@plunk/db": "*",
|
||||
"@plunk/email": "*",
|
||||
"@plunk/shared": "*",
|
||||
"@plunk/types": "*",
|
||||
"@react-email/render": "^2.0.0",
|
||||
"@zootools/email-spell-checker": "^1.12.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"body-parser": "^2.2.0",
|
||||
"bullmq": "^5.63.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"csv-parse": "^6.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.8.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailchecker": "^6.0.19",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.1.1",
|
||||
"signale": "^1.4.0",
|
||||
"stripe": "^20.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/cookie-parser": "^1.4.7",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.5",
|
||||
"@types/helmet": "^4.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/signale": "^1.4.7",
|
||||
"concurrently": "^9.2.1",
|
||||
"tsx": "^4.20.6"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,208 @@
|
||||
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
// Note: To run these integration tests, you need to:
|
||||
// 1. Have the API server running or import the app instance
|
||||
// 2. For now, these tests demonstrate the pattern for integration testing
|
||||
|
||||
describe('Campaigns API Integration Tests', () => {
|
||||
let projectId: string;
|
||||
let _authToken: string;
|
||||
let _apiUrl: string;
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
beforeAll(() => {
|
||||
// In a real setup, you'd either:
|
||||
// 1. Import the Express app instance
|
||||
// 2. Or use the actual API server URL
|
||||
_apiUrl = process.env.TEST_API_URL || 'http://localhost:3000';
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
projectId = project.id;
|
||||
|
||||
// Generate auth token (you'd need to implement this based on your auth system)
|
||||
// This is a placeholder - adjust based on your actual auth implementation
|
||||
_authToken = 'test-jwt-token';
|
||||
});
|
||||
|
||||
describe('POST /campaigns', () => {
|
||||
it('should create a new campaign', async () => {
|
||||
const campaignData = {
|
||||
name: 'Test Campaign',
|
||||
subject: 'Test Subject',
|
||||
body: '<p>Test Body</p>',
|
||||
from: 'test@example.com',
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
};
|
||||
|
||||
// Example of how the integration test would look
|
||||
// Uncomment when you have the app instance available:
|
||||
/*
|
||||
const response = await request(app)
|
||||
.post('/campaigns')
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send(campaignData)
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.name).toBe('Test Campaign');
|
||||
expect(response.body.status).toBe(CampaignStatus.DRAFT);
|
||||
*/
|
||||
|
||||
// For now, just verify the factory can create the data
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
...campaignData,
|
||||
});
|
||||
|
||||
expect(campaign.name).toBe('Test Campaign');
|
||||
});
|
||||
|
||||
it('should validate required fields', async () => {
|
||||
// Test that API validates required fields
|
||||
// This would fail without name, subject, etc.
|
||||
|
||||
const _invalidData = {
|
||||
from: 'test@example.com',
|
||||
};
|
||||
|
||||
// When integrated with supertest:
|
||||
/*
|
||||
await request(app)
|
||||
.post('/campaigns')
|
||||
.set('Authorization', `Bearer ${_authToken}`)
|
||||
.send(_invalidData)
|
||||
.expect(400);
|
||||
*/
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /campaigns', () => {
|
||||
it('should list campaigns with pagination', async () => {
|
||||
// Create test campaigns using bulk insert to avoid memory issues
|
||||
const campaignData = Array.from({length: 25}, (_, i) => ({
|
||||
projectId,
|
||||
name: `Campaign ${i}`,
|
||||
subject: 'Test Subject',
|
||||
body: '<p>Test Body</p>',
|
||||
from: 'test@example.com',
|
||||
status: 'DRAFT' as const,
|
||||
}));
|
||||
|
||||
await prisma.campaign.createMany({data: campaignData});
|
||||
|
||||
// When integrated:
|
||||
/*
|
||||
const response = await request(app)
|
||||
.get('/campaigns')
|
||||
.query({ page: 1, pageSize: 10 })
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.campaigns).toHaveLength(10);
|
||||
expect(response.body.total).toBe(25);
|
||||
expect(response.body.totalPages).toBe(3);
|
||||
*/
|
||||
|
||||
const campaigns = await prisma.campaign.findMany({
|
||||
where: {projectId},
|
||||
take: 10,
|
||||
});
|
||||
|
||||
expect(campaigns.length).toBeLessThanOrEqual(10);
|
||||
});
|
||||
|
||||
it('should filter campaigns by status', async () => {
|
||||
await factories.createCampaign({projectId, status: CampaignStatus.DRAFT});
|
||||
await factories.createCampaign({projectId, status: CampaignStatus.COMPLETED});
|
||||
|
||||
// When integrated:
|
||||
/*
|
||||
const response = await request(app)
|
||||
.get('/campaigns')
|
||||
.query({ status: CampaignStatus.DRAFT })
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.campaigns.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
|
||||
*/
|
||||
|
||||
const draftCampaigns = await prisma.campaign.findMany({
|
||||
where: {projectId, status: CampaignStatus.DRAFT},
|
||||
});
|
||||
|
||||
expect(draftCampaigns.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /campaigns/:id', () => {
|
||||
it('should update a draft campaign', async () => {
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
name: 'Original Name',
|
||||
});
|
||||
|
||||
// When integrated:
|
||||
/*
|
||||
const response = await request(app)
|
||||
.put(`/campaigns/${campaign.id}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.send({ name: 'Updated Name' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.name).toBe('Updated Name');
|
||||
*/
|
||||
|
||||
const updated = await prisma.campaign.update({
|
||||
where: {id: campaign.id},
|
||||
data: {name: 'Updated Name'},
|
||||
});
|
||||
|
||||
expect(updated.name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('should not update a completed campaign', async () => {
|
||||
const _campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.COMPLETED,
|
||||
});
|
||||
|
||||
// When integrated:
|
||||
/*
|
||||
await request(app)
|
||||
.put(`/campaigns/${_campaign.id}`)
|
||||
.set('Authorization', `Bearer ${_authToken}`)
|
||||
.send({ name: 'New Name' })
|
||||
.expect(400);
|
||||
*/
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /campaigns/:id', () => {
|
||||
it('should delete a draft campaign', async () => {
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
});
|
||||
|
||||
// When integrated:
|
||||
/*
|
||||
await request(app)
|
||||
.delete(`/campaigns/${campaign.id}`)
|
||||
.set('Authorization', `Bearer ${authToken}`)
|
||||
.expect(204);
|
||||
|
||||
const deleted = await prisma.campaign.findUnique({ where: { id: campaign.id } });
|
||||
expect(deleted).toBeNull();
|
||||
*/
|
||||
|
||||
await prisma.campaign.delete({where: {id: campaign.id}});
|
||||
|
||||
const deleted = await prisma.campaign.findUnique({where: {id: campaign.id}});
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,458 @@
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
import {DomainService} from '../../services/DomainService.js';
|
||||
import * as SESService from '../../services/SESService.js';
|
||||
|
||||
/**
|
||||
* Integration tests for Domain verification and ownership checks
|
||||
* Tests the security feature that prevents domains from being linked to multiple projects
|
||||
* unless the user is a member of the project that owns the domain
|
||||
*/
|
||||
describe('Domain Verification and Ownership Tests', () => {
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Mock SES service to avoid external AWS calls
|
||||
beforeEach(() => {
|
||||
vi.spyOn(SESService, 'verifyDomain').mockResolvedValue(['token1', 'token2', 'token3']);
|
||||
vi.spyOn(SESService, 'getDomainVerificationAttributes').mockResolvedValue({
|
||||
status: 'Success',
|
||||
tokens: ['token1', 'token2', 'token3'],
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// DOMAIN OWNERSHIP CHECKS
|
||||
// ========================================
|
||||
describe('Domain Ownership Verification', () => {
|
||||
it('should allow adding a domain that does not exist yet', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
const domain = 'new-domain.com';
|
||||
|
||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, 'any-user-id');
|
||||
|
||||
expect(ownershipCheck.exists).toBe(false);
|
||||
|
||||
// Should be able to add the domain
|
||||
const newDomain = await DomainService.addDomain(project.id, domain);
|
||||
expect(newDomain.domain).toBe(domain);
|
||||
expect(newDomain.projectId).toBe(project.id);
|
||||
});
|
||||
|
||||
it('should detect when a domain already exists', async () => {
|
||||
const {user, project} = await factories.createUserWithProject();
|
||||
const domain = 'existing-domain.com';
|
||||
|
||||
// First project adds the domain
|
||||
await DomainService.addDomain(project.id, domain);
|
||||
|
||||
// Check ownership - user IS a member of the project
|
||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user.id);
|
||||
|
||||
expect(ownershipCheck.exists).toBe(true);
|
||||
expect(ownershipCheck.projectId).toBe(project.id);
|
||||
expect(ownershipCheck.projectName).toBe(project.name);
|
||||
expect(ownershipCheck.isMember).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect when a domain exists but user is not a member', async () => {
|
||||
const {project: project1} = await factories.createUserWithProject();
|
||||
const {user: user2} = await factories.createUserWithProject();
|
||||
const domain = 'other-project-domain.com';
|
||||
|
||||
// Project 1 adds the domain
|
||||
await DomainService.addDomain(project1.id, domain);
|
||||
|
||||
// Check ownership for User 2 (not a member of Project 1)
|
||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user2.id);
|
||||
|
||||
expect(ownershipCheck.exists).toBe(true);
|
||||
expect(ownershipCheck.projectId).toBe(project1.id);
|
||||
expect(ownershipCheck.isMember).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow access when user is a member of the project that owns the domain', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
const domain = 'shared-domain.com';
|
||||
|
||||
// User 1 adds the domain to their project
|
||||
await DomainService.addDomain(project.id, domain);
|
||||
|
||||
// Create another user and add them to the same project
|
||||
const user2 = await factories.createUser({email: 'user2@test.com'});
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
userId: user2.id,
|
||||
projectId: project.id,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
});
|
||||
|
||||
// Check ownership for User 2 (who is now a member)
|
||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user2.id);
|
||||
|
||||
expect(ownershipCheck.exists).toBe(true);
|
||||
expect(ownershipCheck.isMember).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// PREVENTING UNAUTHORIZED DOMAIN LINKING
|
||||
// ========================================
|
||||
describe('Preventing Unauthorized Domain Linking', () => {
|
||||
it('should prevent linking a domain to another project when user is not a member', async () => {
|
||||
const {project: project1} = await factories.createUserWithProject();
|
||||
const {user: user2} = await factories.createUserWithProject();
|
||||
const domain = 'protected-domain.com';
|
||||
|
||||
// Project 1 adds the domain
|
||||
await DomainService.addDomain(project1.id, domain);
|
||||
|
||||
// User 2 tries to link the same domain to Project 2
|
||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user2.id);
|
||||
|
||||
// The controller would use this check to deny access
|
||||
expect(ownershipCheck.exists).toBe(true);
|
||||
expect(ownershipCheck.isMember).toBe(false);
|
||||
|
||||
// Verify the domain is still only linked to Project 1
|
||||
const domains = await prisma.domain.findMany({
|
||||
where: {domain},
|
||||
});
|
||||
|
||||
expect(domains).toHaveLength(1);
|
||||
expect(domains[0].projectId).toBe(project1.id);
|
||||
});
|
||||
|
||||
it('should allow member to see they can access domain from the original project', async () => {
|
||||
const {user, project: project1} = await factories.createUserWithProject();
|
||||
const domain = 'member-domain.com';
|
||||
|
||||
// Add domain to project 1
|
||||
await DomainService.addDomain(project1.id, domain);
|
||||
|
||||
// User creates a second project (same user, different project)
|
||||
const project2 = await factories.createProject();
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: project2.id,
|
||||
role: 'OWNER',
|
||||
},
|
||||
});
|
||||
|
||||
// User tries to link the domain to project 2
|
||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user.id);
|
||||
|
||||
// Should indicate that domain exists and user IS a member
|
||||
expect(ownershipCheck.exists).toBe(true);
|
||||
expect(ownershipCheck.isMember).toBe(true);
|
||||
expect(ownershipCheck.projectId).toBe(project1.id);
|
||||
expect(ownershipCheck.projectName).toBe(project1.name);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// DOMAIN VERIFICATION STATUS
|
||||
// ========================================
|
||||
describe('Domain Verification Status', () => {
|
||||
it('should create unverified domain when first added', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
const domain = 'unverified-domain.com';
|
||||
|
||||
const newDomain = await DomainService.addDomain(project.id, domain);
|
||||
|
||||
expect(newDomain.verified).toBe(false);
|
||||
expect(newDomain.dkimTokens).toEqual(['token1', 'token2', 'token3']);
|
||||
});
|
||||
|
||||
it('should prevent using unverified domain for sending emails', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
const domain = 'unverified-domain.com';
|
||||
|
||||
await DomainService.addDomain(project.id, domain);
|
||||
|
||||
// Try to verify email domain
|
||||
await expect(DomainService.verifyEmailDomain(`sender@${domain}`, project.id)).rejects.toThrow(/not verified/i);
|
||||
});
|
||||
|
||||
it('should allow using verified domain for sending emails', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
const domain = 'verified-domain.com';
|
||||
|
||||
const newDomain = await DomainService.addDomain(project.id, domain);
|
||||
|
||||
// Manually mark as verified (simulating DNS verification)
|
||||
await prisma.domain.update({
|
||||
where: {id: newDomain.id},
|
||||
data: {verified: true},
|
||||
});
|
||||
|
||||
// Should not throw error
|
||||
const verifiedDomain = await DomainService.verifyEmailDomain(`sender@${domain}`, project.id);
|
||||
|
||||
expect(verifiedDomain.verified).toBe(true);
|
||||
expect(verifiedDomain.domain).toBe(domain);
|
||||
});
|
||||
|
||||
it('should prevent using domain from different project', async () => {
|
||||
const {project: project1} = await factories.createUserWithProject();
|
||||
const {project: project2} = await factories.createUserWithProject();
|
||||
const domain = 'project1-domain.com';
|
||||
|
||||
const newDomain = await DomainService.addDomain(project1.id, domain);
|
||||
|
||||
// Mark as verified
|
||||
await prisma.domain.update({
|
||||
where: {id: newDomain.id},
|
||||
data: {verified: true},
|
||||
});
|
||||
|
||||
// Try to use from different project
|
||||
await expect(DomainService.verifyEmailDomain(`sender@${domain}`, project2.id)).rejects.toThrow(
|
||||
/belongs to a different project/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should prevent using unregistered domain', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
const domain = 'not-registered.com';
|
||||
|
||||
// Try to use domain that was never added
|
||||
await expect(DomainService.verifyEmailDomain(`sender@${domain}`, project.id)).rejects.toThrow(/not registered/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// MULTIPLE USERS AND PROJECTS
|
||||
// ========================================
|
||||
describe('Multiple Users and Projects Scenarios', () => {
|
||||
it('should handle 3 users: owner, member, and non-member', async () => {
|
||||
const {user: owner, project} = await factories.createUserWithProject();
|
||||
const member = await factories.createUser({email: 'member@test.com'});
|
||||
const nonMember = await factories.createUser({email: 'nonmember@test.com'});
|
||||
const domain = 'team-domain.com';
|
||||
|
||||
// Owner adds domain
|
||||
await DomainService.addDomain(project.id, domain);
|
||||
|
||||
// Add member to project
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
userId: member.id,
|
||||
projectId: project.id,
|
||||
role: 'MEMBER',
|
||||
},
|
||||
});
|
||||
|
||||
// Check ownership for each user
|
||||
const ownerCheck = await DomainService.checkDomainOwnership(domain, owner.id);
|
||||
const memberCheck = await DomainService.checkDomainOwnership(domain, member.id);
|
||||
const nonMemberCheck = await DomainService.checkDomainOwnership(domain, nonMember.id);
|
||||
|
||||
expect(ownerCheck.isMember).toBe(true);
|
||||
expect(memberCheck.isMember).toBe(true);
|
||||
expect(nonMemberCheck.isMember).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle user with multiple projects', async () => {
|
||||
const {user, project: project1} = await factories.createUserWithProject();
|
||||
const project2 = await factories.createProject();
|
||||
const domain = 'multi-project-domain.com';
|
||||
|
||||
// Add user to second project
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
projectId: project2.id,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
});
|
||||
|
||||
// Add domain to project 1
|
||||
await DomainService.addDomain(project1.id, domain);
|
||||
|
||||
// User is member of both projects, but domain belongs to project 1
|
||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, user.id);
|
||||
|
||||
expect(ownershipCheck.exists).toBe(true);
|
||||
expect(ownershipCheck.isMember).toBe(true);
|
||||
expect(ownershipCheck.projectId).toBe(project1.id);
|
||||
});
|
||||
|
||||
it('should handle domain being removed and re-added', async () => {
|
||||
const {user, project} = await factories.createUserWithProject();
|
||||
const domain = 'reusable-domain.com';
|
||||
|
||||
// Add domain
|
||||
const domain1 = await DomainService.addDomain(project.id, domain);
|
||||
|
||||
// Remove domain
|
||||
await DomainService.removeDomain(domain1.id);
|
||||
|
||||
// Check ownership - should not exist
|
||||
const checkAfterRemoval = await DomainService.checkDomainOwnership(domain, user.id);
|
||||
expect(checkAfterRemoval.exists).toBe(false);
|
||||
|
||||
// Re-add domain
|
||||
const domain2 = await DomainService.addDomain(project.id, domain);
|
||||
|
||||
expect(domain2.domain).toBe(domain);
|
||||
expect(domain2.projectId).toBe(project.id);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// ROLE-BASED RESTRICTIONS
|
||||
// ========================================
|
||||
describe('Role-Based Domain Access', () => {
|
||||
it('should verify that only ADMIN and OWNER can add domains', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
const regularMember = await factories.createUser({email: 'member@test.com'});
|
||||
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
userId: regularMember.id,
|
||||
projectId: project.id,
|
||||
role: 'MEMBER',
|
||||
},
|
||||
});
|
||||
|
||||
// The controller checks for role: { in: ['ADMIN', 'OWNER'] }
|
||||
// This test verifies the database structure supports this check
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: regularMember.id,
|
||||
projectId: project.id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(membership).toBeNull();
|
||||
});
|
||||
|
||||
it('should verify that ADMIN and OWNER roles can add domains', async () => {
|
||||
const {user: owner, project} = await factories.createUserWithProject();
|
||||
const admin = await factories.createUser({email: 'admin@test.com'});
|
||||
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
userId: admin.id,
|
||||
projectId: project.id,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
});
|
||||
|
||||
// Verify both owner and admin have required roles
|
||||
const ownerMembership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: owner.id,
|
||||
projectId: project.id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const adminMembership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: admin.id,
|
||||
projectId: project.id,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(ownerMembership).not.toBeNull();
|
||||
expect(adminMembership).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// EDGE CASES
|
||||
// ========================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle case-sensitive domain names', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
// Domains are typically case-insensitive in DNS, but stored as-is in DB
|
||||
const domain1 = await DomainService.addDomain(project.id, 'Example.com');
|
||||
|
||||
expect(domain1.domain).toBe('Example.com');
|
||||
});
|
||||
|
||||
it('should handle subdomain vs root domain', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
const rootDomain = await DomainService.addDomain(project.id, 'example.com');
|
||||
const subDomain = await DomainService.addDomain(project.id, 'mail.example.com');
|
||||
|
||||
expect(rootDomain.domain).toBe('example.com');
|
||||
expect(subDomain.domain).toBe('mail.example.com');
|
||||
|
||||
// Both should be separate entries
|
||||
const domains = await prisma.domain.findMany({
|
||||
where: {projectId: project.id},
|
||||
});
|
||||
|
||||
expect(domains).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle invalid email format in verifyEmailDomain', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('not-an-email', project.id)).rejects.toThrow(
|
||||
/invalid email format/i,
|
||||
);
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('multiple@at@signs.com', project.id)).rejects.toThrow(
|
||||
/invalid email format/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// GET PROJECT DOMAINS
|
||||
// ========================================
|
||||
describe('Get Project Domains', () => {
|
||||
it('should return all domains for a project', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await DomainService.addDomain(project.id, 'domain1.com');
|
||||
await DomainService.addDomain(project.id, 'domain2.com');
|
||||
await DomainService.addDomain(project.id, 'domain3.com');
|
||||
|
||||
const domains = await DomainService.getProjectDomains(project.id);
|
||||
|
||||
expect(domains).toHaveLength(3);
|
||||
expect(domains.map(d => d.domain).sort()).toEqual(['domain1.com', 'domain2.com', 'domain3.com']);
|
||||
});
|
||||
|
||||
it('should return empty array for project with no domains', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
const domains = await DomainService.getProjectDomains(project.id);
|
||||
|
||||
expect(domains).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not return domains from other projects', async () => {
|
||||
const {project: project1} = await factories.createUserWithProject();
|
||||
const {project: project2} = await factories.createUserWithProject();
|
||||
|
||||
await DomainService.addDomain(project1.id, 'project1-domain.com');
|
||||
await DomainService.addDomain(project2.id, 'project2-domain.com');
|
||||
|
||||
const project1Domains = await DomainService.getProjectDomains(project1.id);
|
||||
const project2Domains = await DomainService.getProjectDomains(project2.id);
|
||||
|
||||
expect(project1Domains).toHaveLength(1);
|
||||
expect(project1Domains[0].domain).toBe('project1-domain.com');
|
||||
|
||||
expect(project2Domains).toHaveLength(1);
|
||||
expect(project2Domains[0].domain).toBe('project2-domain.com');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
import {Server} from '@overnightjs/core';
|
||||
import cookies from 'cookie-parser';
|
||||
import cors from 'cors';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import {json, raw} from 'express';
|
||||
import helmet from 'helmet';
|
||||
import morgan from 'morgan';
|
||||
import signale from 'signale';
|
||||
import {ZodError} from 'zod';
|
||||
|
||||
import {
|
||||
DASHBOARD_URI,
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
LANDING_URI,
|
||||
NODE_ENV,
|
||||
PLUNK_ENABLED,
|
||||
PORT,
|
||||
S3_ENABLED,
|
||||
SMTP_ENABLED,
|
||||
STRIPE_ENABLED,
|
||||
TRACKING_TOGGLE_ENABLED,
|
||||
WIKI_URI,
|
||||
} from './app/constants.js';
|
||||
import {Actions} from './controllers/Actions.js';
|
||||
import {Activity} from './controllers/Activity.js';
|
||||
import {Analytics} from './controllers/Analytics.js';
|
||||
import {Auth} from './controllers/Auth.js';
|
||||
import {Campaigns} from './controllers/Campaigns.js';
|
||||
import {Contacts} from './controllers/Contacts.js';
|
||||
import {Domains} from './controllers/Domains.js';
|
||||
import {Events} from './controllers/Events.js';
|
||||
import {Oauth} from './controllers/Oauth/index.js';
|
||||
import {Projects} from './controllers/Projects.js';
|
||||
import {Segments} from './controllers/Segments.js';
|
||||
import {Templates} from './controllers/Templates.js';
|
||||
import {Uploads} from './controllers/Uploads.js';
|
||||
import {Users} from './controllers/Users.js';
|
||||
import {Webhooks} from './controllers/Webhooks.js';
|
||||
import {Workflows} from './controllers/Workflows.js';
|
||||
import {Config} from './controllers/Config.js';
|
||||
import {prisma} from './database/prisma.js';
|
||||
import {ErrorCode, type FieldError, HttpException, ValidationError} from './exceptions/index.js';
|
||||
import {apiRequestCleanupQueue, domainVerificationQueue, segmentCountQueue} from './services/QueueService.js';
|
||||
import * as S3Service from './services/S3Service.js';
|
||||
import {requestIdMiddleware} from './middleware/requestId.js';
|
||||
import {databaseRequestLogger} from './middleware/requestLogger.js';
|
||||
import {logger, requestLogger} from './utils/logger.js';
|
||||
|
||||
const server = new (class extends Server {
|
||||
public constructor() {
|
||||
super();
|
||||
|
||||
// Specify that we need raw json for the webhook
|
||||
this.app.use('/webhooks/incoming/stripe', raw({type: 'application/json'}));
|
||||
|
||||
// Set the content-type to JSON for any request coming from AWS SNS
|
||||
this.app.use(function (req, res, next) {
|
||||
if (req.get('x-amz-sns-message-type')) {
|
||||
req.headers['content-type'] = 'application/json';
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Parse the rest of our application as json
|
||||
this.app.use(json({limit: '50mb'}));
|
||||
this.app.use(cookies());
|
||||
this.app.use(helmet());
|
||||
|
||||
// Add request ID to all requests for tracking
|
||||
this.app.use(requestIdMiddleware);
|
||||
|
||||
// Log all requests and responses with request ID (console/file logs)
|
||||
this.app.use(requestLogger);
|
||||
|
||||
// Log all requests to database for historical tracking and analytics
|
||||
this.app.use(databaseRequestLogger);
|
||||
|
||||
// Build allowed origins from environment variables
|
||||
const allowedOrigins =
|
||||
NODE_ENV === 'development'
|
||||
? [/.*\.localhost:1000/, 'http://localhost:3000', 'http://localhost:4000']
|
||||
: [DASHBOARD_URI, LANDING_URI, WIKI_URI];
|
||||
|
||||
// Public API endpoints that should allow all origins
|
||||
const publicApiPaths = ['/v1', '/v1/track', '/v1/send'];
|
||||
|
||||
// Log CORS configuration on startup
|
||||
signale.info('CORS configuration', {
|
||||
environment: NODE_ENV,
|
||||
allowedOrigins: allowedOrigins.map(o => (o instanceof RegExp ? o.toString() : o)),
|
||||
publicApiPaths,
|
||||
});
|
||||
|
||||
// Apply restrictive CORS to all routes EXCEPT public API endpoints
|
||||
this.app.use((req, res, next) => {
|
||||
// Check if this is a public API endpoint
|
||||
const isPublicApi = publicApiPaths.some(path => req.path === path || req.path.startsWith(path + '/'));
|
||||
|
||||
if (isPublicApi) {
|
||||
// For public API endpoints, allow all origins
|
||||
res.set({
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
});
|
||||
// Handle preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
return res.sendStatus(200);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
// For other endpoints, apply restrictive CORS
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no origin (e.g., mobile apps, curl, server-to-server)
|
||||
if (!origin) {
|
||||
return callback(null, true);
|
||||
}
|
||||
|
||||
// Check if origin matches any allowed origin (string or regex)
|
||||
const isAllowed = allowedOrigins.some(allowed => {
|
||||
if (allowed instanceof RegExp) {
|
||||
return allowed.test(origin);
|
||||
}
|
||||
return allowed === origin;
|
||||
});
|
||||
|
||||
if (isAllowed) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
// Log CORS rejection with helpful information
|
||||
signale.warn('CORS request rejected', {
|
||||
origin,
|
||||
allowedOrigins: allowedOrigins.map(o => (o instanceof RegExp ? o.toString() : o)),
|
||||
hint: 'If using HTTPS, ensure USE_HTTPS=true is set in your environment variables',
|
||||
});
|
||||
// Reject the CORS request by passing false (don't send CORS headers)
|
||||
callback(null, false);
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
})(req, res, next);
|
||||
});
|
||||
|
||||
this.app.use(morgan(NODE_ENV === 'development' ? 'dev' : 'short'));
|
||||
|
||||
this.addControllers([
|
||||
new Actions(),
|
||||
new Activity(),
|
||||
new Analytics(),
|
||||
new Auth(),
|
||||
new Campaigns(),
|
||||
new Oauth(),
|
||||
new Users(),
|
||||
new Contacts(),
|
||||
new Domains(),
|
||||
new Projects(),
|
||||
new Segments(),
|
||||
new Templates(),
|
||||
new Uploads(),
|
||||
new Webhooks(),
|
||||
new Workflows(),
|
||||
new Events(),
|
||||
new Config(),
|
||||
]);
|
||||
|
||||
this.app.get('/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
status: 'ok',
|
||||
time: Date.now(),
|
||||
environment: NODE_ENV,
|
||||
uptime: process.uptime(),
|
||||
memoryUsage: process.memoryUsage(),
|
||||
});
|
||||
});
|
||||
|
||||
this.app.get('/', (_, res) => res.redirect(LANDING_URI));
|
||||
|
||||
this.app.use('*', () => {
|
||||
throw new HttpException(404, 'Unknown route');
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* Standardized error response format
|
||||
*/
|
||||
interface ErrorResponse {
|
||||
success: false;
|
||||
error: {
|
||||
code: string; // Machine-readable error code (e.g., "VALIDATION_ERROR")
|
||||
message: string; // Human-readable error message
|
||||
statusCode: number; // HTTP status code
|
||||
requestId?: string; // Request ID for tracking
|
||||
errors?: FieldError[]; // Field-level validation errors
|
||||
details?: Record<string, unknown>; // Additional error context
|
||||
suggestion?: string; // Helpful suggestion for fixing the error
|
||||
};
|
||||
timestamp: string; // ISO timestamp
|
||||
}
|
||||
|
||||
server.app.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
|
||||
const requestId = res.locals.requestId as string | undefined;
|
||||
|
||||
// Handle JSON parsing errors (from express.json() middleware)
|
||||
if (error instanceof SyntaxError && 'body' in error) {
|
||||
const statusCode = 400;
|
||||
|
||||
logger.warn(
|
||||
'JSON parsing failed',
|
||||
{
|
||||
endpoint: `${req.method} ${req.path}`,
|
||||
contentType: req.get('content-type'),
|
||||
},
|
||||
res,
|
||||
);
|
||||
|
||||
const response: ErrorResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code: ErrorCode.VALIDATION_ERROR,
|
||||
message: 'Invalid JSON in request body',
|
||||
statusCode,
|
||||
requestId,
|
||||
suggestion: 'Ensure your request body is valid JSON and Content-Type header is set to "application/json".',
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return res.status(statusCode).json(response);
|
||||
}
|
||||
|
||||
// Handle Zod validation errors
|
||||
if (error instanceof ZodError) {
|
||||
const fieldErrors: FieldError[] = error.errors.map(err => ({
|
||||
field: err.path.join('.'),
|
||||
message: err.message,
|
||||
code: err.code,
|
||||
received: err.code !== 'invalid_type' ? undefined : 'received' in err ? err.received : undefined,
|
||||
}));
|
||||
|
||||
const statusCode = 422;
|
||||
|
||||
// Create helpful suggestions based on common validation errors
|
||||
let suggestion = 'Please check the API documentation for the correct request format.';
|
||||
if (fieldErrors.some(e => e.code === 'invalid_type')) {
|
||||
suggestion =
|
||||
'One or more fields have incorrect types. Check that strings are quoted, numbers are unquoted, and booleans are true/false.';
|
||||
} else if (fieldErrors.some(e => e.message.includes('required'))) {
|
||||
suggestion = 'Required fields are missing. Ensure all required fields are included in your request.';
|
||||
}
|
||||
|
||||
// Log validation errors with structured logging
|
||||
logger.warn(
|
||||
'Validation failed',
|
||||
{
|
||||
endpoint: `${req.method} ${req.path}`,
|
||||
fieldCount: fieldErrors.length,
|
||||
fields: fieldErrors.map(e => e.field),
|
||||
},
|
||||
res,
|
||||
);
|
||||
|
||||
const response: ErrorResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code: ErrorCode.VALIDATION_ERROR,
|
||||
message: 'Request validation failed',
|
||||
statusCode,
|
||||
requestId,
|
||||
errors: fieldErrors,
|
||||
suggestion,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return res.status(statusCode).json(response);
|
||||
}
|
||||
|
||||
// Handle custom HTTP exceptions
|
||||
if (error instanceof HttpException) {
|
||||
const statusCode = error.code;
|
||||
const errorCode = error.errorCode || ErrorCode.INTERNAL_SERVER_ERROR;
|
||||
|
||||
// Extract validation errors if this is a ValidationError
|
||||
const validationError = error instanceof ValidationError ? error : null;
|
||||
|
||||
// Log based on severity with structured logging
|
||||
if (statusCode >= 500) {
|
||||
logger.error(
|
||||
error.message,
|
||||
error,
|
||||
{
|
||||
endpoint: `${req.method} ${req.path}`,
|
||||
errorCode,
|
||||
statusCode,
|
||||
},
|
||||
res,
|
||||
);
|
||||
} else if (statusCode >= 400) {
|
||||
logger.warn(
|
||||
error.message,
|
||||
{
|
||||
endpoint: `${req.method} ${req.path}`,
|
||||
errorCode,
|
||||
statusCode,
|
||||
},
|
||||
res,
|
||||
);
|
||||
}
|
||||
|
||||
// Generate helpful suggestions based on error type
|
||||
let suggestion: string | undefined;
|
||||
switch (errorCode) {
|
||||
case ErrorCode.INVALID_API_KEY:
|
||||
suggestion = 'Verify your API key is correct and starts with "sk_" for secret keys or "pk_" for public keys.';
|
||||
break;
|
||||
case ErrorCode.MISSING_AUTH:
|
||||
suggestion = 'Include an Authorization header with format: "Authorization: Bearer YOUR_API_KEY"';
|
||||
break;
|
||||
case ErrorCode.FORBIDDEN:
|
||||
suggestion =
|
||||
'This action requires additional permissions. Check that you have access to this resource or contact support.';
|
||||
break;
|
||||
case ErrorCode.TEMPLATE_NOT_FOUND:
|
||||
suggestion =
|
||||
'Ensure the template ID is correct and belongs to your project. You can list available templates via the API.';
|
||||
break;
|
||||
case ErrorCode.VALIDATION_ERROR:
|
||||
suggestion = 'Check the "errors" array for specific field-level validation issues.';
|
||||
break;
|
||||
case ErrorCode.RATE_LIMIT_EXCEEDED:
|
||||
suggestion = 'You have exceeded the rate limit. Wait a moment before retrying, or upgrade your plan.';
|
||||
break;
|
||||
case ErrorCode.UPGRADE_REQUIRED:
|
||||
suggestion = 'This feature requires a higher plan. Visit your dashboard to upgrade.';
|
||||
break;
|
||||
case ErrorCode.PROJECT_DISABLED:
|
||||
suggestion = 'Your project has been disabled. Contact support for assistance.';
|
||||
break;
|
||||
}
|
||||
|
||||
const response: ErrorResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code: errorCode,
|
||||
message: error.message,
|
||||
statusCode,
|
||||
requestId,
|
||||
errors: validationError?.errors,
|
||||
details: error.details,
|
||||
suggestion,
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return res.status(statusCode).json(response);
|
||||
}
|
||||
|
||||
// Handle unexpected errors (not HttpException)
|
||||
const statusCode = 500;
|
||||
logger.error(
|
||||
'Unexpected error occurred',
|
||||
error,
|
||||
{
|
||||
endpoint: `${req.method} ${req.path}`,
|
||||
},
|
||||
res,
|
||||
);
|
||||
|
||||
const response: ErrorResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code: ErrorCode.INTERNAL_SERVER_ERROR,
|
||||
message: 'An unexpected error occurred',
|
||||
statusCode,
|
||||
requestId,
|
||||
suggestion: 'This is an internal server error. Please contact support with the request ID if the issue persists.',
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
res.status(statusCode).json(response);
|
||||
});
|
||||
|
||||
// Global error handlers to prevent server crashes
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
signale.error('Unhandled Promise Rejection:', reason);
|
||||
signale.error('Promise:', promise);
|
||||
// Don't exit the process - just log the error
|
||||
});
|
||||
|
||||
process.on('uncaughtException', error => {
|
||||
signale.error('Uncaught Exception:', error);
|
||||
// Don't exit the process - just log the error
|
||||
});
|
||||
|
||||
void prisma.$connect().then(async () => {
|
||||
server.app.listen(PORT, '0.0.0.0', () => signale.success('[HTTPS] Ready on', PORT));
|
||||
|
||||
// Feature matrix
|
||||
const features = [
|
||||
{
|
||||
name: 'Billing (Stripe)',
|
||||
enabled: STRIPE_ENABLED,
|
||||
details: STRIPE_ENABLED ? 'Stripe billing enabled' : 'No Stripe keys configured',
|
||||
},
|
||||
{
|
||||
name: 'File uploads (S3)',
|
||||
enabled: S3_ENABLED,
|
||||
details: S3_ENABLED ? 'S3 storage enabled' : 'S3 credentials missing',
|
||||
},
|
||||
{name: 'SMTP relay', enabled: SMTP_ENABLED, details: SMTP_ENABLED ? 'SMTP server enabled' : 'SMTP disabled'},
|
||||
{
|
||||
name: 'OAuth - GitHub',
|
||||
enabled: GITHUB_OAUTH_ENABLED,
|
||||
details: GITHUB_OAUTH_ENABLED ? 'GitHub login enabled' : 'GitHub OAuth not configured',
|
||||
},
|
||||
{
|
||||
name: 'OAuth - Google',
|
||||
enabled: GOOGLE_OAUTH_ENABLED,
|
||||
details: GOOGLE_OAUTH_ENABLED ? 'Google login enabled' : 'Google OAuth not configured',
|
||||
},
|
||||
{
|
||||
name: 'Tracking toggle',
|
||||
enabled: TRACKING_TOGGLE_ENABLED,
|
||||
details: TRACKING_TOGGLE_ENABLED
|
||||
? 'Per-project tracking toggle enabled'
|
||||
: 'Always tracking or always no-tracking',
|
||||
},
|
||||
{
|
||||
name: 'Platform emails',
|
||||
enabled: PLUNK_ENABLED,
|
||||
details: PLUNK_ENABLED ? 'Platform email notifications enabled' : 'PLUNK_API_KEY not configured',
|
||||
},
|
||||
];
|
||||
|
||||
const rows = features.map(f => ({
|
||||
Feature: f.name,
|
||||
Enabled: f.enabled ? '✅' : '❌',
|
||||
}));
|
||||
|
||||
console.table(rows);
|
||||
|
||||
if (S3_ENABLED) {
|
||||
try {
|
||||
await S3Service.initializeBucket();
|
||||
} catch (error) {
|
||||
signale.error('[S3] Failed to initialize bucket:', error);
|
||||
signale.warn('[S3] File uploads may not work properly');
|
||||
}
|
||||
}
|
||||
|
||||
// Set up repeatable job for domain verification (BullMQ)
|
||||
// Run every 5 minutes to check domain verification status with AWS SES
|
||||
await domainVerificationQueue.add(
|
||||
'check-domain-verification',
|
||||
{},
|
||||
{
|
||||
repeat: {
|
||||
pattern: '*/5 * * * *', // Every 5 minutes
|
||||
},
|
||||
jobId: 'domain-verification-repeatable', // Fixed ID to prevent duplicates
|
||||
},
|
||||
);
|
||||
|
||||
signale.info('[BACKGROUND-JOB] Domain verification scheduled (BullMQ repeatable job, runs every 5 minutes)');
|
||||
|
||||
// Set up repeatable job for segment count updates (BullMQ)
|
||||
// Run every 5 minutes to compute membership changes and trigger events
|
||||
await segmentCountQueue.add(
|
||||
'update-segment-counts',
|
||||
{},
|
||||
{
|
||||
repeat: {
|
||||
pattern: '*/5 * * * *', // Every 5 minutes
|
||||
},
|
||||
jobId: 'segment-count-repeatable', // Fixed ID to prevent duplicates
|
||||
},
|
||||
);
|
||||
|
||||
signale.info('[BACKGROUND-JOB] Segment count updater scheduled (BullMQ repeatable job, runs every 5 minutes)');
|
||||
|
||||
// Set up repeatable job for API request log cleanup (BullMQ)
|
||||
// Run daily at 3 AM to clean up old request logs
|
||||
await apiRequestCleanupQueue.add(
|
||||
'cleanup-old-requests',
|
||||
{},
|
||||
{
|
||||
repeat: {
|
||||
pattern: '0 3 * * *', // Daily at 3 AM
|
||||
},
|
||||
jobId: 'api-request-cleanup-repeatable', // Fixed ID to prevent duplicates
|
||||
},
|
||||
);
|
||||
|
||||
signale.info('[BACKGROUND-JOB] API request cleanup scheduled (BullMQ repeatable job, runs daily at 3 AM)');
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config({quiet: true});
|
||||
|
||||
/**
|
||||
* 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;
|
||||
} else {
|
||||
throw new Error(`${key} is not defined in environment variables`);
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// Environment
|
||||
export const NODE_ENV = validateEnv('NODE_ENV', 'development');
|
||||
export const JWT_SECRET = validateEnv('JWT_SECRET');
|
||||
export const PORT = Number(validateEnv('PORT', '8080'));
|
||||
|
||||
// URLs
|
||||
export const API_URI = validateEnv('API_URI');
|
||||
export const DASHBOARD_URI = validateEnv('DASHBOARD_URI');
|
||||
export const LANDING_URI = validateEnv('LANDING_URI');
|
||||
export const WIKI_URI = validateEnv('WIKI_URI');
|
||||
|
||||
// S3-compatible storage (Minio)
|
||||
export const S3_ENDPOINT = validateEnv('S3_ENDPOINT', 'http://minio:9000');
|
||||
export const S3_ACCESS_KEY_ID = validateEnv('S3_ACCESS_KEY_ID', '');
|
||||
export const S3_ACCESS_KEY_SECRET = validateEnv('S3_ACCESS_KEY_SECRET', '');
|
||||
export const S3_BUCKET = validateEnv('S3_BUCKET', 'uploads');
|
||||
export const S3_PUBLIC_URL = validateEnv('S3_PUBLIC_URL', '');
|
||||
export const S3_FORCE_PATH_STYLE = validateEnv('S3_FORCE_PATH_STYLE', 'true') === 'true';
|
||||
export const S3_ENABLED = S3_ACCESS_KEY_ID !== '' && S3_ACCESS_KEY_SECRET !== '';
|
||||
|
||||
// AWS SES (required for email sending)
|
||||
export const AWS_SES_REGION = validateEnv('AWS_SES_REGION');
|
||||
export const AWS_SES_ACCESS_KEY_ID = validateEnv('AWS_SES_ACCESS_KEY_ID');
|
||||
export const AWS_SES_SECRET_ACCESS_KEY = validateEnv('AWS_SES_SECRET_ACCESS_KEY');
|
||||
|
||||
// Email Processing Rate Limit (optional override)
|
||||
// If not set, will automatically fetch from AWS SES account quota
|
||||
// Set this to override AWS quota (useful for setting lower limits or testing)
|
||||
export const EMAIL_RATE_LIMIT_PER_SECOND = process.env.EMAIL_RATE_LIMIT_PER_SECOND
|
||||
? Number(process.env.EMAIL_RATE_LIMIT_PER_SECOND)
|
||||
: undefined;
|
||||
|
||||
// Storage
|
||||
export const REDIS_URL = validateEnv('REDIS_URL');
|
||||
export const DATABASE_URL = validateEnv('DATABASE_URL');
|
||||
export const DIRECT_DATABASE_URL = validateEnv('DIRECT_DATABASE_URL');
|
||||
|
||||
// OAuth (optional - for social login)
|
||||
export const GITHUB_OAUTH_CLIENT = validateEnv('GITHUB_OAUTH_CLIENT', '');
|
||||
export const GITHUB_OAUTH_SECRET = validateEnv('GITHUB_OAUTH_SECRET', '');
|
||||
export const GITHUB_OAUTH_ENABLED = GITHUB_OAUTH_CLIENT !== '' && GITHUB_OAUTH_SECRET !== '';
|
||||
|
||||
export const GOOGLE_OAUTH_CLIENT = validateEnv('GOOGLE_OAUTH_CLIENT', '');
|
||||
export const GOOGLE_OAUTH_SECRET = validateEnv('GOOGLE_OAUTH_SECRET', '');
|
||||
export const GOOGLE_OAUTH_ENABLED = GOOGLE_OAUTH_CLIENT !== '' && GOOGLE_OAUTH_SECRET !== '';
|
||||
|
||||
// Stripe (optional - if not set, billing features are disabled)
|
||||
export const STRIPE_SK = validateEnv('STRIPE_SK', '');
|
||||
export const STRIPE_WEBHOOK_SECRET = validateEnv('STRIPE_WEBHOOK_SECRET', '');
|
||||
export const STRIPE_ENABLED = STRIPE_SK !== '' && STRIPE_WEBHOOK_SECRET !== '';
|
||||
|
||||
// Stripe Pricing Configuration
|
||||
export const STRIPE_PRICE_ONBOARDING = validateEnv('STRIPE_PRICE_ONBOARDING', ''); // One-time onboarding fee
|
||||
export const STRIPE_PRICE_EMAIL_USAGE = validateEnv('STRIPE_PRICE_EMAIL_USAGE', ''); // Metered usage price for pay-per-email
|
||||
export const STRIPE_METER_EVENT_NAME = validateEnv('STRIPE_METER_EVENT_NAME', 'emails'); // Meter event name (API key in Stripe)
|
||||
|
||||
// Email Tracking
|
||||
export const SES_CONFIGURATION_SET = validateEnv('SES_CONFIGURATION_SET', 'plunk-configuration-set');
|
||||
export const SES_CONFIGURATION_SET_NO_TRACKING = validateEnv(
|
||||
'SES_CONFIGURATION_SET_NO_TRACKING',
|
||||
'plunk-configuration-set-no-tracking',
|
||||
);
|
||||
// Check if no-tracking configuration set was explicitly provided (not using default)
|
||||
export const TRACKING_TOGGLE_ENABLED = process.env.SES_CONFIGURATION_SET_NO_TRACKING !== undefined;
|
||||
|
||||
// SMTP Server Configuration (optional)
|
||||
// SMTP server can run with or without a domain (runs without TLS in dev mode)
|
||||
// Check if we should enable SMTP features in the UI
|
||||
export const SMTP_DOMAIN = validateEnv('SMTP_DOMAIN', 'localhost');
|
||||
export const SMTP_PORT_SECURE = Number(validateEnv('PORT_SECURE', '465'));
|
||||
export const SMTP_PORT_SUBMISSION = Number(validateEnv('PORT_SUBMISSION', '587'));
|
||||
// Enable SMTP features only when explicitly enabled via env or when a non-default domain is configured
|
||||
export const SMTP_ENABLED =
|
||||
process.env.SMTP_ENABLED === 'true' || (SMTP_DOMAIN !== 'localhost' && NODE_ENV !== 'development');
|
||||
|
||||
export const PLUNK_API_KEY = validateEnv('PLUNK_API_KEY', '');
|
||||
export const PLUNK_FROM_ADDRESS = validateEnv('PLUNK_FROM_ADDRESS', '');
|
||||
export const PLUNK_ENABLED = PLUNK_API_KEY !== '' && PLUNK_FROM_ADDRESS !== '';
|
||||
|
||||
// Security (optional)
|
||||
// Controls whether projects are automatically disabled when bounce/complaint rate thresholds are exceeded
|
||||
// Useful for self-hosters who want to manage project status manually
|
||||
export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true';
|
||||
|
||||
// Self-hosting Configuration (optional)
|
||||
// Controls whether new user signups are allowed (default: false)
|
||||
export const DISABLE_SIGNUPS = process.env.DISABLE_SIGNUPS === 'true';
|
||||
// Controls whether email validation checks are performed on signup (default: false)
|
||||
export const VERIFY_EMAIL_ON_SIGNUP = process.env.VERIFY_EMAIL_ON_SIGNUP === 'true';
|
||||
|
||||
// Email Verification & Password Reset
|
||||
export const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
|
||||
export const EMAIL_VERIFICATION_RATE_LIMIT = 3; // Max 3 emails per hour
|
||||
export const PASSWORD_RESET_RATE_LIMIT = 3; // Max 3 emails per hour
|
||||
export const EMAIL_VERIFICATION_RATE_WINDOW = 3600; // 1 hour in seconds
|
||||
@@ -0,0 +1,14 @@
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import {STRIPE_ENABLED, STRIPE_SK} from './constants.js';
|
||||
|
||||
/**
|
||||
* Stripe client instance
|
||||
* Only initialized if STRIPE_SK is configured
|
||||
* Returns null if billing is disabled (self-hosted mode)
|
||||
*/
|
||||
export const stripe = STRIPE_ENABLED
|
||||
? new Stripe(STRIPE_SK, {
|
||||
apiVersion: '2025-11-17.clover',
|
||||
})
|
||||
: null;
|
||||
@@ -0,0 +1,403 @@
|
||||
import {Controller, Middleware, Post} from '@overnightjs/core';
|
||||
import {ActionSchemas} from '@plunk/shared';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import {requirePublicKey, requireSecretKey} from '../middleware/auth.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {DomainService} from '../services/DomainService.js';
|
||||
import {EmailService} from '../services/EmailService.js';
|
||||
import {EmailVerificationService} from '../services/EmailVerificationService.js';
|
||||
import {EventService} from '../services/EventService.js';
|
||||
import {NotFound, ValidationError} from '../exceptions/index.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
import {DASHBOARD_URI} from '../app/constants.js';
|
||||
|
||||
/**
|
||||
* Public API Actions Controller
|
||||
* Handles track event, transactional email, and email verification endpoints
|
||||
*/
|
||||
@Controller('v1')
|
||||
export class Actions {
|
||||
/**
|
||||
* POST /v1/track
|
||||
* Track an event for a contact (creates/updates contact and tracks event)
|
||||
*
|
||||
* Request body:
|
||||
* - event: string (required) - Event name
|
||||
* - email: string (required) - Contact email
|
||||
* - subscribed: boolean (optional) - Contact subscription status (only updates if explicitly specified)
|
||||
* - data: object (optional) - Event and contact data
|
||||
* - Simple values are saved to contact (persistent)
|
||||
* - {value: any, persistent: false} are only available to workflows (non-persistent)
|
||||
*
|
||||
* Response:
|
||||
* - success: boolean
|
||||
* - data: object with contact ID, event ID, and timestamp
|
||||
*
|
||||
* Example:
|
||||
* {
|
||||
* event: "purchase",
|
||||
* email: "user@example.com",
|
||||
* data: {
|
||||
* totalSpent: 1500, // Persistent - saved to contact
|
||||
* plan: "pro", // Persistent - saved to contact
|
||||
* orderId: {value: "12345", persistent: false}, // Non-persistent - workflows only
|
||||
* receiptUrl: {value: "https://...", persistent: false} // Non-persistent - workflows only
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
@Post('track')
|
||||
@Middleware([requirePublicKey])
|
||||
@CatchAsync
|
||||
public async track(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
|
||||
// Zod validation - errors automatically handled by global error handler
|
||||
const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body);
|
||||
|
||||
// Prevent manual tracking of reserved system events
|
||||
if (EventService.isReservedEvent(event)) {
|
||||
throw new ValidationError(
|
||||
[
|
||||
{
|
||||
field: 'event',
|
||||
message: `Event name "${event}" is reserved for system use and cannot be manually tracked`,
|
||||
code: 'reserved_event',
|
||||
received: event,
|
||||
},
|
||||
],
|
||||
'Cannot track reserved system event',
|
||||
);
|
||||
}
|
||||
|
||||
// Create or update contact with persistent data only
|
||||
// ContactService.upsert will filter out non-persistent fields
|
||||
// Event tracking should subscribe new contacts by default (subscribed=true in ContactService)
|
||||
// but preserve existing subscription state for existing contacts
|
||||
const contact = await ContactService.upsert(
|
||||
auth.projectId,
|
||||
email,
|
||||
data as Record<string, unknown> | undefined,
|
||||
subscribed,
|
||||
);
|
||||
|
||||
// Track the event with ALL data (persistent + non-persistent)
|
||||
// Non-persistent data flows to workflows via execution context
|
||||
const eventRecord = await EventService.trackEvent(
|
||||
auth.projectId,
|
||||
event,
|
||||
contact.id,
|
||||
undefined,
|
||||
data as Record<string, unknown> | undefined,
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
contact: contact.id,
|
||||
event: eventRecord.id,
|
||||
timestamp: eventRecord.createdAt.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/send
|
||||
* Send transactional email(s)
|
||||
*
|
||||
* Request body:
|
||||
* - to: string | object | array (required) - Recipient email(s)
|
||||
* - String: "user@example.com"
|
||||
* - Object: {name: "Jane Doe", email: "user@example.com"}
|
||||
* - Array: ["user1@example.com", {name: "Jane", email: "user2@example.com"}]
|
||||
* - subject: string (required) - Email subject
|
||||
* - body: string (required) - Email HTML body
|
||||
* - subscribed: boolean (optional) - Contact subscription status (only updates if explicitly specified)
|
||||
* - name: string (optional) - Sender name (alternative to from.name)
|
||||
* - from: string | object (optional) - Sender email or {name, email} object (must be from verified domain)
|
||||
* - reply: string (optional) - Reply-to email
|
||||
* - headers: object (optional) - Additional email headers
|
||||
* - data: object (optional) - Contact data and template variables
|
||||
* - Simple values are saved to contact (persistent)
|
||||
* - {value: any, persistent: false} are only used for this email (non-persistent)
|
||||
* - attachments: array (optional) - Email attachments (max 10, 10MB total)
|
||||
* - filename: string (required) - Attachment filename
|
||||
* - content: string (required) - Base64 encoded file content
|
||||
* - contentType: string (required) - MIME type (e.g., "application/pdf")
|
||||
*
|
||||
* Response:
|
||||
* - success: boolean
|
||||
* - data: object with emails array and timestamp
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* Simple format (backward compatible):
|
||||
* {
|
||||
* to: "user@example.com",
|
||||
* subject: "Password Reset",
|
||||
* body: "<p>Reset code: {{resetCode}}</p><p>Hello {{firstName}}!</p>",
|
||||
* from: "noreply@example.com",
|
||||
* name: "My App",
|
||||
* data: {
|
||||
* firstName: "John", // Persistent - saved to contact
|
||||
* resetCode: {value: "ABC123", persistent: false} // Non-persistent - this email only
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Object format (recommended):
|
||||
* {
|
||||
* to: {
|
||||
* name: "Jane Doe",
|
||||
* email: "user@example.com"
|
||||
* },
|
||||
* subject: "Password Reset",
|
||||
* body: "<p>Reset code: {{resetCode}}</p>",
|
||||
* from: {
|
||||
* name: "My App",
|
||||
* email: "noreply@example.com"
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Multiple recipients with names:
|
||||
* {
|
||||
* to: [
|
||||
* {name: "Jane Doe", email: "jane@example.com"},
|
||||
* {name: "John Smith", email: "john@example.com"}
|
||||
* ],
|
||||
* subject: "Newsletter",
|
||||
* body: "<p>Hello {{name}}!</p>",
|
||||
* from: {name: "Newsletter", email: "news@example.com"}
|
||||
* }
|
||||
*/
|
||||
@Post('send')
|
||||
@Middleware([requireSecretKey])
|
||||
@CatchAsync
|
||||
public async send(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
|
||||
// Zod validation - errors automatically handled by global error handler
|
||||
const {to, subject, body, subscribed, name, from, reply, headers, data, template, attachments} =
|
||||
ActionSchemas.send.parse(req.body);
|
||||
|
||||
// Normalize recipients to array and parse email/name
|
||||
type Recipient = {email: string; name?: string};
|
||||
const recipients: Recipient[] = (Array.isArray(to) ? to : [to]).map(recipient => {
|
||||
if (typeof recipient === 'string') {
|
||||
return {email: recipient};
|
||||
} else {
|
||||
return {email: recipient.email, name: recipient.name};
|
||||
}
|
||||
});
|
||||
|
||||
// Parse 'from' field - can be string or object {name, email}
|
||||
let emailFrom: string | undefined;
|
||||
let emailFromName: string | undefined;
|
||||
|
||||
if (typeof from === 'string') {
|
||||
// Backward compatible: from is just an email string
|
||||
emailFrom = from;
|
||||
emailFromName = name; // Use separate 'name' field if provided
|
||||
} else if (from && typeof from === 'object') {
|
||||
// New format: from is an object with {name, email}
|
||||
emailFrom = from.email;
|
||||
emailFromName = from.name || name; // Prefer from.name, fallback to separate 'name' field
|
||||
} else {
|
||||
// No 'from' provided
|
||||
emailFromName = name;
|
||||
}
|
||||
|
||||
// Fetch template if provided
|
||||
let emailSubject = subject;
|
||||
let emailBody = body;
|
||||
let emailReplyTo = reply;
|
||||
let templateId: string | undefined;
|
||||
|
||||
if (template) {
|
||||
const templateRecord = await prisma.template.findUnique({
|
||||
where: {
|
||||
id: template,
|
||||
projectId: auth.projectId, // Ensure template belongs to this project
|
||||
},
|
||||
});
|
||||
|
||||
if (!templateRecord) {
|
||||
throw new NotFound('Template', template);
|
||||
}
|
||||
|
||||
// Use template values, allow overrides from request
|
||||
emailSubject = subject || templateRecord.subject;
|
||||
emailBody = body || templateRecord.body;
|
||||
|
||||
// Handle from field - if not already set and template has a from, use it
|
||||
if (!emailFrom && templateRecord.from) {
|
||||
emailFrom = templateRecord.from;
|
||||
}
|
||||
if (!emailFromName && templateRecord.fromName) {
|
||||
emailFromName = templateRecord.fromName;
|
||||
}
|
||||
|
||||
emailReplyTo = reply || templateRecord.replyTo || undefined;
|
||||
templateId = templateRecord.id;
|
||||
}
|
||||
|
||||
if (!emailFrom) {
|
||||
throw new ValidationError(
|
||||
[
|
||||
{
|
||||
field: 'from',
|
||||
message: 'Sender email is required either in request or template',
|
||||
code: 'required',
|
||||
},
|
||||
],
|
||||
'Could not parse sender email',
|
||||
);
|
||||
}
|
||||
|
||||
await DomainService.verifyEmailDomain(emailFrom, auth.projectId);
|
||||
|
||||
const replyToEmail = emailReplyTo;
|
||||
|
||||
const timestamp = new Date();
|
||||
const emailResults = [];
|
||||
|
||||
// Process each recipient
|
||||
for (const recipient of recipients) {
|
||||
// Merge recipient name with data if provided
|
||||
const recipientData = recipient.name
|
||||
? {...(data as Record<string, unknown> | undefined), name: recipient.name}
|
||||
: (data as Record<string, unknown> | undefined);
|
||||
|
||||
// Create or update contact with metadata
|
||||
// Transactional emails should not subscribe contacts by default
|
||||
// New contacts default to unsubscribed unless explicitly opted in
|
||||
// Existing contacts preserve their subscription state unless explicitly changed
|
||||
const contact = await ContactService.upsert(auth.projectId, recipient.email, recipientData, subscribed, false);
|
||||
|
||||
// Get merged data including non-persistent fields for template rendering
|
||||
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
|
||||
|
||||
// Add system variables (email, unsubscribe URLs, etc.) to merged data
|
||||
// These are always available for template rendering
|
||||
const dataWithSystemVars = {
|
||||
...mergedData,
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
data: mergedData, // Also available as nested data for {{data.fieldName}} syntax
|
||||
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`,
|
||||
subscribeUrl: `${DASHBOARD_URI}/subscribe/${contact.id}`,
|
||||
manageUrl: `${DASHBOARD_URI}/manage/${contact.id}`,
|
||||
};
|
||||
|
||||
// Render template with contact data
|
||||
// Simple template variable replacement: {{fieldname}}
|
||||
let renderedSubject = emailSubject!;
|
||||
let renderedBody = emailBody!;
|
||||
|
||||
for (const [key, value] of Object.entries(dataWithSystemVars)) {
|
||||
const placeholder = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g');
|
||||
const fallbackPlaceholder = new RegExp(`\\{\\{\\s*${key}\\s*\\?\\?\\s*([^}]+)\\}\\}`, 'g');
|
||||
|
||||
// Replace with value
|
||||
const stringValue = value !== null && value !== undefined ? String(value) : '';
|
||||
renderedSubject = renderedSubject!.replace(placeholder, stringValue);
|
||||
renderedBody = renderedBody!.replace(placeholder, stringValue);
|
||||
|
||||
// Handle fallback syntax: {{field ?? default}}
|
||||
renderedSubject = renderedSubject!.replace(fallbackPlaceholder, stringValue || '$1');
|
||||
renderedBody = renderedBody!.replace(fallbackPlaceholder, stringValue || '$1');
|
||||
}
|
||||
|
||||
// Replace any remaining placeholders with empty string or fallback value
|
||||
renderedSubject = renderedSubject!.replace(/\{\{\s*(\w+)\s*\}\}/g, '');
|
||||
renderedBody = renderedBody!.replace(/\{\{\s*(\w+)\s*\}\}/g, '');
|
||||
|
||||
// Handle fallback placeholders that weren't matched
|
||||
renderedSubject = renderedSubject!.replace(/\{\{\s*\w+\s*\?\?\s*([^}]+)\}\}/g, '$1');
|
||||
renderedBody = renderedBody!.replace(/\{\{\s*\w+\s*\?\?\s*([^}]+)\}\}/g, '$1');
|
||||
|
||||
const email = await EmailService.sendTransactionalEmail({
|
||||
projectId: auth.projectId,
|
||||
contactId: contact.id,
|
||||
subject: renderedSubject,
|
||||
body: renderedBody,
|
||||
from: emailFrom,
|
||||
fromName: emailFromName,
|
||||
toName: recipient.name,
|
||||
replyTo: replyToEmail,
|
||||
headers: headers || undefined,
|
||||
attachments: attachments || undefined,
|
||||
templateId: templateId,
|
||||
});
|
||||
|
||||
emailResults.push({
|
||||
contact: {
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
},
|
||||
email: email.id,
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
emails: emailResults,
|
||||
timestamp: timestamp.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/verify
|
||||
* Verify an email address
|
||||
*
|
||||
* Request body:
|
||||
* - email: string (required) - Email address to verify
|
||||
*
|
||||
* Response:
|
||||
* - success: boolean
|
||||
* - data: object with verification results
|
||||
* - email: string - Email address that was verified
|
||||
* - valid: boolean - Whether the email appears to be valid
|
||||
* - isDisposable: boolean - Whether the email is from a disposable domain
|
||||
* - hasMxRecords: boolean - Whether the domain has MX records configured
|
||||
* - suggestedEmail?: string - Suggested correction if typo detected
|
||||
* - reasons: string[] - Array of reasons describing the verification results
|
||||
*
|
||||
* Example:
|
||||
* {
|
||||
* email: "user@gmial.com"
|
||||
* }
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* success: true,
|
||||
* data: {
|
||||
* email: "user@gmial.com",
|
||||
* valid: false,
|
||||
* isDisposable: false,
|
||||
* hasMxRecords: false,
|
||||
* suggestedEmail: "user@gmail.com",
|
||||
* reasons: [
|
||||
* "Possible typo detected, did you mean user@gmail.com?",
|
||||
* "Domain does not exist or has no MX records"
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
@Post('verify')
|
||||
@Middleware([requireSecretKey])
|
||||
@CatchAsync
|
||||
public async verify(req: Request, res: Response, _next: NextFunction) {
|
||||
// Zod validation - errors automatically handled by global error handler
|
||||
const {email} = ActionSchemas.verify.parse(req.body);
|
||||
|
||||
// Verify the email address
|
||||
const verificationResult = await EmailVerificationService.verifyEmail(email);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: verificationResult,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import {Controller, Get, Middleware} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import {ActivityType} from '@plunk/types';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {ActivityService} from '../services/ActivityService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@Controller('activity')
|
||||
export class Activity {
|
||||
/**
|
||||
* GET /activity
|
||||
* Get unified activity feed for the project
|
||||
*
|
||||
* Query params:
|
||||
* - limit: number (default 50, max 100)
|
||||
* - cursor: string (pagination cursor: timestamp_id)
|
||||
* - types: ActivityType[] (filter by activity types)
|
||||
* - contactId: string (filter by contact)
|
||||
* - startDate: ISO date string
|
||||
* - endDate: ISO date string
|
||||
*/
|
||||
@Get('')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getActivities(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
const cursor = req.query.cursor as string | undefined;
|
||||
const contactId = req.query.contactId as string | undefined;
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||
|
||||
// Parse types filter (comma-separated)
|
||||
let types: ActivityType[] | undefined;
|
||||
if (req.query.types) {
|
||||
const typesParam = req.query.types as string;
|
||||
types = typesParam
|
||||
.split(',')
|
||||
.filter(t => Object.values(ActivityType).includes(t as ActivityType)) as ActivityType[];
|
||||
}
|
||||
|
||||
const result = await ActivityService.getActivities(
|
||||
auth.projectId,
|
||||
limit,
|
||||
cursor,
|
||||
types,
|
||||
contactId,
|
||||
startDate,
|
||||
endDate,
|
||||
);
|
||||
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /activity/stats
|
||||
* Get activity statistics for the project
|
||||
*
|
||||
* Query params:
|
||||
* - startDate: ISO date string (defaults to 30 days ago)
|
||||
* - endDate: ISO date string (defaults to now)
|
||||
*/
|
||||
@Get('stats')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getStats(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||
|
||||
const stats = await ActivityService.getStats(auth.projectId, startDate, endDate);
|
||||
|
||||
return res.status(200).json(stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /activity/recent-count
|
||||
* Get count of recent activities (for real-time updates)
|
||||
*
|
||||
* Query params:
|
||||
* - minutes: number (default 5)
|
||||
*/
|
||||
@Get('recent-count')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getRecentCount(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const minutes = Math.min(parseInt(req.query.minutes as string) || 5, 60); // Max 60 minutes
|
||||
|
||||
const count = await ActivityService.getRecentActivityCount(auth.projectId, minutes);
|
||||
|
||||
return res.status(200).json({count, minutes});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /activity/types
|
||||
* Get available activity types (for UI filters)
|
||||
*/
|
||||
@Get('types')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getTypes(_req: Request, res: Response, _next: NextFunction) {
|
||||
const types = Object.values(ActivityType);
|
||||
return res.status(200).json({types});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /activity/upcoming
|
||||
* Get upcoming scheduled activities (campaigns and workflow emails)
|
||||
*
|
||||
* Query params:
|
||||
* - limit: number (default 50, max 100)
|
||||
* - daysAhead: number (default 30, max 90)
|
||||
*/
|
||||
@Get('upcoming')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getUpcoming(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
const daysAhead = Math.min(parseInt(req.query.daysAhead as string) || 30, 90);
|
||||
|
||||
const activities = await ActivityService.getUpcomingActivities(auth.projectId, limit, daysAhead);
|
||||
|
||||
return res.status(200).json({activities});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {Controller, Get, Middleware} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {AnalyticsService} from '../services/AnalyticsService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@Controller('analytics')
|
||||
export class Analytics {
|
||||
/**
|
||||
* GET /analytics/timeseries
|
||||
* Get time series data for email analytics
|
||||
*
|
||||
* Query params:
|
||||
* - startDate: ISO date string (defaults to 30 days ago, max 90 days)
|
||||
* - endDate: ISO date string (defaults to now)
|
||||
*
|
||||
* Returns daily aggregated email metrics (sent, opened, clicked, bounced, delivered)
|
||||
*/
|
||||
@Get('timeseries')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getTimeSeries(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||
|
||||
const timeSeries = await AnalyticsService.getTimeSeriesData(auth.projectId, startDate, endDate);
|
||||
|
||||
return res.status(200).json(timeSeries);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /analytics/top-campaigns
|
||||
* Get top performing campaigns by open rate
|
||||
*
|
||||
* Query params:
|
||||
* - limit: number (default 10)
|
||||
* - startDate: ISO date string (defaults to 30 days ago)
|
||||
* - endDate: ISO date string (defaults to now)
|
||||
*/
|
||||
@Get('top-campaigns')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getTopCampaigns(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||
|
||||
const topCampaigns = await AnalyticsService.getTopCampaigns(auth.projectId, limit, startDate, endDate);
|
||||
|
||||
return res.status(200).json(topCampaigns);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /analytics/campaign-stats
|
||||
* Get campaign overview statistics
|
||||
*
|
||||
* Query params:
|
||||
* - startDate: ISO date string (defaults to 30 days ago)
|
||||
* - endDate: ISO date string (defaults to now)
|
||||
*
|
||||
* Returns aggregate stats: total campaigns, active, completed, average rates
|
||||
*/
|
||||
@Get('campaign-stats')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getCampaignStats(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||
|
||||
const campaignStats = await AnalyticsService.getCampaignStats(auth.projectId, startDate, endDate);
|
||||
|
||||
return res.status(200).json(campaignStats);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /analytics/top-events
|
||||
* Get most frequently triggered events
|
||||
*
|
||||
* Query params:
|
||||
* - limit: number (default 5, max 20)
|
||||
* - startDate: ISO date string (defaults to 30 days ago)
|
||||
* - endDate: ISO date string (defaults to now)
|
||||
*
|
||||
* Returns events sorted by frequency with trend data
|
||||
*/
|
||||
@Get('top-events')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getTopEvents(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 5, 20);
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||
|
||||
const topEvents = await AnalyticsService.getTopEvents(auth.projectId, limit, startDate, endDate);
|
||||
|
||||
return res.status(200).json(topEvents);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import {Controller, Get, Post} from '@overnightjs/core';
|
||||
import {AuthenticationSchemas} from '@plunk/shared';
|
||||
import {EmailVerificationEmail, PasswordResetEmail, sendPlatformEmail} from '@plunk/email';
|
||||
import {randomBytes} from 'node:crypto';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
DASHBOARD_URI,
|
||||
DISABLE_SIGNUPS,
|
||||
EMAIL_VERIFICATION_RATE_LIMIT,
|
||||
EMAIL_VERIFICATION_RATE_WINDOW,
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
LANDING_URI,
|
||||
PASSWORD_RESET_RATE_LIMIT,
|
||||
PLUNK_ENABLED,
|
||||
TOKEN_EXPIRY_SECONDS,
|
||||
VERIFY_EMAIL_ON_SIGNUP,
|
||||
} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
|
||||
import {BadRequest, NotAuthenticated, RateLimitError} from '../exceptions/index.js';
|
||||
import {jwt, parseJwt} from '../middleware/auth.js';
|
||||
import {AuthService} from '../services/AuthService.js';
|
||||
import {EmailVerificationService} from '../services/EmailVerificationService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {UserService} from '../services/UserService.js';
|
||||
import {Keys} from '../services/keys.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@Controller('auth')
|
||||
export class Auth {
|
||||
@Post('login')
|
||||
@CatchAsync
|
||||
public async login(req: Request, res: Response, _next: NextFunction) {
|
||||
const {email, password} = AuthenticationSchemas.login.parse(req.body);
|
||||
|
||||
const user = await UserService.email(email);
|
||||
|
||||
if (!user) {
|
||||
return res.json({success: false, data: 'Incorrect email or password'});
|
||||
}
|
||||
|
||||
if (user.type === 'PASSWORD' && !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')
|
||||
@CatchAsync
|
||||
public async signup(req: Request, res: Response, _next: NextFunction) {
|
||||
// Check if signups are disabled
|
||||
if (DISABLE_SIGNUPS) {
|
||||
return res.json({
|
||||
success: false,
|
||||
data: 'New user signups are currently disabled',
|
||||
});
|
||||
}
|
||||
|
||||
const {email, password} = AuthenticationSchemas.login.parse(req.body);
|
||||
|
||||
// Verify email is valid and not disposable/plus-addressed (if verification enabled)
|
||||
if (VERIFY_EMAIL_ON_SIGNUP) {
|
||||
const verification = await EmailVerificationService.verifyEmail(email);
|
||||
|
||||
if (
|
||||
verification.isDisposable ||
|
||||
verification.isPlusAddressed ||
|
||||
!verification.domainExists ||
|
||||
!verification.hasMxRecords
|
||||
) {
|
||||
// Build list of reasons for notification
|
||||
const reasons: string[] = [];
|
||||
if (verification.isDisposable) reasons.push('disposable email');
|
||||
if (verification.isPlusAddressed) reasons.push('plus addressing');
|
||||
if (!verification.domainExists) reasons.push('domain does not exist');
|
||||
if (!verification.hasMxRecords) reasons.push('no MX records');
|
||||
|
||||
// Send notification about failed signup attempt
|
||||
await NtfyService.notifyFailedSignupAttempt(email, reasons);
|
||||
|
||||
return res.json({
|
||||
success: false,
|
||||
data: 'This email address cannot be used for signup',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 AuthService.generateHash(password),
|
||||
type: 'PASSWORD',
|
||||
// Auto-verify email if platform emails are disabled
|
||||
emailVerified: !PLUNK_ENABLED,
|
||||
},
|
||||
});
|
||||
|
||||
await redis.set(Keys.User.id(created_user.id), JSON.stringify(created_user), 'EX', REDIS_ONE_MINUTE * 60);
|
||||
|
||||
// Send notification about new user signup
|
||||
await NtfyService.notifyUserSignup(created_user.email, created_user.id);
|
||||
|
||||
// Send email verification if platform emails are enabled
|
||||
if (PLUNK_ENABLED) {
|
||||
const verificationToken = randomBytes(32).toString('hex');
|
||||
await redis.setex(
|
||||
Keys.User.emailVerificationToken(verificationToken),
|
||||
TOKEN_EXPIRY_SECONDS,
|
||||
JSON.stringify({userId: created_user.id, email: created_user.email, createdAt: Date.now()}),
|
||||
);
|
||||
|
||||
const verificationUrl = `${DASHBOARD_URI}/auth/verify-email?token=${verificationToken}`;
|
||||
await sendPlatformEmail(
|
||||
created_user.email,
|
||||
'Verify your email address',
|
||||
React.createElement(EmailVerificationEmail, {
|
||||
email: created_user.email,
|
||||
verificationUrl,
|
||||
landingUrl: LANDING_URI,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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},
|
||||
});
|
||||
}
|
||||
|
||||
@Get('logout')
|
||||
public logout(req: Request, res: Response) {
|
||||
res.cookie(UserService.COOKIE_NAME, '', UserService.cookieOptions(new Date()));
|
||||
return res.json(true);
|
||||
}
|
||||
|
||||
@Get('oauth-config')
|
||||
public oauthConfig(req: Request, res: Response) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
github: GITHUB_OAUTH_ENABLED,
|
||||
google: GOOGLE_OAUTH_ENABLED,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@Post('verify-email')
|
||||
@CatchAsync
|
||||
public async verifyEmail(req: Request, res: Response, _next: NextFunction) {
|
||||
const {token} = AuthenticationSchemas.verifyEmail.parse(req.body);
|
||||
|
||||
// Look up token in Redis
|
||||
const data = await redis.get(Keys.User.emailVerificationToken(token));
|
||||
|
||||
if (!data) {
|
||||
throw new BadRequest('Invalid or expired verification token');
|
||||
}
|
||||
|
||||
const {userId} = JSON.parse(data);
|
||||
|
||||
// Update user
|
||||
await prisma.user.update({
|
||||
where: {id: userId},
|
||||
data: {emailVerified: true},
|
||||
});
|
||||
|
||||
// Delete token (single use) and invalidate cache
|
||||
await redis.del(Keys.User.emailVerificationToken(token));
|
||||
await redis.del(Keys.User.id(userId));
|
||||
|
||||
return res.json({success: true, data: {message: 'Email verified successfully'}});
|
||||
}
|
||||
|
||||
@Post('request-verification')
|
||||
@CatchAsync
|
||||
public async requestVerification(req: Request, res: Response, _next: NextFunction) {
|
||||
const userId = parseJwt(req);
|
||||
const user = await UserService.id(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
return res.json({success: true, data: {message: 'Email already verified'}});
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
const rateLimitKey = Keys.User.emailVerificationRateLimit(userId);
|
||||
const count = await redis.get(rateLimitKey);
|
||||
|
||||
if (count && parseInt(count) >= EMAIL_VERIFICATION_RATE_LIMIT) {
|
||||
throw new RateLimitError('Too many verification emails sent. Please try again later.');
|
||||
}
|
||||
|
||||
// Generate token
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await redis.setex(
|
||||
Keys.User.emailVerificationToken(token),
|
||||
TOKEN_EXPIRY_SECONDS,
|
||||
JSON.stringify({userId, email: user.email, createdAt: Date.now()}),
|
||||
);
|
||||
|
||||
// Send email
|
||||
const verificationUrl = `${DASHBOARD_URI}/auth/verify-email?token=${token}`;
|
||||
await sendPlatformEmail(
|
||||
user.email,
|
||||
'Verify your email address',
|
||||
React.createElement(EmailVerificationEmail, {email: user.email, verificationUrl, landingUrl: LANDING_URI}),
|
||||
);
|
||||
|
||||
// Increment rate limit
|
||||
if (count) {
|
||||
await redis.incr(rateLimitKey);
|
||||
} else {
|
||||
await redis.setex(rateLimitKey, EMAIL_VERIFICATION_RATE_WINDOW, '1');
|
||||
}
|
||||
|
||||
return res.json({success: true, data: {message: 'Verification email sent'}});
|
||||
}
|
||||
|
||||
@Post('request-password-reset')
|
||||
@CatchAsync
|
||||
public async requestPasswordReset(req: Request, res: Response, _next: NextFunction) {
|
||||
const {email} = AuthenticationSchemas.requestPasswordReset.parse(req.body);
|
||||
|
||||
// Check rate limit
|
||||
const rateLimitKey = Keys.User.passwordResetRateLimit(email);
|
||||
const count = await redis.get(rateLimitKey);
|
||||
|
||||
if (count && parseInt(count) >= PASSWORD_RESET_RATE_LIMIT) {
|
||||
// Still return success to prevent enumeration
|
||||
return res.json({success: true, data: {message: 'If that email exists, a reset link has been sent'}});
|
||||
}
|
||||
|
||||
// Look up user
|
||||
const user = await UserService.email(email);
|
||||
|
||||
// Only send email if user exists and is PASSWORD type
|
||||
if (user && user.type === 'PASSWORD') {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await redis.setex(
|
||||
Keys.User.passwordResetToken(token),
|
||||
TOKEN_EXPIRY_SECONDS,
|
||||
JSON.stringify({userId: user.id, email: user.email, createdAt: Date.now()}),
|
||||
);
|
||||
|
||||
const resetUrl = `${DASHBOARD_URI}/auth/reset-password?token=${token}`;
|
||||
await sendPlatformEmail(
|
||||
user.email,
|
||||
'Reset your password',
|
||||
React.createElement(PasswordResetEmail, {email: user.email, resetUrl, landingUrl: LANDING_URI}),
|
||||
);
|
||||
|
||||
// Increment rate limit
|
||||
if (count) {
|
||||
await redis.incr(rateLimitKey);
|
||||
} else {
|
||||
await redis.setex(rateLimitKey, EMAIL_VERIFICATION_RATE_WINDOW, '1');
|
||||
}
|
||||
}
|
||||
|
||||
// Always return success (prevent enumeration)
|
||||
return res.json({success: true, data: {message: 'If that email exists, a reset link has been sent'}});
|
||||
}
|
||||
|
||||
@Post('reset-password')
|
||||
@CatchAsync
|
||||
public async resetPassword(req: Request, res: Response, _next: NextFunction) {
|
||||
const {token, newPassword} = AuthenticationSchemas.resetPassword.parse(req.body);
|
||||
|
||||
// Look up token
|
||||
const data = await redis.get(Keys.User.passwordResetToken(token));
|
||||
|
||||
if (!data) {
|
||||
throw new BadRequest('Invalid or expired reset token');
|
||||
}
|
||||
|
||||
const {userId} = JSON.parse(data);
|
||||
|
||||
// Get user and verify type
|
||||
const user = await prisma.user.findUnique({where: {id: userId}});
|
||||
|
||||
if (!user || user.type !== 'PASSWORD') {
|
||||
throw new BadRequest('Invalid reset token');
|
||||
}
|
||||
|
||||
// Hash new password and update
|
||||
const hashedPassword = await AuthService.generateHash(newPassword);
|
||||
await prisma.user.update({
|
||||
where: {id: userId},
|
||||
data: {password: hashedPassword},
|
||||
});
|
||||
|
||||
// Delete token and invalidate cache
|
||||
await redis.del(Keys.User.passwordResetToken(token));
|
||||
await redis.del(Keys.User.id(userId));
|
||||
|
||||
return res.json({success: true, data: {message: 'Password reset successfully'}});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import {Controller, Delete, Get, Middleware, Post, Put} from '@overnightjs/core';
|
||||
import {CampaignAudienceType, CampaignStatus, TemplateType} from '@plunk/db';
|
||||
import {CampaignSchemas, UtilitySchemas} from '@plunk/shared';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {CampaignService} from '../services/CampaignService.js';
|
||||
import {DomainService} from '../services/DomainService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@Controller('campaigns')
|
||||
export class Campaigns {
|
||||
/**
|
||||
* Create a new campaign
|
||||
* POST /campaigns
|
||||
*/
|
||||
@Post('')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async create(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {name, description, subject, body, from, fromName, replyTo, type, audienceType, audienceCondition, segmentId} =
|
||||
CampaignSchemas.create.parse(req.body);
|
||||
|
||||
if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) {
|
||||
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
|
||||
}
|
||||
|
||||
if (audienceType === CampaignAudienceType.FILTERED && !audienceCondition) {
|
||||
throw new HttpException(400, 'Audience condition is required for FILTERED audience type');
|
||||
}
|
||||
|
||||
// Verify domain ownership and verification
|
||||
await DomainService.verifyEmailDomain(from, auth.projectId);
|
||||
|
||||
const campaign = await CampaignService.create(auth.projectId, {
|
||||
name,
|
||||
description,
|
||||
subject,
|
||||
body,
|
||||
from,
|
||||
fromName,
|
||||
replyTo,
|
||||
type,
|
||||
audienceType,
|
||||
audienceCondition,
|
||||
segmentId,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: campaign,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all campaigns for a project
|
||||
* GET /campaigns
|
||||
*/
|
||||
@Get('')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async list(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const status = req.query.status as CampaignStatus | undefined;
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = parseInt(req.query.pageSize as string) || 20;
|
||||
|
||||
// Validate status if provided
|
||||
if (status && !Object.values(CampaignStatus).includes(status)) {
|
||||
throw new HttpException(400, 'Invalid status value');
|
||||
}
|
||||
|
||||
const result = await CampaignService.list(auth.projectId, {
|
||||
status,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
|
||||
return res.json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific campaign
|
||||
* GET /campaigns/:id
|
||||
*/
|
||||
@Get(':id')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async get(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const campaign = await CampaignService.get(auth.projectId, id!);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: campaign,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a campaign
|
||||
* PUT /campaigns/:id
|
||||
*/
|
||||
@Put(':id')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async update(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const {name, description, subject, body, from, fromName, replyTo, type, audienceType, audienceCondition, segmentId} =
|
||||
req.body;
|
||||
|
||||
// Validate audience-specific fields if audienceType is being updated
|
||||
if (audienceType === CampaignAudienceType.SEGMENT && segmentId === undefined) {
|
||||
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
|
||||
}
|
||||
|
||||
if (audienceType === CampaignAudienceType.FILTERED && audienceCondition === undefined) {
|
||||
throw new HttpException(400, 'Audience condition is required for FILTERED audience type');
|
||||
}
|
||||
|
||||
// Verify domain ownership and verification if 'from' is being updated
|
||||
if (from) {
|
||||
await DomainService.verifyEmailDomain(from, auth.projectId);
|
||||
}
|
||||
|
||||
const campaign = await CampaignService.update(auth.projectId, id!, {
|
||||
name,
|
||||
description,
|
||||
subject,
|
||||
body,
|
||||
from,
|
||||
fromName,
|
||||
replyTo,
|
||||
type: type as TemplateType | undefined,
|
||||
audienceType,
|
||||
audienceCondition,
|
||||
segmentId,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: campaign,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a campaign
|
||||
* DELETE /campaigns/:id
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async delete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
await CampaignService.delete(auth.projectId, id!);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'Campaign deleted successfully',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a campaign
|
||||
* POST /campaigns/:id/duplicate
|
||||
*/
|
||||
@Post(':id/duplicate')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async duplicate(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const campaign = await CampaignService.duplicate(auth.projectId, id!);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: campaign,
|
||||
message: 'Campaign duplicated successfully',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send or schedule a campaign
|
||||
* POST /campaigns/:id/send
|
||||
*/
|
||||
@Post(':id/send')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async send(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const scheduledFor = req.body?.scheduledFor;
|
||||
|
||||
// Parse scheduledFor if provided
|
||||
let scheduledDate: Date | undefined;
|
||||
if (scheduledFor) {
|
||||
scheduledDate = new Date(scheduledFor);
|
||||
|
||||
if (isNaN(scheduledDate.getTime())) {
|
||||
throw new HttpException(400, 'Invalid scheduledFor date format');
|
||||
}
|
||||
}
|
||||
|
||||
const campaign = await CampaignService.send(auth.projectId, id!, scheduledDate);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: campaign,
|
||||
message: scheduledDate ? `Campaign scheduled for ${scheduledDate.toISOString()}` : 'Campaign is being sent',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a campaign
|
||||
* POST /campaigns/:id/cancel
|
||||
*/
|
||||
@Post(':id/cancel')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async cancel(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const campaign = await CampaignService.cancel(auth.projectId, id!);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: campaign,
|
||||
message: 'Campaign cancelled successfully',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get campaign statistics
|
||||
* GET /campaigns/:id/stats
|
||||
*/
|
||||
@Get(':id/stats')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async stats(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const stats = await CampaignService.getStats(auth.projectId, id!);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: stats,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a test email for a campaign
|
||||
* POST /campaigns/:id/test
|
||||
*/
|
||||
@Post(':id/test')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async sendTest(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const {email} = CampaignSchemas.sendTest.parse(req.body);
|
||||
|
||||
await CampaignService.sendTest(auth.projectId, id!, email);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `Test email sent to ${email}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {Controller, Get} from '@overnightjs/core';
|
||||
import type {Request, Response} from 'express';
|
||||
|
||||
import {
|
||||
API_URI,
|
||||
AWS_SES_REGION,
|
||||
DASHBOARD_URI,
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
LANDING_URI,
|
||||
NODE_ENV,
|
||||
S3_ENABLED,
|
||||
SMTP_DOMAIN,
|
||||
SMTP_ENABLED,
|
||||
SMTP_PORT_SECURE,
|
||||
SMTP_PORT_SUBMISSION,
|
||||
STRIPE_ENABLED,
|
||||
TRACKING_TOGGLE_ENABLED,
|
||||
WIKI_URI,
|
||||
} from '../app/constants.js';
|
||||
|
||||
@Controller('config')
|
||||
export class Config {
|
||||
/**
|
||||
* GET /config
|
||||
* Expose a unified view of instance capabilities and feature flags for frontends.
|
||||
*/
|
||||
@Get('')
|
||||
public getConfig(req: Request, res: Response) {
|
||||
return res.status(200).json({
|
||||
environment: NODE_ENV,
|
||||
urls: {
|
||||
api: API_URI,
|
||||
dashboard: DASHBOARD_URI,
|
||||
landing: LANDING_URI,
|
||||
wiki: WIKI_URI || null,
|
||||
},
|
||||
features: {
|
||||
billing: {
|
||||
enabled: STRIPE_ENABLED,
|
||||
},
|
||||
storage: {
|
||||
s3Enabled: S3_ENABLED,
|
||||
},
|
||||
authProviders: {
|
||||
github: GITHUB_OAUTH_ENABLED,
|
||||
google: GOOGLE_OAUTH_ENABLED,
|
||||
},
|
||||
email: {
|
||||
trackingToggleEnabled: TRACKING_TOGGLE_ENABLED,
|
||||
},
|
||||
smtp: {
|
||||
enabled: SMTP_ENABLED,
|
||||
domain: SMTP_ENABLED ? SMTP_DOMAIN : null,
|
||||
ports: SMTP_ENABLED
|
||||
? {
|
||||
secure: SMTP_PORT_SECURE,
|
||||
submission: SMTP_PORT_SUBMISSION,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
},
|
||||
aws: {
|
||||
sesRegion: AWS_SES_REGION,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,528 @@
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import multer from 'multer';
|
||||
import signale from 'signale';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {QueueService} from '../services/QueueService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
// Configure multer for file uploads (memory storage)
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024, // 5MB max file size
|
||||
},
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (file.mimetype === 'text/csv' || file.originalname.endsWith('.csv')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only CSV files are allowed'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@Controller('contacts')
|
||||
export class Contacts {
|
||||
/**
|
||||
* GET /contacts
|
||||
* List all contacts for the authenticated project with cursor-based pagination
|
||||
*/
|
||||
@Get('')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async list(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
const cursor = req.query.cursor as string | undefined;
|
||||
const search = req.query.search as string | undefined;
|
||||
|
||||
const result = await ContactService.list(auth.projectId!, limit, cursor, search);
|
||||
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /contacts/fields
|
||||
* Get all available contact fields (both standard and custom fields from data JSON)
|
||||
* Returns field names with inferred types (string, number, boolean, date)
|
||||
*/
|
||||
@Get('fields')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
|
||||
try {
|
||||
const fieldsWithTypes = await ContactService.getAvailableFields(auth.projectId!);
|
||||
|
||||
return res.status(200).json({
|
||||
fields: fieldsWithTypes,
|
||||
count: fieldsWithTypes.length,
|
||||
});
|
||||
} catch (error) {
|
||||
signale.error('[CONTACTS] Failed to get available fields:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get available fields',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /contacts/fields/:field/values
|
||||
* Get unique values for a contact field (for workflow conditions, segment filters, etc.)
|
||||
* Example: /contacts/fields/data.plan/values or /contacts/fields/subscribed/values
|
||||
*/
|
||||
@Get('fields/:field/values')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getFieldValues(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const field = req.params.field;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 100, 200);
|
||||
|
||||
if (!field) {
|
||||
return res.status(400).json({error: 'Field is required'});
|
||||
}
|
||||
|
||||
try {
|
||||
const values = await ContactService.getUniqueFieldValues(auth.projectId!, field, limit);
|
||||
|
||||
return res.status(200).json({
|
||||
field,
|
||||
values,
|
||||
count: values.length,
|
||||
limit,
|
||||
});
|
||||
} catch (error) {
|
||||
signale.error('[CONTACTS] Failed to get field values:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get field values',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /contacts/:id
|
||||
* Get a specific contact by ID
|
||||
*/
|
||||
@Get(':id')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async get(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const contactId = req.params.id;
|
||||
|
||||
if (!contactId) {
|
||||
return res.status(400).json({error: 'Contact ID is required'});
|
||||
}
|
||||
|
||||
const contact = await ContactService.get(auth.projectId!, contactId);
|
||||
|
||||
return res.status(200).json(contact);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /contacts
|
||||
* Create or update a contact (upsert)
|
||||
*/
|
||||
@Post('')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {email, data, subscribed} = req.body;
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({error: 'Email is required'});
|
||||
}
|
||||
|
||||
// Check if contact exists before upserting
|
||||
const existingContact = await ContactService.findByEmail(auth.projectId!, email);
|
||||
const isUpdate = !!existingContact;
|
||||
|
||||
const contact = await ContactService.upsert(auth.projectId!, email, data, subscribed);
|
||||
|
||||
return res.status(isUpdate ? 200 : 201).json({
|
||||
...contact,
|
||||
_meta: {
|
||||
isNew: !isUpdate,
|
||||
isUpdate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /contacts/:id
|
||||
* Update a contact
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async update(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const contactId = req.params.id;
|
||||
const {email, data, subscribed} = req.body;
|
||||
|
||||
if (!contactId) {
|
||||
return res.status(400).json({error: 'Contact ID is required'});
|
||||
}
|
||||
|
||||
const contact = await ContactService.update(auth.projectId!, contactId, {email, data, subscribed});
|
||||
|
||||
return res.status(200).json(contact);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /contacts/:id
|
||||
* Delete a contact
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async delete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const contactId = req.params.id;
|
||||
|
||||
if (!contactId) {
|
||||
return res.status(400).json({error: 'Contact ID is required'});
|
||||
}
|
||||
|
||||
await ContactService.delete(auth.projectId!, contactId);
|
||||
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /contacts/public/:id
|
||||
* PUBLIC: Get contact information (no auth required)
|
||||
*/
|
||||
@Get('public/:id')
|
||||
@CatchAsync
|
||||
public async getPublic(req: Request, res: Response, _next: NextFunction) {
|
||||
const contactId = req.params.id;
|
||||
|
||||
if (!contactId) {
|
||||
return res.status(400).json({error: 'Contact ID is required'});
|
||||
}
|
||||
|
||||
const contact = await ContactService.getById(contactId);
|
||||
|
||||
// Fetch project to get language preference
|
||||
const project = await ContactService.getProjectByContactId(contactId);
|
||||
|
||||
// Get contact-level locale (overrides project language)
|
||||
const contactLocale =
|
||||
contact.data &&
|
||||
typeof contact.data === 'object' &&
|
||||
!Array.isArray(contact.data) &&
|
||||
'locale' in contact.data &&
|
||||
typeof contact.data.locale === 'string'
|
||||
? contact.data.locale
|
||||
: null;
|
||||
|
||||
return res.status(200).json({
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
subscribed: contact.subscribed,
|
||||
language: contactLocale || project?.language || 'en',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /contacts/public/:id/subscribe
|
||||
* PUBLIC: Subscribe a contact (no auth required)
|
||||
*/
|
||||
@Post('public/:id/subscribe')
|
||||
@CatchAsync
|
||||
public async subscribePublic(req: Request, res: Response, _next: NextFunction) {
|
||||
const contactId = req.params.id;
|
||||
|
||||
if (!contactId) {
|
||||
return res.status(400).json({error: 'Contact ID is required'});
|
||||
}
|
||||
|
||||
const contact = await ContactService.subscribe(contactId);
|
||||
|
||||
return res.status(200).json({
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
subscribed: contact.subscribed,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /contacts/public/:id/unsubscribe
|
||||
* PUBLIC: Unsubscribe a contact (no auth required)
|
||||
*/
|
||||
@Post('public/:id/unsubscribe')
|
||||
@CatchAsync
|
||||
public async unsubscribePublic(req: Request, res: Response, _next: NextFunction) {
|
||||
const contactId = req.params.id;
|
||||
|
||||
if (!contactId) {
|
||||
return res.status(400).json({error: 'Contact ID is required'});
|
||||
}
|
||||
|
||||
const contact = await ContactService.unsubscribe(contactId);
|
||||
|
||||
return res.status(200).json({
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
subscribed: contact.subscribed,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /contacts/import
|
||||
* Import contacts from CSV file
|
||||
*/
|
||||
@Post('import')
|
||||
@Middleware([requireAuth, upload.single('file')])
|
||||
@CatchAsync
|
||||
public async importCsv(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({error: 'CSV file is required'});
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert file buffer to base64 for storage in queue
|
||||
const csvData = req.file.buffer.toString('base64');
|
||||
const filename = req.file.originalname;
|
||||
|
||||
// Queue import job
|
||||
const job = await QueueService.queueImport(auth.projectId!, csvData, filename);
|
||||
|
||||
return res.status(202).json({
|
||||
message: 'Import queued successfully',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
signale.error('[CONTACTS] Failed to queue import:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to queue import',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /contacts/import/:jobId
|
||||
* Get import job status
|
||||
*/
|
||||
@Get('import/:jobId')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getImportStatus(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const jobId = req.params.jobId;
|
||||
|
||||
if (!jobId) {
|
||||
return res.status(400).json({error: 'Job ID is required'});
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await QueueService.getImportJobStatus(jobId, auth.projectId!);
|
||||
|
||||
if (!status) {
|
||||
return res.status(404).json({error: 'Import job not found'});
|
||||
}
|
||||
|
||||
return res.status(200).json(status);
|
||||
} catch (error) {
|
||||
signale.error('[CONTACTS] Failed to get import status:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get import status',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /contacts/fields/:field/usage
|
||||
* Check if a field is used in segments/campaigns and get usage statistics
|
||||
* Returns information about where the field is used and whether it can be safely deleted
|
||||
*/
|
||||
@Get('fields/:field/usage')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getFieldUsage(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const field = req.params.field;
|
||||
|
||||
if (!field) {
|
||||
return res.status(400).json({error: 'Field is required'});
|
||||
}
|
||||
|
||||
try {
|
||||
const usage = await ContactService.getFieldUsage(auth.projectId!, field);
|
||||
return res.status(200).json(usage);
|
||||
} catch (error) {
|
||||
signale.error('[CONTACTS] Failed to get field usage:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get field usage',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /contacts/fields/:field
|
||||
* Delete a custom field from all contacts
|
||||
* Only works if the field is not used in any segments or campaigns
|
||||
*/
|
||||
@Delete('fields/:field')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async deleteField(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const field = req.params.field;
|
||||
|
||||
if (!field) {
|
||||
return res.status(400).json({error: 'Field is required'});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ContactService.deleteField(auth.projectId!, field);
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
signale.error('[CONTACTS] Failed to delete field:', error);
|
||||
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to delete field',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /contacts/bulk-subscribe
|
||||
* Queue bulk subscribe operation
|
||||
*/
|
||||
@Post('bulk-subscribe')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {contactIds} = req.body;
|
||||
|
||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||
return res.status(400).json({error: 'contactIds array is required'});
|
||||
}
|
||||
|
||||
// Validate limit
|
||||
if (contactIds.length > 1000) {
|
||||
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'subscribe');
|
||||
|
||||
return res.status(202).json({
|
||||
message: 'Bulk subscribe queued successfully',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
signale.error('[CONTACTS] Failed to queue bulk subscribe:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to queue bulk subscribe',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /contacts/bulk-unsubscribe
|
||||
* Queue bulk unsubscribe operation
|
||||
*/
|
||||
@Post('bulk-unsubscribe')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {contactIds} = req.body;
|
||||
|
||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||
return res.status(400).json({error: 'contactIds array is required'});
|
||||
}
|
||||
|
||||
if (contactIds.length > 1000) {
|
||||
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'unsubscribe');
|
||||
|
||||
return res.status(202).json({
|
||||
message: 'Bulk unsubscribe queued successfully',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
signale.error('[CONTACTS] Failed to queue bulk unsubscribe:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to queue bulk unsubscribe',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /contacts/bulk-delete
|
||||
* Queue bulk delete operation
|
||||
*/
|
||||
@Post('bulk-delete')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {contactIds} = req.body;
|
||||
|
||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||
return res.status(400).json({error: 'contactIds array is required'});
|
||||
}
|
||||
|
||||
if (contactIds.length > 1000) {
|
||||
return res.status(400).json({error: 'Maximum 1000 contacts can be processed at once'});
|
||||
}
|
||||
|
||||
try {
|
||||
const job = await QueueService.queueBulkContactAction(auth.projectId!, contactIds, 'delete');
|
||||
|
||||
return res.status(202).json({
|
||||
message: 'Bulk delete queued successfully',
|
||||
jobId: job.id,
|
||||
});
|
||||
} catch (error) {
|
||||
signale.error('[CONTACTS] Failed to queue bulk delete:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to queue bulk delete',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /contacts/bulk/:jobId
|
||||
* Get bulk action job status
|
||||
*/
|
||||
@Get('bulk/:jobId')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getBulkActionStatus(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const jobId = req.params.jobId;
|
||||
|
||||
if (!jobId) {
|
||||
return res.status(400).json({error: 'Job ID is required'});
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await QueueService.getBulkActionJobStatus(jobId, auth.projectId!);
|
||||
|
||||
if (!status) {
|
||||
return res.status(404).json({error: 'Bulk action job not found'});
|
||||
}
|
||||
|
||||
return res.status(200).json(status);
|
||||
} catch (error) {
|
||||
signale.error('[CONTACTS] Failed to get bulk action status:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get bulk action status',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
|
||||
import {DomainSchemas, UtilitySchemas} from '@plunk/shared';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import {redis} from '../database/redis.js';
|
||||
import {NotFound} from '../exceptions/index.js';
|
||||
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {DomainService} from '../services/DomainService.js';
|
||||
import {Keys} from '../services/keys.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@Controller('domains')
|
||||
export class Domains {
|
||||
/**
|
||||
* Get all domains for a project
|
||||
*/
|
||||
@Get('project/:projectId')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getProjectDomains(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {projectId} = DomainSchemas.projectId.parse(req.params);
|
||||
|
||||
// Verify user has access to this project
|
||||
await MembershipService.requireAccess(auth.userId!, projectId);
|
||||
|
||||
const domains = await DomainService.getProjectDomains(projectId);
|
||||
|
||||
return res.status(200).json(domains);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new domain to a project
|
||||
*/
|
||||
@Post('')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async addDomain(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {projectId, domain} = DomainSchemas.create.parse(req.body);
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotFound('User authentication required');
|
||||
}
|
||||
|
||||
// Verify user has admin access to this project
|
||||
await MembershipService.requireAdminAccess(auth.userId!, projectId);
|
||||
|
||||
// Check if domain is already linked to another project
|
||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
|
||||
|
||||
if (ownershipCheck.exists) {
|
||||
// If domain exists and user is a member of that project, allow it
|
||||
if (ownershipCheck.isMember) {
|
||||
return res.status(400).json({
|
||||
error: `This domain is already linked to project "${ownershipCheck.projectName}". You are a member of that project, so you can use the domain from there.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Domain exists but user is not a member - deny access
|
||||
return res.status(403).json({
|
||||
error: 'This domain is already linked to another project. Only members of that project can use this domain.',
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const newDomain = await DomainService.addDomain(projectId, domain);
|
||||
|
||||
await redis.del(Keys.Domain.project(projectId));
|
||||
|
||||
return res.status(201).json(newDomain);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return res.status(400).json({error: error.message});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check verification status for a domain
|
||||
*/
|
||||
@Get(':id/verify')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async checkVerification(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const domain = await DomainService.id(id);
|
||||
|
||||
if (!domain) {
|
||||
throw new NotFound('Domain not found');
|
||||
}
|
||||
|
||||
// Verify user has access to the project this domain belongs to
|
||||
await MembershipService.requireAccess(auth.userId!, domain.projectId);
|
||||
|
||||
const verificationStatus = await DomainService.checkVerification(id);
|
||||
|
||||
// Invalidate cache if status changed
|
||||
await redis.del(Keys.Domain.id(id));
|
||||
await redis.del(Keys.Domain.project(domain.projectId));
|
||||
|
||||
return res.status(200).json(verificationStatus);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a domain from a project
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async removeDomain(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
const domain = await DomainService.id(id);
|
||||
|
||||
if (!domain) {
|
||||
throw new NotFound('Domain not found');
|
||||
}
|
||||
|
||||
// Verify user has admin access to the project this domain belongs to
|
||||
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
|
||||
|
||||
await DomainService.removeDomain(id);
|
||||
|
||||
await redis.del(Keys.Domain.id(id));
|
||||
await redis.del(Keys.Domain.project(domain.projectId));
|
||||
|
||||
return res.status(200).json({success: true});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import signale from 'signale';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {EventService} from '../services/EventService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@Controller('events')
|
||||
export class Events {
|
||||
/**
|
||||
* POST /events/track
|
||||
* Track a custom event (can trigger workflows)
|
||||
*/
|
||||
@Post('track')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async track(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {name, contactId, emailId, data} = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({error: 'Event name is required'});
|
||||
}
|
||||
|
||||
const event = await EventService.trackEvent(auth.projectId!, name, contactId, emailId, data);
|
||||
|
||||
return res.status(201).json(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /events
|
||||
* List events for the project
|
||||
*/
|
||||
@Get('')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async list(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const eventName = req.query.eventName as string | undefined;
|
||||
const limit = parseInt(req.query.limit as string) || 100;
|
||||
|
||||
const events = await EventService.getProjectEvents(auth.projectId!, eventName, limit);
|
||||
|
||||
return res.status(200).json({events});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /events/stats
|
||||
* Get event statistics
|
||||
*/
|
||||
@Get('stats')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async stats(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate as string) : undefined;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||
|
||||
const stats = await EventService.getEventStats(auth.projectId!, startDate, endDate);
|
||||
|
||||
return res.status(200).json(stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /events/contact/:contactId
|
||||
* Get events for a specific contact
|
||||
*/
|
||||
@Get('contact/:contactId')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getContactEvents(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const contactId = req.params.contactId;
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
|
||||
if (!contactId) {
|
||||
return res.status(400).json({error: 'Contact ID is required'});
|
||||
}
|
||||
|
||||
const events = await EventService.getContactEvents(auth.projectId!, contactId, limit);
|
||||
|
||||
return res.status(200).json({events});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /events/names
|
||||
* Get unique event names for the project
|
||||
*/
|
||||
@Get('names')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getEventNames(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
|
||||
const eventNames = await EventService.getUniqueEventNames(auth.projectId!);
|
||||
|
||||
return res.status(200).json({eventNames});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /events/:eventName/usage
|
||||
* Check if an event is used in segments/workflows and get usage statistics
|
||||
* Returns information about where the event is used and whether it can be safely deleted
|
||||
*/
|
||||
@Get(':eventName/usage')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getEventUsage(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const eventName = req.params.eventName;
|
||||
|
||||
if (!eventName) {
|
||||
return res.status(400).json({error: 'Event name is required'});
|
||||
}
|
||||
|
||||
try {
|
||||
const usage = await EventService.getEventUsage(auth.projectId!, eventName);
|
||||
return res.status(200).json(usage);
|
||||
} catch (error) {
|
||||
signale.error('[EVENTS] Failed to get event usage:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get event usage',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /events/:eventName
|
||||
* Delete all events with a specific name
|
||||
* Only works if the event is not used in any segments or workflows
|
||||
*/
|
||||
@Delete(':eventName')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async deleteEvent(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const eventName = req.params.eventName;
|
||||
|
||||
if (!eventName) {
|
||||
return res.status(400).json({error: 'Event name is required'});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await EventService.deleteEvent(auth.projectId!, eventName);
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
signale.error('[EVENTS] Failed to delete event:', error);
|
||||
return res.status(error instanceof Error && error.message.includes('Cannot delete') ? 400 : 500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to delete event',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import {Controller, Get} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import {
|
||||
API_URI,
|
||||
DASHBOARD_URI,
|
||||
DISABLE_SIGNUPS,
|
||||
GITHUB_OAUTH_CLIENT,
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
GITHUB_OAUTH_SECRET,
|
||||
} from '../../app/constants.js';
|
||||
import {prisma} from '../../database/prisma.js';
|
||||
import {BadRequest} from '../../exceptions/index.js';
|
||||
import {jwt} from '../../middleware/auth.js';
|
||||
import {NtfyService} from '../../services/NtfyService.js';
|
||||
import {UserService} from '../../services/UserService.js';
|
||||
import {CatchAsync} from '../../utils/asyncHandler.js';
|
||||
|
||||
@Controller('github')
|
||||
export class Github {
|
||||
@Get('outbound')
|
||||
public sendToOutbound(req: Request, res: Response) {
|
||||
if (!GITHUB_OAUTH_ENABLED) {
|
||||
return res.status(404).json({error: 'GitHub OAuth is not configured'});
|
||||
}
|
||||
|
||||
const OAUTH_QS = new URLSearchParams({
|
||||
client_id: GITHUB_OAUTH_CLIENT,
|
||||
redirect_uri: `${API_URI}/oauth/github/callback`,
|
||||
response_type: 'code',
|
||||
scope: 'user:email',
|
||||
});
|
||||
|
||||
return res.redirect(`https://github.com/login/oauth/authorize?${OAUTH_QS.toString()}`);
|
||||
}
|
||||
|
||||
@Get('callback')
|
||||
@CatchAsync
|
||||
public async callback(req: Request, res: Response, _next: NextFunction) {
|
||||
if (!GITHUB_OAUTH_ENABLED) {
|
||||
return res.status(404).json({error: 'GitHub OAuth is not configured'});
|
||||
}
|
||||
const {code} = req.query;
|
||||
|
||||
if (!code || typeof code !== 'string') {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Invalid OAuth callback');
|
||||
}
|
||||
|
||||
const data = new URLSearchParams({
|
||||
client_id: GITHUB_OAUTH_CLIENT,
|
||||
client_secret: GITHUB_OAUTH_SECRET,
|
||||
code: code as string,
|
||||
redirect_uri: `${API_URI}/oauth/github/callback`,
|
||||
});
|
||||
|
||||
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
|
||||
method: 'POST',
|
||||
headers: {'Content-type': 'application/x-www-form-urlencoded', 'Accept': 'application/json'},
|
||||
body: data,
|
||||
}).then(res => res.json());
|
||||
|
||||
if (!tokenResponse.access_token || !tokenResponse.token_type) {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to authenticate with GitHub');
|
||||
}
|
||||
|
||||
const emails = await fetch(`https://api.github.com/user/emails`, {
|
||||
headers: {Authorization: `${tokenResponse.token_type} ${tokenResponse.access_token}`},
|
||||
}).then(res => res.json());
|
||||
|
||||
if (!Array.isArray(emails) || emails.length === 0) {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to retrieve emails from GitHub');
|
||||
}
|
||||
|
||||
const primaryEmail = emails.find((e: {primary: boolean; email: string}) => e.primary);
|
||||
|
||||
if (!primaryEmail || !primaryEmail.email || typeof primaryEmail.email !== 'string') {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to retrieve primary email from GitHub');
|
||||
}
|
||||
|
||||
const email = primaryEmail.email;
|
||||
|
||||
let user = await UserService.email(email);
|
||||
let isNewUser = false;
|
||||
|
||||
if (!user) {
|
||||
// Check if signups are disabled
|
||||
if (DISABLE_SIGNUPS) {
|
||||
throw new BadRequest('New user signups are currently disabled');
|
||||
}
|
||||
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
type: 'GITHUB_OAUTH',
|
||||
emailVerified: true,
|
||||
},
|
||||
});
|
||||
isNewUser = true;
|
||||
}
|
||||
|
||||
if (user.type !== 'GITHUB_OAUTH') {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=You used another form of authentication');
|
||||
}
|
||||
|
||||
// Send notification if this is a new user
|
||||
if (isNewUser) {
|
||||
await NtfyService.notifyUserOAuthSignup(user.email, user.id, 'GitHub');
|
||||
}
|
||||
|
||||
const token = jwt.sign(user.id);
|
||||
const cookie = UserService.cookieOptions();
|
||||
|
||||
res.cookie(UserService.COOKIE_NAME, token, cookie).redirect(DASHBOARD_URI);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {Controller, Get} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import {
|
||||
API_URI,
|
||||
DASHBOARD_URI,
|
||||
DISABLE_SIGNUPS,
|
||||
GOOGLE_OAUTH_CLIENT,
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
GOOGLE_OAUTH_SECRET,
|
||||
} from '../../app/constants.js';
|
||||
import {prisma} from '../../database/prisma.js';
|
||||
import {BadRequest} from '../../exceptions/index.js';
|
||||
import {jwt} from '../../middleware/auth.js';
|
||||
import {NtfyService} from '../../services/NtfyService.js';
|
||||
import {UserService} from '../../services/UserService.js';
|
||||
import {CatchAsync} from '../../utils/asyncHandler.js';
|
||||
|
||||
@Controller('google')
|
||||
export class Google {
|
||||
@Get('outbound')
|
||||
public sendToOutbound(req: Request, res: Response) {
|
||||
if (!GOOGLE_OAUTH_ENABLED) {
|
||||
return res.status(404).json({error: 'Google OAuth is not configured'});
|
||||
}
|
||||
|
||||
return res.redirect(
|
||||
`https://accounts.google.com/o/oauth2/v2/auth?scope=https://www.googleapis.com/auth/userinfo.email&access_type=offline&include_granted_scopes=true&prompt=select_account&response_type=code&redirect_uri=${API_URI}/oauth/google/callback&client_id=${GOOGLE_OAUTH_CLIENT}`,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('callback')
|
||||
@CatchAsync
|
||||
public async callback(req: Request, res: Response, _next: NextFunction) {
|
||||
if (!GOOGLE_OAUTH_ENABLED) {
|
||||
return res.status(404).json({error: 'Google OAuth is not configured'});
|
||||
}
|
||||
const {code} = req.query;
|
||||
|
||||
if (!code || typeof code !== 'string') {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Invalid OAuth callback');
|
||||
}
|
||||
|
||||
const data = new URLSearchParams({
|
||||
client_id: GOOGLE_OAUTH_CLIENT,
|
||||
client_secret: GOOGLE_OAUTH_SECRET,
|
||||
code: code as string,
|
||||
redirect_uri: `${API_URI}/oauth/google/callback`,
|
||||
grant_type: 'authorization_code',
|
||||
});
|
||||
|
||||
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: {'Content-type': 'application/x-www-form-urlencoded'},
|
||||
body: data,
|
||||
}).then(res => res.json());
|
||||
|
||||
if (!tokenResponse.access_token) {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to authenticate with Google');
|
||||
}
|
||||
|
||||
const userInfoResponse = await fetch(
|
||||
`https://www.googleapis.com/oauth2/v3/userinfo?access_token=${tokenResponse.access_token}`,
|
||||
).then(res => res.json());
|
||||
|
||||
if (!userInfoResponse.email || typeof userInfoResponse.email !== 'string') {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=Failed to retrieve email from Google');
|
||||
}
|
||||
|
||||
const email = userInfoResponse.email;
|
||||
|
||||
let user = await UserService.email(email);
|
||||
let isNewUser = false;
|
||||
|
||||
if (!user) {
|
||||
// Check if signups are disabled
|
||||
if (DISABLE_SIGNUPS) {
|
||||
throw new BadRequest('New user signups are currently disabled');
|
||||
}
|
||||
|
||||
user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
type: 'GOOGLE_OAUTH',
|
||||
emailVerified: true,
|
||||
},
|
||||
});
|
||||
isNewUser = true;
|
||||
}
|
||||
|
||||
if (user.type !== 'GOOGLE_OAUTH') {
|
||||
return res.redirect(DASHBOARD_URI + '/auth/login?message=You used another form of authentication');
|
||||
}
|
||||
|
||||
// Send notification if this is a new user
|
||||
if (isNewUser) {
|
||||
await NtfyService.notifyUserOAuthSignup(user.email, user.id, 'Google');
|
||||
}
|
||||
|
||||
const token = jwt.sign(user.id);
|
||||
const cookie = UserService.cookieOptions();
|
||||
|
||||
res.cookie(UserService.COOKIE_NAME, token, cookie).redirect(DASHBOARD_URI);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import {ChildControllers, Controller} from '@overnightjs/core';
|
||||
|
||||
import {Github} from './Github.js';
|
||||
import {Google} from './Google.js';
|
||||
|
||||
@Controller('oauth')
|
||||
@ChildControllers([new Google(), new Github()])
|
||||
export class Oauth {}
|
||||
@@ -0,0 +1,264 @@
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import {MembershipSchemas, UtilitySchemas} from '@plunk/shared';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@Controller('projects')
|
||||
export class Projects {
|
||||
/**
|
||||
* Get project setup state for dashboard quick start
|
||||
* GET /projects/:id/setup-state
|
||||
*/
|
||||
@Get(':id/setup-state')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async getSetupState(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Verify user has access to this project
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Get project with relevant data
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id},
|
||||
select: {
|
||||
subscription: true,
|
||||
_count: {
|
||||
select: {
|
||||
contacts: true,
|
||||
domains: {
|
||||
where: {verified: true},
|
||||
},
|
||||
workflows: {
|
||||
where: {enabled: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpException(404, 'Project not found');
|
||||
}
|
||||
|
||||
// Get last sent campaign
|
||||
const lastCampaign = await prisma.campaign.findFirst({
|
||||
where: {
|
||||
projectId: id,
|
||||
status: 'SENT',
|
||||
sentAt: {not: null},
|
||||
},
|
||||
orderBy: {
|
||||
sentAt: 'desc',
|
||||
},
|
||||
select: {
|
||||
sentAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
hasSubscription: !!project.subscription,
|
||||
hasVerifiedDomain: project._count.domains > 0,
|
||||
contactCount: project._count.contacts,
|
||||
lastCampaignSentAt: lastCampaign?.sentAt || null,
|
||||
hasEnabledWorkflow: project._count.workflows > 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project security metrics
|
||||
* GET /projects/:id/security
|
||||
*/
|
||||
@Get(':id/security')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Verify user has access to this project
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Use existing SecurityService
|
||||
const metrics = await SecurityService.getProjectSecurityMetrics(id);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: metrics,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all members of a project
|
||||
* GET /projects/:id/members
|
||||
*/
|
||||
@Get(':id/members')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async getMembers(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Verify user has access to this project
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Get all members of the project
|
||||
const members = await MembershipService.getMembers(id);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: members,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a member to a project by email
|
||||
* POST /projects/:id/members
|
||||
* Body: { email: string, role?: 'ADMIN' | 'MEMBER' }
|
||||
*/
|
||||
@Post(':id/members')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async addMember(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Validate params
|
||||
if (!id) {
|
||||
throw new HttpException(400, 'Project ID is required');
|
||||
}
|
||||
|
||||
// Validate and parse request body
|
||||
const parseResult = MembershipSchemas.addMember.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new HttpException(400, parseResult.error.errors[0]?.message || 'Invalid request body');
|
||||
}
|
||||
|
||||
const {email, role} = parseResult.data;
|
||||
|
||||
// Verify current user is ADMIN or OWNER
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Find user by email
|
||||
const userToAdd = await prisma.user.findUnique({
|
||||
where: {email: email.toLowerCase()},
|
||||
select: {id: true, email: true},
|
||||
});
|
||||
|
||||
if (!userToAdd) {
|
||||
throw new HttpException(404, 'User with this email does not have an account');
|
||||
}
|
||||
|
||||
// Add member to project
|
||||
const newMembership = await MembershipService.addMember(id, userToAdd.id, role);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
userId: userToAdd.id,
|
||||
email: userToAdd.email,
|
||||
role: newMembership.role,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a member's role
|
||||
* PATCH /projects/:id/members/:userId
|
||||
* Body: { role: 'ADMIN' | 'MEMBER' }
|
||||
*/
|
||||
@Patch(':id/members/:userId')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async updateMemberRole(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id, userId} = req.params;
|
||||
|
||||
// Validate params
|
||||
if (!id) {
|
||||
throw new HttpException(400, 'Project ID is required');
|
||||
}
|
||||
if (!userId) {
|
||||
throw new HttpException(400, 'User ID is required');
|
||||
}
|
||||
|
||||
// Validate and parse request body
|
||||
const parseResult = MembershipSchemas.updateRole.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
throw new HttpException(400, parseResult.error.errors[0]?.message || 'Invalid request body');
|
||||
}
|
||||
|
||||
const {role} = parseResult.data;
|
||||
|
||||
// Verify current user is ADMIN or OWNER
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Get user info
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {id: userId},
|
||||
select: {id: true, email: true},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new HttpException(404, 'User not found');
|
||||
}
|
||||
|
||||
// Update role (service handles validation)
|
||||
await MembershipService.updateRole(id, userId, role);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from a project
|
||||
* DELETE /projects/:id/members/:userId
|
||||
*/
|
||||
@Delete(':id/members/:userId')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
private async removeMember(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id, userId} = req.params;
|
||||
|
||||
// Validate params
|
||||
if (!id) {
|
||||
throw new HttpException(400, 'Project ID is required');
|
||||
}
|
||||
if (!userId) {
|
||||
throw new HttpException(400, 'User ID is required');
|
||||
}
|
||||
|
||||
// Verify current user is ADMIN or OWNER
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Cannot remove yourself
|
||||
if (userId === auth.userId) {
|
||||
throw new HttpException(403, 'You cannot remove yourself from the project');
|
||||
}
|
||||
|
||||
// Remove member (service handles validation)
|
||||
await MembershipService.removeMember(id, userId);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {message: 'Member removed successfully'},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {SegmentService} from '../services/SegmentService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@Controller('segments')
|
||||
export class Segments {
|
||||
/**
|
||||
* GET /segments
|
||||
* List all segments for the authenticated project
|
||||
*/
|
||||
@Get('')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async list(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
|
||||
const segments = await SegmentService.list(auth.projectId!);
|
||||
|
||||
return res.status(200).json(segments);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /segments/:id
|
||||
* Get a specific segment by ID with member count
|
||||
*/
|
||||
@Get(':id')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async get(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
|
||||
if (!segmentId) {
|
||||
return res.status(400).json({error: 'Segment ID is required'});
|
||||
}
|
||||
|
||||
const segment = await SegmentService.get(auth.projectId!, segmentId);
|
||||
|
||||
return res.status(200).json(segment);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /segments/:id/contacts
|
||||
* Get contacts that match a segment's filters
|
||||
*/
|
||||
@Get(':id/contacts')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getContacts(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
|
||||
|
||||
if (!segmentId) {
|
||||
return res.status(400).json({error: 'Segment ID is required'});
|
||||
}
|
||||
|
||||
const result = await SegmentService.getContacts(auth.projectId!, segmentId, page, pageSize);
|
||||
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /segments
|
||||
* Create a new segment
|
||||
*/
|
||||
@Post('')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {name, description, type, condition, trackMembership} = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({error: 'Name is required'});
|
||||
}
|
||||
|
||||
const segmentType = type ?? 'DYNAMIC';
|
||||
|
||||
if (segmentType === 'DYNAMIC' && (!condition || typeof condition !== 'object')) {
|
||||
return res.status(400).json({error: 'Condition is required and must be an object for DYNAMIC segments'});
|
||||
}
|
||||
|
||||
const segment = await SegmentService.create(auth.projectId!, {
|
||||
name,
|
||||
description,
|
||||
type: segmentType,
|
||||
condition: segmentType === 'DYNAMIC' ? condition : undefined,
|
||||
trackMembership,
|
||||
});
|
||||
|
||||
return res.status(201).json(segment);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /segments/:id
|
||||
* Update a segment
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async update(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
const {name, description, condition, trackMembership} = req.body;
|
||||
|
||||
if (!segmentId) {
|
||||
return res.status(400).json({error: 'Segment ID is required'});
|
||||
}
|
||||
|
||||
if (condition !== undefined && typeof condition !== 'object') {
|
||||
return res.status(400).json({error: 'Condition must be an object'});
|
||||
}
|
||||
|
||||
const segment = await SegmentService.update(auth.projectId!, segmentId, {
|
||||
name,
|
||||
description,
|
||||
condition,
|
||||
trackMembership,
|
||||
});
|
||||
|
||||
return res.status(200).json(segment);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /segments/:id
|
||||
* Delete a segment
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async delete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
|
||||
if (!segmentId) {
|
||||
return res.status(400).json({error: 'Segment ID is required'});
|
||||
}
|
||||
|
||||
await SegmentService.delete(auth.projectId!, segmentId);
|
||||
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /segments/:id/members
|
||||
* Add contacts to a static segment by email
|
||||
*/
|
||||
@Post(':id/members')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async addMembers(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
const {emails} = req.body;
|
||||
|
||||
if (!segmentId) {
|
||||
return res.status(400).json({error: 'Segment ID is required'});
|
||||
}
|
||||
|
||||
if (!Array.isArray(emails) || emails.length === 0) {
|
||||
return res.status(400).json({error: 'emails must be a non-empty array'});
|
||||
}
|
||||
|
||||
const result = await SegmentService.addContacts(auth.projectId!, segmentId, emails);
|
||||
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /segments/:id/members
|
||||
* Remove contacts from a static segment by email
|
||||
*/
|
||||
@Delete(':id/members')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async removeMembers(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
const {emails} = req.body;
|
||||
|
||||
if (!segmentId) {
|
||||
return res.status(400).json({error: 'Segment ID is required'});
|
||||
}
|
||||
|
||||
if (!Array.isArray(emails) || emails.length === 0) {
|
||||
return res.status(400).json({error: 'emails must be a non-empty array'});
|
||||
}
|
||||
|
||||
const result = await SegmentService.removeContacts(auth.projectId!, segmentId, emails);
|
||||
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /segments/:id/compute
|
||||
* Recompute segment membership for all contacts
|
||||
*/
|
||||
@Post(':id/compute')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async compute(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
|
||||
if (!segmentId) {
|
||||
return res.status(400).json({error: 'Segment ID is required'});
|
||||
}
|
||||
|
||||
const result = await SegmentService.computeMembership(auth.projectId!, segmentId);
|
||||
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /segments/:id/refresh
|
||||
* Refresh segment member count
|
||||
*/
|
||||
@Post(':id/refresh')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async refresh(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
|
||||
if (!segmentId) {
|
||||
return res.status(400).json({error: 'Segment ID is required'});
|
||||
}
|
||||
|
||||
const memberCount = await SegmentService.refreshMemberCount(auth.projectId!, segmentId);
|
||||
|
||||
return res.status(200).json({memberCount});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||
import {TemplateType} from '@plunk/db';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {DomainService} from '../services/DomainService.js';
|
||||
import {TemplateService} from '../services/TemplateService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@Controller('templates')
|
||||
export class Templates {
|
||||
/**
|
||||
* GET /templates
|
||||
* List all templates for the authenticated project
|
||||
*/
|
||||
@Get('')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async list(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
|
||||
const search = req.query.search as string | undefined;
|
||||
const type = req.query.type as TemplateType | undefined;
|
||||
|
||||
const result = await TemplateService.list(auth.projectId!, page, pageSize, search, type);
|
||||
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /templates/:id
|
||||
* Get a specific template by ID
|
||||
*/
|
||||
@Get(':id')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async get(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const templateId = req.params.id;
|
||||
|
||||
if (!templateId) {
|
||||
return res.status(400).json({error: 'Template ID is required'});
|
||||
}
|
||||
|
||||
const template = await TemplateService.get(auth.projectId!, templateId);
|
||||
|
||||
return res.status(200).json(template);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /templates
|
||||
* Create a new template
|
||||
*/
|
||||
@Post('')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({error: 'Name is required'});
|
||||
}
|
||||
|
||||
if (!subject) {
|
||||
return res.status(400).json({error: 'Subject is required'});
|
||||
}
|
||||
|
||||
if (!body) {
|
||||
return res.status(400).json({error: 'Body is required'});
|
||||
}
|
||||
|
||||
if (!from) {
|
||||
return res.status(400).json({error: 'From address is required'});
|
||||
}
|
||||
|
||||
// Verify domain ownership and verification
|
||||
await DomainService.verifyEmailDomain(from, auth.projectId!);
|
||||
|
||||
const template = await TemplateService.create(auth.projectId!, {
|
||||
name,
|
||||
description,
|
||||
subject,
|
||||
body,
|
||||
from,
|
||||
fromName,
|
||||
replyTo,
|
||||
type,
|
||||
});
|
||||
|
||||
return res.status(201).json(template);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /templates/:id
|
||||
* Update a template
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async update(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const templateId = req.params.id;
|
||||
const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
|
||||
|
||||
if (!templateId) {
|
||||
return res.status(400).json({error: 'Template ID is required'});
|
||||
}
|
||||
|
||||
// Verify domain ownership and verification if 'from' is being updated
|
||||
if (from) {
|
||||
await DomainService.verifyEmailDomain(from, auth.projectId!);
|
||||
}
|
||||
|
||||
const template = await TemplateService.update(auth.projectId!, templateId, {
|
||||
name,
|
||||
description,
|
||||
subject,
|
||||
body,
|
||||
from,
|
||||
fromName,
|
||||
replyTo,
|
||||
type,
|
||||
});
|
||||
|
||||
return res.status(200).json(template);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /templates/:id
|
||||
* Delete a template
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async delete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const templateId = req.params.id;
|
||||
|
||||
if (!templateId) {
|
||||
return res.status(400).json({error: 'Template ID is required'});
|
||||
}
|
||||
|
||||
await TemplateService.delete(auth.projectId!, templateId);
|
||||
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /templates/:id/duplicate
|
||||
* Duplicate a template
|
||||
*/
|
||||
@Post(':id/duplicate')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async duplicate(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const templateId = req.params.id;
|
||||
|
||||
if (!templateId) {
|
||||
return res.status(400).json({error: 'Template ID is required'});
|
||||
}
|
||||
|
||||
const template = await TemplateService.duplicate(auth.projectId!, templateId);
|
||||
|
||||
return res.status(201).json(template);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /templates/:id/usage
|
||||
* Get template usage statistics
|
||||
*/
|
||||
@Get(':id/usage')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getUsage(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const templateId = req.params.id;
|
||||
|
||||
if (!templateId) {
|
||||
return res.status(400).json({error: 'Template ID is required'});
|
||||
}
|
||||
|
||||
const usage = await TemplateService.getUsage(auth.projectId!, templateId);
|
||||
|
||||
return res.status(200).json(usage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {Controller, Middleware, Post} from '@overnightjs/core';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import multer from 'multer';
|
||||
import signale from 'signale';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import * as S3Service from '../services/S3Service.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
const MAGIC_BYTES: Record<string, Buffer[]> = {
|
||||
'image/jpeg': [Buffer.from([0xff, 0xd8, 0xff])],
|
||||
'image/jpg': [Buffer.from([0xff, 0xd8, 0xff])],
|
||||
'image/png': [Buffer.from([0x89, 0x50, 0x4e, 0x47])],
|
||||
'image/gif': [Buffer.from('GIF87a'), Buffer.from('GIF89a')],
|
||||
'image/webp': [Buffer.from('RIFF')],
|
||||
};
|
||||
|
||||
function validateMagicBytes(buffer: Buffer, mimetype: string): boolean {
|
||||
const signatures = MAGIC_BYTES[mimetype];
|
||||
if (!signatures) return false;
|
||||
return signatures.some(sig => buffer.subarray(0, sig.length).equals(sig));
|
||||
}
|
||||
|
||||
// Configure multer for file uploads (memory storage)
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB max file size
|
||||
},
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||
|
||||
if (allowedMimeTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only image files are allowed (JPEG, PNG, GIF, WebP)'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@Controller('uploads')
|
||||
export class Uploads {
|
||||
/**
|
||||
* POST /uploads/image
|
||||
* Upload an image file to S3/Minio
|
||||
*/
|
||||
@Post('image')
|
||||
@Middleware([requireAuth, requireEmailVerified, upload.single('image')])
|
||||
@CatchAsync
|
||||
public async uploadImage(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
|
||||
try {
|
||||
if (!S3Service.isS3Enabled()) {
|
||||
return res.status(503).json({
|
||||
error: 'File uploads are not enabled. Please configure S3 storage.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
error: 'No image file provided',
|
||||
});
|
||||
}
|
||||
|
||||
if (!validateMagicBytes(req.file.buffer, req.file.mimetype)) {
|
||||
return res.status(400).json({
|
||||
error: 'File contents do not match the declared image type',
|
||||
});
|
||||
}
|
||||
|
||||
// Upload file to S3/Minio
|
||||
const result = await S3Service.uploadFile({
|
||||
file: req.file.buffer,
|
||||
filename: req.file.originalname,
|
||||
contentType: req.file.mimetype,
|
||||
projectId: auth.projectId!,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
url: result.url,
|
||||
key: result.key,
|
||||
filename: req.file.originalname,
|
||||
contentType: req.file.mimetype,
|
||||
size: req.file.size,
|
||||
});
|
||||
} catch (error) {
|
||||
signale.error('[UPLOADS] Failed to upload image:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to upload image',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
import {randomBytes} from 'node:crypto';
|
||||
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post, Put} from '@overnightjs/core';
|
||||
import {BillingLimitSchemas, ProjectSchemas, UtilitySchemas} from '@plunk/shared';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ONBOARDING} from '../app/constants.js';
|
||||
import {stripe} from '../app/stripe.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exceptions/index.js';
|
||||
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {BillingLimitService} from '../services/BillingLimitService.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
import {UserService} from '../services/UserService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
import signale from 'signale';
|
||||
|
||||
@Controller('users')
|
||||
export class Users {
|
||||
@Get('@me')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async me(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
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, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async meProjects(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
const projects = await UserService.projects(auth.userId);
|
||||
|
||||
return res.status(200).json(projects);
|
||||
}
|
||||
|
||||
@Post('@me/projects')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async createProject(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
// Check if user is a member of any disabled project
|
||||
const {hasDisabledProject, disabledProjectNames} = await SecurityService.userHasDisabledProject(auth.userId);
|
||||
if (hasDisabledProject) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
`You cannot create new projects while you are a member of disabled projects: ${disabledProjectNames.join(', ')}. Please contact support to resolve security violations.`,
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
|
||||
const {name} = ProjectSchemas.create.parse(req.body);
|
||||
|
||||
// Generate unique API keys
|
||||
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
|
||||
const secretKey = `sk_${randomBytes(32).toString('hex')}`;
|
||||
|
||||
// Create the project
|
||||
const project = await prisma.project.create({
|
||||
data: {
|
||||
name,
|
||||
public: publicKey,
|
||||
secret: secretKey,
|
||||
members: {
|
||||
create: {
|
||||
userId: auth.userId,
|
||||
role: 'OWNER',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification about project creation
|
||||
await NtfyService.notifyProjectCreated(project.name, project.id, auth.userId);
|
||||
|
||||
return res.status(201).json(project);
|
||||
}
|
||||
|
||||
@Patch('@me/projects/:id')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async updateProject(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const data = ProjectSchemas.update.parse(req.body);
|
||||
|
||||
// Verify user has admin/owner access to this project
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Update the project
|
||||
const project = await prisma.project.update({
|
||||
where: {id},
|
||||
data,
|
||||
});
|
||||
|
||||
return res.status(200).json(project);
|
||||
}
|
||||
|
||||
@Post('@me/projects/:id/regenerate-keys')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Verify user has admin/owner access to this project
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Generate new unique API keys
|
||||
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
|
||||
const secretKey = `sk_${randomBytes(32).toString('hex')}`;
|
||||
|
||||
// Update the project with new keys
|
||||
const project = await prisma.project.update({
|
||||
where: {id},
|
||||
data: {
|
||||
public: publicKey,
|
||||
secret: secretKey,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
public: true,
|
||||
secret: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
disabled: true,
|
||||
customer: true,
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification about API key regeneration
|
||||
await NtfyService.notifyApiKeysRegenerated(project.name!, id!, auth.userId!);
|
||||
|
||||
return res.status(200).json(project);
|
||||
}
|
||||
|
||||
@Post('@me/projects/:id/checkout')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
const {currency} = req.query;
|
||||
|
||||
// Check if billing is enabled
|
||||
if (!STRIPE_ENABLED || !stripe) {
|
||||
return res.status(404).json({error: 'Billing is not enabled'});
|
||||
}
|
||||
|
||||
// Verify user has admin/owner access to this project
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Get the project
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound('Project not found');
|
||||
}
|
||||
|
||||
// If project already has a subscription, return error
|
||||
if (project.subscription) {
|
||||
return res.status(400).json({error: 'Project already has a subscription'});
|
||||
}
|
||||
|
||||
// Build line items for subscription
|
||||
const lineItems = [];
|
||||
|
||||
// Add one-time onboarding fee if configured
|
||||
if (STRIPE_PRICE_ONBOARDING) {
|
||||
lineItems.push({price: STRIPE_PRICE_ONBOARDING, quantity: 1});
|
||||
}
|
||||
|
||||
// Add metered pricing for pay-per-email (required)
|
||||
if (!STRIPE_PRICE_EMAIL_USAGE) {
|
||||
return res.status(500).json({error: 'Usage-based pricing not configured. Set STRIPE_PRICE_EMAIL_USAGE.'});
|
||||
}
|
||||
lineItems.push({price: STRIPE_PRICE_EMAIL_USAGE}); // No quantity for metered items
|
||||
|
||||
// Calculate billing cycle anchor to first day of next month
|
||||
// This ensures all subscriptions renew on the 1st of each month
|
||||
const now = new Date();
|
||||
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const billingCycleAnchor = Math.floor(nextMonth.getTime() / 1000);
|
||||
|
||||
// Validate currency if provided
|
||||
let checkoutCurrency: string | undefined;
|
||||
if (currency && typeof currency === 'string') {
|
||||
const validCurrencies = ['usd', 'eur', 'gbp'];
|
||||
if (validCurrencies.includes(currency.toLowerCase())) {
|
||||
checkoutCurrency = currency.toLowerCase();
|
||||
} else {
|
||||
return res.status(400).json({error: 'Invalid currency. Supported: USD, EUR, GBP'});
|
||||
}
|
||||
}
|
||||
|
||||
// Create checkout session
|
||||
// Note: proration_behavior cannot be set when one-time prices are included
|
||||
// The billing_cycle_anchor alone ensures the subscription is anchored to the 1st of the month
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
customer: project.customer ?? undefined, // Use existing customer if available
|
||||
client_reference_id: project.id, // Store project ID for webhook
|
||||
line_items: lineItems,
|
||||
...(checkoutCurrency && {currency: checkoutCurrency}),
|
||||
subscription_data: {
|
||||
billing_cycle_anchor: billingCycleAnchor,
|
||||
},
|
||||
success_url: `${DASHBOARD_URI}/settings?tab=billing&success=true`,
|
||||
cancel_url: `${DASHBOARD_URI}/settings?tab=billing&canceled=true`,
|
||||
});
|
||||
|
||||
return res.status(200).json({url: session.url});
|
||||
}
|
||||
|
||||
@Post('@me/projects/:id/billing-portal')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Check if billing is enabled
|
||||
if (!STRIPE_ENABLED || !stripe) {
|
||||
return res.status(404).json({error: 'Billing is not enabled'});
|
||||
}
|
||||
|
||||
// Verify user has admin/owner access to this project
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Get the project
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound('Project not found');
|
||||
}
|
||||
|
||||
// Project must have a customer ID to access billing portal
|
||||
if (!project.customer) {
|
||||
return res.status(400).json({error: 'No customer found for this project'});
|
||||
}
|
||||
|
||||
// Create billing portal session
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: project.customer,
|
||||
return_url: `${DASHBOARD_URI}/settings?tab=billing`,
|
||||
});
|
||||
|
||||
return res.status(200).json({url: session.url});
|
||||
}
|
||||
|
||||
@Get('@me/projects/:id/billing-limits')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
throw new NotFound('Project ID is required');
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Get billing limits and usage
|
||||
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
|
||||
|
||||
return res.status(200).json(limitsAndUsage);
|
||||
}
|
||||
|
||||
@Put('@me/projects/:id/billing-limits')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
throw new NotFound('Project ID is required');
|
||||
}
|
||||
|
||||
const data = BillingLimitSchemas.update.parse(req.body);
|
||||
|
||||
// Verify user has admin/owner access to this project
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Get the project with current limits
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id},
|
||||
select: {
|
||||
subscription: true,
|
||||
billingLimitWorkflows: true,
|
||||
billingLimitCampaigns: true,
|
||||
billingLimitTransactional: true,
|
||||
billingLimitInbound: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound('Project not found');
|
||||
}
|
||||
|
||||
// Require active subscription to set billing limits
|
||||
if (!project.subscription) {
|
||||
return res.status(400).json({error: 'An active subscription is required to set billing limits'});
|
||||
}
|
||||
|
||||
// Update billing limits
|
||||
await prisma.project.update({
|
||||
where: {id},
|
||||
data: {
|
||||
billingLimitWorkflows: data.workflows,
|
||||
billingLimitCampaigns: data.campaigns,
|
||||
billingLimitTransactional: data.transactional,
|
||||
billingLimitInbound: data.inbound,
|
||||
},
|
||||
});
|
||||
|
||||
// Clear notification cache keys for limits that changed
|
||||
// This allows new warning/limit emails to be sent when the new limits are reached
|
||||
await BillingLimitService.clearNotificationCacheForChangedLimits(
|
||||
id,
|
||||
{
|
||||
workflows: project.billingLimitWorkflows,
|
||||
campaigns: project.billingLimitCampaigns,
|
||||
transactional: project.billingLimitTransactional,
|
||||
inbound: project.billingLimitInbound,
|
||||
},
|
||||
data,
|
||||
);
|
||||
|
||||
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
|
||||
|
||||
return res.status(200).json(limitsAndUsage);
|
||||
}
|
||||
|
||||
@Get('@me/projects/:id/billing-consumption')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Check if billing is enabled
|
||||
if (!STRIPE_ENABLED || !stripe) {
|
||||
return res.status(404).json({error: 'Billing is not enabled'});
|
||||
}
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
throw new NotFound('Project ID is required');
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id},
|
||||
select: {
|
||||
customer: true,
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound('Project not found');
|
||||
}
|
||||
|
||||
if (!project.customer || !project.subscription) {
|
||||
return res.status(400).json({error: 'No active subscription found'});
|
||||
}
|
||||
|
||||
// Get the current billing period (start of current month to now)
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
// Get upcoming invoice to see costs and usage
|
||||
let upcomingInvoice = null;
|
||||
let totalUsage = 0;
|
||||
|
||||
try {
|
||||
upcomingInvoice = (await stripe.invoices.createPreview({
|
||||
customer: project.customer,
|
||||
subscription: project.subscription,
|
||||
})) as any;
|
||||
|
||||
// Extract metered usage from invoice line items
|
||||
if (upcomingInvoice && upcomingInvoice.lines && upcomingInvoice.lines.data) {
|
||||
// Since we only have one meter (email usage), we can safely use the first line item
|
||||
// The invoice preview doesn't expand the price object, so we can't match by price ID
|
||||
const meteredLine = upcomingInvoice.lines.data[0];
|
||||
|
||||
if (meteredLine && meteredLine.quantity) {
|
||||
totalUsage = meteredLine.quantity;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// No upcoming invoice yet or error retrieving it
|
||||
// This is normal for new subscriptions or if there's no usage yet
|
||||
signale.error('[BILLING] Error retrieving upcoming invoice:', error);
|
||||
}
|
||||
|
||||
// Get customer to retrieve balance (credits)
|
||||
const customer = await stripe.customers.retrieve(project.customer);
|
||||
let credits = null;
|
||||
|
||||
if (!customer.deleted) {
|
||||
// Stripe stores balance in cents as a negative number (credit) or positive (debit)
|
||||
// A negative balance means the customer has credits
|
||||
const balance = customer.balance || 0;
|
||||
const hasCredits = balance < 0;
|
||||
const creditAmount = hasCredits ? Math.abs(balance) : 0;
|
||||
|
||||
credits = {
|
||||
balance,
|
||||
hasCredits,
|
||||
creditAmount,
|
||||
currency: customer.currency || 'usd',
|
||||
};
|
||||
}
|
||||
|
||||
const responseData = {
|
||||
period: {
|
||||
start: startOfMonth.toISOString(),
|
||||
end: now.toISOString(),
|
||||
},
|
||||
usage: {
|
||||
total: totalUsage,
|
||||
records: [], // Deprecated in favor of invoice line items
|
||||
},
|
||||
credits,
|
||||
upcomingInvoice: upcomingInvoice
|
||||
? {
|
||||
amountDue: upcomingInvoice.amount_due,
|
||||
currency: upcomingInvoice.currency,
|
||||
periodStart: upcomingInvoice.period_start
|
||||
? new Date(upcomingInvoice.period_start * 1000).toISOString()
|
||||
: startOfMonth.toISOString(),
|
||||
periodEnd: upcomingInvoice.period_end
|
||||
? new Date(upcomingInvoice.period_end * 1000).toISOString()
|
||||
: now.toISOString(),
|
||||
subtotal: upcomingInvoice.subtotal ?? 0,
|
||||
total: upcomingInvoice.total ?? 0,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
return res.status(200).json(responseData);
|
||||
}
|
||||
|
||||
@Get('@me/projects/:id/billing-invoices')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
// Check if billing is enabled
|
||||
if (!STRIPE_ENABLED || !stripe) {
|
||||
return res.status(404).json({error: 'Billing is not enabled'});
|
||||
}
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
throw new NotFound('Project ID is required');
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Get the project
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id},
|
||||
select: {
|
||||
customer: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound('Project not found');
|
||||
}
|
||||
|
||||
if (!project.customer) {
|
||||
return res.status(400).json({error: 'No customer found'});
|
||||
}
|
||||
|
||||
// Get all invoices for this customer
|
||||
const invoices = await stripe.invoices.list({
|
||||
customer: project.customer,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
// Check for unpaid invoices
|
||||
const unpaidInvoices = invoices.data.filter(
|
||||
invoice => invoice.status === 'open' || invoice.status === 'uncollectible',
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
invoices: invoices.data.map(invoice => ({
|
||||
id: invoice.id,
|
||||
number: invoice.number,
|
||||
status: invoice.status,
|
||||
amountDue: invoice.amount_due,
|
||||
amountPaid: invoice.amount_paid,
|
||||
currency: invoice.currency,
|
||||
created: new Date(invoice.created * 1000).toISOString(),
|
||||
periodStart: invoice.period_start ? new Date(invoice.period_start * 1000).toISOString() : null,
|
||||
periodEnd: invoice.period_end ? new Date(invoice.period_end * 1000).toISOString() : null,
|
||||
hostedInvoiceUrl: invoice.hosted_invoice_url,
|
||||
invoicePdf: invoice.invoice_pdf,
|
||||
subtotal: invoice.subtotal,
|
||||
total: invoice.total,
|
||||
paid: invoice.status === 'paid',
|
||||
})),
|
||||
hasUnpaidInvoices: unpaidInvoices.length > 0,
|
||||
unpaidInvoices: unpaidInvoices.map(invoice => ({
|
||||
id: invoice.id,
|
||||
number: invoice.number,
|
||||
amountDue: invoice.amount_due,
|
||||
currency: invoice.currency,
|
||||
dueDate: invoice.due_date ? new Date(invoice.due_date * 1000).toISOString() : null,
|
||||
hostedInvoiceUrl: invoice.hosted_invoice_url,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('@me/projects/:id/security')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
throw new NotFound('Project ID is required');
|
||||
}
|
||||
|
||||
// Verify user has access to this project
|
||||
await MembershipService.requireAccess(auth.userId!, id);
|
||||
|
||||
// Get security metrics
|
||||
const metrics = await SecurityService.getProjectSecurityMetrics(id);
|
||||
|
||||
return res.status(200).json(metrics);
|
||||
}
|
||||
|
||||
@Post('@me/projects/:id/reset')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async resetProject(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
throw new NotFound('Project ID is required');
|
||||
}
|
||||
|
||||
// Verify user has admin/owner access to this project
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Check if project is disabled - block reset operation
|
||||
const isDisabled = await SecurityService.isProjectDisabled(id);
|
||||
if (isDisabled) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
'Cannot reset a disabled project. Please contact support to resolve security violations before making changes.',
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
|
||||
// Delete all project data in a transaction
|
||||
await prisma.$transaction(async tx => {
|
||||
// Delete all emails
|
||||
await tx.email.deleteMany({
|
||||
where: {projectId: id},
|
||||
});
|
||||
|
||||
// Delete all events
|
||||
await tx.event.deleteMany({
|
||||
where: {projectId: id},
|
||||
});
|
||||
|
||||
// Delete all campaigns
|
||||
await tx.campaign.deleteMany({
|
||||
where: {projectId: id},
|
||||
});
|
||||
|
||||
// Delete all workflows
|
||||
await tx.workflow.deleteMany({
|
||||
where: {projectId: id},
|
||||
});
|
||||
|
||||
// Delete all segments
|
||||
await tx.segment.deleteMany({
|
||||
where: {projectId: id},
|
||||
});
|
||||
|
||||
// Delete all contacts
|
||||
await tx.contact.deleteMany({
|
||||
where: {projectId: id},
|
||||
});
|
||||
|
||||
// Delete all templates
|
||||
await tx.template.deleteMany({
|
||||
where: {projectId: id},
|
||||
});
|
||||
|
||||
// Delete all API requests
|
||||
await tx.apiRequest.deleteMany({
|
||||
where: {projectId: id},
|
||||
});
|
||||
});
|
||||
|
||||
return res.status(200).json({success: true, message: 'Project reset successfully'});
|
||||
}
|
||||
|
||||
@Delete('@me/projects/:id')
|
||||
@Middleware([isAuthenticated, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async deleteProject(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {id} = UtilitySchemas.id.parse(req.params);
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
throw new NotFound('Project ID is required');
|
||||
}
|
||||
|
||||
// Verify user has owner or admin access to this project
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Get project to check for active subscription and disabled status
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id},
|
||||
select: {
|
||||
name: true,
|
||||
subscription: true,
|
||||
customer: true,
|
||||
disabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new NotFound('Project not found');
|
||||
}
|
||||
|
||||
// Check if project is disabled - block delete operation
|
||||
if (project.disabled) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
'Cannot delete a disabled project. Please contact support to resolve security violations.',
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
|
||||
// If project has an active subscription, cancel it first
|
||||
if (STRIPE_ENABLED && stripe && project.subscription) {
|
||||
try {
|
||||
await stripe.subscriptions.cancel(project.subscription);
|
||||
} catch (error) {
|
||||
// Log but don't fail if subscription cancellation fails
|
||||
signale.error('Failed to cancel subscription:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the project (cascading deletes will handle related data)
|
||||
await prisma.project.delete({
|
||||
where: {id},
|
||||
});
|
||||
|
||||
// Send notification about project deletion
|
||||
await NtfyService.notifyProjectDeleted(project.name, id, auth.userId);
|
||||
|
||||
return res.status(200).json({success: true, message: 'Project deleted successfully'});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
import {Controller, Post} from '@overnightjs/core';
|
||||
import type {Prisma} from '@plunk/db';
|
||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||
import type {Request, Response} from 'express';
|
||||
import signale from 'signale';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import {ProjectDisabledPaymentEmail, sendPlatformEmail} from '@plunk/email';
|
||||
import React from 'react';
|
||||
|
||||
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
|
||||
import {stripe} from '../app/stripe.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {BillingLimitService} from '../services/BillingLimitService.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {EventService} from '../services/EventService.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {MeterService} from '../services/MeterService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
/**
|
||||
* Webhooks Controller
|
||||
* Handles incoming webhooks from external services (AWS SNS/SES)
|
||||
*/
|
||||
@Controller('webhooks')
|
||||
export class Webhooks {
|
||||
/**
|
||||
* Receive SNS webhook notifications from AWS SES
|
||||
* Handles outbound email events: delivery, open, click, bounce, complaint
|
||||
* Handles inbound email notifications: received emails via SES receiving
|
||||
*/
|
||||
@Post('sns')
|
||||
@CatchAsync
|
||||
public async receiveSNSWebhook(req: Request, res: Response) {
|
||||
try {
|
||||
// Handle SNS subscription confirmation FIRST (before parsing Message field)
|
||||
if (req.body.Type === 'SubscriptionConfirmation') {
|
||||
signale.info('SNS Subscription Confirmation received');
|
||||
|
||||
// Validate SubscribeURL to prevent SSRF: must be HTTPS and from an official AWS SNS host.
|
||||
// Legitimate URLs look like:
|
||||
// https://sns.<region>.amazonaws.com/?Action=ConfirmSubscription&...
|
||||
const subscribeURL: unknown = req.body.SubscribeURL;
|
||||
if (typeof subscribeURL !== 'string') {
|
||||
signale.warn('SNS SubscriptionConfirmation missing SubscribeURL');
|
||||
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
|
||||
}
|
||||
|
||||
let parsedURL: URL;
|
||||
try {
|
||||
parsedURL = new URL(subscribeURL);
|
||||
} catch {
|
||||
signale.warn('SNS SubscriptionConfirmation has unparseable SubscribeURL');
|
||||
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
|
||||
}
|
||||
|
||||
// Only allow HTTPS requests to official AWS SNS endpoints.
|
||||
// The hostname must be exactly sns.<region>.amazonaws.com or sns.<region>.amazonaws.eu
|
||||
const SNS_HOST_RE = /^sns\.[a-z0-9-]+\.amazonaws\.(com|eu)$/;
|
||||
if (parsedURL.protocol !== 'https:' || !SNS_HOST_RE.test(parsedURL.hostname)) {
|
||||
signale.warn(`SNS SubscriptionConfirmation rejected — disallowed SubscribeURL host: ${parsedURL.hostname}`);
|
||||
return res.status(400).json({success: false, message: 'Invalid SubscribeURL'});
|
||||
}
|
||||
|
||||
// Automatically confirm the subscription
|
||||
try {
|
||||
const confirmResponse = await fetch(subscribeURL);
|
||||
if (confirmResponse.ok) {
|
||||
signale.success('SNS subscription confirmed successfully');
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Subscription confirmed',
|
||||
});
|
||||
} else {
|
||||
signale.error('Failed to confirm SNS subscription:', confirmResponse.statusText);
|
||||
return res.status(200).json({
|
||||
success: false,
|
||||
message: 'Failed to confirm subscription',
|
||||
});
|
||||
}
|
||||
} catch (confirmError) {
|
||||
signale.error('Error confirming SNS subscription:', confirmError);
|
||||
return res.status(200).json({
|
||||
success: false,
|
||||
message: 'Error confirming subscription',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle SNS notification messages - parse the Message field
|
||||
if (req.body.Type !== 'Notification') {
|
||||
signale.warn('[WEBHOOK] Unknown SNS message type:', req.body.Type);
|
||||
return res.status(200).json({success: false, message: 'Unknown message type'});
|
||||
}
|
||||
|
||||
// Parse the nested SES event from the Message field
|
||||
const body = JSON.parse(req.body.Message);
|
||||
|
||||
// Check if this is an inbound email notification (SES Receiving)
|
||||
if (body.notificationType === 'Received') {
|
||||
signale.info('[WEBHOOK] Received inbound email notification from SES');
|
||||
|
||||
try {
|
||||
// Extract recipient addresses from the inbound email
|
||||
const recipients = body.receipt?.recipients || [];
|
||||
|
||||
if (recipients.length === 0) {
|
||||
signale.warn('[WEBHOOK] No recipients found in inbound email');
|
||||
return res.status(200).json({success: true, message: 'No recipients found'});
|
||||
}
|
||||
|
||||
// For each recipient, identify the domain and create events
|
||||
for (const recipient of recipients) {
|
||||
const recipientEmail = recipient as string;
|
||||
const domain = recipientEmail.split('@')[1];
|
||||
|
||||
if (!domain) {
|
||||
signale.warn('[WEBHOOK] Invalid recipient email format:', recipientEmail);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find ALL projects that have this domain verified
|
||||
// A domain can be shared across multiple projects if users are members of both
|
||||
const domainRecords = await prisma.domain.findMany({
|
||||
where: {
|
||||
domain,
|
||||
verified: true, // Only process emails for verified domains
|
||||
},
|
||||
include: {
|
||||
project: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (domainRecords.length === 0) {
|
||||
signale.info(`[WEBHOOK] No verified domain found for: ${domain}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
signale.info(
|
||||
`[WEBHOOK] Found ${domainRecords.length} project(s) with verified domain ${domain}. Processing inbound email for all.`,
|
||||
);
|
||||
|
||||
// Extract sender information (same for all projects)
|
||||
const senderEmail = body.mail?.source;
|
||||
const senderFromHeader = body.mail?.commonHeaders?.from?.[0] || senderEmail;
|
||||
|
||||
// Process inbound email for each project that has this domain verified
|
||||
for (const domainRecord of domainRecords) {
|
||||
signale.info(`[WEBHOOK] Processing inbound email for project: ${domainRecord.project.name}`);
|
||||
|
||||
// Check billing limits before processing inbound email
|
||||
const limitCheck = await BillingLimitService.checkLimit(domainRecord.projectId, EmailSourceType.INBOUND);
|
||||
|
||||
if (!limitCheck.allowed) {
|
||||
signale.warn(
|
||||
`[WEBHOOK] Inbound email blocked for project ${domainRecord.project.name}: ${limitCheck.message}`,
|
||||
);
|
||||
continue; // Skip this project but continue processing for other projects
|
||||
}
|
||||
|
||||
// Find or create a contact for the sender in this project
|
||||
let contact;
|
||||
if (senderEmail) {
|
||||
contact = await ContactService.upsert(
|
||||
domainRecord.projectId,
|
||||
senderEmail,
|
||||
undefined, // No additional data
|
||||
true, // Subscribe by default for inbound email senders
|
||||
);
|
||||
}
|
||||
|
||||
// Create an Email record for tracking (no actual email content since it's inbound)
|
||||
const inboundEmail = await prisma.email.create({
|
||||
data: {
|
||||
projectId: domainRecord.projectId,
|
||||
contactId: contact!.id,
|
||||
subject: body.mail?.commonHeaders?.subject || '(No subject)',
|
||||
body: '', // Inbound emails don't have body content in our system
|
||||
from: recipientEmail, // The recipient address that received the email
|
||||
sourceType: EmailSourceType.INBOUND,
|
||||
status: EmailStatus.RECEIVED, // Inbound emails use RECEIVED status
|
||||
deliveredAt: new Date(body.mail?.timestamp || new Date()),
|
||||
},
|
||||
});
|
||||
|
||||
// Increment usage counter in cache
|
||||
await BillingLimitService.incrementUsage(domainRecord.projectId, EmailSourceType.INBOUND);
|
||||
|
||||
// Record Stripe metering if project has customer
|
||||
if (domainRecord.project.customer) {
|
||||
await MeterService.recordEmailSent(
|
||||
domainRecord.project.customer,
|
||||
1, // Inbound emails count as 1 credit
|
||||
`email_${inboundEmail.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare event data with all inbound email details
|
||||
const eventData = {
|
||||
messageId: body.mail?.messageId,
|
||||
from: senderEmail,
|
||||
fromHeader: senderFromHeader,
|
||||
to: recipientEmail,
|
||||
subject: body.mail?.commonHeaders?.subject,
|
||||
timestamp: body.mail?.timestamp,
|
||||
recipients: body.receipt?.recipients,
|
||||
hasContent: !!body.content,
|
||||
// Security verdicts
|
||||
spamVerdict: body.receipt?.spamVerdict?.status,
|
||||
virusVerdict: body.receipt?.virusVerdict?.status,
|
||||
spfVerdict: body.receipt?.spfVerdict?.status,
|
||||
dkimVerdict: body.receipt?.dkimVerdict?.status,
|
||||
dmarcVerdict: body.receipt?.dmarcVerdict?.status,
|
||||
// Processing metadata
|
||||
processingTimeMillis: body.receipt?.processingTimeMillis,
|
||||
};
|
||||
|
||||
// Create the email.received event (this will trigger workflows)
|
||||
await EventService.trackEvent(
|
||||
domainRecord.projectId,
|
||||
'email.received',
|
||||
contact?.id,
|
||||
inboundEmail.id, // Link the event to the inbound email record
|
||||
eventData,
|
||||
);
|
||||
|
||||
signale.success(
|
||||
`[WEBHOOK] Created email.received event for ${senderEmail} → ${recipientEmail} (project: ${domainRecord.project.name})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json({success: true, message: 'Inbound email processed'});
|
||||
} catch (inboundError) {
|
||||
signale.error('[WEBHOOK] Error processing inbound email:', inboundError);
|
||||
// Return 200 to acknowledge receipt even if processing failed
|
||||
return res.status(200).json({success: true, message: 'Error processing inbound email'});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle outbound email event notifications (existing logic)
|
||||
const eventType = body.eventType as 'Bounce' | 'Delivery' | 'Open' | 'Complaint' | 'Click';
|
||||
const messageId = body.mail?.messageId;
|
||||
|
||||
if (!messageId) {
|
||||
signale.warn('[WEBHOOK] No messageId found in SNS notification');
|
||||
return res.status(400).json({success: false, error: 'No messageId found'});
|
||||
}
|
||||
|
||||
// Look up email by SES messageId
|
||||
const email = await prisma.email.findUnique({
|
||||
where: {messageId},
|
||||
include: {
|
||||
contact: true,
|
||||
project: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!email) {
|
||||
signale.warn(`[WEBHOOK] Email not found for messageId: ${messageId}`);
|
||||
return res.status(404).json({success: false, error: 'Email not found'});
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const updateData: Prisma.EmailUpdateInput = {};
|
||||
const eventName = `email.${eventType.toLowerCase()}`;
|
||||
|
||||
// Base event data with email metadata
|
||||
const baseEventData = {
|
||||
subject: email.subject,
|
||||
from: email.from,
|
||||
fromName: email.fromName,
|
||||
messageId: email.messageId,
|
||||
templateId: email.templateId,
|
||||
campaignId: email.campaignId,
|
||||
sourceType: email.sourceType,
|
||||
};
|
||||
let eventData: Record<string, unknown> = baseEventData;
|
||||
|
||||
// Process event based on type
|
||||
switch (eventType) {
|
||||
case 'Delivery':
|
||||
signale.success(`[WEBHOOK] Delivery confirmed for ${email.contact.email} from ${email.project.name}`);
|
||||
updateData.status = EmailStatus.DELIVERED;
|
||||
updateData.deliveredAt = now;
|
||||
eventData = {
|
||||
...baseEventData,
|
||||
deliveredAt: now.toISOString(),
|
||||
};
|
||||
break;
|
||||
|
||||
case 'Open':
|
||||
signale.success(`[WEBHOOK] Open received for ${email.contact.email} from ${email.project.name}`);
|
||||
// Only set openedAt on first open
|
||||
if (!email.openedAt) {
|
||||
updateData.openedAt = now;
|
||||
}
|
||||
updateData.opens = (email.opens || 0) + 1;
|
||||
updateData.status = EmailStatus.OPENED;
|
||||
eventData = {
|
||||
...baseEventData,
|
||||
openedAt: email.openedAt?.toISOString() || now.toISOString(),
|
||||
opens: (email.opens || 0) + 1,
|
||||
isFirstOpen: !email.openedAt,
|
||||
};
|
||||
break;
|
||||
|
||||
case 'Click': {
|
||||
signale.success(`[WEBHOOK] Click received for ${email.contact.email} from ${email.project.name}`);
|
||||
const clickedLink = body.click?.link;
|
||||
// Only set clickedAt on first click
|
||||
if (!email.clickedAt) {
|
||||
updateData.clickedAt = now;
|
||||
}
|
||||
updateData.clicks = (email.clicks || 0) + 1;
|
||||
updateData.status = EmailStatus.CLICKED;
|
||||
eventData = {
|
||||
...baseEventData,
|
||||
link: clickedLink,
|
||||
clickedAt: email.clickedAt?.toISOString() || now.toISOString(),
|
||||
clicks: (email.clicks || 0) + 1,
|
||||
isFirstClick: !email.clickedAt,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Bounce': {
|
||||
const bounceType = body.bounce?.bounceType;
|
||||
const isPermanentBounce = bounceType === 'Permanent';
|
||||
const isTransientBounce = bounceType === 'Transient';
|
||||
|
||||
if (isPermanentBounce) {
|
||||
// Hard bounce - counts toward bounce rate and unsubscribes contact
|
||||
signale.warn(`[WEBHOOK] Permanent bounce received for ${email.contact.email} from ${email.project.name}`);
|
||||
updateData.status = EmailStatus.BOUNCED;
|
||||
updateData.bouncedAt = now;
|
||||
// Unsubscribe contact on permanent bounce
|
||||
await prisma.contact.update({
|
||||
where: {id: email.contactId},
|
||||
data: {subscribed: false},
|
||||
});
|
||||
eventData = {
|
||||
...baseEventData,
|
||||
bounceType,
|
||||
bouncedAt: now.toISOString(),
|
||||
};
|
||||
|
||||
// Send notification about permanent bounce
|
||||
await NtfyService.notifyEmailBounce(email.project.name, email.projectId, email.contact.email, bounceType);
|
||||
} else if (isTransientBounce) {
|
||||
// Soft bounce (e.g., out-of-office, mailbox full) - don't count toward bounce rate
|
||||
signale.info(
|
||||
`[WEBHOOK] Transient bounce received for ${email.contact.email} from ${email.project.name} (not counted toward bounce rate)`,
|
||||
);
|
||||
// Don't update email status or unsubscribe contact
|
||||
// Just track the event for visibility
|
||||
eventData = {
|
||||
...baseEventData,
|
||||
bounceType,
|
||||
transientBounce: true,
|
||||
};
|
||||
} else {
|
||||
// Unknown bounce type - treat as permanent to be safe
|
||||
signale.warn(
|
||||
`[WEBHOOK] Unknown bounce type (${bounceType}) received for ${email.contact.email} from ${email.project.name} - treating as permanent`,
|
||||
);
|
||||
updateData.status = EmailStatus.BOUNCED;
|
||||
updateData.bouncedAt = now;
|
||||
await prisma.contact.update({
|
||||
where: {id: email.contactId},
|
||||
data: {subscribed: false},
|
||||
});
|
||||
eventData = {
|
||||
...baseEventData,
|
||||
bounceType,
|
||||
bouncedAt: now.toISOString(),
|
||||
};
|
||||
|
||||
await NtfyService.notifyEmailBounce(email.project.name, email.projectId, email.contact.email, bounceType);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Complaint':
|
||||
signale.warn(`[WEBHOOK] Complaint received for ${email.contact.email} from ${email.project.name}`);
|
||||
updateData.status = EmailStatus.COMPLAINED;
|
||||
updateData.complainedAt = now;
|
||||
// Unsubscribe contact on complaint
|
||||
await prisma.contact.update({
|
||||
where: {id: email.contactId},
|
||||
data: {subscribed: false},
|
||||
});
|
||||
eventData = {
|
||||
...baseEventData,
|
||||
complainedAt: now.toISOString(),
|
||||
};
|
||||
|
||||
// Send notification about complaint
|
||||
await NtfyService.notifyEmailComplaint(email.project.name, email.projectId, email.contact.email);
|
||||
break;
|
||||
|
||||
default:
|
||||
signale.warn(`[WEBHOOK] Unknown event type: ${eventType}`);
|
||||
return res.status(200).json({success: true});
|
||||
}
|
||||
|
||||
// Update email with new status and timestamps
|
||||
await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// Track event (this will trigger workflows)
|
||||
await EventService.trackEvent(email.projectId, eventName, email.contactId, email.id, eventData);
|
||||
|
||||
// Check security limits only for permanent bounces and complaints
|
||||
// Transient bounces (soft bounces) don't count toward bounce rate
|
||||
const isPermanentBounce = eventType === 'Bounce' && body.bounce?.bounceType === 'Permanent';
|
||||
if (isPermanentBounce || eventType === 'Complaint') {
|
||||
await SecurityService.checkAndEnforceSecurityLimits(email.projectId);
|
||||
}
|
||||
|
||||
signale.success(`[WEBHOOK] Processed ${eventType} event for email ${email.id}`);
|
||||
return res.status(200).json({success: true});
|
||||
} catch (error) {
|
||||
signale.error('[WEBHOOK] Error processing SNS webhook:', error);
|
||||
// Always return 200 to prevent SNS from retrying
|
||||
return res.status(200).json({success: true});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive Stripe webhook notifications
|
||||
* Handles subscription and payment events: checkout.session.completed, invoice.paid, etc.
|
||||
*/
|
||||
@Post('incoming/stripe')
|
||||
@CatchAsync
|
||||
public async receiveStripeWebhook(req: Request, res: Response) {
|
||||
// Return 404 if billing is disabled
|
||||
if (!STRIPE_ENABLED || !stripe) {
|
||||
signale.warn('[WEBHOOK] Stripe webhook received but billing is disabled');
|
||||
return res.status(404).json({success: false, error: 'Billing is disabled'});
|
||||
}
|
||||
|
||||
try {
|
||||
const sig = req.headers['stripe-signature'];
|
||||
|
||||
if (!sig) {
|
||||
signale.warn('[WEBHOOK] Missing Stripe signature header');
|
||||
return res.status(400).json({success: false, error: 'Missing signature'});
|
||||
}
|
||||
|
||||
// Verify webhook signature using raw body
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(req.body, sig, STRIPE_WEBHOOK_SECRET);
|
||||
} catch (err) {
|
||||
signale.error('[WEBHOOK] Stripe signature verification failed:', err);
|
||||
return res.status(400).json({success: false, error: 'Invalid signature'});
|
||||
}
|
||||
|
||||
signale.info(`[WEBHOOK] Received Stripe event: ${event.type}`);
|
||||
|
||||
// Handle different event types
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const session = event.data.object;
|
||||
const customerId = session.customer as string;
|
||||
const subscriptionId = session.subscription as string;
|
||||
const projectId = session.client_reference_id; // Assuming project ID is passed as reference
|
||||
|
||||
if (!projectId) {
|
||||
signale.warn('[WEBHOOK] No client_reference_id in checkout session');
|
||||
break;
|
||||
}
|
||||
|
||||
// Update project with customer and subscription IDs
|
||||
const updatedProject = await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {
|
||||
customer: customerId,
|
||||
subscription: subscriptionId,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Stripe customer name to match project name and add credit for onboarding fee
|
||||
await stripe.customers.update(customerId, {
|
||||
name: updatedProject.name,
|
||||
balance: -100,
|
||||
});
|
||||
|
||||
signale.success(`[WEBHOOK] Checkout completed for project ${projectId}`);
|
||||
|
||||
// Send notification about subscription started
|
||||
await NtfyService.notifySubscriptionStarted(updatedProject.name, projectId, subscriptionId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'invoice.paid': {
|
||||
const invoice = event.data.object;
|
||||
const customerId = invoice.customer as string;
|
||||
|
||||
// Find project by customer ID
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {customer: customerId},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
signale.warn(`[WEBHOOK] No project found for customer ${customerId}`);
|
||||
break;
|
||||
}
|
||||
|
||||
signale.success(`[WEBHOOK] Invoice paid for project ${project.name} (${project.id})`);
|
||||
|
||||
// Send notification about invoice payment
|
||||
await NtfyService.notifyInvoicePaid(project.name, project.id);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'invoice.payment_failed': {
|
||||
const invoice = event.data.object;
|
||||
const customerId = invoice.customer as string;
|
||||
|
||||
// Only disable projects that are already consuming (recurring billing).
|
||||
// If billing_reason is 'subscription_create', this is a first-time payment
|
||||
// attempt and the project has never had an active subscription — don't disable.
|
||||
if (invoice.billing_reason === 'subscription_create') {
|
||||
signale.info(
|
||||
`[WEBHOOK] Payment failed on initial subscription attempt for customer ${customerId}, skipping disable`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Find project by customer ID
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {customer: customerId},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
signale.warn(`[WEBHOOK] No project found for customer ${customerId}`);
|
||||
break;
|
||||
}
|
||||
|
||||
signale.warn(`[WEBHOOK] Payment failed for project ${project.name} (${project.id}), disabling project`);
|
||||
|
||||
await prisma.project.update({
|
||||
where: {id: project.id},
|
||||
data: {disabled: true},
|
||||
});
|
||||
|
||||
await NtfyService.notifyProjectDisabledForPayment(project.name, project.id);
|
||||
|
||||
// Send email notification to project members
|
||||
try {
|
||||
const members = await MembershipService.getMembers(project.id);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(ProjectDisabledPaymentEmail, {
|
||||
projectName: project.name,
|
||||
projectId: project.id,
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
await Promise.all(
|
||||
emails.map(email => sendPlatformEmail(email, 'Project Disabled - Payment Failed', template)),
|
||||
);
|
||||
}
|
||||
} catch (emailError) {
|
||||
signale.error(`[WEBHOOK] Failed to send project disabled email:`, emailError);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.deleted': {
|
||||
const subscription = event.data.object;
|
||||
const subscriptionId = subscription.id;
|
||||
|
||||
// Find project by subscription ID
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {subscription: subscriptionId},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
signale.warn(`[WEBHOOK] No project found for subscription ${subscriptionId}`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Clear subscription from project
|
||||
await prisma.project.update({
|
||||
where: {id: project.id},
|
||||
data: {
|
||||
subscription: null,
|
||||
},
|
||||
});
|
||||
|
||||
signale.warn(`[WEBHOOK] Subscription deleted for project ${project.name} (${project.id})`);
|
||||
|
||||
// Send notification about subscription cancellation
|
||||
await NtfyService.notifySubscriptionCancelled(project.name, project.id, subscriptionId);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'customer.subscription.updated': {
|
||||
const subscription = event.data.object;
|
||||
const subscriptionId = subscription.id;
|
||||
|
||||
// Find project by subscription ID
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {subscription: subscriptionId},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
signale.warn(`[WEBHOOK] No project found for subscription ${subscriptionId}`);
|
||||
break;
|
||||
}
|
||||
|
||||
signale.info(`[WEBHOOK] Subscription updated for project ${project.name} (${project.id})`);
|
||||
signale.info(
|
||||
`[WEBHOOK] Status: ${subscription.status}, Cancel at period end: ${subscription.cancel_at_period_end}`,
|
||||
);
|
||||
|
||||
// Send notification about subscription update
|
||||
await NtfyService.notifySubscriptionUpdated(project.name, project.id);
|
||||
break;
|
||||
}
|
||||
|
||||
// Unhandled events
|
||||
default:
|
||||
signale.info(`[WEBHOOK] Unhandled Stripe event type: ${event.type}`);
|
||||
break;
|
||||
}
|
||||
|
||||
return res.status(200).json({success: true, received: true});
|
||||
} catch (error) {
|
||||
signale.error('[WEBHOOK] Error processing Stripe webhook:', error);
|
||||
return res.status(400).json({success: false, error: 'Webhook processing failed'});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||
import {WorkflowExecutionStatus} from '@plunk/db';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import signale from 'signale';
|
||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {WorkflowService} from '../services/WorkflowService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@Controller('workflows')
|
||||
export class Workflows {
|
||||
/**
|
||||
* GET /workflows
|
||||
* List all workflows for the authenticated project
|
||||
*/
|
||||
@Get('')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async list(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
|
||||
const search = req.query.search as string | undefined;
|
||||
|
||||
const result = await WorkflowService.list(auth.projectId!, page, pageSize, search);
|
||||
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /workflows/fields
|
||||
* Get all available fields for workflow conditions (contact fields + event fields)
|
||||
* Query param: eventName - Optional event name to filter event fields
|
||||
* NOTE: This must be defined BEFORE the :id route to avoid conflicts
|
||||
*/
|
||||
@Get('fields')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const eventName = req.query.eventName as string | undefined;
|
||||
|
||||
try {
|
||||
const result = await WorkflowService.getAvailableFields(auth.projectId!, eventName);
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
signale.error('[WORKFLOWS] Failed to get available fields:', error);
|
||||
return res.status(500).json({
|
||||
error: error instanceof Error ? error.message : 'Failed to get available fields',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /workflows/:id
|
||||
* Get a specific workflow with all steps and transitions
|
||||
*/
|
||||
@Get(':id')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async get(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
|
||||
if (!workflowId) {
|
||||
return res.status(400).json({error: 'Workflow ID is required'});
|
||||
}
|
||||
|
||||
const workflow = await WorkflowService.get(auth.projectId!, workflowId);
|
||||
|
||||
return res.status(200).json(workflow);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /workflows
|
||||
* Create a new workflow
|
||||
*/
|
||||
@Post('')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {name, description, eventName, enabled, allowReentry} = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({error: 'Name is required'});
|
||||
}
|
||||
|
||||
if (!eventName) {
|
||||
return res.status(400).json({error: 'Event name is required'});
|
||||
}
|
||||
|
||||
const workflow = await WorkflowService.create(auth.projectId!, {
|
||||
name,
|
||||
description,
|
||||
eventName,
|
||||
enabled,
|
||||
allowReentry,
|
||||
});
|
||||
|
||||
return res.status(201).json(workflow);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /workflows/:id
|
||||
* Update a workflow
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async update(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const {name, description, triggerType, triggerConfig, enabled, allowReentry} = req.body;
|
||||
|
||||
if (!workflowId) {
|
||||
return res.status(400).json({error: 'Workflow ID is required'});
|
||||
}
|
||||
|
||||
const workflow = await WorkflowService.update(auth.projectId!, workflowId, {
|
||||
name,
|
||||
description,
|
||||
triggerType,
|
||||
triggerConfig,
|
||||
enabled,
|
||||
allowReentry,
|
||||
});
|
||||
|
||||
return res.status(200).json(workflow);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /workflows/:id
|
||||
* Delete a workflow
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async delete(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
|
||||
if (!workflowId) {
|
||||
return res.status(400).json({error: 'Workflow ID is required'});
|
||||
}
|
||||
|
||||
await WorkflowService.delete(auth.projectId!, workflowId);
|
||||
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /workflows/:id/steps
|
||||
* Add a step to a workflow
|
||||
*/
|
||||
@Post(':id/steps')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async addStep(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const {type, name, position, config, templateId, autoConnect} = req.body;
|
||||
|
||||
if (!workflowId) {
|
||||
return res.status(400).json({error: 'Workflow ID is required'});
|
||||
}
|
||||
|
||||
if (!type || !name || !position || !config) {
|
||||
return res.status(400).json({error: 'Type, name, position, and config are required'});
|
||||
}
|
||||
|
||||
const step = await WorkflowService.addStep(auth.projectId!, workflowId, {
|
||||
type,
|
||||
name,
|
||||
position,
|
||||
config,
|
||||
templateId,
|
||||
autoConnect,
|
||||
});
|
||||
|
||||
return res.status(201).json(step);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /workflows/:id/steps/:stepId
|
||||
* Update a workflow step
|
||||
*/
|
||||
@Patch(':id/steps/:stepId')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async updateStep(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const stepId = req.params.stepId;
|
||||
const {name, position, config, templateId} = req.body;
|
||||
|
||||
if (!workflowId || !stepId) {
|
||||
return res.status(400).json({error: 'Workflow ID and Step ID are required'});
|
||||
}
|
||||
|
||||
const step = await WorkflowService.updateStep(auth.projectId!, workflowId, stepId, {
|
||||
name,
|
||||
position,
|
||||
config,
|
||||
templateId,
|
||||
});
|
||||
|
||||
return res.status(200).json(step);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /workflows/:id/steps/:stepId
|
||||
* Delete a workflow step
|
||||
*/
|
||||
@Delete(':id/steps/:stepId')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async deleteStep(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const stepId = req.params.stepId;
|
||||
|
||||
if (!workflowId || !stepId) {
|
||||
return res.status(400).json({error: 'Workflow ID and Step ID are required'});
|
||||
}
|
||||
|
||||
await WorkflowService.deleteStep(auth.projectId!, workflowId, stepId);
|
||||
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /workflows/:id/transitions
|
||||
* Create a transition between steps
|
||||
*/
|
||||
@Post(':id/transitions')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async createTransition(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const {fromStepId, toStepId, condition, priority} = req.body;
|
||||
|
||||
if (!workflowId) {
|
||||
return res.status(400).json({error: 'Workflow ID is required'});
|
||||
}
|
||||
|
||||
if (!fromStepId || !toStepId) {
|
||||
return res.status(400).json({error: 'From step ID and to step ID are required'});
|
||||
}
|
||||
|
||||
const transition = await WorkflowService.createTransition(auth.projectId!, workflowId, {
|
||||
fromStepId,
|
||||
toStepId,
|
||||
condition,
|
||||
priority,
|
||||
});
|
||||
|
||||
return res.status(201).json(transition);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /workflows/:id/transitions/:transitionId
|
||||
* Delete a transition
|
||||
*/
|
||||
@Delete(':id/transitions/:transitionId')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async deleteTransition(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const transitionId = req.params.transitionId;
|
||||
|
||||
if (!workflowId || !transitionId) {
|
||||
return res.status(400).json({error: 'Workflow ID and Transition ID are required'});
|
||||
}
|
||||
|
||||
await WorkflowService.deleteTransition(auth.projectId!, workflowId, transitionId);
|
||||
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /workflows/:id/executions
|
||||
* Start a workflow execution for a contact
|
||||
*/
|
||||
@Post(':id/executions')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async startExecution(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const {contactId, context} = req.body;
|
||||
|
||||
if (!workflowId) {
|
||||
return res.status(400).json({error: 'Workflow ID is required'});
|
||||
}
|
||||
|
||||
if (!contactId) {
|
||||
return res.status(400).json({error: 'Contact ID is required'});
|
||||
}
|
||||
|
||||
const execution = await WorkflowService.startExecution(auth.projectId!, workflowId, contactId, context);
|
||||
|
||||
return res.status(201).json(execution);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /workflows/:id/executions
|
||||
* List executions for a workflow
|
||||
*/
|
||||
@Get(':id/executions')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async listExecutions(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
|
||||
const status = req.query.status as WorkflowExecutionStatus | undefined;
|
||||
|
||||
if (!workflowId) {
|
||||
return res.status(400).json({error: 'Workflow ID is required'});
|
||||
}
|
||||
|
||||
const result = await WorkflowService.listExecutions(auth.projectId!, workflowId, page, pageSize, status);
|
||||
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /workflows/:id/executions/:executionId
|
||||
* Get a specific execution with details
|
||||
*/
|
||||
@Get(':id/executions/:executionId')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async getExecution(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const executionId = req.params.executionId;
|
||||
|
||||
if (!workflowId || !executionId) {
|
||||
return res.status(400).json({error: 'Workflow ID and Execution ID are required'});
|
||||
}
|
||||
|
||||
const execution = await WorkflowService.getExecution(auth.projectId!, workflowId, executionId);
|
||||
|
||||
return res.status(200).json(execution);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /workflows/:id/executions/:executionId
|
||||
* Cancel a workflow execution
|
||||
*/
|
||||
@Delete(':id/executions/:executionId')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async cancelExecution(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const executionId = req.params.executionId;
|
||||
|
||||
if (!workflowId || !executionId) {
|
||||
return res.status(400).json({error: 'Workflow ID and Execution ID are required'});
|
||||
}
|
||||
|
||||
const execution = await WorkflowService.cancelExecution(auth.projectId!, workflowId, executionId);
|
||||
|
||||
return res.status(200).json(execution);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /workflows/:id/executions/cancel-all
|
||||
* Cancel all active executions for a workflow
|
||||
*/
|
||||
@Post(':id/executions/cancel-all')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async cancelAllExecutions(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
|
||||
if (!workflowId) {
|
||||
return res.status(400).json({error: 'Workflow ID is required'});
|
||||
}
|
||||
|
||||
const result = await WorkflowService.cancelAllExecutions(auth.projectId!, workflowId);
|
||||
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
import {factories} from '../../../../../test/helpers';
|
||||
import {EventService} from '../../services/EventService';
|
||||
import {WorkflowService} from '../../services/WorkflowService';
|
||||
|
||||
describe('Workflows Controller', () => {
|
||||
let projectId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
projectId = project.id;
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// GET /workflows/fields
|
||||
// ========================================
|
||||
describe('GET /workflows/fields', () => {
|
||||
it('should return contact and event fields', async () => {
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
data: {
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
plan: 'premium',
|
||||
},
|
||||
});
|
||||
|
||||
// Create email events with data
|
||||
await EventService.trackEvent(projectId, 'email.opened', contact.id, undefined, {
|
||||
subject: 'Welcome',
|
||||
from: 'hello@example.com',
|
||||
openedAt: new Date().toISOString(),
|
||||
isFirstOpen: true,
|
||||
});
|
||||
|
||||
const responseBody = await WorkflowService.getAvailableFields(projectId);
|
||||
|
||||
expect(responseBody.fields).toBeInstanceOf(Array);
|
||||
expect(responseBody.count).toBeGreaterThan(0);
|
||||
|
||||
// Should include standard contact fields
|
||||
expect(responseBody.fields).toContain('contact.email');
|
||||
expect(responseBody.fields).toContain('contact.subscribed');
|
||||
|
||||
// Should include custom contact data fields
|
||||
expect(responseBody.fields).toContain('contact.data.firstName');
|
||||
expect(responseBody.fields).toContain('contact.data.lastName');
|
||||
expect(responseBody.fields).toContain('contact.data.plan');
|
||||
|
||||
// Should include event fields
|
||||
expect(responseBody.fields).toContain('event.subject');
|
||||
expect(responseBody.fields).toContain('event.from');
|
||||
expect(responseBody.fields).toContain('event.openedAt');
|
||||
expect(responseBody.fields).toContain('event.isFirstOpen');
|
||||
});
|
||||
|
||||
it('should filter event fields by eventName query param', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
|
||||
// Create email.opened events
|
||||
await EventService.trackEvent(projectId, 'email.opened', contact.id, undefined, {
|
||||
subject: 'Test',
|
||||
openedAt: new Date().toISOString(),
|
||||
isFirstOpen: true,
|
||||
});
|
||||
|
||||
// Create email.clicked events
|
||||
await EventService.trackEvent(projectId, 'email.clicked', contact.id, undefined, {
|
||||
subject: 'Test',
|
||||
link: 'https://example.com',
|
||||
clickedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.opened');
|
||||
|
||||
// Should include email.opened fields
|
||||
expect(responseBody.fields).toContain('event.subject');
|
||||
expect(responseBody.fields).toContain('event.openedAt');
|
||||
expect(responseBody.fields).toContain('event.isFirstOpen');
|
||||
|
||||
// Should NOT include email.clicked fields
|
||||
expect(responseBody.fields).not.toContain('event.link');
|
||||
expect(responseBody.fields).not.toContain('event.clickedAt');
|
||||
});
|
||||
|
||||
it('should return fields in sorted order', async () => {
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
data: {
|
||||
zebra: 'last',
|
||||
apple: 'first',
|
||||
},
|
||||
});
|
||||
|
||||
await EventService.trackEvent(projectId, 'test.event', contact.id, undefined, {
|
||||
zoo: 'value',
|
||||
aardvark: 'value',
|
||||
});
|
||||
|
||||
const responseBody = await WorkflowService.getAvailableFields(projectId);
|
||||
|
||||
const fields = responseBody.fields as string[];
|
||||
|
||||
// Verify fields are sorted
|
||||
const sortedFields = [...fields].sort();
|
||||
expect(fields).toEqual(sortedFields);
|
||||
});
|
||||
|
||||
it('should return empty event fields when no events exist', async () => {
|
||||
const responseBody = await WorkflowService.getAvailableFields(projectId);
|
||||
|
||||
// Should still have contact fields
|
||||
expect(responseBody.fields).toContain('contact.email');
|
||||
expect(responseBody.fields).toContain('contact.subscribed');
|
||||
|
||||
// But no event fields
|
||||
const eventFields = responseBody.fields.filter((f: string) => f.startsWith('event.'));
|
||||
expect(eventFields).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should only return fields for authenticated project', async () => {
|
||||
// Create another project with events
|
||||
const {project: otherProject} = await factories.createUserWithProject();
|
||||
const otherContact = await factories.createContact({projectId: otherProject.id});
|
||||
|
||||
await EventService.trackEvent(otherProject.id, 'other.event', otherContact.id, undefined, {
|
||||
secretField: 'secret value',
|
||||
});
|
||||
|
||||
const responseBody = await WorkflowService.getAvailableFields(projectId);
|
||||
|
||||
// Should not include fields from other project
|
||||
expect(responseBody.fields).not.toContain('event.secretField');
|
||||
});
|
||||
|
||||
it('should handle URL encoded event names', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
|
||||
await EventService.trackEvent(projectId, 'email.opened', contact.id, undefined, {
|
||||
field1: 'value',
|
||||
});
|
||||
|
||||
const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.opened');
|
||||
|
||||
expect(responseBody.fields).toContain('event.field1');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Integration Tests
|
||||
// ========================================
|
||||
describe('Workflow field discovery integration', () => {
|
||||
it('should discover correct fields for email.sent events', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
const email = await factories.createEmail(projectId, contact.id);
|
||||
|
||||
// Create email.sent event with actual structure
|
||||
await EventService.trackEvent(projectId, 'email.sent', contact.id, email.id, {
|
||||
subject: 'Welcome Email',
|
||||
from: 'hello@example.com',
|
||||
fromName: 'Example Team',
|
||||
messageId: 'msg-123',
|
||||
templateId: 'tpl-456',
|
||||
campaignId: null,
|
||||
sourceType: 'TRANSACTIONAL',
|
||||
sentAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.sent');
|
||||
|
||||
expect(responseBody.fields).toContain('event.subject');
|
||||
expect(responseBody.fields).toContain('event.from');
|
||||
expect(responseBody.fields).toContain('event.fromName');
|
||||
expect(responseBody.fields).toContain('event.messageId');
|
||||
expect(responseBody.fields).toContain('event.templateId');
|
||||
expect(responseBody.fields).toContain('event.sourceType');
|
||||
expect(responseBody.fields).toContain('event.sentAt');
|
||||
});
|
||||
|
||||
it('should discover correct fields for email.opened events', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
const email = await factories.createEmail(projectId, contact.id);
|
||||
|
||||
await EventService.trackEvent(projectId, 'email.opened', contact.id, email.id, {
|
||||
subject: 'Newsletter',
|
||||
from: 'news@example.com',
|
||||
fromName: 'Newsletter Team',
|
||||
messageId: 'msg-456',
|
||||
openedAt: new Date().toISOString(),
|
||||
opens: 1,
|
||||
isFirstOpen: true,
|
||||
});
|
||||
|
||||
const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.opened');
|
||||
|
||||
expect(responseBody.fields).toContain('event.openedAt');
|
||||
expect(responseBody.fields).toContain('event.opens');
|
||||
expect(responseBody.fields).toContain('event.isFirstOpen');
|
||||
});
|
||||
|
||||
it('should discover correct fields for email.clicked events', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
const email = await factories.createEmail(projectId, contact.id);
|
||||
|
||||
await EventService.trackEvent(projectId, 'email.clicked', contact.id, email.id, {
|
||||
subject: 'Sale Announcement',
|
||||
from: 'sales@example.com',
|
||||
link: 'https://example.com/sale',
|
||||
clickedAt: new Date().toISOString(),
|
||||
clicks: 1,
|
||||
isFirstClick: true,
|
||||
});
|
||||
|
||||
const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.clicked');
|
||||
|
||||
expect(responseBody.fields).toContain('event.link');
|
||||
expect(responseBody.fields).toContain('event.clickedAt');
|
||||
expect(responseBody.fields).toContain('event.clicks');
|
||||
expect(responseBody.fields).toContain('event.isFirstClick');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import {PrismaClient} from '@prisma/client';
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
@@ -1,19 +1,12 @@
|
||||
import Redis from 'ioredis';
|
||||
import {REDIS_URL} from '../app/constants';
|
||||
import signale from "signale";
|
||||
import {Redis} from 'ioredis';
|
||||
|
||||
let redis: Redis;
|
||||
try {
|
||||
redis = new Redis(REDIS_URL);
|
||||
const infoString = redis.info();
|
||||
signale.info('Redis initialized: ', infoString);
|
||||
} catch (error) {
|
||||
signale.error('Failed to initialize Redis: ', error);
|
||||
}
|
||||
export {redis};
|
||||
import {REDIS_URL} from '../app/constants.js';
|
||||
|
||||
export const redis = new Redis(REDIS_URL + '?family=0');
|
||||
|
||||
export const REDIS_ONE_MINUTE = 60;
|
||||
export const REDIS_DEFAULT_EXPIRY = REDIS_ONE_MINUTE / 60;
|
||||
export const REDIS_DEFAULT_EXPIRY = REDIS_ONE_MINUTE;
|
||||
export const TEN_MINUTES_IN_SECONDS = 60 * 10;
|
||||
|
||||
/**
|
||||
* @param key The key for redis (use Keys#<type>)
|
||||
@@ -34,3 +27,15 @@ export async function wrapRedis<T>(key: string, fn: () => Promise<T>, seconds =
|
||||
|
||||
return recent;
|
||||
}
|
||||
|
||||
export const cache = {
|
||||
set<T>(key: string, value: T, seconds = REDIS_DEFAULT_EXPIRY): Promise<'OK' | null> {
|
||||
return redis.set(key, JSON.stringify(value), 'EX', seconds);
|
||||
},
|
||||
incr(key: string): Promise<number> {
|
||||
return redis.incr(key);
|
||||
},
|
||||
decr(key: string): Promise<number> {
|
||||
return redis.decr(key);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Machine-readable error codes for programmatic error handling
|
||||
* These codes allow API consumers to handle specific error types reliably
|
||||
*/
|
||||
export enum ErrorCode {
|
||||
// Authentication & Authorization (401-403)
|
||||
UNAUTHORIZED = 'UNAUTHORIZED',
|
||||
INVALID_CREDENTIALS = 'INVALID_CREDENTIALS',
|
||||
MISSING_AUTH = 'MISSING_AUTH',
|
||||
INVALID_API_KEY = 'INVALID_API_KEY',
|
||||
FORBIDDEN = 'FORBIDDEN',
|
||||
PROJECT_ACCESS_DENIED = 'PROJECT_ACCESS_DENIED',
|
||||
PROJECT_DISABLED = 'PROJECT_DISABLED',
|
||||
EMAIL_VERIFICATION_REQUIRED = 'EMAIL_VERIFICATION_REQUIRED',
|
||||
|
||||
// Resource Errors (404-409)
|
||||
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
|
||||
CONTACT_NOT_FOUND = 'CONTACT_NOT_FOUND',
|
||||
TEMPLATE_NOT_FOUND = 'TEMPLATE_NOT_FOUND',
|
||||
CAMPAIGN_NOT_FOUND = 'CAMPAIGN_NOT_FOUND',
|
||||
WORKFLOW_NOT_FOUND = 'WORKFLOW_NOT_FOUND',
|
||||
CONFLICT = 'CONFLICT',
|
||||
|
||||
// Validation & Input Errors (400, 422)
|
||||
BAD_REQUEST = 'BAD_REQUEST',
|
||||
VALIDATION_ERROR = 'VALIDATION_ERROR',
|
||||
INVALID_EMAIL = 'INVALID_EMAIL',
|
||||
INVALID_REQUEST_BODY = 'INVALID_REQUEST_BODY',
|
||||
MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD',
|
||||
|
||||
// Rate Limiting & Billing (429, 402)
|
||||
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
|
||||
BILLING_LIMIT_EXCEEDED = 'BILLING_LIMIT_EXCEEDED',
|
||||
UPGRADE_REQUIRED = 'UPGRADE_REQUIRED',
|
||||
|
||||
// Server Errors (500+)
|
||||
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
|
||||
DATABASE_ERROR = 'DATABASE_ERROR',
|
||||
EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR',
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured field-level validation error
|
||||
*/
|
||||
export interface FieldError {
|
||||
field: string; // Dot-notation path (e.g., "email", "data.firstName")
|
||||
message: string; // Human-readable error message
|
||||
code: string; // Validation error code (e.g., "invalid_email", "too_short")
|
||||
received?: unknown; // The invalid value that was provided
|
||||
}
|
||||
|
||||
/**
|
||||
* Base HTTP exception with support for error codes and structured data
|
||||
*/
|
||||
export class HttpException extends Error {
|
||||
public constructor(
|
||||
public readonly code: number,
|
||||
message: string,
|
||||
public readonly errorCode?: ErrorCode,
|
||||
public readonly details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource not found (404)
|
||||
*/
|
||||
export class NotFound extends HttpException {
|
||||
/**
|
||||
* Construct a new NotFound exception
|
||||
* @param resource The type of resource that was not found
|
||||
* @param id Optional resource identifier to include in the message
|
||||
*/
|
||||
public constructor(resource: string, id?: string) {
|
||||
const message = id ? `${resource} with ID "${id}" was not found` : `That ${resource.toLowerCase()} was not found`;
|
||||
|
||||
// Map common resources to specific error codes
|
||||
const errorCodeMap: Record<string, ErrorCode> = {
|
||||
contact: ErrorCode.CONTACT_NOT_FOUND,
|
||||
template: ErrorCode.TEMPLATE_NOT_FOUND,
|
||||
campaign: ErrorCode.CAMPAIGN_NOT_FOUND,
|
||||
workflow: ErrorCode.WORKFLOW_NOT_FOUND,
|
||||
};
|
||||
|
||||
const errorCode = errorCodeMap[resource.toLowerCase()] || ErrorCode.RESOURCE_NOT_FOUND;
|
||||
|
||||
super(404, message, errorCode, {resource, id});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forbidden - user is not allowed to perform this action (403)
|
||||
*/
|
||||
export class NotAllowed extends HttpException {
|
||||
/**
|
||||
* Construct a new NotAllowed exception
|
||||
* @param msg Custom error message
|
||||
* @param reason Optional reason for the forbidden action
|
||||
*/
|
||||
public constructor(msg = 'You are not allowed to perform this action', reason?: string) {
|
||||
super(403, msg, ErrorCode.FORBIDDEN, reason ? {reason} : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthorized - user needs to authenticate (401)
|
||||
*/
|
||||
export class NotAuthenticated extends HttpException {
|
||||
public constructor(message = 'You need to be authenticated to do this', errorCode = ErrorCode.UNAUTHORIZED) {
|
||||
super(401, message, errorCode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment required - user needs to upgrade plan (402)
|
||||
*/
|
||||
export class NeedToUpgrade extends HttpException {
|
||||
public constructor(msg = 'You need to upgrade your plan to do this') {
|
||||
super(402, msg, ErrorCode.UPGRADE_REQUIRED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation error with field-level details (422)
|
||||
*/
|
||||
export class ValidationError extends HttpException {
|
||||
/**
|
||||
* Construct a new ValidationError exception
|
||||
* @param errors Array of field-level validation errors
|
||||
* @param message Optional override for the main error message
|
||||
*/
|
||||
public constructor(
|
||||
public readonly errors: FieldError[],
|
||||
message = 'Validation failed',
|
||||
) {
|
||||
super(422, message, ErrorCode.VALIDATION_ERROR, {errors});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Conflict - resource already exists or state conflict (409)
|
||||
*/
|
||||
export class ConflictError extends HttpException {
|
||||
public constructor(message: string, details?: Record<string, unknown>) {
|
||||
super(409, message, ErrorCode.CONFLICT, details);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit exceeded (429)
|
||||
*/
|
||||
export class RateLimitError extends HttpException {
|
||||
public constructor(message = 'Rate limit exceeded. Please try again later.', retryAfter?: number) {
|
||||
super(429, message, ErrorCode.RATE_LIMIT_EXCEEDED, retryAfter ? {retryAfter} : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bad request - generic client error (400)
|
||||
*/
|
||||
export class BadRequest extends HttpException {
|
||||
public constructor(message: string, errorCode = ErrorCode.BAD_REQUEST, details?: Record<string, unknown>) {
|
||||
super(400, message, errorCode, details);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
import {createServiceMocks, factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
// Mock MeterService
|
||||
vi.mock('../../services/MeterService.js', () => ({
|
||||
MeterService: {
|
||||
recordEmailSent: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Email Processor', () => {
|
||||
let projectId: string;
|
||||
const prisma = getPrismaClient();
|
||||
const _serviceMocks = createServiceMocks();
|
||||
|
||||
beforeEach(async () => {
|
||||
const {project} = await factories.createUserWithProject({}, {tracking: TrackingMode.ENABLED});
|
||||
projectId = project.id;
|
||||
});
|
||||
|
||||
describe('Email Processing', () => {
|
||||
it('should process a pending email', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
const email = await factories.createEmail(projectId, contact.id, {
|
||||
subject: 'Test Email',
|
||||
body: '<p>Hello {{firstName}}</p>',
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
|
||||
|
||||
// Simulate processing
|
||||
await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {status: EmailStatus.SENDING},
|
||||
});
|
||||
|
||||
// Simulate successful send
|
||||
await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {
|
||||
status: EmailStatus.SENT,
|
||||
sentAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const processed = await prisma.email.findUnique({where: {id: email.id}});
|
||||
expect(processed?.status).toBe(EmailStatus.SENT);
|
||||
expect(processed?.sentAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should skip emails that are not pending', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
const email = await factories.createEmail(projectId, contact.id, {
|
||||
status: EmailStatus.SENT, // Already sent
|
||||
});
|
||||
|
||||
// Processor should skip this email
|
||||
const shouldProcess = email.status === EmailStatus.PENDING;
|
||||
expect(shouldProcess).toBe(false);
|
||||
});
|
||||
|
||||
it('should fail email if project is disabled', async () => {
|
||||
// Create project with disabled flag
|
||||
const {project: disabledProject} = await factories.createUserWithProject({}, {disabled: true});
|
||||
|
||||
const contact = await factories.createContact({projectId: disabledProject.id});
|
||||
const email = await factories.createEmail(disabledProject.id, contact.id, {
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
|
||||
// Verify project is disabled
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: disabledProject.id},
|
||||
});
|
||||
expect(project?.disabled).toBe(true);
|
||||
|
||||
// Processor should fail this email
|
||||
await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {
|
||||
status: EmailStatus.FAILED,
|
||||
error: 'Project is disabled',
|
||||
},
|
||||
});
|
||||
|
||||
const failed = await prisma.email.findUnique({where: {id: email.id}});
|
||||
expect(failed?.status).toBe(EmailStatus.FAILED);
|
||||
expect(failed?.error).toBe('Project is disabled');
|
||||
});
|
||||
|
||||
it('should handle campaign emails', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
const campaign = await factories.createCampaign({projectId});
|
||||
const email = await factories.createEmail(projectId, contact.id, {
|
||||
campaignId: campaign.id,
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
|
||||
expect(email.campaignId).toBe(campaign.id);
|
||||
|
||||
// Process the email
|
||||
await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {status: EmailStatus.SENT, sentAt: new Date()},
|
||||
});
|
||||
|
||||
const sent = await prisma.email.findUnique({where: {id: email.id}});
|
||||
expect(sent?.status).toBe(EmailStatus.SENT);
|
||||
});
|
||||
|
||||
it('should handle transactional emails without unsubscribe', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
const template = await factories.createTemplate({
|
||||
projectId,
|
||||
type: 'TRANSACTIONAL',
|
||||
});
|
||||
|
||||
await factories.createEmail(projectId, contact.id, {
|
||||
templateId: template.id,
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
|
||||
expect(template.type).toBe('TRANSACTIONAL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Email Status Transitions', () => {
|
||||
it('should transition PENDING -> SENDING -> SENT', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
const email = await factories.createEmail(projectId, contact.id, {
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
|
||||
expect(email.status).toBe(EmailStatus.PENDING);
|
||||
|
||||
// Transition to SENDING
|
||||
await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {status: EmailStatus.SENDING},
|
||||
});
|
||||
|
||||
let updated = await prisma.email.findUnique({where: {id: email.id}});
|
||||
expect(updated?.status).toBe(EmailStatus.SENDING);
|
||||
|
||||
// Transition to SENT
|
||||
await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {status: EmailStatus.SENT, sentAt: new Date()},
|
||||
});
|
||||
|
||||
updated = await prisma.email.findUnique({where: {id: email.id}});
|
||||
expect(updated?.status).toBe(EmailStatus.SENT);
|
||||
expect(updated?.sentAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle PENDING -> SENDING -> FAILED', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
const email = await factories.createEmail(projectId, contact.id, {
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
|
||||
// Transition to SENDING
|
||||
await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {status: EmailStatus.SENDING},
|
||||
});
|
||||
|
||||
// Fail with error
|
||||
await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {
|
||||
status: EmailStatus.FAILED,
|
||||
error: 'SES send failed: Invalid email address',
|
||||
},
|
||||
});
|
||||
|
||||
const failed = await prisma.email.findUnique({where: {id: email.id}});
|
||||
expect(failed?.status).toBe(EmailStatus.FAILED);
|
||||
expect(failed?.error).toContain('SES send failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Batch Processing', () => {
|
||||
it('should handle multiple emails from a campaign', async () => {
|
||||
const campaign = await factories.createCampaign({projectId});
|
||||
const contacts = await factories.createContacts(projectId, 10);
|
||||
|
||||
// Create emails for all contacts
|
||||
const emails = await Promise.all(
|
||||
contacts.map(contact =>
|
||||
factories.createEmail(projectId, contact.id, {
|
||||
campaignId: campaign.id,
|
||||
status: EmailStatus.PENDING,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(emails).toHaveLength(10);
|
||||
expect(emails.every(e => e.campaignId === campaign.id)).toBe(true);
|
||||
|
||||
// Simulate processing all emails
|
||||
await Promise.all(
|
||||
emails.map(email =>
|
||||
prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {status: EmailStatus.SENT, sentAt: new Date()},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const processed = await prisma.email.findMany({
|
||||
where: {campaignId: campaign.id},
|
||||
});
|
||||
|
||||
expect(processed.every(e => e.status === EmailStatus.SENT)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should record error message on failure', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
const email = await factories.createEmail(projectId, contact.id, {
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
|
||||
// Simulate failure
|
||||
const errorMessage = 'Failed to send: Rate limit exceeded';
|
||||
await prisma.email.update({
|
||||
where: {id: email.id},
|
||||
data: {
|
||||
status: EmailStatus.FAILED,
|
||||
error: errorMessage,
|
||||
},
|
||||
});
|
||||
|
||||
const failed = await prisma.email.findUnique({where: {id: email.id}});
|
||||
expect(failed?.status).toBe(EmailStatus.FAILED);
|
||||
expect(failed?.error).toBe(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Attachment Billing', () => {
|
||||
it('should verify emails with attachments have attachment data', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
|
||||
// Create email with attachments
|
||||
const emailWithAttachments = await prisma.email.create({
|
||||
data: {
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
subject: 'Email with attachments',
|
||||
body: '<p>Test email with attachments</p>',
|
||||
from: 'test@example.com',
|
||||
status: EmailStatus.PENDING,
|
||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||
attachments: toPrismaJson([
|
||||
{
|
||||
filename: 'document.pdf',
|
||||
content: 'base64encodedcontent',
|
||||
contentType: 'application/pdf',
|
||||
},
|
||||
]),
|
||||
},
|
||||
});
|
||||
|
||||
// Create email without attachments
|
||||
const emailWithoutAttachments = await factories.createEmail(projectId, contact.id, {
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
|
||||
// Verify attachments are stored correctly
|
||||
const emailWithAttachmentsData = await prisma.email.findUnique({
|
||||
where: {id: emailWithAttachments.id},
|
||||
});
|
||||
const emailWithoutAttachmentsData = await prisma.email.findUnique({
|
||||
where: {id: emailWithoutAttachments.id},
|
||||
});
|
||||
|
||||
expect(emailWithAttachmentsData?.attachments).toBeDefined();
|
||||
expect(Array.isArray(emailWithAttachmentsData?.attachments)).toBe(true);
|
||||
expect(
|
||||
Array.isArray(emailWithAttachmentsData?.attachments) ? emailWithAttachmentsData.attachments.length : 0,
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
expect(emailWithoutAttachmentsData?.attachments).toBeNull();
|
||||
});
|
||||
|
||||
it('should verify attachment logic determines charging correctly', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
|
||||
// Create email with attachments
|
||||
const emailWithAttachments = await prisma.email.create({
|
||||
data: {
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
subject: 'Email with attachments',
|
||||
body: '<p>Test</p>',
|
||||
from: 'test@example.com',
|
||||
status: EmailStatus.PENDING,
|
||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||
attachments: toPrismaJson([{filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'}]),
|
||||
},
|
||||
include: {
|
||||
project: true,
|
||||
contact: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Simulate the logic from email-processor.ts
|
||||
const hasAttachments =
|
||||
emailWithAttachments.attachments &&
|
||||
Array.isArray(emailWithAttachments.attachments) &&
|
||||
emailWithAttachments.attachments.length > 0;
|
||||
const emailCount = hasAttachments ? 2 : 1;
|
||||
|
||||
// Verify logic correctly identifies attachments
|
||||
expect(hasAttachments).toBe(true);
|
||||
expect(emailCount).toBe(2);
|
||||
|
||||
// Test without attachments
|
||||
const emailWithoutAttachments = await factories.createEmail(projectId, contact.id, {
|
||||
status: EmailStatus.PENDING,
|
||||
});
|
||||
const emailWithoutAttachmentsData = await prisma.email.findUnique({
|
||||
where: {id: emailWithoutAttachments.id},
|
||||
});
|
||||
|
||||
const hasNoAttachments =
|
||||
emailWithoutAttachmentsData?.attachments &&
|
||||
Array.isArray(emailWithoutAttachmentsData.attachments) &&
|
||||
emailWithoutAttachmentsData.attachments.length > 0;
|
||||
const emailCountNoAttachments = hasNoAttachments ? 2 : 1;
|
||||
|
||||
expect(hasNoAttachments).toBeFalsy();
|
||||
expect(emailCountNoAttachments).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
import {ContactService} from '../../services/ContactService.js';
|
||||
|
||||
/**
|
||||
* Tests for Contact Import Processor - Subscription Status Preservation
|
||||
* Verifies that CSV imports preserve subscription status correctly
|
||||
*/
|
||||
describe('Contact Import - Subscription Status Preservation', () => {
|
||||
let projectId: string;
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
beforeEach(async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
projectId = project.id;
|
||||
});
|
||||
|
||||
describe('Existing contacts', () => {
|
||||
it('should NOT change subscription status when CSV has no subscribed column for subscribed contact', async () => {
|
||||
// Create a subscribed contact
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
email: 'existing-subscribed@example.com',
|
||||
});
|
||||
|
||||
// Verify initial state
|
||||
expect(contact.subscribed).toBe(true);
|
||||
|
||||
// Simulate import without subscribed column (undefined)
|
||||
await ContactService.upsert(projectId, contact.email, {firstName: 'John'}, undefined);
|
||||
|
||||
// Verify subscription status unchanged
|
||||
const updated = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
expect(updated?.subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should NOT re-subscribe unsubscribed contact when CSV has no subscribed column', async () => {
|
||||
// Create an unsubscribed contact
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: false,
|
||||
email: 'existing-unsubscribed@example.com',
|
||||
});
|
||||
|
||||
// Verify initial state
|
||||
expect(contact.subscribed).toBe(false);
|
||||
|
||||
// Simulate import without subscribed column (undefined)
|
||||
await ContactService.upsert(projectId, contact.email, {firstName: 'Jane'}, undefined);
|
||||
|
||||
// Verify subscription status unchanged (should NOT be re-subscribed)
|
||||
const updated = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
expect(updated?.subscribed).toBe(false);
|
||||
});
|
||||
|
||||
it('should update subscription status when CSV explicitly has subscribed=true', async () => {
|
||||
// Create an unsubscribed contact
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: false,
|
||||
email: 'import-resubscribe@example.com',
|
||||
});
|
||||
|
||||
// Verify initial state
|
||||
expect(contact.subscribed).toBe(false);
|
||||
|
||||
// Simulate import with explicit subscribed=true
|
||||
await ContactService.upsert(projectId, contact.email, {firstName: 'John'}, true);
|
||||
|
||||
// Verify subscription status changed
|
||||
const updated = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
expect(updated?.subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should update subscription status when CSV explicitly has subscribed=false', async () => {
|
||||
// Create a subscribed contact
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
email: 'import-unsubscribe@example.com',
|
||||
});
|
||||
|
||||
// Verify initial state
|
||||
expect(contact.subscribed).toBe(true);
|
||||
|
||||
// Simulate import with explicit subscribed=false
|
||||
await ContactService.upsert(projectId, contact.email, {firstName: 'Jane'}, false);
|
||||
|
||||
// Verify subscription status changed
|
||||
const updated = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
expect(updated?.subscribed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('New contacts', () => {
|
||||
it('should create new contact as subscribed when CSV has no subscribed column', async () => {
|
||||
const newEmail = 'new-import-default@example.com';
|
||||
|
||||
// Simulate import without subscribed column (undefined)
|
||||
const contact = await ContactService.upsert(projectId, newEmail, {firstName: 'New'}, undefined);
|
||||
|
||||
// New contacts should default to subscribed=true
|
||||
expect(contact.subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should create new contact as subscribed when CSV explicitly has subscribed=true', async () => {
|
||||
const newEmail = 'new-import-explicit-sub@example.com';
|
||||
|
||||
// Simulate import with explicit subscribed=true
|
||||
const contact = await ContactService.upsert(projectId, newEmail, {firstName: 'New'}, true);
|
||||
|
||||
expect(contact.subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should create new contact as unsubscribed when CSV explicitly has subscribed=false', async () => {
|
||||
const newEmail = 'new-import-explicit-unsub@example.com';
|
||||
|
||||
// Simulate import with explicit subscribed=false
|
||||
const contact = await ContactService.upsert(projectId, newEmail, {firstName: 'New'}, false);
|
||||
|
||||
expect(contact.subscribed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSV parsing logic', () => {
|
||||
it('should parse "true" string as boolean true', () => {
|
||||
const subscribedValue = 'true';
|
||||
const lowerValue = subscribedValue.toLowerCase().trim();
|
||||
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
|
||||
|
||||
expect(subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse "1" string as boolean true', () => {
|
||||
const subscribedValue = '1';
|
||||
const lowerValue = subscribedValue.toLowerCase().trim();
|
||||
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
|
||||
|
||||
expect(subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse "yes" string as boolean true', () => {
|
||||
const subscribedValue = 'yes';
|
||||
const lowerValue = subscribedValue.toLowerCase().trim();
|
||||
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
|
||||
|
||||
expect(subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse "false" string as boolean false', () => {
|
||||
const subscribedValue = 'false';
|
||||
const lowerValue = subscribedValue.toLowerCase().trim();
|
||||
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
|
||||
|
||||
expect(subscribed).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse "0" string as boolean false', () => {
|
||||
const subscribedValue = '0';
|
||||
const lowerValue = subscribedValue.toLowerCase().trim();
|
||||
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
|
||||
|
||||
expect(subscribed).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse "no" string as boolean false', () => {
|
||||
const subscribedValue = 'no';
|
||||
const lowerValue = subscribedValue.toLowerCase().trim();
|
||||
const subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
|
||||
|
||||
expect(subscribed).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty string as undefined', () => {
|
||||
const subscribedValue = '';
|
||||
let subscribed: boolean | undefined;
|
||||
|
||||
if (subscribedValue !== undefined && subscribedValue !== '') {
|
||||
const lowerValue = subscribedValue.toLowerCase().trim();
|
||||
subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
|
||||
}
|
||||
|
||||
expect(subscribed).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle undefined as undefined', () => {
|
||||
const subscribedValue = undefined;
|
||||
let subscribed: boolean | undefined;
|
||||
|
||||
if (subscribedValue !== undefined && subscribedValue !== '') {
|
||||
const lowerValue = subscribedValue.toLowerCase().trim();
|
||||
subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
|
||||
}
|
||||
|
||||
expect(subscribed).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data preservation', () => {
|
||||
it('should preserve existing contact data while updating subscription', async () => {
|
||||
// Create contact with existing data
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: false,
|
||||
email: 'preserve-data@example.com',
|
||||
data: {
|
||||
firstName: 'Original',
|
||||
lastName: 'Name',
|
||||
plan: 'pro',
|
||||
},
|
||||
});
|
||||
|
||||
// Update only subscription via import
|
||||
await ContactService.upsert(projectId, contact.email, {}, true);
|
||||
|
||||
const updated = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
// Subscription should be updated
|
||||
expect(updated?.subscribed).toBe(true);
|
||||
|
||||
// Original data should be preserved
|
||||
const data = updated?.data as Record<string, unknown>;
|
||||
expect(data?.firstName).toBe('Original');
|
||||
expect(data?.lastName).toBe('Name');
|
||||
expect(data?.plan).toBe('pro');
|
||||
});
|
||||
|
||||
it('should merge new data while preserving subscription', async () => {
|
||||
// Create contact with existing data
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: false,
|
||||
email: 'merge-data@example.com',
|
||||
data: {
|
||||
firstName: 'John',
|
||||
plan: 'pro',
|
||||
},
|
||||
});
|
||||
|
||||
// Import new data without changing subscription
|
||||
await ContactService.upsert(
|
||||
projectId,
|
||||
contact.email,
|
||||
{
|
||||
lastName: 'Doe',
|
||||
company: 'Acme Inc',
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
const updated = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
// Subscription should be unchanged
|
||||
expect(updated?.subscribed).toBe(false);
|
||||
|
||||
// Data should be merged
|
||||
const data = updated?.data as Record<string, unknown>;
|
||||
expect(data?.firstName).toBe('John'); // Preserved
|
||||
expect(data?.plan).toBe('pro'); // Preserved
|
||||
expect(data?.lastName).toBe('Doe'); // New
|
||||
expect(data?.company).toBe('Acme Inc'); // New
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {CampaignStatus} from '@plunk/db';
|
||||
import {createTimeControl, factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
describe('Scheduled Campaign Processor', () => {
|
||||
let projectId: string;
|
||||
const prisma = getPrismaClient();
|
||||
const timeControl = createTimeControl();
|
||||
|
||||
beforeEach(async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
projectId = project.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Ensure time is always restored between tests
|
||||
timeControl.restore();
|
||||
});
|
||||
|
||||
describe('Campaign Scheduling', () => {
|
||||
it('should execute campaign at scheduled time', async () => {
|
||||
// Freeze time at a specific moment
|
||||
timeControl.freeze(new Date('2025-01-20T09:00:00Z'));
|
||||
|
||||
// Schedule campaign for 1 hour from now
|
||||
const scheduledTime = timeControl.helpers.relative(1, 'hour');
|
||||
const campaign = await factories.createScheduledCampaign(projectId, scheduledTime, {
|
||||
name: 'Scheduled Newsletter',
|
||||
subject: 'Weekly Update',
|
||||
});
|
||||
|
||||
expect(campaign.status).toBe(CampaignStatus.SCHEDULED);
|
||||
expect(campaign.scheduledFor).toEqual(scheduledTime);
|
||||
|
||||
// Advance time to scheduled time
|
||||
timeControl.advanceTo(scheduledTime);
|
||||
|
||||
// Verify we're at the right time
|
||||
expect(timeControl.now()).toEqual(scheduledTime);
|
||||
|
||||
// At this point, the scheduled processor would pick up this campaign
|
||||
// and change its status to SENDING
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaign.id},
|
||||
data: {status: CampaignStatus.SENDING},
|
||||
});
|
||||
|
||||
const updatedCampaign = await prisma.campaign.findUnique({
|
||||
where: {id: campaign.id},
|
||||
});
|
||||
|
||||
expect(updatedCampaign?.status).toBe(CampaignStatus.SENDING);
|
||||
|
||||
timeControl.restore();
|
||||
});
|
||||
|
||||
it('should not execute campaign before scheduled time', async () => {
|
||||
timeControl.freeze(new Date('2025-01-20T09:00:00Z'));
|
||||
|
||||
// Schedule campaign for 2 hours from now
|
||||
const scheduledTime = timeControl.helpers.relative(2, 'hour');
|
||||
const campaign = await factories.createScheduledCampaign(projectId, scheduledTime);
|
||||
|
||||
// Advance time by only 1 hour (before scheduled time)
|
||||
timeControl.helpers.advanceHours(1);
|
||||
|
||||
// Campaign should still be scheduled
|
||||
const stillScheduled = await prisma.campaign.findUnique({
|
||||
where: {id: campaign.id},
|
||||
});
|
||||
|
||||
expect(stillScheduled?.status).toBe(CampaignStatus.SCHEDULED);
|
||||
expect(stillScheduled?.scheduledFor).toEqual(scheduledTime);
|
||||
|
||||
timeControl.restore();
|
||||
});
|
||||
|
||||
it('should handle multiple scheduled campaigns at different times', async () => {
|
||||
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
|
||||
|
||||
// Create campaigns scheduled at different times
|
||||
await factories.createScheduledCampaign(
|
||||
projectId,
|
||||
timeControl.helpers.relative(1, 'hour'), // 11:00
|
||||
{name: 'Campaign 1'},
|
||||
);
|
||||
|
||||
await factories.createScheduledCampaign(
|
||||
projectId,
|
||||
timeControl.helpers.relative(2, 'hour'), // 12:00
|
||||
{name: 'Campaign 2'},
|
||||
);
|
||||
|
||||
await factories.createScheduledCampaign(
|
||||
projectId,
|
||||
timeControl.helpers.relative(3, 'hour'), // 13:00
|
||||
{name: 'Campaign 3'},
|
||||
);
|
||||
|
||||
// Advance to 11:00 - first campaign should be processable
|
||||
timeControl.helpers.advanceHours(1);
|
||||
expect(timeControl.now().getUTCHours()).toBe(11);
|
||||
|
||||
// Advance to 12:00 - second campaign should be processable
|
||||
timeControl.helpers.advanceHours(1);
|
||||
expect(timeControl.now().getUTCHours()).toBe(12);
|
||||
|
||||
// Advance to 13:00 - third campaign should be processable
|
||||
timeControl.helpers.advanceHours(1);
|
||||
expect(timeControl.now().getUTCHours()).toBe(13);
|
||||
|
||||
// All campaigns should still be in scheduled state (until processor runs)
|
||||
const campaigns = await prisma.campaign.findMany({
|
||||
where: {projectId},
|
||||
orderBy: {scheduledFor: 'asc'},
|
||||
});
|
||||
|
||||
expect(campaigns).toHaveLength(3);
|
||||
expect(campaigns.every(c => c.status === CampaignStatus.SCHEDULED)).toBe(true);
|
||||
|
||||
timeControl.restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Campaign Status Transitions', () => {
|
||||
it('should transition from SCHEDULED to SENDING', async () => {
|
||||
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
|
||||
const scheduledTime = timeControl.helpers.relative(30, 'minute');
|
||||
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.SCHEDULED,
|
||||
scheduledFor: scheduledTime,
|
||||
});
|
||||
|
||||
// Advance past scheduled time
|
||||
timeControl.helpers.advanceMinutes(31);
|
||||
|
||||
// Processor would transition to SENDING
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaign.id},
|
||||
data: {status: CampaignStatus.SENDING},
|
||||
});
|
||||
|
||||
const sending = await prisma.campaign.findUnique({
|
||||
where: {id: campaign.id},
|
||||
});
|
||||
|
||||
expect(sending?.status).toBe(CampaignStatus.SENDING);
|
||||
|
||||
timeControl.restore();
|
||||
});
|
||||
|
||||
it('should eventually transition from SENDING to SENT', async () => {
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.SENDING,
|
||||
});
|
||||
|
||||
// Create some contacts and emails
|
||||
const contact = await factories.createContact({projectId});
|
||||
await factories.createEmail(projectId, contact.id, {
|
||||
campaignId: campaign.id,
|
||||
});
|
||||
|
||||
// Mark campaign as sent
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaign.id},
|
||||
data: {
|
||||
status: CampaignStatus.SENT,
|
||||
sentAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const sent = await prisma.campaign.findUnique({
|
||||
where: {id: campaign.id},
|
||||
});
|
||||
|
||||
expect(sent?.status).toBe(CampaignStatus.SENT);
|
||||
expect(sent?.sentAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle campaigns scheduled in the past', async () => {
|
||||
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
|
||||
|
||||
// Schedule campaign in the past
|
||||
const pastTime = new Date('2025-01-19T10:00:00Z');
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.SCHEDULED,
|
||||
scheduledFor: pastTime,
|
||||
});
|
||||
|
||||
// Processor should immediately pick this up
|
||||
const shouldRun = campaign.scheduledFor && campaign.scheduledFor <= timeControl.now();
|
||||
expect(shouldRun).toBe(true);
|
||||
|
||||
timeControl.restore();
|
||||
});
|
||||
|
||||
it('should handle campaigns scheduled far in the future', async () => {
|
||||
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
|
||||
|
||||
// Schedule campaign 30 days in the future
|
||||
const futureTime = timeControl.helpers.relative(30, 'day');
|
||||
const campaign = await factories.createScheduledCampaign(projectId, futureTime);
|
||||
|
||||
expect(campaign.status).toBe(CampaignStatus.SCHEDULED);
|
||||
|
||||
// Campaign should not be processable yet
|
||||
const shouldNotRun = campaign.scheduledFor && campaign.scheduledFor > timeControl.now();
|
||||
expect(shouldNotRun).toBe(true);
|
||||
|
||||
// Advance time by 29 days - still not ready
|
||||
timeControl.helpers.advanceDays(29);
|
||||
expect(campaign.scheduledFor! > timeControl.now()).toBe(true);
|
||||
|
||||
// Advance to exactly the scheduled time
|
||||
timeControl.advanceTo(futureTime);
|
||||
expect(timeControl.now()).toEqual(futureTime);
|
||||
|
||||
timeControl.restore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import type {ApiRequestCleanupJobData} from '@plunk/types';
|
||||
import type {Job} from 'bullmq';
|
||||
import {Worker} from 'bullmq';
|
||||
import type {RedisOptions} from 'ioredis';
|
||||
import signale from 'signale';
|
||||
|
||||
import {REDIS_URL} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
|
||||
/**
|
||||
* API Request Cleanup Worker
|
||||
* Deletes old API request logs to prevent unbounded table growth
|
||||
* Runs daily to clean up logs older than 30 days (configurable)
|
||||
*/
|
||||
|
||||
const RETENTION_DAYS = 30; // Keep logs for 30 days
|
||||
const BATCH_SIZE = 10000; // Delete in batches for performance
|
||||
|
||||
/**
|
||||
* Process API request cleanup job
|
||||
*/
|
||||
async function processCleanup(job: Job<ApiRequestCleanupJobData>): Promise<{deleted: number}> {
|
||||
signale.info('[API-REQUEST-CLEANUP] Starting cleanup of old API request logs...');
|
||||
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - RETENTION_DAYS);
|
||||
|
||||
let totalDeleted = 0;
|
||||
let hasMore = true;
|
||||
|
||||
try {
|
||||
// Delete in batches to avoid locking the table for too long
|
||||
while (hasMore) {
|
||||
const result = await prisma.apiRequest.deleteMany({
|
||||
where: {
|
||||
createdAt: {
|
||||
lt: cutoffDate,
|
||||
},
|
||||
},
|
||||
// Note: Prisma doesn't support LIMIT in deleteMany, so we use a different approach
|
||||
});
|
||||
|
||||
totalDeleted += result.count;
|
||||
|
||||
// If we deleted fewer than batch size, we're done
|
||||
hasMore = result.count >= BATCH_SIZE;
|
||||
|
||||
if (hasMore) {
|
||||
signale.info(`[API-REQUEST-CLEANUP] Deleted ${totalDeleted} records so far, continuing...`);
|
||||
// Small delay between batches to reduce database load
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
signale.success(
|
||||
`[API-REQUEST-CLEANUP] Cleanup complete. Deleted ${totalDeleted} records older than ${RETENTION_DAYS} days (before ${cutoffDate.toISOString()})`,
|
||||
);
|
||||
|
||||
// Update job progress
|
||||
await job.updateProgress(100);
|
||||
|
||||
return {deleted: totalDeleted};
|
||||
} catch (error) {
|
||||
signale.error('[API-REQUEST-CLEANUP] Error during cleanup:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the API request cleanup worker
|
||||
*/
|
||||
export function createApiRequestCleanupWorker(): Worker<ApiRequestCleanupJobData> {
|
||||
const redisConnection: RedisOptions = {
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
...parseRedisUrl(REDIS_URL),
|
||||
};
|
||||
|
||||
const worker = new Worker<ApiRequestCleanupJobData>('api-request-cleanup', processCleanup, {
|
||||
connection: redisConnection,
|
||||
concurrency: 1, // Only run one cleanup job at a time
|
||||
});
|
||||
|
||||
worker.on('completed', job => {
|
||||
signale.success(`[API-REQUEST-CLEANUP] Job ${job.id} completed:`, job.returnvalue);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
signale.error(`[API-REQUEST-CLEANUP] Job ${job?.id} failed:`, err);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
signale.error('[API-REQUEST-CLEANUP] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
|
||||
function parseRedisUrl(url: string): {host: string; port: number; password?: string; db?: number} {
|
||||
const urlObj = new URL(url);
|
||||
return {
|
||||
host: urlObj.hostname,
|
||||
port: parseInt(urlObj.port || '6379', 10),
|
||||
password: urlObj.password || undefined,
|
||||
db: parseInt(urlObj.pathname.slice(1) || '0', 10),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Background Job: Bulk Contact Action Processor
|
||||
* Processes bulk subscribe, unsubscribe, and delete operations
|
||||
*/
|
||||
|
||||
import type {BulkContactActionJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {bulkContactQueue} from '../services/QueueService.js';
|
||||
|
||||
const BATCH_SIZE = 100; // Process contacts in batches of 100
|
||||
|
||||
interface BulkActionResult {
|
||||
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
||||
totalRequested: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
errors: {contactId: string; email: string; error: string}[];
|
||||
}
|
||||
|
||||
export function createBulkContactWorker() {
|
||||
const worker = new Worker<BulkContactActionJobData>(
|
||||
bulkContactQueue.name,
|
||||
async (job: Job<BulkContactActionJobData>) => {
|
||||
const {projectId, contactIds, operation} = job.data;
|
||||
|
||||
signale.info(
|
||||
`[BULK-CONTACT-PROCESSOR] Processing ${operation} for ${contactIds.length} contacts in project ${projectId}`,
|
||||
);
|
||||
|
||||
const result: BulkActionResult = {
|
||||
operation,
|
||||
totalRequested: contactIds.length,
|
||||
successCount: 0,
|
||||
failureCount: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
// Process contacts in batches
|
||||
for (let i = 0; i < contactIds.length; i += BATCH_SIZE) {
|
||||
const batchIds = contactIds.slice(i, Math.min(i + BATCH_SIZE, contactIds.length));
|
||||
|
||||
try {
|
||||
let batchResult: {updated?: number; deleted?: number};
|
||||
|
||||
switch (operation) {
|
||||
case 'subscribe':
|
||||
batchResult = await ContactService.bulkSubscribe(projectId, batchIds);
|
||||
result.successCount += batchResult.updated || 0;
|
||||
break;
|
||||
case 'unsubscribe':
|
||||
batchResult = await ContactService.bulkUnsubscribe(projectId, batchIds);
|
||||
result.successCount += batchResult.updated || 0;
|
||||
break;
|
||||
case 'delete':
|
||||
batchResult = await ContactService.bulkDelete(projectId, batchIds);
|
||||
result.successCount += batchResult.deleted || 0;
|
||||
break;
|
||||
}
|
||||
|
||||
// If some contacts in batch weren't processed, track them as failures
|
||||
const processedCount = batchResult.updated || batchResult.deleted || 0;
|
||||
const failedCount = batchIds.length - processedCount;
|
||||
if (failedCount > 0) {
|
||||
result.failureCount += failedCount;
|
||||
// Note: We don't have individual contact details for batch failures
|
||||
}
|
||||
} catch (error) {
|
||||
signale.error(`[BULK-CONTACT-PROCESSOR] Batch failed:`, error);
|
||||
result.failureCount += batchIds.length;
|
||||
result.errors.push({
|
||||
contactId: 'batch',
|
||||
email: '',
|
||||
error: error instanceof Error ? error.message : 'Batch processing failed',
|
||||
});
|
||||
}
|
||||
|
||||
// Update progress
|
||||
const progress = Math.round(((i + batchIds.length) / contactIds.length) * 100);
|
||||
await job.updateProgress(progress);
|
||||
}
|
||||
|
||||
signale.info(
|
||||
`[BULK-CONTACT-PROCESSOR] ${operation} completed: ${result.successCount} succeeded, ${result.failureCount} failed`,
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
signale.error(`[BULK-CONTACT-PROCESSOR] Failed to process ${operation}:`, error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
connection: bulkContactQueue.opts.connection,
|
||||
concurrency: 3, // Process max 3 bulk operations concurrently
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
signale.info(`[BULK-CONTACT-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
signale.error(`[BULK-CONTACT-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
signale.error('[BULK-CONTACT-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Background Job: Campaign Processor
|
||||
* Processes campaign batches (queues emails for each contact in the batch)
|
||||
*/
|
||||
|
||||
import type {CampaignBatchJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {CampaignService} from '../services/CampaignService.js';
|
||||
import {campaignQueue} from '../services/QueueService.js';
|
||||
|
||||
export function createCampaignWorker() {
|
||||
const worker = new Worker<CampaignBatchJobData>(
|
||||
campaignQueue.name,
|
||||
async (job: Job<CampaignBatchJobData>) => {
|
||||
const {campaignId, batchNumber, offset, limit, cursor} = job.data;
|
||||
|
||||
signale.info(`[CAMPAIGN-PROCESSOR] Processing batch ${batchNumber} for campaign ${campaignId}`);
|
||||
|
||||
await CampaignService.processBatch(campaignId, batchNumber, offset, limit, cursor);
|
||||
|
||||
signale.info(`[CAMPAIGN-PROCESSOR] Completed batch ${batchNumber} for campaign ${campaignId}`);
|
||||
},
|
||||
{
|
||||
connection: campaignQueue.opts.connection,
|
||||
concurrency: 5, // Process up to 5 batches concurrently
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
signale.info(`[CAMPAIGN-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
signale.error(`[CAMPAIGN-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
signale.error('[CAMPAIGN-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Domain Verification Worker
|
||||
* Processes domain verification jobs from the BullMQ queue
|
||||
*/
|
||||
|
||||
import type {DomainVerificationJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {domainVerificationQueue} from '../services/QueueService.js';
|
||||
|
||||
import {checkDomainVerifications} from './domain-verification.js';
|
||||
|
||||
/**
|
||||
* Process domain verification job
|
||||
*/
|
||||
async function processDomainVerification(job: Job<DomainVerificationJobData>): Promise<void> {
|
||||
signale.info(`[DOMAIN-VERIFICATION-WORKER] Starting domain verification job ${job.id}`);
|
||||
|
||||
try {
|
||||
await checkDomainVerifications();
|
||||
signale.success(`[DOMAIN-VERIFICATION-WORKER] Completed domain verification job ${job.id}`);
|
||||
} catch (error) {
|
||||
signale.error(`[DOMAIN-VERIFICATION-WORKER] Error processing job ${job.id}:`, error);
|
||||
throw error; // Re-throw to trigger retry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and export the domain verification worker
|
||||
*/
|
||||
export function createDomainVerificationWorker(): Worker {
|
||||
const worker = new Worker<DomainVerificationJobData>(
|
||||
domainVerificationQueue.name,
|
||||
async (job: Job<DomainVerificationJobData>) => {
|
||||
await processDomainVerification(job);
|
||||
},
|
||||
{
|
||||
connection: domainVerificationQueue.opts.connection,
|
||||
concurrency: 1, // Process one domain verification job at a time
|
||||
limiter: {
|
||||
max: 1, // Max 1 job per duration
|
||||
duration: 60000, // Per minute (prevents rapid-fire verification checks)
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
signale.success(`[DOMAIN-VERIFICATION-WORKER] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, error) => {
|
||||
signale.error(`[DOMAIN-VERIFICATION-WORKER] Job ${job?.id} failed:`, error);
|
||||
});
|
||||
|
||||
worker.on('error', error => {
|
||||
signale.error('[DOMAIN-VERIFICATION-WORKER] Worker error:', error);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Background Job: Domain Verification Checker
|
||||
* Checks domain verification status with AWS SES
|
||||
*
|
||||
* This is processed by BullMQ workers (see domain-verification-processor.ts)
|
||||
* Scheduled to run every 5 minutes via repeatable jobs
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import signale from 'signale';
|
||||
import {DomainUnverifiedEmail, DomainVerifiedEmail, sendPlatformEmail} from '@plunk/email';
|
||||
|
||||
import {DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {disableFeedbackForwarding, getIdentities, verifyDomain} from '../services/SESService.js';
|
||||
import {Keys} from '../services/keys.js';
|
||||
|
||||
/**
|
||||
* Check verification status for all domains in the database
|
||||
*/
|
||||
export async function checkDomainVerifications() {
|
||||
signale.info('[DOMAIN-VERIFICATION] Starting domain verification check...');
|
||||
|
||||
try {
|
||||
const count = await prisma.domain.count();
|
||||
signale.info(`[DOMAIN-VERIFICATION] Found ${count} domains to check`);
|
||||
|
||||
// Process domains in batches of 99 (AWS SES limit is 100)
|
||||
for (let i = 0; i < count; i += 99) {
|
||||
const domains = await prisma.domain.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
domain: true,
|
||||
projectId: true,
|
||||
verified: true,
|
||||
project: {
|
||||
select: {name: true},
|
||||
},
|
||||
},
|
||||
skip: i,
|
||||
take: 99,
|
||||
});
|
||||
|
||||
if (domains.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get verification status from AWS SES
|
||||
const sesIdentities = await getIdentities(domains.map(d => d.domain));
|
||||
|
||||
// Update each domain based on SES status
|
||||
for (const sesIdentity of sesIdentities) {
|
||||
const dbDomain = domains.find(d => d.domain === sesIdentity.domain);
|
||||
|
||||
if (!dbDomain) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isVerified = sesIdentity.status === 'Success';
|
||||
|
||||
// If domain failed verification, retry
|
||||
if (sesIdentity.status === 'Failed') {
|
||||
signale.warn(`[DOMAIN-VERIFICATION] Restarting verification for ${sesIdentity.domain}`);
|
||||
|
||||
let attempt = 0;
|
||||
const maxAttempts = 5;
|
||||
let success = false;
|
||||
let delay = 5000;
|
||||
|
||||
while (attempt < maxAttempts && !success) {
|
||||
try {
|
||||
await verifyDomain(sesIdentity.domain);
|
||||
success = true;
|
||||
signale.success(`[DOMAIN-VERIFICATION] Restarted verification for ${sesIdentity.domain}`);
|
||||
} catch (e: unknown) {
|
||||
const error = e as {Code?: string; name?: string; message?: string};
|
||||
if (
|
||||
error?.Code === 'Throttling' ||
|
||||
error?.name === 'Throttling' ||
|
||||
error?.message?.includes('Throttling')
|
||||
) {
|
||||
signale.warn(
|
||||
`[DOMAIN-VERIFICATION] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
|
||||
);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
delay *= 2; // Exponential backoff
|
||||
attempt++;
|
||||
} else {
|
||||
signale.error(
|
||||
`[DOMAIN-VERIFICATION] Error restarting verification: ${error?.message || 'Unknown error'}`,
|
||||
);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
signale.error(
|
||||
`[DOMAIN-VERIFICATION] Failed to verify ${sesIdentity.domain} after ${maxAttempts} attempts due to throttling`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update verification status in database
|
||||
await prisma.domain.update({
|
||||
where: {id: dbDomain.id},
|
||||
data: {verified: isVerified},
|
||||
});
|
||||
|
||||
// If domain was just verified, disable feedback forwarding
|
||||
if (!dbDomain.verified && isVerified) {
|
||||
signale.success(`[DOMAIN-VERIFICATION] Domain ${sesIdentity.domain} is now verified!`);
|
||||
|
||||
try {
|
||||
await disableFeedbackForwarding(sesIdentity.domain);
|
||||
signale.info(`[DOMAIN-VERIFICATION] Disabled feedback forwarding for ${sesIdentity.domain}`);
|
||||
} catch (error) {
|
||||
signale.error(`[DOMAIN-VERIFICATION] Error disabling feedback forwarding: ${error}`);
|
||||
}
|
||||
|
||||
// Send email notification about domain verified
|
||||
try {
|
||||
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||
const cacheKey = Keys.Domain.verifiedEmail(dbDomain.id);
|
||||
const ttl = 604800; // 7 days
|
||||
|
||||
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||
if (wasSet) {
|
||||
const members = await MembershipService.getMembers(dbDomain.projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(DomainVerifiedEmail, {
|
||||
projectName: dbDomain.project.name,
|
||||
projectId: dbDomain.projectId,
|
||||
domain: sesIdentity.domain,
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
await Promise.all(
|
||||
emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
signale.error(`[DOMAIN-VERIFICATION] Error sending verified email: ${error}`);
|
||||
}
|
||||
|
||||
// Invalidate cache
|
||||
await redis.del(Keys.Domain.id(dbDomain.id));
|
||||
await redis.del(Keys.Domain.project(dbDomain.projectId));
|
||||
}
|
||||
|
||||
// If domain was unverified, invalidate cache
|
||||
if (dbDomain.verified && !isVerified) {
|
||||
signale.warn(`[DOMAIN-VERIFICATION] Domain ${sesIdentity.domain} is no longer verified`);
|
||||
|
||||
// Send email notification about domain verification failed
|
||||
try {
|
||||
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const cacheKey = Keys.Domain.unverifiedEmail(dbDomain.id, year, month);
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||
|
||||
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||
if (wasSet) {
|
||||
const members = await MembershipService.getMembers(dbDomain.projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(DomainUnverifiedEmail, {
|
||||
projectName: dbDomain.project.name,
|
||||
projectId: dbDomain.projectId,
|
||||
domain: sesIdentity.domain,
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
await Promise.all(
|
||||
emails.map(email => sendPlatformEmail(email, 'Domain Verification Failed', template)),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
signale.error(`[DOMAIN-VERIFICATION] Error sending unverified email: ${error}`);
|
||||
}
|
||||
|
||||
await redis.del(Keys.Domain.id(dbDomain.id));
|
||||
await redis.del(Keys.Domain.project(dbDomain.projectId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
signale.success('[DOMAIN-VERIFICATION] Domain verification check completed');
|
||||
} catch (error) {
|
||||
signale.error('[DOMAIN-VERIFICATION] Error checking domain verifications:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main processor function
|
||||
* Call this from your scheduler/cron
|
||||
*/
|
||||
export async function runDomainVerificationJob() {
|
||||
await checkDomainVerifications();
|
||||
}
|
||||
|
||||
// If running this file directly (for testing or manual execution)
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
signale.info('[DOMAIN-VERIFICATION] Running domain verification job manually...');
|
||||
runDomainVerificationJob()
|
||||
.then(() => {
|
||||
signale.success('[DOMAIN-VERIFICATION] Completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(error => {
|
||||
signale.error('[DOMAIN-VERIFICATION] Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Background Job: Email Processor
|
||||
* Processes individual emails from the queue (for all sources: transactional, campaign, workflow)
|
||||
*/
|
||||
|
||||
import {CampaignStatus, EmailSourceType, EmailStatus} from '@plunk/db';
|
||||
import type {SendEmailJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {DASHBOARD_URI, EMAIL_RATE_LIMIT_PER_SECOND} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {EmailService} from '../services/EmailService.js';
|
||||
import {EventService} from '../services/EventService.js';
|
||||
import {MeterService} from '../services/MeterService.js';
|
||||
import {emailQueue} from '../services/QueueService.js';
|
||||
import {getSendingQuota, sendRawEmail} from '../services/SESService.js';
|
||||
|
||||
/**
|
||||
* Determine the email sending rate limit (emails per second)
|
||||
* Priority: ENV variable > AWS SES quota > Safe default (14)
|
||||
*/
|
||||
async function getEmailRateLimit(): Promise<number> {
|
||||
const DEFAULT_RATE_LIMIT = 14; // AWS SES sandbox limit - safe default
|
||||
|
||||
// If env variable is set, use it (override)
|
||||
if (EMAIL_RATE_LIMIT_PER_SECOND !== undefined) {
|
||||
signale.info(`[EMAIL-PROCESSOR] Using rate limit from environment: ${EMAIL_RATE_LIMIT_PER_SECOND} emails/second`);
|
||||
return EMAIL_RATE_LIMIT_PER_SECOND;
|
||||
}
|
||||
|
||||
// Try to fetch from AWS SES
|
||||
signale.info('[EMAIL-PROCESSOR] Fetching rate limit from AWS SES...');
|
||||
const quota = await getSendingQuota();
|
||||
|
||||
if (quota) {
|
||||
signale.info(
|
||||
`[EMAIL-PROCESSOR] AWS SES quota: ${quota.maxSendRate} emails/second (${quota.sentLast24Hours}/${quota.max24HourSend} emails sent today)`,
|
||||
);
|
||||
return quota.maxSendRate;
|
||||
}
|
||||
|
||||
// Fallback to safe default
|
||||
signale.warn(`[EMAIL-PROCESSOR] Failed to fetch AWS quota, using safe default: ${DEFAULT_RATE_LIMIT} emails/second`);
|
||||
return DEFAULT_RATE_LIMIT;
|
||||
}
|
||||
|
||||
export async function createEmailWorker() {
|
||||
// Fetch the rate limit (from env, AWS, or default)
|
||||
const rateLimit = await getEmailRateLimit();
|
||||
const worker = new Worker<SendEmailJobData>(
|
||||
emailQueue.name,
|
||||
async (job: Job<SendEmailJobData>) => {
|
||||
const {emailId} = job.data;
|
||||
|
||||
const email = await prisma.email.findUnique({
|
||||
where: {id: emailId},
|
||||
include: {
|
||||
contact: true,
|
||||
project: true,
|
||||
template: {select: {type: true}},
|
||||
campaign: {select: {type: true}},
|
||||
},
|
||||
});
|
||||
|
||||
if (!email) {
|
||||
throw new Error(`Email ${emailId} not found`);
|
||||
}
|
||||
|
||||
if (email.status !== EmailStatus.PENDING) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if project is disabled
|
||||
if (email.project.disabled) {
|
||||
signale.warn(`[EMAIL-PROCESSOR] Project ${email.projectId} is disabled, cancelling email ${emailId}`);
|
||||
await prisma.email.update({
|
||||
where: {id: emailId},
|
||||
data: {
|
||||
status: EmailStatus.FAILED,
|
||||
error: 'Project is disabled',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Update status to sending
|
||||
await prisma.email.update({
|
||||
where: {id: emailId},
|
||||
data: {status: EmailStatus.SENDING},
|
||||
});
|
||||
|
||||
// Format template variables in subject and body
|
||||
const contactData = (email.contact.data as Record<string, unknown>) || {};
|
||||
const formattedEmail = EmailService.format({
|
||||
subject: email.subject,
|
||||
body: email.body,
|
||||
data: {
|
||||
email: email.contact.email,
|
||||
...contactData,
|
||||
data: contactData,
|
||||
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${email.contact.id}`,
|
||||
subscribeUrl: `${DASHBOARD_URI}/subscribe/${email.contact.id}`,
|
||||
manageUrl: `${DASHBOARD_URI}/manage/${email.contact.id}`,
|
||||
},
|
||||
});
|
||||
|
||||
// Compile HTML with unsubscribe footer and badge
|
||||
// TRANSACTIONAL and HEADLESS emails don't get the Plunk unsubscribe footer
|
||||
const compiledHtml = EmailService.compile({
|
||||
content: formattedEmail.body,
|
||||
contact: email.contact,
|
||||
project: email.project,
|
||||
includeUnsubscribe:
|
||||
email.sourceType !== EmailSourceType.TRANSACTIONAL &&
|
||||
email.template?.type !== 'HEADLESS' &&
|
||||
email.campaign?.type !== 'HEADLESS',
|
||||
});
|
||||
|
||||
// Use fromName from database if available, otherwise fall back to project name
|
||||
// The 'from' field in the database is just the email address
|
||||
const fromName = email.fromName || email.project.name;
|
||||
const fromEmail = email.from;
|
||||
|
||||
// Parse custom headers from JSON
|
||||
const customHeaders =
|
||||
email.headers && typeof email.headers === 'object' && !Array.isArray(email.headers)
|
||||
? (email.headers as Record<string, string>)
|
||||
: undefined;
|
||||
|
||||
// Check for custom recipient override in headers
|
||||
const recipientEmail = customHeaders?.['X-Plunk-Recipient-Override'] || email.contact.email;
|
||||
|
||||
// Remove internal headers before sending
|
||||
const publicHeaders = customHeaders ? {...customHeaders} : undefined;
|
||||
if (publicHeaders && 'X-Plunk-Recipient-Override' in publicHeaders) {
|
||||
delete publicHeaders['X-Plunk-Recipient-Override'];
|
||||
}
|
||||
|
||||
// Build recipient with name if available
|
||||
const recipient: {name?: string; email: string} | string = email.toName
|
||||
? {name: email.toName, email: recipientEmail}
|
||||
: recipientEmail;
|
||||
|
||||
// Determine tracking based on project settings and email type
|
||||
const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType);
|
||||
|
||||
// Send via AWS SES
|
||||
const result = await sendRawEmail({
|
||||
from: {
|
||||
name: fromName,
|
||||
email: fromEmail,
|
||||
},
|
||||
to: typeof recipient === 'string' ? [recipient] : [{name: recipient.name, email: recipient.email}],
|
||||
content: {
|
||||
subject: formattedEmail.subject,
|
||||
html: compiledHtml,
|
||||
},
|
||||
reply: email.replyTo || undefined,
|
||||
headers: publicHeaders,
|
||||
tracking: shouldTrack,
|
||||
attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null,
|
||||
});
|
||||
|
||||
// Mark as sent with SES message ID
|
||||
await prisma.email.update({
|
||||
where: {id: emailId},
|
||||
data: {
|
||||
status: EmailStatus.SENT,
|
||||
sentAt: new Date(),
|
||||
messageId: result.messageId,
|
||||
},
|
||||
});
|
||||
|
||||
// Record usage for billing (pay-per-email)
|
||||
// Uses email ID as idempotency key to prevent double-charging on retries
|
||||
// Charge 2 emails if attachments are present
|
||||
if (email.project.customer) {
|
||||
const hasAttachments = email.attachments && Array.isArray(email.attachments) && email.attachments.length > 0;
|
||||
const emailCount = hasAttachments ? 2 : 1;
|
||||
await MeterService.recordEmailSent(email.project.customer, emailCount, `email_${emailId}`);
|
||||
}
|
||||
|
||||
// Track event (this will trigger workflows)
|
||||
await EventService.trackEvent(email.projectId, 'email.sent', email.contactId, email.id, {
|
||||
subject: formattedEmail.subject,
|
||||
from: email.from,
|
||||
fromName: email.fromName,
|
||||
messageId: result.messageId,
|
||||
templateId: email.templateId,
|
||||
campaignId: email.campaignId,
|
||||
sourceType: email.sourceType,
|
||||
sentAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// If this email belongs to a campaign, check if all campaign emails have been sent
|
||||
if (email.campaignId) {
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
where: {id: email.campaignId},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
totalRecipients: true,
|
||||
projectId: true,
|
||||
project: {
|
||||
select: {name: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Only check if campaign is still in SENDING status
|
||||
if (campaign && campaign.status === CampaignStatus.SENDING) {
|
||||
// Count how many emails have been sent for this campaign
|
||||
const sentCount = await prisma.email.count({
|
||||
where: {
|
||||
campaignId: email.campaignId,
|
||||
sentAt: {not: null},
|
||||
},
|
||||
});
|
||||
|
||||
// If all emails have been sent, mark campaign as SENT
|
||||
if (sentCount >= campaign.totalRecipients) {
|
||||
await prisma.campaign.update({
|
||||
where: {id: email.campaignId},
|
||||
data: {
|
||||
status: CampaignStatus.SENT,
|
||||
sentCount,
|
||||
},
|
||||
});
|
||||
|
||||
signale.success(
|
||||
`[EMAIL-PROCESSOR] Campaign ${campaign.name} completed: ${sentCount}/${campaign.totalRecipients} emails sent`,
|
||||
);
|
||||
|
||||
// Send notification about campaign send completed
|
||||
const {NtfyService} = await import('../services/NtfyService.js');
|
||||
await NtfyService.notifyCampaignSendCompleted(
|
||||
campaign.name,
|
||||
campaign.project.name,
|
||||
campaign.projectId,
|
||||
campaign.totalRecipients,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
signale.error(`[EMAIL-PROCESSOR] Failed to send email ${emailId}:`, error);
|
||||
|
||||
// Mark as failed
|
||||
await prisma.email.update({
|
||||
where: {id: emailId},
|
||||
data: {
|
||||
status: EmailStatus.FAILED,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
});
|
||||
|
||||
throw error; // Re-throw to trigger retry
|
||||
}
|
||||
},
|
||||
{
|
||||
connection: emailQueue.opts.connection,
|
||||
concurrency: 10, // Process up to 10 emails concurrently
|
||||
limiter: {
|
||||
max: rateLimit, // Max emails per second (from env, AWS SES quota, or default)
|
||||
duration: 1000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
signale.info(`[EMAIL-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
signale.error(`[EMAIL-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
signale.error('[EMAIL-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Background Job: Contact Import Processor
|
||||
* Processes CSV contact imports with validation and batch processing
|
||||
*/
|
||||
|
||||
import type {ContactImportJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import {parse} from 'csv-parse/sync';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {ContactService} from '../services/ContactService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {importQueue} from '../services/QueueService.js';
|
||||
|
||||
const BATCH_SIZE = 100; // Process contacts in batches of 100
|
||||
|
||||
interface ImportResult {
|
||||
totalRows: number;
|
||||
successCount: number;
|
||||
createdCount: number;
|
||||
updatedCount: number;
|
||||
failureCount: number;
|
||||
errors: {row: number; email: string; error: string}[];
|
||||
}
|
||||
|
||||
export function createImportWorker() {
|
||||
const worker = new Worker<ContactImportJobData>(
|
||||
importQueue.name,
|
||||
async (job: Job<ContactImportJobData>) => {
|
||||
const {projectId, csvData, filename} = job.data;
|
||||
|
||||
signale.info(`[IMPORT-PROCESSOR] Processing import for project ${projectId} (${filename})`);
|
||||
|
||||
// Fetch project information for notifications
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {name: true},
|
||||
});
|
||||
|
||||
const projectName = project?.name || projectId;
|
||||
|
||||
const result: ImportResult = {
|
||||
totalRows: 0,
|
||||
successCount: 0,
|
||||
createdCount: 0,
|
||||
updatedCount: 0,
|
||||
failureCount: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
try {
|
||||
// Decode base64 CSV data
|
||||
const csvContent = Buffer.from(csvData, 'base64').toString('utf-8');
|
||||
|
||||
// Parse CSV with column header normalization
|
||||
const records = parse(csvContent, {
|
||||
columns: (header: string[]) => header.map(h => h.toLowerCase()), // Normalize headers to lowercase
|
||||
skip_empty_lines: true,
|
||||
trim: true,
|
||||
relax_column_count: true, // Allow rows with different column counts
|
||||
}) as Record<string, string>[];
|
||||
|
||||
result.totalRows = records.length;
|
||||
|
||||
// Validate row count
|
||||
if (records.length === 0) {
|
||||
throw new Error('CSV file is empty');
|
||||
}
|
||||
|
||||
signale.info(`[IMPORT-PROCESSOR] Parsed ${records.length} rows from CSV`);
|
||||
|
||||
// Notify that import has started
|
||||
await NtfyService.notifyContactImportStarted(projectName, projectId, filename, result.totalRows);
|
||||
|
||||
// Validate that 'email' column exists (case-insensitive)
|
||||
const firstRecord = records[0];
|
||||
if (firstRecord && typeof firstRecord === 'object' && !('email' in firstRecord)) {
|
||||
throw new Error('CSV must have an "email" column (case-insensitive)');
|
||||
}
|
||||
|
||||
// Process contacts in batches
|
||||
for (let i = 0; i < records.length; i += BATCH_SIZE) {
|
||||
const batch = records.slice(i, Math.min(i + BATCH_SIZE, records.length));
|
||||
|
||||
// Process batch sequentially (to avoid overwhelming the database)
|
||||
for (const [batchIndex, record] of batch.entries()) {
|
||||
const rowNumber = i + batchIndex + 2; // +2 for header row and 1-based index
|
||||
|
||||
try {
|
||||
// Validate email
|
||||
const email = record.email?.trim();
|
||||
if (!email) {
|
||||
result.failureCount++;
|
||||
result.errors.push({
|
||||
row: rowNumber,
|
||||
email: '',
|
||||
error: 'Email is required',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
if (!isValidEmail(email)) {
|
||||
result.failureCount++;
|
||||
result.errors.push({
|
||||
row: rowNumber,
|
||||
email,
|
||||
error: 'Invalid email format',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract subscribed field if present (case-insensitive)
|
||||
const subscribedValue = record.subscribed;
|
||||
let subscribed: boolean | undefined;
|
||||
|
||||
if (subscribedValue !== undefined && subscribedValue !== '') {
|
||||
// Handle various truthy/falsy values
|
||||
const lowerValue = subscribedValue.toLowerCase().trim();
|
||||
subscribed = lowerValue === 'true' || lowerValue === '1' || lowerValue === 'yes';
|
||||
}
|
||||
|
||||
// Extract custom data (all fields except email and subscribed)
|
||||
const {email: _, subscribed: __, ...customData} = record;
|
||||
const data = Object.keys(customData).length > 0 ? customData : undefined;
|
||||
|
||||
// Check if contact exists before upserting
|
||||
const existingContact = await ContactService.findByEmail(projectId, email);
|
||||
const isUpdate = !!existingContact;
|
||||
|
||||
// Upsert contact with subscribed value from CSV if provided
|
||||
// For new contacts, ContactService.upsert defaults to true
|
||||
// For existing contacts, only update if explicitly provided in CSV
|
||||
await ContactService.upsert(projectId, email, data, subscribed);
|
||||
|
||||
result.successCount++;
|
||||
if (isUpdate) {
|
||||
result.updatedCount++;
|
||||
} else {
|
||||
result.createdCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
result.failureCount++;
|
||||
result.errors.push({
|
||||
row: rowNumber,
|
||||
email: record.email || '',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update progress
|
||||
const progress = Math.round(((i + batch.length) / records.length) * 100);
|
||||
await job.updateProgress(progress);
|
||||
}
|
||||
|
||||
signale.info(
|
||||
`[IMPORT-PROCESSOR] Import completed: ${result.createdCount} created, ${result.updatedCount} updated, ${result.failureCount} failed`,
|
||||
);
|
||||
|
||||
// Notify that import has completed
|
||||
await NtfyService.notifyContactImportCompleted(
|
||||
projectName,
|
||||
projectId,
|
||||
filename,
|
||||
result.successCount,
|
||||
result.createdCount,
|
||||
result.updatedCount,
|
||||
result.failureCount,
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
signale.error(`[IMPORT-PROCESSOR] Failed to process import:`, error);
|
||||
|
||||
// Notify that import has failed
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
await NtfyService.notifyContactImportFailed(projectName, projectId, filename, errorMessage);
|
||||
|
||||
// Return partial results with error
|
||||
result.errors.push({
|
||||
row: 0,
|
||||
email: '',
|
||||
error: errorMessage,
|
||||
});
|
||||
|
||||
throw error; // Re-throw to mark job as failed
|
||||
}
|
||||
},
|
||||
{
|
||||
connection: importQueue.opts.connection,
|
||||
concurrency: 2, // Process max 2 imports concurrently
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
signale.info(`[IMPORT-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
signale.error(`[IMPORT-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
signale.error('[IMPORT-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic email validation
|
||||
*/
|
||||
function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Background Job: Scheduled Campaign Processor
|
||||
* Processes scheduled campaigns when their time arrives
|
||||
*/
|
||||
|
||||
import {CampaignStatus} from '@plunk/db';
|
||||
import type {ScheduledCampaignJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {CampaignService} from '../services/CampaignService.js';
|
||||
import {scheduledQueue} from '../services/QueueService.js';
|
||||
|
||||
export function createScheduledCampaignWorker() {
|
||||
const worker = new Worker<ScheduledCampaignJobData>(
|
||||
scheduledQueue.name,
|
||||
async (job: Job<ScheduledCampaignJobData>) => {
|
||||
const {campaignId} = job.data;
|
||||
|
||||
signale.info(`[SCHEDULED-PROCESSOR] Processing scheduled campaign ${campaignId}`);
|
||||
|
||||
// Get campaign with project
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
where: {id: campaignId},
|
||||
include: {
|
||||
project: {
|
||||
select: {disabled: true, id: true, name: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!campaign) {
|
||||
signale.warn(`[SCHEDULED-PROCESSOR] Campaign ${campaignId} not found, skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if project is disabled
|
||||
if (campaign.project.disabled) {
|
||||
signale.warn(
|
||||
`[SCHEDULED-PROCESSOR] Project ${campaign.projectId} (${campaign.project.name}) is disabled, cancelling campaign ${campaignId}`,
|
||||
);
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {status: CampaignStatus.CANCELLED},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify campaign is still in SCHEDULED status
|
||||
if (campaign.status !== CampaignStatus.SCHEDULED) {
|
||||
signale.warn(
|
||||
`[SCHEDULED-PROCESSOR] Campaign ${campaignId} is not in SCHEDULED status (${campaign.status}), skipping`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Start sending the campaign
|
||||
await CampaignService.startSending(campaign.projectId, campaignId);
|
||||
|
||||
signale.info(`[SCHEDULED-PROCESSOR] Started sending campaign ${campaignId}`);
|
||||
},
|
||||
{
|
||||
connection: scheduledQueue.opts.connection,
|
||||
concurrency: 2, // Process up to 2 scheduled campaigns concurrently
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
signale.info(`[SCHEDULED-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
signale.error(`[SCHEDULED-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
signale.error('[SCHEDULED-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Segment Count Update Worker
|
||||
* Processes segment count update jobs from the BullMQ queue
|
||||
*/
|
||||
|
||||
import type {SegmentCountJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {segmentCountQueue} from '../services/QueueService.js';
|
||||
import {SegmentService} from '../services/SegmentService.js';
|
||||
|
||||
/**
|
||||
* Process segments for a single project
|
||||
* - For segments with trackMembership: compute full membership and trigger events
|
||||
* - For segments without trackMembership: only update counts
|
||||
*/
|
||||
async function processProjectSegments(projectId: string, projectName?: string): Promise<void> {
|
||||
// Get all segments for this project, separating tracked vs non-tracked
|
||||
const segments = await prisma.segment.findMany({
|
||||
where: {projectId},
|
||||
select: {id: true, name: true, trackMembership: true},
|
||||
});
|
||||
|
||||
const trackedSegments = segments.filter(s => s.trackMembership);
|
||||
const nonTrackedSegments = segments.filter(s => !s.trackMembership);
|
||||
|
||||
// Process tracked segments with full membership computation (creates events)
|
||||
let updatedSegmentCount = 0;
|
||||
let totalAdded = 0;
|
||||
let totalRemoved = 0;
|
||||
|
||||
if (trackedSegments.length > 0) {
|
||||
for (const segment of trackedSegments) {
|
||||
try {
|
||||
const result = await SegmentService.computeMembership(projectId, segment.id);
|
||||
|
||||
// Track segments with actual changes for bundled notification
|
||||
if (result.added > 0 || result.removed > 0) {
|
||||
updatedSegmentCount++;
|
||||
totalAdded += result.added;
|
||||
totalRemoved += result.removed;
|
||||
}
|
||||
} catch (error) {
|
||||
signale.error(`[SEGMENT-COUNT-WORKER] Failed to compute membership for segment ${segment.id}:`, error);
|
||||
// Continue with other segments
|
||||
}
|
||||
}
|
||||
|
||||
// Send bundled notification if there were any changes
|
||||
if (projectName && updatedSegmentCount > 0) {
|
||||
await NtfyService.notifySegmentMembershipBundled(
|
||||
projectName,
|
||||
projectId,
|
||||
updatedSegmentCount,
|
||||
totalAdded,
|
||||
totalRemoved,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Process non-tracked segments with count-only update (lightweight)
|
||||
if (nonTrackedSegments.length > 0) {
|
||||
try {
|
||||
await SegmentService.refreshAllMemberCounts(projectId);
|
||||
} catch (error) {
|
||||
signale.error(`[SEGMENT-COUNT-WORKER] Failed to update counts for non-tracked segments:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process segment count update job
|
||||
*/
|
||||
async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise<void> {
|
||||
const {projectId} = job.data;
|
||||
|
||||
try {
|
||||
if (projectId) {
|
||||
// Process specific project
|
||||
await processProjectSegments(projectId);
|
||||
} else {
|
||||
// Process all active projects
|
||||
const projects = await prisma.project.findMany({
|
||||
where: {disabled: false},
|
||||
select: {id: true, name: true},
|
||||
});
|
||||
|
||||
signale.info(`[SEGMENT-COUNT-WORKER] Processing ${projects.length} active projects`);
|
||||
|
||||
// Process projects in batches to avoid overwhelming the database
|
||||
const PROJECT_BATCH_SIZE = 10;
|
||||
for (let i = 0; i < projects.length; i += PROJECT_BATCH_SIZE) {
|
||||
const batch = projects.slice(i, i + PROJECT_BATCH_SIZE);
|
||||
|
||||
await Promise.all(
|
||||
batch.map(async project => {
|
||||
try {
|
||||
await processProjectSegments(project.id, project.name);
|
||||
} catch (error) {
|
||||
signale.error(`[SEGMENT-COUNT-WORKER] Failed to process project ${project.id}:`, error);
|
||||
// Don't throw - continue with other projects
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Small delay between project batches to avoid overwhelming the database
|
||||
if (i + PROJECT_BATCH_SIZE < projects.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
signale.error(`[SEGMENT-COUNT-WORKER] Error processing job ${job.id}:`, error);
|
||||
throw error; // Re-throw to trigger retry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and export the segment count worker
|
||||
*/
|
||||
export function createSegmentCountWorker(): Worker {
|
||||
const worker = new Worker<SegmentCountJobData>(
|
||||
segmentCountQueue.name,
|
||||
async (job: Job<SegmentCountJobData>) => {
|
||||
await processSegmentCountUpdate(job);
|
||||
},
|
||||
{
|
||||
connection: segmentCountQueue.opts.connection,
|
||||
concurrency: 1, // Process one segment count job at a time to avoid database overload
|
||||
limiter: {
|
||||
max: 1, // Max 1 job per duration
|
||||
duration: 60000, // Per minute (prevents rapid-fire updates)
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('failed', (job, error) => {
|
||||
signale.error(`[SEGMENT-COUNT-WORKER] Job ${job?.id} failed:`, error);
|
||||
});
|
||||
|
||||
worker.on('error', error => {
|
||||
signale.error('[SEGMENT-COUNT-WORKER] Worker error:', error);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Unified Queue Worker
|
||||
* Starts all queue processors (email, campaign, scheduled, workflow, import, segment-count, domain-verification)
|
||||
*
|
||||
* This should be run as a separate process in production:
|
||||
* node dist/jobs/worker.js
|
||||
*/
|
||||
|
||||
import {Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {createApiRequestCleanupWorker} from './api-request-cleanup-processor.js';
|
||||
import {createBulkContactWorker} from './bulk-contact-processor.js';
|
||||
import {createCampaignWorker} from './campaign-processor.js';
|
||||
import {createDomainVerificationWorker} from './domain-verification-processor.js';
|
||||
import {createEmailWorker} from './email-processor.js';
|
||||
import {createImportWorker} from './import-processor.js';
|
||||
import {createScheduledCampaignWorker} from './scheduled-processor.js';
|
||||
import {createSegmentCountWorker} from './segment-count-processor.js';
|
||||
import {createWorkflowWorker} from './workflow-processor-queue.js';
|
||||
|
||||
const workers: {name: string; worker: Worker}[] = [];
|
||||
|
||||
async function startWorkers() {
|
||||
signale.info('[WORKER] Starting queue workers...');
|
||||
|
||||
try {
|
||||
// Start email worker
|
||||
const emailWorker = await createEmailWorker();
|
||||
workers.push({name: 'email', worker: emailWorker});
|
||||
signale.success('[WORKER] Email worker started');
|
||||
|
||||
// Start campaign worker
|
||||
const campaignWorker = createCampaignWorker();
|
||||
workers.push({name: 'campaign', worker: campaignWorker});
|
||||
signale.success('[WORKER] Campaign worker started');
|
||||
|
||||
// Start scheduled campaign worker
|
||||
const scheduledWorker = createScheduledCampaignWorker();
|
||||
workers.push({name: 'scheduled', worker: scheduledWorker});
|
||||
signale.success('[WORKER] Scheduled campaign worker started');
|
||||
|
||||
// Start workflow worker
|
||||
const workflowWorker = createWorkflowWorker();
|
||||
workers.push({name: 'workflow', worker: workflowWorker});
|
||||
signale.success('[WORKER] Workflow worker started');
|
||||
|
||||
// Start import worker
|
||||
const importWorker = createImportWorker();
|
||||
workers.push({name: 'import', worker: importWorker});
|
||||
signale.success('[WORKER] Import worker started');
|
||||
|
||||
// Start bulk contact action worker
|
||||
const bulkContactWorker = createBulkContactWorker();
|
||||
workers.push({name: 'bulk-contact-actions', worker: bulkContactWorker});
|
||||
signale.success('[WORKER] Bulk contact action worker started');
|
||||
|
||||
// Start segment count worker
|
||||
const segmentCountWorker = createSegmentCountWorker();
|
||||
workers.push({name: 'segment-count', worker: segmentCountWorker});
|
||||
signale.success('[WORKER] Segment count worker started');
|
||||
|
||||
// Start domain verification worker
|
||||
const domainVerificationWorker = createDomainVerificationWorker();
|
||||
workers.push({name: 'domain-verification', worker: domainVerificationWorker});
|
||||
signale.success('[WORKER] Domain verification worker started');
|
||||
|
||||
// Start API request cleanup worker
|
||||
const apiRequestCleanupWorker = createApiRequestCleanupWorker();
|
||||
workers.push({name: 'api-request-cleanup', worker: apiRequestCleanupWorker});
|
||||
signale.success('[WORKER] API request cleanup worker started');
|
||||
|
||||
signale.success('[WORKER] All workers started successfully');
|
||||
} catch (error) {
|
||||
signale.error('[WORKER] Failed to start workers:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopWorkers() {
|
||||
signale.info('[WORKER] Stopping workers...');
|
||||
|
||||
for (const {name, worker} of workers) {
|
||||
try {
|
||||
await worker.close();
|
||||
signale.info(`[WORKER] ${name} worker stopped`);
|
||||
} catch (error) {
|
||||
signale.error(`[WORKER] Error stopping ${name} worker:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
signale.success('[WORKER] All workers stopped');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
signale.info('[WORKER] Received SIGINT, shutting down gracefully...');
|
||||
void stopWorkers();
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
signale.info('[WORKER] Received SIGTERM, shutting down gracefully...');
|
||||
void stopWorkers();
|
||||
});
|
||||
|
||||
process.on('uncaughtException', error => {
|
||||
signale.error('[WORKER] Uncaught exception:', error);
|
||||
void stopWorkers();
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
signale.error('[WORKER] Unhandled rejection at:', promise, 'reason:', reason);
|
||||
void stopWorkers();
|
||||
});
|
||||
|
||||
// Start workers
|
||||
void startWorkers();
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Background Job: Workflow Queue Processor
|
||||
* Processes workflow steps from the queue (for delayed steps)
|
||||
*/
|
||||
|
||||
import type {WorkflowStepJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {workflowQueue} from '../services/QueueService.js';
|
||||
import {WorkflowExecutionService} from '../services/WorkflowExecutionService.js';
|
||||
|
||||
export function createWorkflowWorker() {
|
||||
const worker = new Worker<WorkflowStepJobData>(
|
||||
workflowQueue.name,
|
||||
async (job: Job<WorkflowStepJobData>) => {
|
||||
const {executionId, stepId, type, stepExecutionId} = job.data;
|
||||
|
||||
if (type === 'timeout') {
|
||||
// Handle timeout for WAIT_FOR_EVENT steps
|
||||
if (!stepExecutionId) {
|
||||
throw new Error('stepExecutionId is required for timeout jobs');
|
||||
}
|
||||
|
||||
await WorkflowExecutionService.processTimeout(executionId, stepId, stepExecutionId);
|
||||
} else {
|
||||
// Handle regular step execution
|
||||
await WorkflowExecutionService.processStepExecution(executionId, stepId);
|
||||
}
|
||||
},
|
||||
{
|
||||
connection: workflowQueue.opts.connection,
|
||||
concurrency: 10, // Process up to 10 workflow steps concurrently
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('completed', job => {
|
||||
signale.info(`[WORKFLOW-PROCESSOR] Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
signale.error(`[WORKFLOW-PROCESSOR] Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
worker.on('error', err => {
|
||||
signale.error('[WORKFLOW-PROCESSOR] Worker error:', err);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import {databaseRequestLogger} from '../requestLogger.js';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
describe('Request Logger Middleware', () => {
|
||||
const prisma = getPrismaClient();
|
||||
let req: Partial<Request>;
|
||||
let res: Partial<Response>;
|
||||
let next: NextFunction;
|
||||
let projectId: string;
|
||||
let userId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
const {user, project} = await factories.createUserWithProject();
|
||||
projectId = project.id;
|
||||
userId = user.id;
|
||||
|
||||
req = {
|
||||
method: 'POST',
|
||||
path: '/v1/send',
|
||||
ip: '192.168.1.100',
|
||||
socket: {remoteAddress: '192.168.1.100'} as Request['socket'],
|
||||
get: vi.fn((header: string) => {
|
||||
if (header === 'user-agent') {
|
||||
return 'Mozilla/5.0 Test Browser';
|
||||
}
|
||||
return undefined;
|
||||
}),
|
||||
headers: {
|
||||
'content-length': '1234',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock response object with a json function that references the res object
|
||||
let statusCode = 200;
|
||||
res = {
|
||||
locals: {
|
||||
requestId: 'test-request-id-123',
|
||||
auth: {
|
||||
type: 'secret_key',
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
get statusCode() {
|
||||
return statusCode;
|
||||
},
|
||||
set statusCode(value: number) {
|
||||
statusCode = value;
|
||||
},
|
||||
json: vi.fn(function (this: Response, body: unknown) {
|
||||
return body;
|
||||
}),
|
||||
};
|
||||
|
||||
next = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
// Clean up API request logs to avoid duplicate key errors
|
||||
await prisma.apiRequest.deleteMany({});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// SUCCESSFUL REQUEST LOGGING
|
||||
// ========================================
|
||||
describe('Successful Request Logging', () => {
|
||||
it('should log successful API request to database', async () => {
|
||||
databaseRequestLogger(req as Request, res as Response, next);
|
||||
|
||||
// Middleware should call next immediately
|
||||
expect(next).toHaveBeenCalled();
|
||||
|
||||
// Simulate response
|
||||
const responseBody = {success: true, data: {id: '123'}};
|
||||
await res.json!(responseBody);
|
||||
|
||||
// Wait for async logging to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Verify database record was created
|
||||
const loggedRequest = await prisma.apiRequest.findUnique({
|
||||
where: {id: 'test-request-id-123'},
|
||||
});
|
||||
|
||||
expect(loggedRequest).toBeDefined();
|
||||
expect(loggedRequest?.method).toBe('POST');
|
||||
expect(loggedRequest?.path).toBe('/v1/send');
|
||||
expect(loggedRequest?.statusCode).toBe(200);
|
||||
expect(loggedRequest?.projectId).toBe(projectId);
|
||||
expect(loggedRequest?.userId).toBe(userId);
|
||||
expect(loggedRequest?.authType).toBe('secret_key');
|
||||
expect(loggedRequest?.ip).toBe('192.168.1.100');
|
||||
expect(loggedRequest?.userAgent).toBe('Mozilla/5.0 Test Browser');
|
||||
expect(loggedRequest?.duration).toBeGreaterThanOrEqual(0);
|
||||
expect(loggedRequest?.requestSize).toBe(1234);
|
||||
expect(loggedRequest?.responseSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should log request without auth information', async () => {
|
||||
res.locals = {
|
||||
requestId: 'public-request-id',
|
||||
};
|
||||
|
||||
databaseRequestLogger(req as Request, res as Response, next);
|
||||
|
||||
const responseBody = {success: true};
|
||||
await res.json!(responseBody);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const loggedRequest = await prisma.apiRequest.findUnique({
|
||||
where: {id: 'public-request-id'},
|
||||
});
|
||||
|
||||
expect(loggedRequest).toBeDefined();
|
||||
expect(loggedRequest?.projectId).toBeNull();
|
||||
expect(loggedRequest?.userId).toBeNull();
|
||||
expect(loggedRequest?.authType).toBeNull();
|
||||
});
|
||||
|
||||
it('should calculate request duration', async () => {
|
||||
const startTime = Date.now();
|
||||
|
||||
databaseRequestLogger(req as Request, res as Response, next);
|
||||
|
||||
// Simulate some processing time
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
await res.json!({success: true});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const loggedRequest = await prisma.apiRequest.findUnique({
|
||||
where: {id: 'test-request-id-123'},
|
||||
});
|
||||
|
||||
// Allow for timer imprecision (especially in CI environments)
|
||||
expect(loggedRequest?.duration).toBeGreaterThanOrEqual(45);
|
||||
expect(loggedRequest?.duration).toBeLessThan(Date.now() - startTime + 100);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// ERROR REQUEST LOGGING
|
||||
// ========================================
|
||||
describe('Error Request Logging', () => {
|
||||
it('should log failed requests with error details', async () => {
|
||||
res.statusCode = 400;
|
||||
|
||||
databaseRequestLogger(req as Request, res as Response, next);
|
||||
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Invalid email format',
|
||||
},
|
||||
};
|
||||
|
||||
await res.json!(errorResponse);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const loggedRequest = await prisma.apiRequest.findUnique({
|
||||
where: {id: 'test-request-id-123'},
|
||||
});
|
||||
|
||||
expect(loggedRequest).toBeDefined();
|
||||
expect(loggedRequest?.statusCode).toBe(400);
|
||||
expect(loggedRequest?.errorCode).toBe('VALIDATION_ERROR');
|
||||
expect(loggedRequest?.errorMessage).toBe('Invalid email format');
|
||||
});
|
||||
|
||||
it('should log 500 errors', async () => {
|
||||
res.statusCode = 500;
|
||||
|
||||
databaseRequestLogger(req as Request, res as Response, next);
|
||||
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Database connection failed',
|
||||
},
|
||||
};
|
||||
|
||||
await res.json!(errorResponse);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const loggedRequest = await prisma.apiRequest.findUnique({
|
||||
where: {id: 'test-request-id-123'},
|
||||
});
|
||||
|
||||
expect(loggedRequest?.statusCode).toBe(500);
|
||||
expect(loggedRequest?.errorCode).toBe('INTERNAL_SERVER_ERROR');
|
||||
});
|
||||
|
||||
it('should log 404 not found errors', async () => {
|
||||
res.statusCode = 404;
|
||||
|
||||
databaseRequestLogger(req as Request, res as Response, next);
|
||||
|
||||
await res.json!({
|
||||
success: false,
|
||||
error: {code: 'RESOURCE_NOT_FOUND', message: 'Template not found'},
|
||||
});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const loggedRequest = await prisma.apiRequest.findUnique({
|
||||
where: {id: 'test-request-id-123'},
|
||||
});
|
||||
|
||||
expect(loggedRequest?.statusCode).toBe(404);
|
||||
expect(loggedRequest?.errorCode).toBe('RESOURCE_NOT_FOUND');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// SKIP LOGGING FOR EXCLUDED PATHS
|
||||
// ========================================
|
||||
describe('Skip Logging for Excluded Paths', () => {
|
||||
it('should skip logging for health check endpoint', async () => {
|
||||
req.path = '/health';
|
||||
|
||||
databaseRequestLogger(req as Request, res as Response, next);
|
||||
|
||||
await res.json!({status: 'ok'});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Should not create database record
|
||||
const loggedRequest = await prisma.apiRequest.findUnique({
|
||||
where: {id: 'test-request-id-123'},
|
||||
});
|
||||
|
||||
expect(loggedRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('should skip logging for user session endpoints', async () => {
|
||||
const sessionPaths = ['/users/@me', '/users/@me/projects', '/users/me', '/users/me/projects'];
|
||||
|
||||
for (const path of sessionPaths) {
|
||||
req.path = path;
|
||||
res.locals!.requestId = `skip-${path}`;
|
||||
|
||||
databaseRequestLogger(req as Request, res as Response, next);
|
||||
await res.json!({user: 'data'});
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const loggedRequest = await prisma.apiRequest.findUnique({
|
||||
where: {id: `skip-${path}`},
|
||||
});
|
||||
|
||||
expect(loggedRequest).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('should skip logging for static assets', async () => {
|
||||
const assetPaths = ['/assets/logo.png', '/assets/styles.css', '/assets/script.js'];
|
||||
|
||||
for (const path of assetPaths) {
|
||||
req.path = path;
|
||||
res.locals!.requestId = `asset-${path}`;
|
||||
|
||||
databaseRequestLogger(req as Request, res as Response, next);
|
||||
await res.json!({});
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
const loggedRequest = await prisma.apiRequest.findUnique({
|
||||
where: {id: `asset-${path}`},
|
||||
});
|
||||
|
||||
expect(loggedRequest).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('should skip logging for config endpoints', async () => {
|
||||
const reqConfig = {...req, path: '/config'};
|
||||
const resConfig = {
|
||||
...res,
|
||||
locals: {...res.locals!, requestId: 'test-config-skip'},
|
||||
};
|
||||
|
||||
databaseRequestLogger(reqConfig as Request, resConfig as Response, next);
|
||||
await resConfig.json!({features: {}});
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const loggedRequest = await prisma.apiRequest.findUnique({
|
||||
where: {id: 'test-config-skip'},
|
||||
});
|
||||
|
||||
expect(loggedRequest).toBeNull();
|
||||
});
|
||||
|
||||
it('should LOG important API endpoints', async () => {
|
||||
const importantPaths = ['/v1/send', '/v1/track', '/contacts', '/campaigns', '/templates'];
|
||||
|
||||
for (const path of importantPaths) {
|
||||
req.path = path;
|
||||
res.locals!.requestId = `log-${path.replace(/\//g, '-')}`;
|
||||
|
||||
databaseRequestLogger(req as Request, res as Response, next);
|
||||
await res.json!({success: true});
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const loggedRequest = await prisma.apiRequest.findUnique({
|
||||
where: {id: `log-${path.replace(/\//g, '-')}`},
|
||||
});
|
||||
|
||||
expect(loggedRequest).toBeDefined();
|
||||
expect(loggedRequest?.path).toBe(path);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// CONFIGURATION & ERROR HANDLING
|
||||
// ========================================
|
||||
describe('Configuration & Error Handling', () => {
|
||||
it('should respect REQUEST_LOGGING=false environment variable', async () => {
|
||||
const originalEnv = process.env.REQUEST_LOGGING;
|
||||
process.env.REQUEST_LOGGING = 'false';
|
||||
|
||||
// Re-import to get updated config
|
||||
// Note: This test may need to be adjusted based on how your module caching works
|
||||
databaseRequestLogger(req as Request, res as Response, next);
|
||||
|
||||
await res.json!({success: true});
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
|
||||
// Restore original value
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.REQUEST_LOGGING = originalEnv;
|
||||
} else {
|
||||
delete process.env.REQUEST_LOGGING;
|
||||
}
|
||||
|
||||
// This test might fail in current implementation since the env is read at module load time
|
||||
// Consider this a documentation of desired behavior
|
||||
});
|
||||
|
||||
it('should handle missing request ID gracefully', async () => {
|
||||
res.locals = {}; // No request ID
|
||||
|
||||
databaseRequestLogger(req as Request, res as Response, next);
|
||||
|
||||
await res.json!({success: true});
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Should create a record with generated UUID
|
||||
const allRequests = await prisma.apiRequest.findMany({
|
||||
where: {
|
||||
path: '/v1/send',
|
||||
method: 'POST',
|
||||
},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
take: 1,
|
||||
});
|
||||
|
||||
expect(allRequests.length).toBeGreaterThan(0);
|
||||
expect(allRequests[0].id).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not block response if database logging fails', async () => {
|
||||
// Simulate database error by using invalid data
|
||||
const resInvalid = {
|
||||
...res,
|
||||
locals: {
|
||||
requestId: null as unknown, // Invalid request ID - will cause database error
|
||||
},
|
||||
};
|
||||
|
||||
databaseRequestLogger(req as Request, resInvalid as Response, next);
|
||||
|
||||
// Should not throw and should call next
|
||||
expect(next).toHaveBeenCalled();
|
||||
|
||||
// Response should still work even if logging fails
|
||||
const result = resInvalid.json!({success: true});
|
||||
expect(result).toEqual({success: true});
|
||||
|
||||
// Wait for async logging to fail silently
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// The logging should have failed but not affected the response
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// REQUEST/RESPONSE SIZE TRACKING
|
||||
// ========================================
|
||||
describe('Request/Response Size Tracking', () => {
|
||||
it('should track request size from content-length header', async () => {
|
||||
// Create a new request with different content-length
|
||||
const reqWithSize = {
|
||||
...req,
|
||||
headers: {
|
||||
'content-length': '5000',
|
||||
},
|
||||
};
|
||||
|
||||
// Create a new response with unique request ID
|
||||
const resWithId = {
|
||||
...res,
|
||||
locals: {
|
||||
...res.locals!,
|
||||
requestId: 'test-size-5000',
|
||||
},
|
||||
};
|
||||
|
||||
databaseRequestLogger(reqWithSize as Request, resWithId as Response, next);
|
||||
|
||||
await resWithId.json!({success: true});
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const loggedRequest = await prisma.apiRequest.findUnique({
|
||||
where: {id: 'test-size-5000'},
|
||||
});
|
||||
|
||||
expect(loggedRequest?.requestSize).toBe(5000);
|
||||
});
|
||||
|
||||
it('should handle missing content-length header', async () => {
|
||||
// Create request without content-length
|
||||
const reqNoSize = {
|
||||
...req,
|
||||
headers: {},
|
||||
};
|
||||
|
||||
const resWithId = {
|
||||
...res,
|
||||
locals: {
|
||||
...res.locals!,
|
||||
requestId: 'test-no-size',
|
||||
},
|
||||
};
|
||||
|
||||
databaseRequestLogger(reqNoSize as Request, resWithId as Response, next);
|
||||
|
||||
await resWithId.json!({success: true});
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const loggedRequest = await prisma.apiRequest.findUnique({
|
||||
where: {id: 'test-no-size'},
|
||||
});
|
||||
|
||||
expect(loggedRequest?.requestSize).toBeNull();
|
||||
});
|
||||
|
||||
it('should calculate response size from JSON body', async () => {
|
||||
const jsonMock = vi.fn(function (this: Response, body: unknown) {
|
||||
return body;
|
||||
});
|
||||
const resLarge = {
|
||||
...res,
|
||||
locals: {...res.locals!, requestId: 'test-large-response'},
|
||||
json: jsonMock,
|
||||
};
|
||||
|
||||
databaseRequestLogger(req as Request, resLarge as Response, next);
|
||||
|
||||
const largeResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
items: Array(100).fill({id: '123', name: 'Test Item', description: 'A test item'}),
|
||||
},
|
||||
};
|
||||
|
||||
await resLarge.json!(largeResponse);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const loggedRequest = await prisma.apiRequest.findUnique({
|
||||
where: {id: 'test-large-response'},
|
||||
});
|
||||
|
||||
const expectedSize = JSON.stringify(largeResponse).length;
|
||||
expect(loggedRequest?.responseSize).toBe(expectedSize);
|
||||
expect(loggedRequest?.responseSize).toBeGreaterThan(1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,378 @@
|
||||
import dayjs from 'dayjs';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import jsonwebtoken from 'jsonwebtoken';
|
||||
|
||||
import type {AuthResponse} from '@plunk/types';
|
||||
|
||||
import {JWT_SECRET, PLUNK_ENABLED} from '../app/constants.js';
|
||||
import {ErrorCode, HttpException, NotAuthenticated} from '../exceptions/index.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {ProjectService} from '../services/ProjectService.js';
|
||||
import {UserService} from '../services/UserService.js';
|
||||
|
||||
/**
|
||||
* 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();
|
||||
};
|
||||
|
||||
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 {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Signs a JWT token
|
||||
* @param id The user'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 user's jwt token
|
||||
*/
|
||||
expires(token: string): dayjs.Dayjs {
|
||||
const {exp} = jsonwebtoken.verify(token, JWT_SECRET) as {
|
||||
exp?: number;
|
||||
};
|
||||
return dayjs(exp);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a user'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.next_token;
|
||||
|
||||
if (!token) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
const id = jwt.verify(token);
|
||||
|
||||
if (!id) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to require public API key authentication (for /v1/track endpoint only)
|
||||
* Validates that the request has a valid public key and sets the project
|
||||
* @param req
|
||||
* @param res
|
||||
* @param next
|
||||
*/
|
||||
export const requirePublicKey = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
// Get API key from Authorization header
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader) {
|
||||
throw new HttpException(401, 'Authorization header is required', ErrorCode.MISSING_AUTH);
|
||||
}
|
||||
|
||||
// Support "Bearer <key>" format
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||
throw new HttpException(
|
||||
401,
|
||||
'Authorization header must use Bearer token format: "Authorization: Bearer YOUR_API_KEY"',
|
||||
ErrorCode.MISSING_AUTH,
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = parts[1];
|
||||
|
||||
if (!apiKey) {
|
||||
throw new HttpException(401, 'API key is required in Authorization header', ErrorCode.MISSING_AUTH);
|
||||
}
|
||||
|
||||
// Look up project by public key only
|
||||
const project = await ProjectService.public(apiKey);
|
||||
|
||||
if (!project) {
|
||||
throw new HttpException(
|
||||
401,
|
||||
'Invalid public API key. Ensure you are using a public key (pk_*) for this endpoint.',
|
||||
ErrorCode.INVALID_API_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
// Set auth response with project ID (before disabled check so it's available for logging)
|
||||
res.locals.auth = {
|
||||
type: 'apiKey',
|
||||
projectId: project.id,
|
||||
};
|
||||
|
||||
// Check if project is disabled - block write operations
|
||||
if (project.disabled) {
|
||||
const method = req.method.toUpperCase();
|
||||
const isWriteOperation = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
|
||||
|
||||
if (isWriteOperation) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
'Project is disabled due to security violations. All write operations are blocked.',
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to require secret API key authentication
|
||||
* Validates that the request has a valid secret key and sets the project
|
||||
* @param req
|
||||
* @param res
|
||||
* @param next
|
||||
*/
|
||||
export const requireSecretKey = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
// Get API key from Authorization header
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader) {
|
||||
throw new HttpException(401, 'Authorization header is required', ErrorCode.MISSING_AUTH);
|
||||
}
|
||||
|
||||
// Support "Bearer <key>" format
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||
throw new HttpException(
|
||||
401,
|
||||
'Authorization header must use Bearer token format: "Authorization: Bearer YOUR_API_KEY"',
|
||||
ErrorCode.MISSING_AUTH,
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = parts[1];
|
||||
|
||||
if (!apiKey) {
|
||||
throw new HttpException(401, 'API key is required in Authorization header', ErrorCode.MISSING_AUTH);
|
||||
}
|
||||
|
||||
// Look up project by secret key only
|
||||
const project = await ProjectService.secret(apiKey);
|
||||
|
||||
if (!project) {
|
||||
throw new HttpException(
|
||||
401,
|
||||
'Invalid secret API key. This endpoint requires a secret key (sk_*), not a public key.',
|
||||
ErrorCode.INVALID_API_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
// Set auth response with project ID (before disabled check so it's available for logging)
|
||||
res.locals.auth = {
|
||||
type: 'apiKey',
|
||||
projectId: project.id,
|
||||
};
|
||||
|
||||
// Check if project is disabled - block write operations
|
||||
if (project.disabled) {
|
||||
const method = req.method.toUpperCase();
|
||||
const isWriteOperation = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
|
||||
|
||||
if (isWriteOperation) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
'Project is disabled due to security violations. All write operations are blocked.',
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to require authentication - supports both JWT and secret API key
|
||||
* For JWT: requires X-Project-Id header and validates user has access to the project
|
||||
* For API key: only accepts secret keys (sk_*), derives project from the key
|
||||
* @param req
|
||||
* @param res
|
||||
* @param next
|
||||
*/
|
||||
export const requireAuth = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
// Check for API key first (Authorization header with Bearer token)
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
// If Authorization header is provided, use secret key authentication
|
||||
if (authHeader) {
|
||||
// Support "Bearer <key>" format
|
||||
const parts = authHeader.split(' ');
|
||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||
throw new HttpException(
|
||||
401,
|
||||
'Authorization header must use Bearer token format: "Authorization: Bearer YOUR_API_KEY"',
|
||||
ErrorCode.MISSING_AUTH,
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = parts[1];
|
||||
|
||||
if (!apiKey) {
|
||||
throw new HttpException(401, 'API key is required in Authorization header', ErrorCode.MISSING_AUTH);
|
||||
}
|
||||
// Look up project by secret key only (public keys not allowed)
|
||||
const project = await ProjectService.secret(apiKey);
|
||||
|
||||
if (!project) {
|
||||
throw new HttpException(
|
||||
401,
|
||||
'Invalid secret API key. This endpoint requires a secret key (sk_*), not a public key.',
|
||||
ErrorCode.INVALID_API_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
// Set auth response with project ID (before disabled check so it's available for logging)
|
||||
res.locals.auth = {
|
||||
type: 'apiKey',
|
||||
projectId: project.id,
|
||||
} as AuthResponse;
|
||||
|
||||
// Check if project is disabled - block write operations
|
||||
if (project.disabled) {
|
||||
const method = req.method.toUpperCase();
|
||||
const isWriteOperation = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
|
||||
|
||||
if (isWriteOperation) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
'Project is disabled due to security violations. All write operations are blocked.',
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
// Otherwise, use JWT authentication
|
||||
const userId = parseJwt(req);
|
||||
|
||||
// Get project ID from header (required for JWT auth)
|
||||
const projectId = req.headers['x-project-id'] as string | undefined;
|
||||
|
||||
if (!projectId) {
|
||||
throw new HttpException(400, 'Project ID is required in X-Project-Id header', ErrorCode.BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Verify user has access to this project and get project status
|
||||
const [membership, project] = await Promise.all([
|
||||
MembershipService.getMembership(userId, projectId),
|
||||
ProjectService.id(projectId),
|
||||
]);
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpException(403, 'You do not have access to this project', ErrorCode.PROJECT_ACCESS_DENIED);
|
||||
}
|
||||
|
||||
// Set auth response with project ID (before disabled check so it's available for logging)
|
||||
res.locals.auth = {
|
||||
type: 'jwt',
|
||||
userId,
|
||||
projectId,
|
||||
};
|
||||
|
||||
// Check if project is disabled - block write operations
|
||||
if (project?.disabled) {
|
||||
const method = req.method.toUpperCase();
|
||||
const isWriteOperation = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method);
|
||||
|
||||
if (isWriteOperation) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
'Project is disabled due to security violations. All write operations are blocked.',
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to require email verification
|
||||
* Must be used AFTER isAuthenticated or requireProjectAccess
|
||||
* @param req
|
||||
* @param res
|
||||
* @param next
|
||||
*/
|
||||
export const requireEmailVerified = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const auth = res.locals.auth;
|
||||
|
||||
if (auth.type === 'apiKey') {
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
const user = await UserService.id(auth.userId);
|
||||
|
||||
if (!user) {
|
||||
throw new NotAuthenticated();
|
||||
}
|
||||
|
||||
// If platform email verification is disabled, skip check
|
||||
if (!PLUNK_ENABLED) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// OAuth users are always considered verified
|
||||
if (user.type !== 'PASSWORD') {
|
||||
return next();
|
||||
}
|
||||
|
||||
// PASSWORD users must verify email
|
||||
if (!user.emailVerified) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
'Please verify your email address to access this resource',
|
||||
ErrorCode.EMAIL_VERIFICATION_REQUIRED,
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
|
||||
/**
|
||||
* Middleware to add a unique request ID to each request
|
||||
* The request ID is used for error tracking and debugging
|
||||
*/
|
||||
export const requestIdMiddleware = (req: Request, res: Response, next: NextFunction) => {
|
||||
// Check if request ID already exists (e.g., from load balancer)
|
||||
const existingRequestId = req.headers['x-request-id'] as string | undefined;
|
||||
|
||||
// Generate new ID if not provided
|
||||
const requestId = existingRequestId || randomUUID();
|
||||
|
||||
// Store in request object for use in handlers
|
||||
res.locals.requestId = requestId;
|
||||
|
||||
// Send back in response headers for client-side tracking
|
||||
res.setHeader('X-Request-ID', requestId);
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
import type {NextFunction, Request, Response} from 'express';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {logger} from '../utils/logger.js';
|
||||
|
||||
/**
|
||||
* Check if request logging is enabled via environment variable
|
||||
* Set REQUEST_LOGGING=false to disable database logging entirely
|
||||
*/
|
||||
const REQUEST_LOGGING_ENABLED = process.env.REQUEST_LOGGING !== 'false';
|
||||
|
||||
/**
|
||||
* Endpoints to exclude from database logging
|
||||
*
|
||||
* Guidelines for what to skip:
|
||||
* - Health checks and monitoring endpoints (called by load balancers every few seconds)
|
||||
* - User session/auth endpoints (called on every page load in the dashboard)
|
||||
* - Real-time polling endpoints (called repeatedly for live updates)
|
||||
* - Static asset requests (should be served by CDN anyway)
|
||||
* - Internal/admin-only endpoints that don't need audit trails
|
||||
*
|
||||
* What TO log (valuable for analytics/debugging):
|
||||
* - Public API endpoints (/v1/send, /v1/track) - track customer usage
|
||||
* - Resource CRUD operations (contacts, templates, campaigns) - audit trail
|
||||
* - Authentication attempts (login, signup) - security monitoring
|
||||
* - Payment/billing operations - compliance
|
||||
* - Failed requests (always logged regardless of endpoint)
|
||||
*/
|
||||
const SKIP_LOGGING_PATHS = [
|
||||
// Health/status checks (called by load balancers constantly)
|
||||
'/health',
|
||||
'/',
|
||||
|
||||
// User session endpoints (called on every dashboard page load)
|
||||
'/users/@me',
|
||||
'/users/@me/projects',
|
||||
'/users/me',
|
||||
'/users/me/projects',
|
||||
|
||||
// Project switching (called frequently when user switches projects)
|
||||
'/users/@me/projects/:id',
|
||||
|
||||
// Real-time polling/stats endpoints
|
||||
'/queue/stats',
|
||||
|
||||
// Feature flags/config (called on app initialization)
|
||||
'/config',
|
||||
'/config/features',
|
||||
'/oauth-config',
|
||||
|
||||
// Field introspection (called when building forms/filters)
|
||||
'/contacts/fields',
|
||||
'/events/names',
|
||||
];
|
||||
|
||||
/**
|
||||
* Path patterns to exclude (regex)
|
||||
* For more complex matching like "/assets/*" or "*.json"
|
||||
*/
|
||||
const SKIP_LOGGING_PATTERNS = [
|
||||
/^\/assets\//i, // Static assets
|
||||
/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)$/i, // Static files
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a request should be logged to the database
|
||||
*/
|
||||
function shouldLogRequest(req: Request): boolean {
|
||||
const path = req.path;
|
||||
|
||||
// Check exact path matches
|
||||
if (SKIP_LOGGING_PATHS.includes(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check pattern matches
|
||||
if (SKIP_LOGGING_PATTERNS.some(pattern => pattern.test(path))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Log everything else
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to log API requests to the database for historical tracking and analytics
|
||||
* This runs asynchronously and does not block the response
|
||||
*
|
||||
* IMPORTANT: Only logs important endpoints to avoid noise and reduce database load
|
||||
*
|
||||
* Configuration:
|
||||
* - Set REQUEST_LOGGING=false to disable entirely
|
||||
* - Modify SKIP_LOGGING_PATHS to exclude specific endpoints
|
||||
* - Modify SKIP_LOGGING_PATTERNS to exclude path patterns
|
||||
*/
|
||||
export const databaseRequestLogger = (req: Request, res: Response, next: NextFunction) => {
|
||||
// Check if logging is enabled
|
||||
if (!REQUEST_LOGGING_ENABLED) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Skip logging for excluded endpoints
|
||||
if (!shouldLogRequest(req)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Capture the original res.json to log after response
|
||||
const originalJson = res.json.bind(res);
|
||||
res.json = function (body: unknown) {
|
||||
// Call original json method first
|
||||
const result = originalJson(body);
|
||||
|
||||
// Log to database asynchronously (don't await, don't block response)
|
||||
void logRequestToDatabase(req, res, startTime, body);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Log the request to the database
|
||||
* This runs asynchronously and failures are logged but don't affect the response
|
||||
*/
|
||||
async function logRequestToDatabase(
|
||||
req: Request,
|
||||
res: Response,
|
||||
startTime: number,
|
||||
responseBody: unknown,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const requestId = res.locals.requestId as string | undefined;
|
||||
const auth = res.locals.auth as {type?: string; userId?: string; projectId?: string} | undefined;
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
const statusCode = res.statusCode;
|
||||
|
||||
// Extract error information if this is an error response
|
||||
let errorCode: string | undefined;
|
||||
let errorMessage: string | undefined;
|
||||
|
||||
if (
|
||||
statusCode >= 400 &&
|
||||
responseBody &&
|
||||
typeof responseBody === 'object' &&
|
||||
'error' in responseBody &&
|
||||
responseBody.error &&
|
||||
typeof responseBody.error === 'object'
|
||||
) {
|
||||
const error = responseBody.error as {code?: string; message?: string};
|
||||
errorCode = error.code;
|
||||
errorMessage = error.message;
|
||||
}
|
||||
|
||||
// Calculate request/response sizes (approximate)
|
||||
const requestSize = req.headers['content-length'] ? parseInt(req.headers['content-length'], 10) : undefined;
|
||||
const responseSize = JSON.stringify(responseBody).length;
|
||||
|
||||
// Insert into database
|
||||
await prisma.apiRequest.create({
|
||||
data: {
|
||||
id: requestId || crypto.randomUUID(), // Use request ID as primary key
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
statusCode,
|
||||
duration,
|
||||
projectId: auth?.projectId || null,
|
||||
userId: auth?.userId || null,
|
||||
authType: auth?.type || null,
|
||||
ip: req.ip || req.socket.remoteAddress || null,
|
||||
userAgent: req.get('user-agent') || null,
|
||||
errorCode: errorCode || null,
|
||||
errorMessage: errorMessage || null,
|
||||
requestSize: requestSize || null,
|
||||
responseSize,
|
||||
},
|
||||
});
|
||||
|
||||
// Log success for debugging (only in development)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
logger.debug('Request logged to database', {requestId}, res);
|
||||
}
|
||||
} catch (error) {
|
||||
// Log the error but don't throw - we don't want logging failures to affect the API
|
||||
logger.error('Failed to log request to database', error, {
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
import type {Prisma} from '@plunk/db';
|
||||
import type {Activity, ActivityStats, CursorPaginatedResponse} from '@plunk/types';
|
||||
import {ActivityType} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
|
||||
/**
|
||||
* Activity Service
|
||||
*
|
||||
* PERFORMANCE CONSIDERATIONS:
|
||||
* - Uses cursor-based pagination for efficient large dataset handling
|
||||
* - Limits date range to prevent expensive queries (default 30 days)
|
||||
* - Caches statistics in Redis with 5-minute TTL
|
||||
* - Uses indexed fields (createdAt) for sorting
|
||||
* - Batch processes and merges results from multiple tables
|
||||
*/
|
||||
export class ActivityService {
|
||||
private static readonly DEFAULT_LIMIT = 50;
|
||||
private static readonly MAX_LIMIT = 100;
|
||||
private static readonly DEFAULT_DAYS_BACK = 30;
|
||||
private static readonly STATS_CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* Get unified activity feed for a project
|
||||
*
|
||||
* Performance: O(n log n) where n = limit
|
||||
* - Fetches up to `limit` items from 3 tables in parallel
|
||||
* - Merges and sorts by timestamp
|
||||
* - Returns top `limit` items
|
||||
*
|
||||
* PAGINATION APPROACH:
|
||||
* This implementation fetches `limit` items from each source (Events, Emails, Workflows),
|
||||
* then merges and returns the top `limit` results by timestamp. This ensures a proper
|
||||
* chronological timeline but has tradeoffs:
|
||||
*
|
||||
* Pros:
|
||||
* - Proper time-based ordering across all activity types
|
||||
* - Simple cursor-based pagination
|
||||
* - Efficient for typical use cases
|
||||
*
|
||||
* Cons:
|
||||
* - May fetch more items from DB than returned to client (up to 3x limit)
|
||||
* - Cursor pagination across sources can miss items in rare edge cases
|
||||
*
|
||||
* For higher scale (10M+ activities), consider:
|
||||
* - Materialized view or unified activity table
|
||||
* - Event sourcing pattern with proper indexing
|
||||
* - Separate pagination per activity type
|
||||
*/
|
||||
public static async getActivities(
|
||||
projectId: string,
|
||||
limit = this.DEFAULT_LIMIT,
|
||||
cursor?: string,
|
||||
types?: ActivityType[],
|
||||
contactId?: string,
|
||||
startDate?: Date,
|
||||
endDate?: Date,
|
||||
): Promise<CursorPaginatedResponse<Activity>> {
|
||||
// Cap limit to prevent abuse
|
||||
const effectiveLimit = Math.min(limit, this.MAX_LIMIT);
|
||||
|
||||
// Fetch extra items from each source to ensure we have enough after merging
|
||||
// We fetch limit items from each, then take top limit after sorting
|
||||
const fetchLimit = effectiveLimit;
|
||||
|
||||
// Default date range to last 30 days if not specified
|
||||
const now = new Date();
|
||||
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
|
||||
const dateFilter: Prisma.DateTimeFilter = {
|
||||
gte: startDate || defaultStartDate,
|
||||
...(endDate ? {lte: endDate} : {}),
|
||||
};
|
||||
|
||||
// Parse cursor if provided (format: timestamp_id)
|
||||
let cursorTimestamp: Date | undefined;
|
||||
let cursorId: string | undefined;
|
||||
if (cursor) {
|
||||
const [timestamp, id] = cursor.split('_');
|
||||
cursorTimestamp = timestamp ? new Date(parseInt(timestamp)) : undefined;
|
||||
cursorId = id;
|
||||
}
|
||||
|
||||
// Fetch activities from different sources in parallel
|
||||
// Each source fetches up to fetchLimit items
|
||||
const [events, emails, workflows] = await Promise.all([
|
||||
this.fetchEvents(projectId, fetchLimit, dateFilter, cursorTimestamp, cursorId, contactId, types),
|
||||
this.fetchEmailActivities(projectId, fetchLimit, dateFilter, cursorTimestamp, cursorId, contactId, types),
|
||||
this.fetchWorkflowActivities(projectId, fetchLimit, dateFilter, cursorTimestamp, cursorId, contactId, types),
|
||||
]);
|
||||
|
||||
// Merge all activities
|
||||
const allActivities = [...events, ...emails, ...workflows];
|
||||
|
||||
// Sort by timestamp descending (most recent first)
|
||||
allActivities.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
|
||||
// Take only the requested limit + 1 (to check if there are more)
|
||||
const paginatedActivities = allActivities.slice(0, effectiveLimit + 1);
|
||||
|
||||
// Check if there are more results
|
||||
const hasMore = paginatedActivities.length > effectiveLimit;
|
||||
const results = hasMore ? paginatedActivities.slice(0, effectiveLimit) : paginatedActivities;
|
||||
|
||||
// Generate cursor from the last item
|
||||
const lastActivity = results[results.length - 1];
|
||||
const nextCursor = hasMore && lastActivity ? `${lastActivity.timestamp.getTime()}_${lastActivity.id}` : undefined;
|
||||
|
||||
return {
|
||||
data: results,
|
||||
cursor: nextCursor,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get activity statistics for a project
|
||||
*
|
||||
* Performance: Uses Redis cache with 5-minute TTL
|
||||
* Falls back to database aggregation if cache miss
|
||||
*/
|
||||
public static async getStats(projectId: string, startDate?: Date, endDate?: Date): Promise<ActivityStats> {
|
||||
// Try to get from cache
|
||||
const cacheKey = Keys.Activity.stats(projectId, startDate?.getTime() || 'all', endDate?.getTime() || 'now');
|
||||
|
||||
try {
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
} catch (error) {
|
||||
signale.warn('[ACTIVITY] Failed to get stats from cache:', error);
|
||||
}
|
||||
|
||||
// Default date range to last 30 days if not specified
|
||||
const now = new Date();
|
||||
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
|
||||
const dateFilter: Prisma.DateTimeFilter = {
|
||||
gte: startDate || defaultStartDate,
|
||||
...(endDate ? {lte: endDate} : {}),
|
||||
};
|
||||
|
||||
// Compute stats from database (in parallel for performance)
|
||||
const [totalEvents, emailStats] = await Promise.all([
|
||||
// Count total events
|
||||
prisma.event.count({
|
||||
where: {
|
||||
projectId,
|
||||
createdAt: dateFilter,
|
||||
},
|
||||
}),
|
||||
|
||||
// Aggregate email stats
|
||||
prisma.email.aggregate({
|
||||
where: {
|
||||
projectId,
|
||||
createdAt: dateFilter,
|
||||
},
|
||||
_count: {
|
||||
id: true,
|
||||
openedAt: true,
|
||||
clickedAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Count workflow executions
|
||||
const totalWorkflowsStarted = await prisma.workflowExecution.count({
|
||||
where: {
|
||||
workflow: {
|
||||
projectId,
|
||||
},
|
||||
startedAt: dateFilter,
|
||||
},
|
||||
});
|
||||
|
||||
const totalEmailsSent = emailStats._count.id;
|
||||
const totalEmailsOpened = emailStats._count.openedAt || 0;
|
||||
const totalEmailsClicked = emailStats._count.clickedAt || 0;
|
||||
|
||||
const stats: ActivityStats = {
|
||||
totalEvents,
|
||||
totalEmailsSent,
|
||||
totalEmailsOpened,
|
||||
totalEmailsClicked,
|
||||
totalWorkflowsStarted,
|
||||
openRate: totalEmailsSent > 0 ? (totalEmailsOpened / totalEmailsSent) * 100 : 0,
|
||||
clickRate: totalEmailsSent > 0 ? (totalEmailsClicked / totalEmailsSent) * 100 : 0,
|
||||
};
|
||||
|
||||
// Cache for 5 minutes
|
||||
try {
|
||||
await redis.setex(cacheKey, this.STATS_CACHE_TTL, JSON.stringify(stats));
|
||||
} catch (error) {
|
||||
signale.warn('[ACTIVITY] Failed to cache stats:', error);
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate activity stats cache for a project
|
||||
* Should be called when new activities are created
|
||||
*/
|
||||
public static async invalidateStatsCache(projectId: string): Promise<void> {
|
||||
try {
|
||||
// Delete all cache keys for this project
|
||||
const pattern = `activity:stats:${projectId}:*`;
|
||||
const keys = await redis.keys(pattern);
|
||||
|
||||
if (keys.length > 0) {
|
||||
await redis.del(...keys);
|
||||
}
|
||||
} catch (error) {
|
||||
signale.warn('[ACTIVITY] Failed to invalidate stats cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent activity count (for real-time updates)
|
||||
* Returns count of activities in the last N minutes
|
||||
*/
|
||||
public static async getRecentActivityCount(projectId: string, minutes = 5): Promise<number> {
|
||||
const since = new Date(Date.now() - minutes * 60 * 1000);
|
||||
const dateFilter: Prisma.DateTimeFilter = {gte: since};
|
||||
|
||||
const [eventCount, emailCount, workflowCount] = await Promise.all([
|
||||
prisma.event.count({
|
||||
where: {projectId, createdAt: dateFilter},
|
||||
}),
|
||||
prisma.email.count({
|
||||
where: {projectId, createdAt: dateFilter},
|
||||
}),
|
||||
prisma.workflowExecution.count({
|
||||
where: {
|
||||
workflow: {projectId},
|
||||
startedAt: dateFilter,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return eventCount + emailCount + workflowCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get upcoming scheduled activities for a project
|
||||
*
|
||||
* Fetches scheduled campaigns and workflow step executions that are
|
||||
* scheduled to send emails in the future.
|
||||
*
|
||||
* @param projectId - Project ID
|
||||
* @param limit - Max number of items to return (default 50)
|
||||
* @param daysAhead - How many days into the future to look (default 30)
|
||||
*/
|
||||
public static async getUpcomingActivities(
|
||||
projectId: string,
|
||||
limit = this.DEFAULT_LIMIT,
|
||||
daysAhead = this.DEFAULT_DAYS_BACK,
|
||||
): Promise<Activity[]> {
|
||||
const effectiveLimit = Math.min(limit, this.MAX_LIMIT);
|
||||
const now = new Date();
|
||||
const futureDate = new Date(now.getTime() + daysAhead * 24 * 60 * 60 * 1000);
|
||||
|
||||
const dateFilter: Prisma.DateTimeFilter = {
|
||||
gte: now,
|
||||
lte: futureDate,
|
||||
};
|
||||
|
||||
// Fetch scheduled items in parallel
|
||||
const [scheduledCampaigns, scheduledWorkflowSteps] = await Promise.all([
|
||||
this.fetchScheduledCampaigns(projectId, effectiveLimit, dateFilter),
|
||||
this.fetchScheduledWorkflowSteps(projectId, effectiveLimit, dateFilter),
|
||||
]);
|
||||
|
||||
// Merge all scheduled activities
|
||||
const allActivities = [...scheduledCampaigns, ...scheduledWorkflowSteps];
|
||||
|
||||
// Sort by timestamp ascending (earliest first for upcoming items)
|
||||
allActivities.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
|
||||
// Return up to the limit
|
||||
return allActivities.slice(0, effectiveLimit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch event activities
|
||||
*/
|
||||
private static async fetchEvents(
|
||||
projectId: string,
|
||||
limit: number,
|
||||
dateFilter: Prisma.DateTimeFilter,
|
||||
cursorTimestamp?: Date,
|
||||
cursorId?: string,
|
||||
contactId?: string,
|
||||
types?: ActivityType[],
|
||||
): Promise<Activity[]> {
|
||||
// Skip if filtering by types and event.triggered is not included
|
||||
if (types && !types.includes(ActivityType.EVENT_TRIGGERED)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const where: Prisma.EventWhereInput = {
|
||||
projectId,
|
||||
createdAt: cursorTimestamp
|
||||
? {
|
||||
...dateFilter,
|
||||
lt: cursorTimestamp,
|
||||
}
|
||||
: dateFilter,
|
||||
...(contactId ? {contactId} : {}),
|
||||
};
|
||||
|
||||
const events = await prisma.event.findMany({
|
||||
where,
|
||||
orderBy: {createdAt: 'desc'},
|
||||
take: limit,
|
||||
include: {
|
||||
contact: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return events.map(event => ({
|
||||
id: event.id,
|
||||
type: ActivityType.EVENT_TRIGGERED,
|
||||
timestamp: event.createdAt,
|
||||
contactEmail: event.contact?.email,
|
||||
contactId: event.contactId || undefined,
|
||||
metadata: {
|
||||
eventName: event.name,
|
||||
eventData: event.data,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch email activities (sent, delivered, opened, clicked, bounced)
|
||||
*/
|
||||
private static async fetchEmailActivities(
|
||||
projectId: string,
|
||||
limit: number,
|
||||
dateFilter: Prisma.DateTimeFilter,
|
||||
cursorTimestamp?: Date,
|
||||
cursorId?: string,
|
||||
contactId?: string,
|
||||
types?: ActivityType[],
|
||||
): Promise<Activity[]> {
|
||||
const activities: Activity[] = [];
|
||||
|
||||
// Determine which email statuses to fetch based on types filter
|
||||
const emailTypes = types
|
||||
? types.filter(t => t.startsWith('email.'))
|
||||
: Object.values(ActivityType).filter(t => t.startsWith('email.'));
|
||||
|
||||
if (emailTypes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const where: Prisma.EmailWhereInput = {
|
||||
projectId,
|
||||
...(contactId ? {contactId} : {}),
|
||||
};
|
||||
|
||||
// Build OR conditions to filter by the appropriate timestamp field for each activity type
|
||||
// This ensures we fetch emails where the specific activity (bounced, sent, etc.) occurred in the date range
|
||||
const orConditions: Prisma.EmailWhereInput[] = [];
|
||||
|
||||
if (!types || types.includes(ActivityType.EMAIL_SENT)) {
|
||||
orConditions.push({sentAt: {not: null, ...dateFilter}});
|
||||
}
|
||||
if (!types || types.includes(ActivityType.EMAIL_DELIVERED)) {
|
||||
orConditions.push({deliveredAt: {not: null, ...dateFilter}});
|
||||
}
|
||||
if (!types || types.includes(ActivityType.EMAIL_RECEIVED)) {
|
||||
orConditions.push({deliveredAt: {not: null, ...dateFilter}, sourceType: 'INBOUND'});
|
||||
}
|
||||
if (!types || types.includes(ActivityType.EMAIL_OPENED)) {
|
||||
orConditions.push({openedAt: {not: null, ...dateFilter}});
|
||||
}
|
||||
if (!types || types.includes(ActivityType.EMAIL_CLICKED)) {
|
||||
orConditions.push({clickedAt: {not: null, ...dateFilter}});
|
||||
}
|
||||
if (!types || types.includes(ActivityType.EMAIL_BOUNCED)) {
|
||||
orConditions.push({bouncedAt: {not: null, ...dateFilter}});
|
||||
}
|
||||
if (!types || types.includes(ActivityType.EMAIL_COMPLAINT)) {
|
||||
orConditions.push({complainedAt: {not: null, ...dateFilter}});
|
||||
}
|
||||
|
||||
// If no OR conditions, return empty (shouldn't happen but defensive)
|
||||
if (orConditions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const emails = await prisma.email.findMany({
|
||||
where: {
|
||||
...where,
|
||||
OR: orConditions,
|
||||
},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
take: limit,
|
||||
include: {
|
||||
contact: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
campaign: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
workflowExecution: {
|
||||
select: {
|
||||
workflow: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Helper function to check if timestamp is within date range
|
||||
const isInDateRange = (timestamp: Date | null) => {
|
||||
if (!timestamp) return false;
|
||||
|
||||
// Check against date filter
|
||||
const time = timestamp.getTime();
|
||||
if (dateFilter.gte) {
|
||||
const gteTime = dateFilter.gte instanceof Date ? dateFilter.gte.getTime() : new Date(dateFilter.gte).getTime();
|
||||
if (time < gteTime) return false;
|
||||
}
|
||||
if (dateFilter.lte) {
|
||||
const lteTime = dateFilter.lte instanceof Date ? dateFilter.lte.getTime() : new Date(dateFilter.lte).getTime();
|
||||
if (time > lteTime) return false;
|
||||
}
|
||||
|
||||
// Check against cursor for pagination
|
||||
if (cursorTimestamp && time >= cursorTimestamp.getTime()) return false;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Convert each email into multiple activities based on its state
|
||||
for (const email of emails) {
|
||||
const baseMetadata = {
|
||||
subject: email.subject,
|
||||
body: email.body,
|
||||
from: email.from,
|
||||
fromName: email.fromName,
|
||||
replyTo: email.replyTo,
|
||||
toName: email.toName,
|
||||
sourceType: email.sourceType,
|
||||
campaignName: email.campaign?.name,
|
||||
workflowName: email.workflowExecution?.workflow?.name,
|
||||
};
|
||||
|
||||
if (email.sentAt && (!types || types.includes(ActivityType.EMAIL_SENT)) && isInDateRange(email.sentAt)) {
|
||||
activities.push({
|
||||
id: `${email.id}_sent`,
|
||||
type: ActivityType.EMAIL_SENT,
|
||||
timestamp: email.sentAt,
|
||||
contactEmail: email.contact.email,
|
||||
contactId: email.contactId,
|
||||
metadata: baseMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
email.deliveredAt &&
|
||||
(!types || types.includes(ActivityType.EMAIL_DELIVERED)) &&
|
||||
isInDateRange(email.deliveredAt)
|
||||
) {
|
||||
activities.push({
|
||||
id: `${email.id}_delivered`,
|
||||
type: ActivityType.EMAIL_DELIVERED,
|
||||
timestamp: email.deliveredAt,
|
||||
contactEmail: email.contact.email,
|
||||
contactId: email.contactId,
|
||||
metadata: baseMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
email.deliveredAt &&
|
||||
email.sourceType === 'INBOUND' &&
|
||||
(!types || types.includes(ActivityType.EMAIL_RECEIVED)) &&
|
||||
isInDateRange(email.deliveredAt)
|
||||
) {
|
||||
activities.push({
|
||||
id: `${email.id}_received`,
|
||||
type: ActivityType.EMAIL_RECEIVED,
|
||||
timestamp: email.deliveredAt,
|
||||
contactEmail: email.contact.email,
|
||||
contactId: email.contactId,
|
||||
metadata: baseMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
if (email.openedAt && (!types || types.includes(ActivityType.EMAIL_OPENED)) && isInDateRange(email.openedAt)) {
|
||||
activities.push({
|
||||
id: `${email.id}_opened`,
|
||||
type: ActivityType.EMAIL_OPENED,
|
||||
timestamp: email.openedAt,
|
||||
contactEmail: email.contact.email,
|
||||
contactId: email.contactId,
|
||||
metadata: {
|
||||
...baseMetadata,
|
||||
totalOpens: email.opens,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Email clicked
|
||||
if (email.clickedAt && (!types || types.includes(ActivityType.EMAIL_CLICKED)) && isInDateRange(email.clickedAt)) {
|
||||
activities.push({
|
||||
id: `${email.id}_clicked`,
|
||||
type: ActivityType.EMAIL_CLICKED,
|
||||
timestamp: email.clickedAt,
|
||||
contactEmail: email.contact.email,
|
||||
contactId: email.contactId,
|
||||
metadata: {
|
||||
...baseMetadata,
|
||||
totalClicks: email.clicks,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Email bounced
|
||||
if (email.bouncedAt && (!types || types.includes(ActivityType.EMAIL_BOUNCED)) && isInDateRange(email.bouncedAt)) {
|
||||
activities.push({
|
||||
id: `${email.id}_bounced`,
|
||||
type: ActivityType.EMAIL_BOUNCED,
|
||||
timestamp: email.bouncedAt,
|
||||
contactEmail: email.contact.email,
|
||||
contactId: email.contactId,
|
||||
metadata: {
|
||||
...baseMetadata,
|
||||
error: email.error,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Email complaint
|
||||
if (
|
||||
email.complainedAt &&
|
||||
(!types || types.includes(ActivityType.EMAIL_COMPLAINT)) &&
|
||||
isInDateRange(email.complainedAt)
|
||||
) {
|
||||
activities.push({
|
||||
id: `${email.id}_complaint`,
|
||||
type: ActivityType.EMAIL_COMPLAINT,
|
||||
timestamp: email.complainedAt,
|
||||
contactEmail: email.contact.email,
|
||||
contactId: email.contactId,
|
||||
metadata: {
|
||||
...baseMetadata,
|
||||
error: email.error,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return activities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch workflow activities
|
||||
*/
|
||||
private static async fetchWorkflowActivities(
|
||||
projectId: string,
|
||||
limit: number,
|
||||
dateFilter: Prisma.DateTimeFilter,
|
||||
cursorTimestamp?: Date,
|
||||
cursorId?: string,
|
||||
contactId?: string,
|
||||
types?: ActivityType[],
|
||||
): Promise<Activity[]> {
|
||||
// Skip if filtering by types and workflow types are not included
|
||||
if (types && !types.some(t => t.startsWith('workflow.'))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const activities: Activity[] = [];
|
||||
|
||||
const where: Prisma.WorkflowExecutionWhereInput = {
|
||||
workflow: {
|
||||
projectId,
|
||||
},
|
||||
...(contactId ? {contactId} : {}),
|
||||
};
|
||||
|
||||
const executions = await prisma.workflowExecution.findMany({
|
||||
where: {
|
||||
...where,
|
||||
startedAt: cursorTimestamp
|
||||
? {
|
||||
...dateFilter,
|
||||
lt: cursorTimestamp,
|
||||
}
|
||||
: dateFilter,
|
||||
},
|
||||
orderBy: {startedAt: 'desc'},
|
||||
take: limit,
|
||||
include: {
|
||||
contact: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
workflow: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const execution of executions) {
|
||||
// Workflow started
|
||||
if (!types || types.includes(ActivityType.WORKFLOW_STARTED)) {
|
||||
activities.push({
|
||||
id: `${execution.id}_started`,
|
||||
type: ActivityType.WORKFLOW_STARTED,
|
||||
timestamp: execution.startedAt,
|
||||
contactEmail: execution.contact?.email,
|
||||
contactId: execution.contactId,
|
||||
metadata: {
|
||||
workflowName: execution.workflow.name,
|
||||
status: execution.status,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Workflow completed
|
||||
if (execution.completedAt && (!types || types.includes(ActivityType.WORKFLOW_COMPLETED))) {
|
||||
activities.push({
|
||||
id: `${execution.id}_completed`,
|
||||
type: ActivityType.WORKFLOW_COMPLETED,
|
||||
timestamp: execution.completedAt,
|
||||
contactEmail: execution.contact?.email,
|
||||
contactId: execution.contactId,
|
||||
metadata: {
|
||||
workflowName: execution.workflow.name,
|
||||
status: execution.status,
|
||||
exitReason: execution.exitReason,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return activities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch scheduled campaigns
|
||||
*/
|
||||
private static async fetchScheduledCampaigns(
|
||||
projectId: string,
|
||||
limit: number,
|
||||
dateFilter: Prisma.DateTimeFilter,
|
||||
): Promise<Activity[]> {
|
||||
const campaigns = await prisma.campaign.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
status: 'SCHEDULED',
|
||||
scheduledFor: dateFilter,
|
||||
},
|
||||
orderBy: {scheduledFor: 'asc'},
|
||||
take: limit,
|
||||
include: {
|
||||
segment: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return campaigns.map(campaign => ({
|
||||
id: `campaign_${campaign.id}_scheduled`,
|
||||
type: ActivityType.CAMPAIGN_SCHEDULED,
|
||||
timestamp: campaign.scheduledFor!,
|
||||
metadata: {
|
||||
campaignName: campaign.name,
|
||||
subject: campaign.subject,
|
||||
totalRecipients: campaign.totalRecipients,
|
||||
segmentName: campaign.segment?.name,
|
||||
audienceType: campaign.audienceType,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch scheduled workflow step executions (emails with delays)
|
||||
*/
|
||||
private static async fetchScheduledWorkflowSteps(
|
||||
projectId: string,
|
||||
limit: number,
|
||||
dateFilter: Prisma.DateTimeFilter,
|
||||
): Promise<Activity[]> {
|
||||
const stepExecutions = await prisma.workflowStepExecution.findMany({
|
||||
where: {
|
||||
execution: {
|
||||
workflow: {
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
status: 'PENDING',
|
||||
scheduledFor: dateFilter,
|
||||
},
|
||||
orderBy: {scheduledFor: 'asc'},
|
||||
take: limit,
|
||||
include: {
|
||||
execution: {
|
||||
include: {
|
||||
contact: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
workflow: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
step: {
|
||||
select: {
|
||||
name: true,
|
||||
type: true,
|
||||
config: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return stepExecutions
|
||||
.filter(stepExec => stepExec.step.type === 'SEND_EMAIL' && stepExec.scheduledFor)
|
||||
.map(stepExec => ({
|
||||
id: `workflow_step_${stepExec.id}_scheduled`,
|
||||
type: ActivityType.WORKFLOW_EMAIL_SCHEDULED,
|
||||
timestamp: stepExec.scheduledFor!,
|
||||
contactEmail: stepExec.execution.contact?.email,
|
||||
contactId: stepExec.execution.contactId,
|
||||
metadata: {
|
||||
workflowName: stepExec.execution.workflow.name,
|
||||
stepName: stepExec.step.name,
|
||||
subject:
|
||||
stepExec.step.config &&
|
||||
typeof stepExec.step.config === 'object' &&
|
||||
stepExec.step.config !== null &&
|
||||
'subject' in stepExec.step.config &&
|
||||
typeof (stepExec.step.config as Record<string, unknown>).subject === 'string'
|
||||
? (stepExec.step.config as Record<string, unknown>).subject
|
||||
: undefined,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
|
||||
/**
|
||||
* Time series data point for analytics
|
||||
*/
|
||||
export interface TimeSeriesDataPoint {
|
||||
date: string;
|
||||
emails: number;
|
||||
opens: number;
|
||||
clicks: number;
|
||||
bounces: number;
|
||||
delivered: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analytics Service
|
||||
*
|
||||
* PERFORMANCE CONSIDERATIONS:
|
||||
* - Aggregates data by day to reduce row count
|
||||
* - Uses Redis caching with 15-minute TTL
|
||||
* - Limits date ranges to prevent expensive queries
|
||||
* - Uses indexed fields (createdAt, projectId) for efficient filtering
|
||||
* - For 1M+ emails, consider background jobs for pre-aggregation
|
||||
*/
|
||||
export class AnalyticsService {
|
||||
private static readonly DEFAULT_DAYS_BACK = 30;
|
||||
private static readonly MAX_DAYS_BACK = 90;
|
||||
private static readonly TIMESERIES_CACHE_TTL = 900; // 15 minutes
|
||||
|
||||
/**
|
||||
* Get time series data for email analytics
|
||||
*
|
||||
* Performance: O(n) where n = number of emails in date range
|
||||
* - Groups emails by day using SQL aggregation
|
||||
* - Cached in Redis for 15 minutes
|
||||
* - Limited to 90 days max to prevent performance issues
|
||||
*
|
||||
* For higher scale (1M+ emails/day), consider:
|
||||
* - Pre-aggregated daily stats table updated by background job
|
||||
* - Materialized view with daily refresh
|
||||
* - Time-series database (TimescaleDB, InfluxDB)
|
||||
*/
|
||||
public static async getTimeSeriesData(
|
||||
projectId: string,
|
||||
startDate?: Date,
|
||||
endDate?: Date,
|
||||
): Promise<TimeSeriesDataPoint[]> {
|
||||
// Calculate date range with limits
|
||||
const now = new Date();
|
||||
const effectiveEndDate = endDate || now;
|
||||
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
|
||||
const effectiveStartDate = startDate || defaultStartDate;
|
||||
|
||||
// Enforce max date range
|
||||
const maxStartDate = new Date(now.getTime() - this.MAX_DAYS_BACK * 24 * 60 * 60 * 1000);
|
||||
const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate;
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = Keys.Analytics.timeseries(
|
||||
projectId,
|
||||
limitedStartDate.toISOString(),
|
||||
effectiveEndDate.toISOString(),
|
||||
);
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
const result = await prisma.$queryRaw<
|
||||
{
|
||||
date: Date;
|
||||
total_emails: bigint;
|
||||
total_opens: bigint;
|
||||
total_clicks: bigint;
|
||||
total_bounces: bigint;
|
||||
total_delivered: bigint;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
DATE_TRUNC('day', "createdAt") as date,
|
||||
COUNT(*) as total_emails,
|
||||
COUNT(CASE WHEN "openedAt" IS NOT NULL THEN 1 END) as total_opens,
|
||||
COUNT(CASE WHEN "clickedAt" IS NOT NULL THEN 1 END) as total_clicks,
|
||||
COUNT(CASE WHEN "bouncedAt" IS NOT NULL THEN 1 END) as total_bounces,
|
||||
COUNT(CASE WHEN "deliveredAt" IS NOT NULL THEN 1 END) as total_delivered
|
||||
FROM "emails"
|
||||
WHERE "projectId" = ${projectId}
|
||||
AND "createdAt" >= ${limitedStartDate}
|
||||
AND "createdAt" <= ${effectiveEndDate}
|
||||
GROUP BY DATE_TRUNC('day', "createdAt")
|
||||
ORDER BY date ASC
|
||||
`;
|
||||
|
||||
// Convert to TimeSeriesDataPoint format
|
||||
const timeSeries: TimeSeriesDataPoint[] = result.map(row => ({
|
||||
date: row.date.toISOString(),
|
||||
emails: Number(row.total_emails),
|
||||
opens: Number(row.total_opens),
|
||||
clicks: Number(row.total_clicks),
|
||||
bounces: Number(row.total_bounces),
|
||||
delivered: Number(row.total_delivered),
|
||||
}));
|
||||
|
||||
// Fill in missing dates with zero values
|
||||
const filledTimeSeries = this.fillMissingDates(timeSeries, limitedStartDate, effectiveEndDate);
|
||||
|
||||
// Cache for 15 minutes
|
||||
await redis.setex(cacheKey, this.TIMESERIES_CACHE_TTL, JSON.stringify(filledTimeSeries));
|
||||
|
||||
return filledTimeSeries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get campaign performance metrics
|
||||
* Returns top performing campaigns by open rate
|
||||
*/
|
||||
public static async getTopCampaigns(
|
||||
projectId: string,
|
||||
limit = 10,
|
||||
startDate?: Date,
|
||||
endDate?: Date,
|
||||
): Promise<
|
||||
{
|
||||
id: string;
|
||||
subject: string;
|
||||
sentCount: number;
|
||||
openedCount: number;
|
||||
clickedCount: number;
|
||||
openRate: number;
|
||||
clickRate: number;
|
||||
}[]
|
||||
> {
|
||||
const now = new Date();
|
||||
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
|
||||
|
||||
const campaigns = await prisma.campaign.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
sentAt: {
|
||||
gte: startDate || defaultStartDate,
|
||||
...(endDate ? {lte: endDate} : {}),
|
||||
},
|
||||
status: 'SENT',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
subject: true,
|
||||
sentCount: true,
|
||||
openedCount: true,
|
||||
clickedCount: true,
|
||||
},
|
||||
orderBy: {
|
||||
openedCount: 'desc',
|
||||
},
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return campaigns.map(campaign => ({
|
||||
id: campaign.id,
|
||||
subject: campaign.subject || 'No subject',
|
||||
sentCount: campaign.sentCount || 0,
|
||||
openedCount: campaign.openedCount || 0,
|
||||
clickedCount: campaign.clickedCount || 0,
|
||||
openRate: campaign.sentCount ? ((campaign.openedCount || 0) / campaign.sentCount) * 100 : 0,
|
||||
clickRate: campaign.sentCount ? ((campaign.clickedCount || 0) / campaign.sentCount) * 100 : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get campaign statistics overview
|
||||
* Returns aggregate stats for all campaigns in date range
|
||||
*
|
||||
* Performance: O(1) with indexed queries
|
||||
* - Uses aggregation on indexed fields
|
||||
* - Cached in Redis for 15 minutes
|
||||
*/
|
||||
public static async getCampaignStats(
|
||||
projectId: string,
|
||||
startDate?: Date,
|
||||
endDate?: Date,
|
||||
): Promise<{
|
||||
total: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
averageOpenRate: number;
|
||||
averageClickRate: number;
|
||||
}> {
|
||||
const now = new Date();
|
||||
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
|
||||
const effectiveStartDate = startDate || defaultStartDate;
|
||||
const effectiveEndDate = endDate || now;
|
||||
|
||||
// Check cache
|
||||
const cacheKey = Keys.Analytics.campaignStats(
|
||||
projectId,
|
||||
effectiveStartDate.toISOString(),
|
||||
effectiveEndDate.toISOString(),
|
||||
);
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
// Get all campaigns in date range
|
||||
const [totalCampaigns, activeCampaigns, sentCampaigns] = await Promise.all([
|
||||
// Total campaigns created in date range
|
||||
prisma.campaign.count({
|
||||
where: {
|
||||
projectId,
|
||||
createdAt: {
|
||||
gte: effectiveStartDate,
|
||||
lte: effectiveEndDate,
|
||||
},
|
||||
},
|
||||
}),
|
||||
// Active campaigns (not sent yet)
|
||||
prisma.campaign.count({
|
||||
where: {
|
||||
projectId,
|
||||
createdAt: {
|
||||
gte: effectiveStartDate,
|
||||
lte: effectiveEndDate,
|
||||
},
|
||||
status: {
|
||||
in: ['DRAFT', 'SCHEDULED'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
// Sent campaigns with stats
|
||||
prisma.campaign.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
sentAt: {
|
||||
gte: effectiveStartDate,
|
||||
lte: effectiveEndDate,
|
||||
},
|
||||
status: 'SENT',
|
||||
},
|
||||
select: {
|
||||
sentCount: true,
|
||||
openedCount: true,
|
||||
clickedCount: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Calculate averages
|
||||
let totalSent = 0;
|
||||
let totalOpened = 0;
|
||||
let totalClicked = 0;
|
||||
|
||||
sentCampaigns.forEach(campaign => {
|
||||
totalSent += campaign.sentCount || 0;
|
||||
totalOpened += campaign.openedCount || 0;
|
||||
totalClicked += campaign.clickedCount || 0;
|
||||
});
|
||||
|
||||
const completed = sentCampaigns.length;
|
||||
const averageOpenRate = totalSent > 0 ? (totalOpened / totalSent) * 100 : 0;
|
||||
const averageClickRate = totalSent > 0 ? (totalClicked / totalSent) * 100 : 0;
|
||||
|
||||
const stats = {
|
||||
total: totalCampaigns,
|
||||
active: activeCampaigns,
|
||||
completed,
|
||||
averageOpenRate: Math.round(averageOpenRate * 10) / 10, // Round to 1 decimal
|
||||
averageClickRate: Math.round(averageClickRate * 10) / 10,
|
||||
};
|
||||
|
||||
// Cache for 15 minutes
|
||||
await redis.setex(cacheKey, this.TIMESERIES_CACHE_TTL, JSON.stringify(stats));
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top events by frequency with trend data
|
||||
*
|
||||
* Performance: O(n log n) where n = number of unique events
|
||||
* - Groups events and counts occurrences
|
||||
* - Compares with previous period for trend calculation
|
||||
* - Cached in Redis for 15 minutes
|
||||
*/
|
||||
public static async getTopEvents(
|
||||
projectId: string,
|
||||
limit = 5,
|
||||
startDate?: Date,
|
||||
endDate?: Date,
|
||||
): Promise<
|
||||
{
|
||||
name: string;
|
||||
count: number;
|
||||
trend: number;
|
||||
}[]
|
||||
> {
|
||||
const now = new Date();
|
||||
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
|
||||
const effectiveStartDate = startDate || defaultStartDate;
|
||||
const effectiveEndDate = endDate || now;
|
||||
|
||||
// Check cache
|
||||
const cacheKey = Keys.Analytics.topEvents(
|
||||
projectId,
|
||||
limit,
|
||||
effectiveStartDate.toISOString(),
|
||||
effectiveEndDate.toISOString(),
|
||||
);
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
// Calculate previous period for trend comparison
|
||||
const periodLength = effectiveEndDate.getTime() - effectiveStartDate.getTime();
|
||||
const previousStartDate = new Date(effectiveStartDate.getTime() - periodLength);
|
||||
const previousEndDate = new Date(effectiveStartDate.getTime());
|
||||
|
||||
// Get current period events
|
||||
const currentEvents = await prisma.event.groupBy({
|
||||
by: ['name'],
|
||||
where: {
|
||||
projectId,
|
||||
createdAt: {
|
||||
gte: effectiveStartDate,
|
||||
lte: effectiveEndDate,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
orderBy: {
|
||||
_count: {
|
||||
id: 'desc',
|
||||
},
|
||||
},
|
||||
take: limit,
|
||||
});
|
||||
|
||||
// Get previous period events for trend calculation
|
||||
const previousEvents = await prisma.event.groupBy({
|
||||
by: ['name'],
|
||||
where: {
|
||||
projectId,
|
||||
name: {
|
||||
in: currentEvents.map(e => e.name),
|
||||
},
|
||||
createdAt: {
|
||||
gte: previousStartDate,
|
||||
lte: previousEndDate,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create map of previous counts
|
||||
const previousCountMap = new Map(previousEvents.map(e => [e.name, e._count.id]));
|
||||
|
||||
// Calculate trends
|
||||
const topEvents = currentEvents.map(event => {
|
||||
const currentCount = event._count.id;
|
||||
const previousCount = previousCountMap.get(event.name) || 0;
|
||||
|
||||
// Calculate percentage change
|
||||
let trend = 0;
|
||||
if (previousCount > 0) {
|
||||
trend = Math.round(((currentCount - previousCount) / previousCount) * 100);
|
||||
} else if (currentCount > 0) {
|
||||
trend = 100; // New event, 100% increase
|
||||
}
|
||||
|
||||
return {
|
||||
name: event.name,
|
||||
count: currentCount,
|
||||
trend,
|
||||
};
|
||||
});
|
||||
|
||||
// Cache for 15 minutes
|
||||
await redis.setex(cacheKey, this.TIMESERIES_CACHE_TTL, JSON.stringify(topEvents));
|
||||
|
||||
return topEvents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in missing dates in time series with zero values
|
||||
* Ensures consistent daily data points even when no emails were sent
|
||||
*/
|
||||
private static fillMissingDates(data: TimeSeriesDataPoint[], startDate: Date, endDate: Date): TimeSeriesDataPoint[] {
|
||||
const result: TimeSeriesDataPoint[] = [];
|
||||
const dataMap = new Map(data.map(point => [new Date(point.date).toDateString(), point]));
|
||||
|
||||
// Iterate through each day in range
|
||||
const currentDate = new Date(startDate);
|
||||
currentDate.setHours(0, 0, 0, 0);
|
||||
const end = new Date(endDate);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
|
||||
while (currentDate <= end) {
|
||||
const dateKey = currentDate.toDateString();
|
||||
const existingData = dataMap.get(dateKey);
|
||||
|
||||
if (existingData) {
|
||||
result.push(existingData);
|
||||
} else {
|
||||
// Fill with zeros for days with no data
|
||||
result.push({
|
||||
date: new Date(currentDate).toISOString(),
|
||||
emails: 0,
|
||||
opens: 0,
|
||||
clicks: 0,
|
||||
bounces: 0,
|
||||
delivered: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Move to next day
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
import {UserService} from './UserService.js';
|
||||
|
||||
export class AuthService {
|
||||
public static async generateHash(password: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
void bcrypt.hash(password, 10, (err, res) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve(res);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static async verifyHash(password: string, hash: string) {
|
||||
return new Promise((resolve, reject) => {
|
||||
void bcrypt.compare(password, hash, (err, res) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
return resolve(res);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static async verifyCredentials(email: string, password: string) {
|
||||
const user = await UserService.email(email);
|
||||
|
||||
if (!user?.password) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await this.verifyHash(password, user.password);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
import {EmailSourceType} from '@plunk/db';
|
||||
import type {BillingLimitsResponse, CategoryUsage, LimitCheckResult} from '@plunk/types';
|
||||
import {BillingLimitExceededEmail, BillingLimitWarningEmail, sendPlatformEmail} from '@plunk/email';
|
||||
import React from 'react';
|
||||
import signale from 'signale';
|
||||
|
||||
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
|
||||
import {stripe} from '../app/stripe.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {MembershipService} from './MembershipService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
|
||||
/**
|
||||
* Billing Limit Service
|
||||
* Handles usage tracking and enforcement of billing limits per email category
|
||||
*
|
||||
* FREE TIER LIMITS:
|
||||
* - Free tier projects (billing enabled, no subscription) have a total limit of 1000 emails/month
|
||||
* - This limit is shared across all email types (workflows + campaigns + transactional)
|
||||
* - Paid tier projects (with subscription) can have custom per-category limits or unlimited
|
||||
*
|
||||
* PERFORMANCE CONSIDERATIONS:
|
||||
* - Operates at scale with 1M+ contacts/month (potentially millions of emails)
|
||||
* - Uses Redis caching (5-min TTL) to avoid expensive DB queries on every email send
|
||||
* - Composite index on (projectId, sourceType, createdAt) enables fast filtered counts
|
||||
* - Graceful degradation: cache misses fall back to DB without blocking
|
||||
* - Non-blocking: errors are logged but don't prevent email sending
|
||||
*/
|
||||
export class BillingLimitService {
|
||||
private static readonly CACHE_TTL = 300; // 5 minutes
|
||||
private static readonly WARNING_THRESHOLD = 0.8; // 80%
|
||||
private static readonly FREE_TIER_TOTAL_LIMIT = 1000; // Total emails per month for free tier projects
|
||||
|
||||
/**
|
||||
* Get total usage count across all email categories
|
||||
* Used for free tier total limit enforcement
|
||||
*
|
||||
* @param projectId - Project ID
|
||||
* @returns Total email count for the calendar month (all types combined)
|
||||
*/
|
||||
public static async getTotalUsage(projectId: string): Promise<number> {
|
||||
const {start, end} = this.getCurrentMonthRange();
|
||||
|
||||
try {
|
||||
const count = await prisma.email.count({
|
||||
where: {
|
||||
projectId,
|
||||
createdAt: {
|
||||
gte: start,
|
||||
lt: end,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return count;
|
||||
} catch (error) {
|
||||
signale.error(`[BILLING_LIMIT] Failed to query total usage for ${projectId}:`, error);
|
||||
return 0; // Return 0 on error to avoid blocking
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current usage count for a specific email category
|
||||
* Uses Redis cache with 5-minute TTL to minimize DB queries
|
||||
*
|
||||
* @param projectId - Project ID
|
||||
* @param sourceType - Email category (TRANSACTIONAL, CAMPAIGN, WORKFLOW)
|
||||
* @returns Current usage count for the calendar month
|
||||
*/
|
||||
public static async getUsage(projectId: string, sourceType: EmailSourceType): Promise<number> {
|
||||
const cacheKey = this.getCacheKey(projectId, sourceType);
|
||||
|
||||
try {
|
||||
// Try to get from cache first
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached !== null) {
|
||||
return parseInt(cached, 10);
|
||||
}
|
||||
} catch (error) {
|
||||
signale.warn(`[BILLING_LIMIT] Cache read failed for ${cacheKey}:`, error);
|
||||
// Continue to DB query on cache failure
|
||||
}
|
||||
|
||||
// Cache miss - query database
|
||||
const {start, end} = this.getCurrentMonthRange();
|
||||
|
||||
try {
|
||||
const count = await prisma.email.count({
|
||||
where: {
|
||||
projectId,
|
||||
sourceType,
|
||||
createdAt: {
|
||||
gte: start,
|
||||
lt: end,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Cache the result
|
||||
try {
|
||||
await redis.setex(cacheKey, this.CACHE_TTL, count.toString());
|
||||
} catch (error) {
|
||||
signale.warn(`[BILLING_LIMIT] Failed to cache usage for ${cacheKey}:`, error);
|
||||
}
|
||||
|
||||
return count;
|
||||
} catch (error) {
|
||||
signale.error(`[BILLING_LIMIT] Failed to query usage for ${projectId}/${sourceType}:`, error);
|
||||
return 0; // Return 0 on error to avoid blocking
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment usage counter in cache (called after successful email send)
|
||||
* This keeps the cache accurate without requiring frequent DB queries
|
||||
*
|
||||
* @param projectId - Project ID
|
||||
* @param sourceType - Email category
|
||||
*/
|
||||
public static async incrementUsage(projectId: string, sourceType: EmailSourceType): Promise<void> {
|
||||
const cacheKey = this.getCacheKey(projectId, sourceType);
|
||||
|
||||
try {
|
||||
const exists = await redis.exists(cacheKey);
|
||||
if (exists) {
|
||||
// Only increment if key exists (was previously cached)
|
||||
await redis.incr(cacheKey);
|
||||
}
|
||||
// If not cached, next getUsage call will fetch from DB
|
||||
} catch (error) {
|
||||
signale.warn(`[BILLING_LIMIT] Failed to increment usage cache for ${cacheKey}:`, error);
|
||||
// Non-blocking: continue even if cache increment fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if sending an email would exceed the billing limit
|
||||
* Returns detailed result including warning status
|
||||
*
|
||||
* For free tier projects (no subscription): checks total usage across all categories against 1000/month limit
|
||||
* For paid tier projects (with subscription): checks per-category limits if set
|
||||
*
|
||||
* @param projectId - Project ID
|
||||
* @param sourceType - Email category
|
||||
* @returns LimitCheckResult with allowed/warning status
|
||||
*/
|
||||
public static async checkLimit(projectId: string, sourceType: EmailSourceType): Promise<LimitCheckResult> {
|
||||
try {
|
||||
// Get project billing info
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {
|
||||
name: true,
|
||||
subscription: true,
|
||||
billingLimitWorkflows: true,
|
||||
billingLimitCampaigns: true,
|
||||
billingLimitTransactional: true,
|
||||
billingLimitInbound: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
signale.warn(`[BILLING_LIMIT] Project ${projectId} not found`);
|
||||
return {
|
||||
allowed: true,
|
||||
warning: false,
|
||||
usage: 0,
|
||||
limit: null,
|
||||
percentage: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Determine which limit to use based on source type
|
||||
let limit: number | null;
|
||||
switch (sourceType) {
|
||||
case EmailSourceType.WORKFLOW:
|
||||
limit = project.billingLimitWorkflows;
|
||||
break;
|
||||
case EmailSourceType.CAMPAIGN:
|
||||
limit = project.billingLimitCampaigns;
|
||||
break;
|
||||
case EmailSourceType.TRANSACTIONAL:
|
||||
limit = project.billingLimitTransactional;
|
||||
break;
|
||||
case EmailSourceType.INBOUND:
|
||||
limit = project.billingLimitInbound;
|
||||
break;
|
||||
default:
|
||||
limit = null;
|
||||
}
|
||||
|
||||
// Check if any custom per-category limits are set
|
||||
const hasCustomLimits =
|
||||
project.billingLimitWorkflows !== null ||
|
||||
project.billingLimitCampaigns !== null ||
|
||||
project.billingLimitTransactional !== null ||
|
||||
project.billingLimitInbound !== null;
|
||||
|
||||
// Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit
|
||||
// Only enforce free tier limits if billing is enabled
|
||||
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
|
||||
const totalUsage = await this.getTotalUsage(projectId);
|
||||
const freeLimit = this.FREE_TIER_TOTAL_LIMIT;
|
||||
const percentage = (totalUsage / freeLimit) * 100;
|
||||
|
||||
// Check if blocked (at or over limit)
|
||||
if (totalUsage >= freeLimit) {
|
||||
await NtfyService.notifyBillingLimitExceeded(
|
||||
project.name,
|
||||
projectId,
|
||||
totalUsage,
|
||||
freeLimit,
|
||||
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
|
||||
);
|
||||
|
||||
// Send email notification
|
||||
await this.sendLimitExceededEmail(projectId, project.name, totalUsage, freeLimit, 'Free Tier (All Types)');
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
warning: false,
|
||||
usage: totalUsage,
|
||||
limit: freeLimit,
|
||||
percentage,
|
||||
message: `Free tier limit reached. You've sent ${totalUsage}/${freeLimit} emails this month. Upgrade to continue sending.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if warning (80% or more)
|
||||
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
|
||||
if (isWarning) {
|
||||
await NtfyService.notifyBillingLimitApproaching(
|
||||
project.name,
|
||||
projectId,
|
||||
totalUsage,
|
||||
freeLimit,
|
||||
percentage,
|
||||
EmailSourceType.TRANSACTIONAL, // Use generic type for notification
|
||||
);
|
||||
|
||||
// Send email notification (only once per month)
|
||||
await this.sendWarningEmail(
|
||||
projectId,
|
||||
project.name,
|
||||
totalUsage,
|
||||
freeLimit,
|
||||
percentage,
|
||||
'Free Tier (All Types)',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
warning: isWarning,
|
||||
usage: totalUsage,
|
||||
limit: freeLimit,
|
||||
percentage,
|
||||
message: isWarning
|
||||
? `Warning: You've used ${Math.round(percentage)}% of your free tier limit (${totalUsage}/${freeLimit} emails)`
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// If no limit set for paid tier, allow unlimited
|
||||
if (limit === null) {
|
||||
return {
|
||||
allowed: true,
|
||||
warning: false,
|
||||
usage: 0,
|
||||
limit: null,
|
||||
percentage: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Get current usage
|
||||
const usage = await this.getUsage(projectId, sourceType);
|
||||
const percentage = limit > 0 ? (usage / limit) * 100 : 0;
|
||||
|
||||
// Check if blocked (at or over limit)
|
||||
if (usage >= limit) {
|
||||
// Get project name for notification
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {name: true},
|
||||
});
|
||||
|
||||
if (project) {
|
||||
// Send notification about limit exceeded
|
||||
await NtfyService.notifyBillingLimitExceeded(project.name, projectId, usage, limit, sourceType);
|
||||
|
||||
// Send email notification
|
||||
await this.sendLimitExceededEmail(projectId, project.name, usage, limit, sourceType);
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
warning: false,
|
||||
usage,
|
||||
limit,
|
||||
percentage,
|
||||
message: `Billing limit reached for ${sourceType.toLowerCase()} emails. Current usage: ${usage}/${limit} (${Math.round(percentage)}%)`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if warning (80% or more)
|
||||
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
|
||||
|
||||
// Send notification for warning threshold
|
||||
if (isWarning) {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {name: true},
|
||||
});
|
||||
|
||||
if (project) {
|
||||
await NtfyService.notifyBillingLimitApproaching(
|
||||
project.name,
|
||||
projectId,
|
||||
usage,
|
||||
limit,
|
||||
percentage,
|
||||
sourceType,
|
||||
);
|
||||
|
||||
// Send email notification (only once per month)
|
||||
await this.sendWarningEmail(projectId, project.name, usage, limit, percentage, sourceType);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
warning: isWarning,
|
||||
usage,
|
||||
limit,
|
||||
percentage,
|
||||
message: isWarning
|
||||
? `Warning: ${sourceType.toLowerCase()} emails at ${Math.round(percentage)}% of limit (${usage}/${limit})`
|
||||
: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
signale.error(`[BILLING_LIMIT] Error checking limit for ${projectId}/${sourceType}:`, error);
|
||||
// On error, allow the email to prevent service disruption
|
||||
return {
|
||||
allowed: true,
|
||||
warning: false,
|
||||
usage: 0,
|
||||
limit: null,
|
||||
percentage: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complete billing limits and usage for all categories
|
||||
* Used for displaying limits in UI
|
||||
*
|
||||
* For free tier projects: shows total usage across all categories with 1000/month limit
|
||||
* For paid tier projects: shows per-category usage with custom limits
|
||||
*
|
||||
* @param projectId - Project ID
|
||||
* @returns Complete billing limits and usage information
|
||||
*/
|
||||
public static async getLimitsAndUsage(projectId: string): Promise<BillingLimitsResponse> {
|
||||
try {
|
||||
// Get project info
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {
|
||||
customer: true,
|
||||
subscription: true,
|
||||
billingLimitWorkflows: true,
|
||||
billingLimitCampaigns: true,
|
||||
billingLimitTransactional: true,
|
||||
billingLimitInbound: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Error('Project not found');
|
||||
}
|
||||
|
||||
// Get currency from Stripe customer if available
|
||||
let currency: string | null = null;
|
||||
if (stripe && project.customer) {
|
||||
try {
|
||||
const customer = await stripe.customers.retrieve(project.customer);
|
||||
if (!customer.deleted) {
|
||||
currency = customer.currency || 'usd';
|
||||
}
|
||||
} catch (error) {
|
||||
signale.warn(`[BILLING_LIMIT] Failed to fetch currency for customer ${project.customer}:`, error);
|
||||
// Continue without currency - will be null in response
|
||||
}
|
||||
}
|
||||
|
||||
// Get usage for all categories in parallel
|
||||
const [workflowUsage, campaignUsage, transactionalUsage, inboundUsage] = await Promise.all([
|
||||
this.getUsage(projectId, EmailSourceType.WORKFLOW),
|
||||
this.getUsage(projectId, EmailSourceType.CAMPAIGN),
|
||||
this.getUsage(projectId, EmailSourceType.TRANSACTIONAL),
|
||||
this.getUsage(projectId, EmailSourceType.INBOUND),
|
||||
]);
|
||||
|
||||
// Helper to calculate category usage
|
||||
const calculateCategoryUsage = (usage: number, limit: number | null): CategoryUsage => {
|
||||
const percentage = limit !== null && limit > 0 ? (usage / limit) * 100 : 0;
|
||||
return {
|
||||
limit,
|
||||
usage,
|
||||
percentage,
|
||||
isWarning: limit !== null && percentage >= this.WARNING_THRESHOLD * 100,
|
||||
isBlocked: limit !== null && usage >= limit,
|
||||
};
|
||||
};
|
||||
|
||||
// Check if any custom per-category limits are set
|
||||
const hasCustomLimits =
|
||||
project.billingLimitWorkflows !== null ||
|
||||
project.billingLimitCampaigns !== null ||
|
||||
project.billingLimitTransactional !== null ||
|
||||
project.billingLimitInbound !== null;
|
||||
|
||||
// Free tier projects (no subscription and no custom limits): show total usage with shared limit
|
||||
// Only show free tier limits if billing is enabled
|
||||
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
|
||||
const totalUsage = workflowUsage + campaignUsage + transactionalUsage + inboundUsage;
|
||||
const limit = this.FREE_TIER_TOTAL_LIMIT;
|
||||
const percentage = (totalUsage / limit) * 100;
|
||||
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
|
||||
const isBlocked = totalUsage >= limit;
|
||||
|
||||
// For free tier, show the same limit and total usage for all four categories
|
||||
// This makes it clear in the UI that it's a shared limit
|
||||
const sharedUsageInfo: CategoryUsage = {
|
||||
limit,
|
||||
usage: totalUsage,
|
||||
percentage,
|
||||
isWarning,
|
||||
isBlocked,
|
||||
};
|
||||
|
||||
return {
|
||||
workflows: sharedUsageInfo,
|
||||
campaigns: sharedUsageInfo,
|
||||
transactional: sharedUsageInfo,
|
||||
inbound: sharedUsageInfo,
|
||||
currency,
|
||||
};
|
||||
}
|
||||
|
||||
// Projects with subscription or custom limits: show per-category limits
|
||||
return {
|
||||
workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows),
|
||||
campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns),
|
||||
transactional: calculateCategoryUsage(transactionalUsage, project.billingLimitTransactional),
|
||||
inbound: calculateCategoryUsage(inboundUsage, project.billingLimitInbound),
|
||||
currency,
|
||||
};
|
||||
} catch (error) {
|
||||
signale.error(`[BILLING_LIMIT] Error getting limits and usage for ${projectId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate usage cache for a project
|
||||
* Primarily used in tests to reset cache state between scenarios.
|
||||
* In production, the cache naturally expires after 5 minutes.
|
||||
*
|
||||
* NOTE: Updating billing limits does NOT require clearing usage cache
|
||||
* (use clearNotificationCacheForChangedLimits instead)
|
||||
*
|
||||
* @param projectId - Project ID
|
||||
*/
|
||||
public static async invalidateCache(projectId: string): Promise<void> {
|
||||
try {
|
||||
const keys = [
|
||||
this.getCacheKey(projectId, EmailSourceType.WORKFLOW),
|
||||
this.getCacheKey(projectId, EmailSourceType.CAMPAIGN),
|
||||
this.getCacheKey(projectId, EmailSourceType.TRANSACTIONAL),
|
||||
this.getCacheKey(projectId, EmailSourceType.INBOUND),
|
||||
];
|
||||
|
||||
await Promise.all(keys.map(key => redis.del(key)));
|
||||
signale.debug(`[BILLING_LIMIT] Invalidated cache for project ${projectId}`);
|
||||
} catch (error) {
|
||||
signale.warn(`[BILLING_LIMIT] Failed to invalidate cache for ${projectId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear notification cache keys for billing limits that have changed
|
||||
* This allows new warning/limit emails to be sent when updated limits are reached
|
||||
*
|
||||
* @param projectId - Project ID
|
||||
* @param oldLimits - Previous billing limits
|
||||
* @param newLimits - New billing limits
|
||||
*/
|
||||
public static async clearNotificationCacheForChangedLimits(
|
||||
projectId: string,
|
||||
oldLimits: {
|
||||
workflows: number | null;
|
||||
campaigns: number | null;
|
||||
transactional: number | null;
|
||||
inbound: number | null;
|
||||
},
|
||||
newLimits: {
|
||||
workflows: number | null;
|
||||
campaigns: number | null;
|
||||
transactional: number | null;
|
||||
inbound: number | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
try {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
|
||||
const keysToDelete: string[] = [];
|
||||
|
||||
// Check workflows limit
|
||||
if (oldLimits.workflows !== newLimits.workflows) {
|
||||
keysToDelete.push(
|
||||
Keys.Billing.warningEmail(projectId, EmailSourceType.WORKFLOW, year, month),
|
||||
Keys.Billing.limitEmail(projectId, EmailSourceType.WORKFLOW, year, month),
|
||||
);
|
||||
}
|
||||
|
||||
// Check campaigns limit
|
||||
if (oldLimits.campaigns !== newLimits.campaigns) {
|
||||
keysToDelete.push(
|
||||
Keys.Billing.warningEmail(projectId, EmailSourceType.CAMPAIGN, year, month),
|
||||
Keys.Billing.limitEmail(projectId, EmailSourceType.CAMPAIGN, year, month),
|
||||
);
|
||||
}
|
||||
|
||||
// Check transactional limit
|
||||
if (oldLimits.transactional !== newLimits.transactional) {
|
||||
keysToDelete.push(
|
||||
Keys.Billing.warningEmail(projectId, EmailSourceType.TRANSACTIONAL, year, month),
|
||||
Keys.Billing.limitEmail(projectId, EmailSourceType.TRANSACTIONAL, year, month),
|
||||
);
|
||||
}
|
||||
|
||||
// Check inbound limit
|
||||
if (oldLimits.inbound !== newLimits.inbound) {
|
||||
keysToDelete.push(
|
||||
Keys.Billing.warningEmail(projectId, EmailSourceType.INBOUND, year, month),
|
||||
Keys.Billing.limitEmail(projectId, EmailSourceType.INBOUND, year, month),
|
||||
);
|
||||
}
|
||||
|
||||
if (keysToDelete.length > 0) {
|
||||
await Promise.all(keysToDelete.map(key => redis.del(key)));
|
||||
signale.debug(
|
||||
`[BILLING_LIMIT] Cleared notification cache for changed limits in project ${projectId} (${keysToDelete.length} keys)`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
signale.warn(`[BILLING_LIMIT] Failed to clear notification cache for ${projectId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Redis cache key for usage count
|
||||
*/
|
||||
private static getCacheKey(projectId: string, sourceType: EmailSourceType): string {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
return Keys.Billing.usage(projectId, sourceType, year, month);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get start and end dates for current calendar month
|
||||
*/
|
||||
private static getCurrentMonthRange(): {start: Date; end: Date} {
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
return {start, end};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send billing limit warning email to project members
|
||||
*/
|
||||
private static async sendWarningEmail(
|
||||
projectId: string,
|
||||
projectName: string,
|
||||
usage: number,
|
||||
limit: number,
|
||||
percentage: number,
|
||||
sourceType: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check if we've already sent this warning email this month
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const cacheKey = Keys.Billing.warningEmail(projectId, sourceType, year, month);
|
||||
|
||||
// Use SETNX (SET if Not eXists) to atomically check and set the flag
|
||||
// This prevents race conditions where multiple concurrent requests could all pass the check
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||
|
||||
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||
if (!wasSet) {
|
||||
// Email was already sent this month
|
||||
return;
|
||||
}
|
||||
|
||||
const members = await MembershipService.getMembers(projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const template = React.createElement(BillingLimitWarningEmail, {
|
||||
projectName,
|
||||
projectId,
|
||||
usage,
|
||||
limit,
|
||||
percentage,
|
||||
sourceType,
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
|
||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Warning', template)));
|
||||
} catch (error) {
|
||||
signale.error(`[BILLING_LIMIT] Failed to send warning email:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send billing limit exceeded email to project members
|
||||
*/
|
||||
private static async sendLimitExceededEmail(
|
||||
projectId: string,
|
||||
projectName: string,
|
||||
usage: number,
|
||||
limit: number,
|
||||
sourceType: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check if we've already sent this warning email this month
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const cacheKey = Keys.Billing.limitEmail(projectId, sourceType, year, month);
|
||||
|
||||
// Use SETNX (SET if Not eXists) to atomically check and set the flag
|
||||
// This prevents race conditions where multiple concurrent requests could all pass the check
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||
|
||||
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||
if (!wasSet) {
|
||||
// Email was already sent this month
|
||||
return;
|
||||
}
|
||||
|
||||
const members = await MembershipService.getMembers(projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const template = React.createElement(BillingLimitExceededEmail, {
|
||||
projectName,
|
||||
projectId,
|
||||
usage,
|
||||
limit,
|
||||
sourceType,
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
|
||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Exceeded', template)));
|
||||
} catch (error) {
|
||||
signale.error(`[BILLING_LIMIT] Failed to send limit exceeded email:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
import type {Campaign, Contact, Prisma} from '@plunk/db';
|
||||
import {CampaignAudienceType, CampaignStatus, EmailSourceType, TemplateType} from '@plunk/db';
|
||||
import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types';
|
||||
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
|
||||
|
||||
import {BillingLimitService} from './BillingLimitService.js';
|
||||
import {DomainService} from './DomainService.js';
|
||||
import {EmailService} from './EmailService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {QueueService} from './QueueService.js';
|
||||
import {SegmentService} from './SegmentService.js';
|
||||
import {DASHBOARD_URI, STRIPE_ENABLED} from '../app/constants.js';
|
||||
import {sendRawEmail} from './SESService.js';
|
||||
|
||||
const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance)
|
||||
|
||||
export class CampaignService {
|
||||
/**
|
||||
* Create a new campaign
|
||||
*/
|
||||
public static async create(projectId: string, data: CreateCampaignData): Promise<Campaign> {
|
||||
// Validate segment if provided
|
||||
if (data.audienceType === CampaignAudienceType.SEGMENT) {
|
||||
if (!data.segmentId) {
|
||||
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
|
||||
}
|
||||
|
||||
const segment = await prisma.segment.findFirst({
|
||||
where: {
|
||||
id: data.segmentId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!segment) {
|
||||
throw new HttpException(404, 'Segment not found');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate condition if provided
|
||||
if (data.audienceType === CampaignAudienceType.FILTERED && data.audienceCondition) {
|
||||
// This will throw if condition is invalid
|
||||
SegmentService.validateCondition(data.audienceCondition);
|
||||
}
|
||||
|
||||
// Create campaign with initial recipient count of 0
|
||||
const campaign = await prisma.campaign.create({
|
||||
data: {
|
||||
projectId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
subject: data.subject,
|
||||
body: data.body,
|
||||
from: data.from,
|
||||
fromName: data.fromName,
|
||||
replyTo: data.replyTo,
|
||||
type: data.type ?? TemplateType.MARKETING,
|
||||
audienceType: data.audienceType,
|
||||
audienceCondition: toPrismaJson(data.audienceCondition || null),
|
||||
segmentId: data.segmentId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
totalRecipients: 0, // Will be updated below
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {name: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate and update recipient count for the draft
|
||||
const recipientCount = await this.getRecipientCount(projectId, campaign);
|
||||
const updatedCampaign = await prisma.campaign.update({
|
||||
where: {id: campaign.id},
|
||||
data: {totalRecipients: recipientCount},
|
||||
});
|
||||
|
||||
// Send notification about campaign creation
|
||||
await NtfyService.notifyCampaignCreated(campaign.name, campaign.project.name, projectId);
|
||||
|
||||
return updatedCampaign;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a campaign
|
||||
*/
|
||||
public static async update(projectId: string, campaignId: string, data: UpdateCampaignData): Promise<Campaign> {
|
||||
const campaign = await this.get(projectId, campaignId);
|
||||
|
||||
// Can only update draft or scheduled campaigns
|
||||
if (campaign.status !== CampaignStatus.DRAFT && campaign.status !== CampaignStatus.SCHEDULED) {
|
||||
throw new HttpException(400, 'Cannot update campaign that is sending or has been sent');
|
||||
}
|
||||
|
||||
// Build base update data using shared utility
|
||||
const updateData: Prisma.CampaignUpdateInput = buildEmailFieldsUpdate(data) as Prisma.CampaignUpdateInput;
|
||||
|
||||
// Handle campaign-specific fields
|
||||
if (data.type !== undefined) {
|
||||
updateData.type = data.type;
|
||||
}
|
||||
|
||||
if (data.audienceType !== undefined) {
|
||||
updateData.audienceType = data.audienceType;
|
||||
}
|
||||
|
||||
if (data.audienceCondition !== undefined) {
|
||||
if (data.audienceCondition) {
|
||||
SegmentService.validateCondition(data.audienceCondition);
|
||||
}
|
||||
updateData.audienceCondition = toPrismaJson(data.audienceCondition || null);
|
||||
}
|
||||
|
||||
if (data.segmentId !== undefined) {
|
||||
// Prevent changing segment on scheduled campaigns
|
||||
if (campaign.status === CampaignStatus.SCHEDULED) {
|
||||
throw new HttpException(400, 'Cannot change segment for scheduled campaigns');
|
||||
}
|
||||
|
||||
if (data.segmentId) {
|
||||
const segment = await prisma.segment.findFirst({
|
||||
where: {id: data.segmentId, projectId},
|
||||
});
|
||||
if (!segment) {
|
||||
throw new HttpException(404, 'Segment not found');
|
||||
}
|
||||
updateData.segment = {connect: {id: data.segmentId}};
|
||||
} else {
|
||||
updateData.segment = {disconnect: true};
|
||||
}
|
||||
// Remove segmentId from updateData to avoid conflict with relation field
|
||||
delete (updateData as Record<string, unknown>).segmentId;
|
||||
}
|
||||
|
||||
// Update the campaign first
|
||||
const updatedCampaign = await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// If audience-related fields changed and campaign is still a draft, recalculate totalRecipients
|
||||
const audienceChanged =
|
||||
data.audienceType !== undefined || data.segmentId !== undefined || data.audienceCondition !== undefined;
|
||||
|
||||
if (audienceChanged && updatedCampaign.status === CampaignStatus.DRAFT) {
|
||||
const recipientCount = await this.getRecipientCount(projectId, updatedCampaign);
|
||||
return prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {totalRecipients: recipientCount},
|
||||
});
|
||||
}
|
||||
|
||||
return updatedCampaign;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a campaign
|
||||
*/
|
||||
public static async get(projectId: string, campaignId: string): Promise<Campaign> {
|
||||
const campaign = await prisma.campaign.findFirst({
|
||||
where: {
|
||||
id: campaignId,
|
||||
projectId,
|
||||
},
|
||||
include: {
|
||||
segment: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!campaign) {
|
||||
throw new HttpException(404, 'Campaign not found');
|
||||
}
|
||||
|
||||
return campaign;
|
||||
}
|
||||
|
||||
/**
|
||||
* List campaigns for a project
|
||||
*/
|
||||
public static async list(
|
||||
projectId: string,
|
||||
options: {
|
||||
status?: CampaignStatus;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
): Promise<PaginatedResponse<Campaign>> {
|
||||
const {status, page = 1, pageSize = 20} = options;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.CampaignWhereInput = {
|
||||
projectId,
|
||||
...(status ? {status} : {}),
|
||||
};
|
||||
|
||||
const [campaigns, total] = await Promise.all([
|
||||
prisma.campaign.findMany({
|
||||
where,
|
||||
include: {
|
||||
segment: true,
|
||||
},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.campaign.count({where}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: campaigns,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a campaign
|
||||
*/
|
||||
public static async delete(projectId: string, campaignId: string): Promise<void> {
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
where: {id: campaignId, projectId},
|
||||
include: {
|
||||
project: {
|
||||
select: {name: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!campaign) {
|
||||
throw new HttpException(404, 'Campaign not found');
|
||||
}
|
||||
|
||||
// Can only delete draft campaigns
|
||||
if (campaign.status !== CampaignStatus.DRAFT) {
|
||||
throw new HttpException(400, 'Can only delete draft campaigns');
|
||||
}
|
||||
|
||||
await prisma.campaign.delete({
|
||||
where: {id: campaignId},
|
||||
});
|
||||
|
||||
// Send notification about campaign deletion
|
||||
await NtfyService.notifyCampaignDeleted(campaign.name, campaign.project.name, projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a campaign
|
||||
*/
|
||||
public static async duplicate(projectId: string, campaignId: string): Promise<Campaign> {
|
||||
const campaign = await this.get(projectId, campaignId);
|
||||
|
||||
// Create a new campaign with the same data but reset status and stats
|
||||
const duplicatedCampaign = await prisma.campaign.create({
|
||||
data: {
|
||||
projectId,
|
||||
name: `${campaign.name} (Copy)`,
|
||||
description: campaign.description,
|
||||
subject: campaign.subject,
|
||||
body: campaign.body,
|
||||
from: campaign.from,
|
||||
fromName: campaign.fromName,
|
||||
replyTo: campaign.replyTo,
|
||||
type: campaign.type,
|
||||
audienceType: campaign.audienceType,
|
||||
audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue,
|
||||
segmentId: campaign.segmentId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
totalRecipients: 0, // Will be updated below
|
||||
sentCount: 0,
|
||||
deliveredCount: 0,
|
||||
openedCount: 0,
|
||||
clickedCount: 0,
|
||||
bouncedCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate and update recipient count
|
||||
const recipientCount = await this.getRecipientCount(projectId, duplicatedCampaign);
|
||||
return prisma.campaign.update({
|
||||
where: {id: duplicatedCampaign.id},
|
||||
data: {totalRecipients: recipientCount},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send campaign immediately or schedule for later
|
||||
*/
|
||||
public static async send(projectId: string, campaignId: string, scheduledFor?: Date): Promise<Campaign> {
|
||||
const campaign = await this.get(projectId, campaignId);
|
||||
|
||||
// Validate status
|
||||
if (campaign.status !== CampaignStatus.DRAFT && campaign.status !== CampaignStatus.SCHEDULED) {
|
||||
throw new HttpException(400, 'Campaign has already been sent or is currently sending');
|
||||
}
|
||||
|
||||
// Get recipient count to validate there are contacts to send to
|
||||
const recipientCount = await this.getRecipientCount(projectId, campaign);
|
||||
|
||||
if (recipientCount === 0) {
|
||||
throw new HttpException(400, 'Campaign has no recipients');
|
||||
}
|
||||
|
||||
// Check billing limits before scheduling/sending the campaign
|
||||
// This ensures users cannot schedule campaigns that would exceed their quota
|
||||
if (STRIPE_ENABLED) {
|
||||
const limitCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
||||
|
||||
// If there's a limit set, verify the campaign won't exceed it
|
||||
if (limitCheck.limit !== null) {
|
||||
const projectedUsage = limitCheck.usage + recipientCount;
|
||||
|
||||
if (projectedUsage > limitCheck.limit) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
`Cannot ${scheduledFor ? 'schedule' : 'send'} campaign: would exceed billing limit. Current usage: ${limitCheck.usage}/${limitCheck.limit} emails, campaign recipients: ${recipientCount}. Upgrade your plan or reduce campaign recipients.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scheduledFor) {
|
||||
// Schedule for later
|
||||
if (scheduledFor.getTime() <= Date.now()) {
|
||||
throw new HttpException(400, 'Scheduled time must be in the future');
|
||||
}
|
||||
|
||||
// Update campaign status
|
||||
const updatedCampaign = await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {
|
||||
status: CampaignStatus.SCHEDULED,
|
||||
scheduledFor,
|
||||
totalRecipients: recipientCount,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {name: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Queue for scheduled sending
|
||||
await QueueService.scheduleCampaign(campaignId, scheduledFor);
|
||||
|
||||
// Send notification about campaign scheduled
|
||||
await NtfyService.notifyCampaignScheduled(
|
||||
updatedCampaign.name,
|
||||
updatedCampaign.project.name,
|
||||
projectId,
|
||||
scheduledFor,
|
||||
recipientCount,
|
||||
);
|
||||
|
||||
return updatedCampaign;
|
||||
} else {
|
||||
// Send immediately - start the batch processing
|
||||
await this.startSending(projectId, campaignId, recipientCount);
|
||||
|
||||
return this.get(projectId, campaignId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start sending campaign (called immediately or when scheduled time arrives)
|
||||
* Now uses cursor-based pagination for better performance with large recipient lists
|
||||
*/
|
||||
public static async startSending(projectId: string, campaignId: string, recipientCount?: number): Promise<void> {
|
||||
const campaign = await this.get(projectId, campaignId);
|
||||
|
||||
// Validate status
|
||||
if (
|
||||
campaign.status !== CampaignStatus.DRAFT &&
|
||||
campaign.status !== CampaignStatus.SCHEDULED &&
|
||||
campaign.status !== CampaignStatus.SENDING
|
||||
) {
|
||||
throw new HttpException(400, 'Campaign cannot be sent in its current status');
|
||||
}
|
||||
|
||||
// Get recipient count if not provided
|
||||
if (recipientCount === undefined) {
|
||||
recipientCount = await this.getRecipientCount(projectId, campaign);
|
||||
}
|
||||
|
||||
// Update campaign to SENDING status
|
||||
const updatedCampaign = await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {
|
||||
status: CampaignStatus.SENDING,
|
||||
totalRecipients: recipientCount,
|
||||
sentAt: new Date(),
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {name: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification about campaign sending started
|
||||
await NtfyService.notifyCampaignSendingStarted(
|
||||
updatedCampaign.name,
|
||||
updatedCampaign.project.name,
|
||||
projectId,
|
||||
recipientCount,
|
||||
);
|
||||
|
||||
// Queue first batch to start the cursor-based chain
|
||||
await QueueService.queueCampaignBatch({
|
||||
campaignId,
|
||||
batchNumber: 1,
|
||||
offset: 0,
|
||||
limit: BATCH_SIZE,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single batch of campaign emails
|
||||
* Now uses cursor-based pagination for better performance
|
||||
*/
|
||||
public static async processBatch(
|
||||
campaignId: string,
|
||||
batchNumber: number,
|
||||
offset: number,
|
||||
limit: number,
|
||||
cursor?: string,
|
||||
): Promise<void> {
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
where: {id: campaignId},
|
||||
include: {
|
||||
project: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!campaign) {
|
||||
throw new HttpException(404, 'Campaign not found');
|
||||
}
|
||||
|
||||
if (campaign.status !== CampaignStatus.SENDING) {
|
||||
signale.warn(`[CAMPAIGN] Campaign ${campaignId} is not in SENDING status, skipping batch ${batchNumber}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get batch of recipients using cursor-based pagination
|
||||
const {contacts, nextCursor, hasMore} = await this.getRecipientsCursor(campaign.projectId, campaign, limit, cursor);
|
||||
|
||||
// Queue emails for each contact
|
||||
for (const contact of contacts) {
|
||||
try {
|
||||
// Render template with contact data
|
||||
const contactData =
|
||||
contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data) ? contact.data : {};
|
||||
const variables = {
|
||||
id: contact.id,
|
||||
email: contact.email,
|
||||
...contactData,
|
||||
data: contactData,
|
||||
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`,
|
||||
subscribeUrl: `${DASHBOARD_URI}/subscribe/${contact.id}`,
|
||||
manageUrl: `${DASHBOARD_URI}/manage/${contact.id}`,
|
||||
};
|
||||
|
||||
const renderedSubject = EmailService.format({
|
||||
subject: campaign.subject,
|
||||
body: '',
|
||||
data: variables,
|
||||
}).subject;
|
||||
|
||||
const renderedBody = EmailService.format({
|
||||
subject: '',
|
||||
body: campaign.body,
|
||||
data: variables,
|
||||
}).body;
|
||||
|
||||
await EmailService.sendCampaignEmail({
|
||||
projectId: campaign.projectId,
|
||||
contactId: contact.id,
|
||||
campaignId: campaign.id,
|
||||
templateId: undefined,
|
||||
subject: renderedSubject,
|
||||
body: renderedBody,
|
||||
from: campaign.from,
|
||||
fromName: campaign.fromName || undefined,
|
||||
replyTo: campaign.replyTo || undefined,
|
||||
isTransactional: campaign.type === TemplateType.TRANSACTIONAL,
|
||||
});
|
||||
} catch (error) {
|
||||
signale.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error);
|
||||
// Continue with other contacts even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
// Queue next batch if there are more contacts
|
||||
if (hasMore && nextCursor) {
|
||||
await QueueService.queueCampaignBatch({
|
||||
campaignId,
|
||||
batchNumber: batchNumber + 1,
|
||||
offset: 0, // Not used with cursor pagination
|
||||
limit,
|
||||
cursor: nextCursor,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a campaign
|
||||
*/
|
||||
public static async cancel(projectId: string, campaignId: string): Promise<Campaign> {
|
||||
const campaign = await this.get(projectId, campaignId);
|
||||
|
||||
// Can only cancel scheduled or sending campaigns
|
||||
if (campaign.status !== CampaignStatus.SCHEDULED && campaign.status !== CampaignStatus.SENDING) {
|
||||
throw new HttpException(400, 'Can only cancel scheduled or sending campaigns');
|
||||
}
|
||||
|
||||
// If scheduled, remove from queue
|
||||
if (campaign.status === CampaignStatus.SCHEDULED) {
|
||||
await QueueService.cancelScheduledCampaign(campaignId);
|
||||
}
|
||||
|
||||
// Update status
|
||||
const cancelledCampaign = await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {
|
||||
status: CampaignStatus.CANCELLED,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {name: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification about campaign cancellation
|
||||
await NtfyService.notifyCampaignCancelled(cancelledCampaign.name, cancelledCampaign.project.name, projectId);
|
||||
|
||||
return cancelledCampaign;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get campaign statistics
|
||||
*/
|
||||
public static async getStats(projectId: string, campaignId: string) {
|
||||
const campaign = await this.get(projectId, campaignId);
|
||||
|
||||
// Get email stats from Email table
|
||||
const [sentEmails, deliveredEmails, openedEmails, clickedEmails, bouncedEmails] = await Promise.all([
|
||||
prisma.email.count({
|
||||
where: {campaignId, sentAt: {not: null}},
|
||||
}),
|
||||
prisma.email.count({
|
||||
where: {campaignId, deliveredAt: {not: null}},
|
||||
}),
|
||||
prisma.email.count({
|
||||
where: {campaignId, openedAt: {not: null}},
|
||||
}),
|
||||
prisma.email.count({
|
||||
where: {campaignId, clickedAt: {not: null}},
|
||||
}),
|
||||
prisma.email.count({
|
||||
where: {campaignId, bouncedAt: {not: null}},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Update campaign stats
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {
|
||||
sentCount: sentEmails,
|
||||
deliveredCount: deliveredEmails,
|
||||
openedCount: openedEmails,
|
||||
clickedCount: clickedEmails,
|
||||
bouncedCount: bouncedEmails,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
totalRecipients: campaign.totalRecipients,
|
||||
sentCount: sentEmails,
|
||||
deliveredCount: deliveredEmails,
|
||||
openedCount: openedEmails,
|
||||
clickedCount: clickedEmails,
|
||||
bouncedCount: bouncedEmails,
|
||||
openRate: sentEmails > 0 ? (openedEmails / sentEmails) * 100 : 0,
|
||||
clickRate: sentEmails > 0 ? (clickedEmails / sentEmails) * 100 : 0,
|
||||
bounceRate: sentEmails > 0 ? (bouncedEmails / sentEmails) * 100 : 0,
|
||||
deliveryRate: sentEmails > 0 ? (deliveredEmails / sentEmails) * 100 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a test email for a campaign
|
||||
*/
|
||||
public static async sendTest(projectId: string, campaignId: string, testEmail: string): Promise<void> {
|
||||
const campaign = await this.get(projectId, campaignId);
|
||||
|
||||
// Validate that the test email belongs to a project member
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
user: {
|
||||
email: testEmail,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpException(403, 'Test emails can only be sent to project members');
|
||||
}
|
||||
|
||||
// Verify domain is registered and verified before sending
|
||||
await DomainService.verifyEmailDomain(campaign.from, projectId);
|
||||
|
||||
// Get project to validate from address
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpException(404, 'Project not found');
|
||||
}
|
||||
|
||||
// Prepare the email content (no variable replacement for test emails)
|
||||
await sendRawEmail({
|
||||
from: {
|
||||
name: campaign.fromName || project.name || 'Plunk',
|
||||
email: campaign.from,
|
||||
},
|
||||
to: [testEmail],
|
||||
content: {
|
||||
subject: `[TEST] ${campaign.subject}`,
|
||||
html: campaign.body,
|
||||
},
|
||||
reply: campaign.replyTo || undefined,
|
||||
headers: {
|
||||
'X-Plunk-Test': 'true',
|
||||
},
|
||||
tracking: false, // Disable tracking for test emails
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recipient count for a campaign
|
||||
*/
|
||||
private static async getRecipientCount(projectId: string, campaign: Campaign): Promise<number> {
|
||||
const where = await this.buildRecipientWhereAsync(projectId, campaign);
|
||||
return prisma.contact.count({where});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recipients for a campaign (legacy offset-based, kept for compatibility)
|
||||
*/
|
||||
private static async getRecipients(
|
||||
projectId: string,
|
||||
campaign: Campaign,
|
||||
offset: number,
|
||||
limit: number,
|
||||
): Promise<Contact[]> {
|
||||
const where = await this.buildRecipientWhereAsync(projectId, campaign);
|
||||
|
||||
return prisma.contact.findMany({
|
||||
where,
|
||||
skip: offset,
|
||||
take: limit,
|
||||
orderBy: {createdAt: 'asc'}, // Consistent ordering for batching
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recipients for a campaign using cursor-based pagination
|
||||
*/
|
||||
private static async getRecipientsCursor(
|
||||
projectId: string,
|
||||
campaign: Campaign,
|
||||
limit: number,
|
||||
cursor?: string,
|
||||
): Promise<{contacts: Contact[]; nextCursor?: string; hasMore: boolean}> {
|
||||
const where = await this.buildRecipientWhereAsync(projectId, campaign);
|
||||
|
||||
// Fetch one extra to determine if there are more results
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where,
|
||||
take: limit + 1,
|
||||
skip: cursor ? 1 : 0,
|
||||
cursor: cursor ? {id: cursor} : undefined,
|
||||
orderBy: {id: 'asc'}, // Use ID for consistent cursor ordering
|
||||
});
|
||||
|
||||
const hasMore = contacts.length > limit;
|
||||
const results = hasMore ? contacts.slice(0, -1) : contacts;
|
||||
const nextCursor = hasMore ? results[results.length - 1]?.id : undefined;
|
||||
|
||||
return {
|
||||
contacts: results,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build WHERE clause for campaign recipients (async for segment lookups)
|
||||
*/
|
||||
private static async buildRecipientWhereAsync(
|
||||
projectId: string,
|
||||
campaign: Campaign,
|
||||
): Promise<Prisma.ContactWhereInput> {
|
||||
const baseWhere: Prisma.ContactWhereInput = {
|
||||
projectId,
|
||||
// Transactional campaigns send to all contacts regardless of subscription status
|
||||
...(campaign.type !== TemplateType.TRANSACTIONAL && {subscribed: true}),
|
||||
};
|
||||
|
||||
switch (campaign.audienceType) {
|
||||
case CampaignAudienceType.ALL:
|
||||
return baseWhere;
|
||||
|
||||
case CampaignAudienceType.SEGMENT:
|
||||
if (!campaign.segmentId) {
|
||||
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
|
||||
}
|
||||
|
||||
// Get segment and use its filters
|
||||
return this.buildSegmentWhereAsync(projectId, campaign.segmentId, baseWhere);
|
||||
|
||||
case CampaignAudienceType.FILTERED: {
|
||||
const condition = fromPrismaJson<FilterCondition>(campaign.audienceCondition);
|
||||
if (!condition) {
|
||||
throw new HttpException(400, 'Audience condition is required for FILTERED audience type');
|
||||
}
|
||||
|
||||
// Use the SegmentService to build the where clause from the condition
|
||||
const segmentWhere = SegmentService.buildConditionClause(condition);
|
||||
|
||||
return {
|
||||
...baseWhere,
|
||||
...segmentWhere,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new HttpException(400, 'Invalid audience type');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build WHERE clause for segment-based campaigns
|
||||
*/
|
||||
private static async buildSegmentWhereAsync(
|
||||
projectId: string,
|
||||
segmentId: string,
|
||||
baseWhere: Prisma.ContactWhereInput,
|
||||
): Promise<Prisma.ContactWhereInput> {
|
||||
// Fetch the segment to get its condition
|
||||
const segment = await prisma.segment.findUnique({
|
||||
where: {id: segmentId},
|
||||
});
|
||||
|
||||
if (!segment) {
|
||||
throw new HttpException(404, 'Segment not found');
|
||||
}
|
||||
|
||||
if (segment.type === 'STATIC') {
|
||||
return {
|
||||
...baseWhere,
|
||||
segmentMemberships: {
|
||||
some: {
|
||||
segmentId,
|
||||
exitedAt: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const segmentWhere = SegmentService.buildConditionClause(condition);
|
||||
|
||||
return {
|
||||
...baseWhere,
|
||||
...segmentWhere,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,856 @@
|
||||
import {type Contact, Prisma} from '@plunk/db';
|
||||
import type {CursorPaginatedResponse, FilterCondition, FilterGroup} from '@plunk/types';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import {EventService} from './EventService.js';
|
||||
|
||||
export class ContactService {
|
||||
/**
|
||||
* Get all contacts for a project with cursor-based pagination
|
||||
* Uses cursor pagination for better performance with large datasets
|
||||
*/
|
||||
public static async list(
|
||||
projectId: string,
|
||||
limit = 20,
|
||||
cursor?: string,
|
||||
search?: string,
|
||||
): Promise<CursorPaginatedResponse<Contact>> {
|
||||
const where: Prisma.ContactWhereInput = {
|
||||
projectId,
|
||||
...(search
|
||||
? {
|
||||
email: {
|
||||
contains: search,
|
||||
mode: 'insensitive' as const,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
// Fetch one extra to determine if there are more results
|
||||
// Use composite ordering (createdAt + id) to ensure stable pagination
|
||||
// This prevents skipping records when multiple contacts have the same createdAt
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where,
|
||||
take: limit + 1,
|
||||
skip: cursor ? 1 : 0,
|
||||
cursor: cursor ? {id: cursor} : undefined,
|
||||
orderBy: [
|
||||
{createdAt: 'desc'},
|
||||
{id: 'desc'}, // Secondary sort by id for stable cursor pagination
|
||||
],
|
||||
});
|
||||
|
||||
const hasMore = contacts.length > limit;
|
||||
const results = hasMore ? contacts.slice(0, -1) : contacts;
|
||||
const nextCursor = hasMore ? results[results.length - 1]?.id : undefined;
|
||||
|
||||
// Get total count only on first page for better performance
|
||||
const total = !cursor ? await prisma.contact.count({where}) : 0;
|
||||
|
||||
return {
|
||||
data: results,
|
||||
total,
|
||||
cursor: nextCursor,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single contact by ID
|
||||
*/
|
||||
public static async get(projectId: string, contactId: string): Promise<Contact> {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
id: contactId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
throw new HttpException(404, 'Contact not found');
|
||||
}
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a contact by email (returns null if not found)
|
||||
*/
|
||||
public static async findByEmail(projectId: string, email: string): Promise<Contact | null> {
|
||||
return prisma.contact.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new contact
|
||||
* Uses unique constraint violation to check for duplicates (more efficient)
|
||||
*/
|
||||
public static async create(
|
||||
projectId: string,
|
||||
data: {email: string; data?: Prisma.JsonValue; subscribed?: boolean},
|
||||
): Promise<Contact> {
|
||||
try {
|
||||
return await prisma.contact.create({
|
||||
data: {
|
||||
projectId,
|
||||
email: data.email,
|
||||
data: data.data ?? Prisma.JsonNull,
|
||||
subscribed: data.subscribed ?? true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Check if this is a unique constraint violation (P2002)
|
||||
if (error instanceof Error && 'code' in error && error.code === 'P2002') {
|
||||
throw new HttpException(409, 'Contact with this email already exists in this project');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a contact
|
||||
* Uses unique constraint violation to check for duplicates (more efficient)
|
||||
*/
|
||||
public static async update(
|
||||
projectId: string,
|
||||
contactId: string,
|
||||
data: {email?: string; data?: Prisma.JsonValue; subscribed?: boolean},
|
||||
): Promise<Contact> {
|
||||
// First verify contact exists and belongs to project
|
||||
const existing = await this.get(projectId, contactId);
|
||||
|
||||
const updateData: Prisma.ContactUpdateInput = {};
|
||||
|
||||
if (data.email !== undefined) {
|
||||
updateData.email = data.email;
|
||||
}
|
||||
if (data.data !== undefined) {
|
||||
updateData.data = data.data === null ? Prisma.JsonNull : data.data;
|
||||
}
|
||||
if (data.subscribed !== undefined) {
|
||||
updateData.subscribed = data.subscribed;
|
||||
}
|
||||
|
||||
// Track subscription status change
|
||||
const isSubscriptionChanging = data.subscribed !== undefined && existing.subscribed !== data.subscribed;
|
||||
const wasSubscribed = existing.subscribed;
|
||||
|
||||
try {
|
||||
const updated = await prisma.contact.update({
|
||||
where: {id: contactId},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// Track subscription event if status changed
|
||||
if (isSubscriptionChanging) {
|
||||
if (data.subscribed && !wasSubscribed) {
|
||||
await EventService.trackEvent(projectId, 'contact.subscribed', contactId);
|
||||
} else if (!data.subscribed && wasSubscribed) {
|
||||
await EventService.trackEvent(projectId, 'contact.unsubscribed', contactId);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
} catch (error) {
|
||||
// Check if this is a unique constraint violation (P2002)
|
||||
if (error instanceof Error && 'code' in error && error.code === 'P2002') {
|
||||
throw new HttpException(409, 'Contact with this email already exists in this project');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a contact
|
||||
*/
|
||||
public static async delete(projectId: string, contactId: string): Promise<void> {
|
||||
// First verify contact exists and belongs to project
|
||||
await this.get(projectId, contactId);
|
||||
|
||||
await prisma.contact.delete({
|
||||
where: {id: contactId},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contact count for a project
|
||||
*/
|
||||
public static async count(projectId: string): Promise<number> {
|
||||
return prisma.contact.count({
|
||||
where: {projectId},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a contact (create or update) with metadata merging
|
||||
* Supports persistent and non-persistent data fields
|
||||
* Reserved fields: plunk_id, plunk_email
|
||||
*/
|
||||
public static async upsert(
|
||||
projectId: string,
|
||||
email: string,
|
||||
data?: Record<string, unknown>,
|
||||
subscribed?: boolean,
|
||||
defaultSubscribed: boolean = true,
|
||||
): Promise<Contact> {
|
||||
// Find existing contact
|
||||
const existing = await prisma.contact.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
// Process data to merge with existing data
|
||||
let mergedData: Record<string, unknown> = {};
|
||||
|
||||
if (existing?.data && typeof existing.data === 'object' && !Array.isArray(existing.data)) {
|
||||
// Start with existing data
|
||||
mergedData = {...existing.data};
|
||||
}
|
||||
|
||||
// Merge new data (if provided)
|
||||
if (data) {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// Skip reserved system-generated fields
|
||||
// These fields are dynamically added during template rendering and cannot be overridden
|
||||
const reservedFields = [
|
||||
'plunk_id',
|
||||
'plunk_email',
|
||||
'id',
|
||||
'email',
|
||||
'unsubscribeUrl',
|
||||
'subscribeUrl',
|
||||
'manageUrl',
|
||||
];
|
||||
if (reservedFields.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate locale field (special user-settable field)
|
||||
// Only validate type - any locale string is accepted since we default to English if unsupported
|
||||
if (key === 'locale') {
|
||||
if (value !== null && value !== undefined && typeof value !== 'string') {
|
||||
throw new HttpException(400, 'Locale must be a string');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle non-persistent data format: { value: "...", persistent: false }
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'value' in value &&
|
||||
'persistent' in value &&
|
||||
value.persistent === false
|
||||
) {
|
||||
// Non-persistent fields are not stored in contact data
|
||||
// They would be used only for the current operation (like email template rendering)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store the value
|
||||
mergedData[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
// Track subscription status change
|
||||
const isSubscriptionChanging = subscribed !== undefined && existing.subscribed !== subscribed;
|
||||
const wasSubscribed = existing.subscribed;
|
||||
|
||||
const updated = await prisma.contact.update({
|
||||
where: {id: existing.id},
|
||||
data: {
|
||||
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
|
||||
...(subscribed !== undefined ? {subscribed} : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Track subscription event if status changed
|
||||
if (isSubscriptionChanging) {
|
||||
if (subscribed && !wasSubscribed) {
|
||||
await EventService.trackEvent(projectId, 'contact.subscribed', updated.id);
|
||||
} else if (!subscribed && wasSubscribed) {
|
||||
await EventService.trackEvent(projectId, 'contact.unsubscribed', updated.id);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
} else {
|
||||
return prisma.contact.create({
|
||||
data: {
|
||||
projectId,
|
||||
email,
|
||||
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
|
||||
subscribed: subscribed ?? defaultSubscribed,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full merged data for a contact including non-persistent fields
|
||||
* This is useful for template rendering
|
||||
*/
|
||||
public static getMergedData(contact: Contact, temporaryData?: Record<string, unknown>): Record<string, unknown> {
|
||||
const mergedData: Record<string, unknown> = {
|
||||
plunk_id: contact.id,
|
||||
plunk_email: contact.email,
|
||||
};
|
||||
|
||||
// Add contact's persistent data
|
||||
if (contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data)) {
|
||||
Object.assign(mergedData, contact.data);
|
||||
}
|
||||
|
||||
// Explicitly expose locale as a predefined field (available in templates)
|
||||
// This ensures locale is always accessible even if not in contact.data
|
||||
if (mergedData.locale === undefined) {
|
||||
mergedData.locale = null;
|
||||
}
|
||||
|
||||
// Add temporary (non-persistent) data
|
||||
if (temporaryData) {
|
||||
for (const [key, value] of Object.entries(temporaryData)) {
|
||||
// Skip reserved system-generated fields
|
||||
const reservedFields = ['plunk_id', 'plunk_email', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl'];
|
||||
if (reservedFields.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle non-persistent data format: { value: "...", persistent: false }
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'value' in value &&
|
||||
'persistent' in value &&
|
||||
value.persistent === false
|
||||
) {
|
||||
mergedData[key] = value.value;
|
||||
} else {
|
||||
mergedData[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* PUBLIC: Get a contact by ID (no project authentication required)
|
||||
* This is used for public-facing pages like unsubscribe
|
||||
*/
|
||||
public static async getById(contactId: string): Promise<Contact> {
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: {id: contactId},
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
throw new HttpException(404, 'Contact not found');
|
||||
}
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project by contact ID
|
||||
* Used to fetch project settings for public endpoints
|
||||
*/
|
||||
public static async getProjectByContactId(contactId: string): Promise<{language: string} | null> {
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: {id: contactId},
|
||||
select: {
|
||||
project: {
|
||||
select: {
|
||||
language: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return contact?.project || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* PUBLIC: Subscribe a contact
|
||||
*/
|
||||
public static async subscribe(contactId: string): Promise<Contact> {
|
||||
const contact = await prisma.contact.update({
|
||||
where: {id: contactId},
|
||||
data: {subscribed: true},
|
||||
});
|
||||
|
||||
// Track subscription event
|
||||
await EventService.trackEvent(contact.projectId, 'contact.subscribed', contactId);
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
/**
|
||||
* PUBLIC: Unsubscribe a contact
|
||||
*/
|
||||
public static async unsubscribe(contactId: string): Promise<Contact> {
|
||||
const contact = await prisma.contact.update({
|
||||
where: {id: contactId},
|
||||
data: {subscribed: false},
|
||||
});
|
||||
|
||||
// Track unsubscription event
|
||||
await EventService.trackEvent(contact.projectId, 'contact.unsubscribed', contactId);
|
||||
|
||||
return contact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available contact fields for a project
|
||||
* Returns both standard fields and custom fields from the data JSON column
|
||||
* Now includes type information inferred from actual data
|
||||
*
|
||||
* @param projectId - The project ID to filter contacts
|
||||
* @returns Array of field objects with name and type
|
||||
*/
|
||||
public static async getAvailableFields(
|
||||
projectId: string,
|
||||
): Promise<Array<{field: string; type: 'string' | 'number' | 'boolean' | 'date'; coverage: number}>> {
|
||||
// Get total contact count for coverage calculation
|
||||
const totalContacts = await prisma.contact.count({
|
||||
where: {projectId},
|
||||
});
|
||||
|
||||
// Standard fields with known types (always 100% coverage)
|
||||
const standardFields = [
|
||||
{field: 'email', type: 'string' as const, coverage: 100},
|
||||
{field: 'subscribed', type: 'boolean' as const, coverage: 100},
|
||||
{field: 'createdAt', type: 'date' as const, coverage: 100},
|
||||
{field: 'updatedAt', type: 'date' as const, coverage: 100},
|
||||
];
|
||||
|
||||
// Get custom fields from the data JSON column with type inference and coverage
|
||||
// Use raw SQL to extract all keys, sample values, and contact counts from the JSON data column
|
||||
const result = await prisma.$queryRaw<
|
||||
Array<{key: string; sample_value: string; json_type: string; contact_count: bigint}>
|
||||
>`
|
||||
WITH field_keys AS (
|
||||
SELECT DISTINCT jsonb_object_keys(data) as key
|
||||
FROM contacts
|
||||
WHERE
|
||||
"projectId" = ${projectId}
|
||||
AND data IS NOT NULL
|
||||
AND jsonb_typeof(data) = 'object'
|
||||
),
|
||||
field_samples AS (
|
||||
SELECT
|
||||
fk.key,
|
||||
jsonb_typeof(c.data->fk.key) as json_type,
|
||||
(c.data->>fk.key) as sample_value
|
||||
FROM field_keys fk
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT data
|
||||
FROM contacts
|
||||
WHERE
|
||||
"projectId" = ${projectId}
|
||||
AND data ? fk.key
|
||||
AND data->fk.key IS NOT NULL
|
||||
LIMIT 1
|
||||
) c
|
||||
),
|
||||
field_counts AS (
|
||||
SELECT
|
||||
fk.key,
|
||||
COUNT(*) as contact_count
|
||||
FROM field_keys fk
|
||||
JOIN contacts c ON c."projectId" = ${projectId}
|
||||
AND c.data ? fk.key
|
||||
AND c.data->fk.key IS NOT NULL
|
||||
GROUP BY fk.key
|
||||
)
|
||||
SELECT
|
||||
fs.key,
|
||||
fs.sample_value,
|
||||
fs.json_type,
|
||||
fc.contact_count
|
||||
FROM field_samples fs
|
||||
JOIN field_counts fc ON fc.key = fs.key
|
||||
`;
|
||||
|
||||
// Infer types from JSON types and sample values, calculate coverage
|
||||
const customFields = result.map(row => {
|
||||
let type: 'string' | 'number' | 'boolean' | 'date' = 'string';
|
||||
|
||||
// PostgreSQL jsonb_typeof returns: "object", "array", "string", "number", "boolean", "null"
|
||||
if (row.json_type === 'boolean') {
|
||||
type = 'boolean';
|
||||
} else if (row.json_type === 'number') {
|
||||
type = 'number';
|
||||
} else if (row.json_type === 'string' && row.sample_value) {
|
||||
// Try to detect dates (ISO 8601 format)
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?)?$/;
|
||||
if (dateRegex.test(row.sample_value)) {
|
||||
type = 'date';
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate coverage percentage
|
||||
const contactCount = Number(row.contact_count);
|
||||
const coverage = totalContacts > 0 ? Math.round((contactCount / totalContacts) * 100) : 0;
|
||||
|
||||
return {
|
||||
field: `data.${row.key}`,
|
||||
type,
|
||||
coverage,
|
||||
};
|
||||
});
|
||||
|
||||
return [...standardFields, ...customFields].sort((a, b) => a.field.localeCompare(b.field));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique values for a contact field
|
||||
* Optimized for large datasets (1M+ contacts) - limits results and uses efficient queries
|
||||
*
|
||||
* @param projectId - The project ID to filter contacts
|
||||
* @param field - The field path (e.g., "subscribed", "email", "data.plan", "data.firstName")
|
||||
* @param limit - Maximum number of unique values to return (default: 100)
|
||||
* @returns Array of unique values, sorted alphabetically
|
||||
*/
|
||||
public static async getUniqueFieldValues(
|
||||
projectId: string,
|
||||
field: string,
|
||||
limit = 100,
|
||||
): Promise<Array<string | number | boolean>> {
|
||||
if (field === 'subscribed') {
|
||||
// Boolean field - return both possible values
|
||||
return [true, false];
|
||||
}
|
||||
|
||||
if (field === 'email') {
|
||||
// Email is not useful for dropdowns, return empty
|
||||
return [];
|
||||
}
|
||||
|
||||
// Handle JSON data fields (e.g., "data.plan" or just "plan")
|
||||
const jsonField = field.startsWith('data.') ? field.substring(5) : field;
|
||||
|
||||
// Use raw SQL for performance with large datasets
|
||||
// Extract unique values from the JSON field using PostgreSQL's JSON operators
|
||||
const result = await prisma.$queryRaw<Array<{value: unknown}>>`
|
||||
SELECT DISTINCT
|
||||
data->>${jsonField} as value
|
||||
FROM contacts
|
||||
WHERE
|
||||
"projectId" = ${projectId}
|
||||
AND data ? ${jsonField}
|
||||
AND data->>${jsonField} IS NOT NULL
|
||||
AND data->>${jsonField} != ''
|
||||
ORDER BY value
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
// Parse and return values, handling different data types
|
||||
return result
|
||||
.map(row => {
|
||||
const value = String(row.value);
|
||||
|
||||
// Try to parse as boolean
|
||||
if (value === 'true') return true;
|
||||
if (value === 'false') return false;
|
||||
|
||||
// Try to parse as number
|
||||
const numValue = Number(value);
|
||||
if (!isNaN(numValue) && value.trim() !== '') {
|
||||
return numValue;
|
||||
}
|
||||
|
||||
// Return as string
|
||||
return value;
|
||||
})
|
||||
.filter(v => v !== null && v !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a contact field is used in any segments or campaigns
|
||||
* Returns usage information including which segments/campaigns use the field
|
||||
*
|
||||
* @param projectId - The project ID
|
||||
* @param field - The field to check (e.g., "data.plan", "email", "subscribed")
|
||||
* @returns Usage information
|
||||
*/
|
||||
public static async getFieldUsage(
|
||||
projectId: string,
|
||||
field: string,
|
||||
): Promise<{
|
||||
usedInSegments: Array<{id: string; name: string}>;
|
||||
usedInCampaigns: Array<{id: string; name: string}>;
|
||||
contactCount: number;
|
||||
canDelete: boolean;
|
||||
}> {
|
||||
// Get all segments for the project
|
||||
const segments = await prisma.segment.findMany({
|
||||
where: {projectId},
|
||||
select: {id: true, name: true, condition: true},
|
||||
});
|
||||
|
||||
// Check which segments use this field
|
||||
const usedInSegments = segments.filter(segment => {
|
||||
const condition = segment.condition as FilterCondition | null;
|
||||
return this.fieldUsedInCondition(field, condition);
|
||||
});
|
||||
|
||||
// For now, we'll check if campaigns use the field in their subject or body
|
||||
// This is a simplified check - you might want to enhance this based on your campaign structure
|
||||
const usedInCampaigns: Array<{id: string; name: string}> = [];
|
||||
|
||||
// Count contacts that have this field (for data fields)
|
||||
let contactCount = 0;
|
||||
if (field.startsWith('data.')) {
|
||||
const jsonField = field.substring(5);
|
||||
const result = await prisma.$queryRaw<Array<{count: bigint}>>`
|
||||
SELECT COUNT(*) as count
|
||||
FROM contacts
|
||||
WHERE
|
||||
"projectId" = ${projectId}
|
||||
AND data ? ${jsonField}
|
||||
AND data->${jsonField} IS NOT NULL
|
||||
`;
|
||||
contactCount = Number(result[0]?.count || 0);
|
||||
} else if (field === 'email' || field === 'subscribed' || field === 'createdAt' || field === 'updatedAt') {
|
||||
// Standard fields exist on all contacts
|
||||
const result = await prisma.contact.count({where: {projectId}});
|
||||
contactCount = result;
|
||||
}
|
||||
|
||||
const canDelete = usedInSegments.length === 0 && usedInCampaigns.length === 0;
|
||||
|
||||
return {
|
||||
usedInSegments: usedInSegments.map(s => ({id: s.id, name: s.name})),
|
||||
usedInCampaigns,
|
||||
contactCount,
|
||||
canDelete,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a custom field from all contacts
|
||||
* WARNING: This is destructive and cannot be undone
|
||||
* Should only be called after verifying the field is not in use
|
||||
*
|
||||
* @param projectId - The project ID
|
||||
* @param field - The field to delete (must be a data.* field)
|
||||
*/
|
||||
public static async deleteField(projectId: string, field: string): Promise<{deletedFrom: number}> {
|
||||
// Only allow deleting custom data fields
|
||||
if (!field.startsWith('data.')) {
|
||||
throw new HttpException(400, 'Can only delete custom data fields (data.*)');
|
||||
}
|
||||
|
||||
// Check if field is in use
|
||||
const usage = await this.getFieldUsage(projectId, field);
|
||||
if (!usage.canDelete) {
|
||||
throw new HttpException(
|
||||
400,
|
||||
`Cannot delete field: used in ${usage.usedInSegments.length} segment(s) and ${usage.usedInCampaigns.length} campaign(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
const jsonField = field.substring(5);
|
||||
|
||||
// Delete the field from all contacts using raw SQL
|
||||
// PostgreSQL's `-` operator removes a key from a JSON object
|
||||
const result = await prisma.$executeRaw`
|
||||
UPDATE contacts
|
||||
SET data = data - ${jsonField}
|
||||
WHERE
|
||||
"projectId" = ${projectId}
|
||||
AND data ? ${jsonField}
|
||||
`;
|
||||
|
||||
return {deletedFrom: result};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk subscribe contacts
|
||||
* Updates multiple contacts to subscribed=true in batches
|
||||
*/
|
||||
public static async bulkSubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
|
||||
// Verify all contacts belong to this project
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
id: {in: contactIds},
|
||||
projectId,
|
||||
},
|
||||
select: {id: true, subscribed: true},
|
||||
});
|
||||
|
||||
const validIds = contacts.map(c => c.id);
|
||||
|
||||
if (validIds.length === 0) {
|
||||
return {updated: 0};
|
||||
}
|
||||
|
||||
// Only update contacts that are currently unsubscribed
|
||||
const unsubscribedIds = contacts.filter(c => !c.subscribed).map(c => c.id);
|
||||
|
||||
if (unsubscribedIds.length === 0) {
|
||||
return {updated: 0};
|
||||
}
|
||||
|
||||
// Update in a single query for performance
|
||||
const result = await prisma.contact.updateMany({
|
||||
where: {
|
||||
id: {in: unsubscribedIds},
|
||||
projectId,
|
||||
},
|
||||
data: {
|
||||
subscribed: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Track events for changed contacts sequentially to avoid database deadlocks
|
||||
// Process in background to avoid blocking the API response
|
||||
this.trackEventsSequentially(projectId, 'contact.subscribed', unsubscribedIds).catch(error => {
|
||||
// Silently ignore errors in tests due to cleanup race conditions
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
console.error('[ContactService] Failed to track bulk subscribe events:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return {updated: result.count};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk unsubscribe contacts
|
||||
*/
|
||||
public static async bulkUnsubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
id: {in: contactIds},
|
||||
projectId,
|
||||
},
|
||||
select: {id: true, subscribed: true},
|
||||
});
|
||||
|
||||
const validIds = contacts.map(c => c.id);
|
||||
|
||||
if (validIds.length === 0) {
|
||||
return {updated: 0};
|
||||
}
|
||||
|
||||
// Only update contacts that are currently subscribed
|
||||
const subscribedIds = contacts.filter(c => c.subscribed).map(c => c.id);
|
||||
|
||||
if (subscribedIds.length === 0) {
|
||||
return {updated: 0};
|
||||
}
|
||||
|
||||
const result = await prisma.contact.updateMany({
|
||||
where: {
|
||||
id: {in: subscribedIds},
|
||||
projectId,
|
||||
},
|
||||
data: {
|
||||
subscribed: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Track events for changed contacts sequentially to avoid database deadlocks
|
||||
// Process in background to avoid blocking the API response
|
||||
this.trackEventsSequentially(projectId, 'contact.unsubscribed', subscribedIds).catch(error => {
|
||||
// Silently ignore errors in tests due to cleanup race conditions
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
console.error('[ContactService] Failed to track bulk unsubscribe events:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return {updated: result.count};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk delete contacts
|
||||
*/
|
||||
public static async bulkDelete(projectId: string, contactIds: string[]): Promise<{deleted: number}> {
|
||||
const result = await prisma.contact.deleteMany({
|
||||
where: {
|
||||
id: {in: contactIds},
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
return {deleted: result.count};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if a field is used in a filter condition (recursive)
|
||||
*/
|
||||
private static fieldUsedInCondition(field: string, condition: FilterCondition | null): boolean {
|
||||
if (!condition || typeof condition !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check groups in the condition
|
||||
if (Array.isArray(condition.groups)) {
|
||||
for (const group of condition.groups) {
|
||||
if (this.fieldUsedInGroup(field, group)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if a field is used in a filter group (recursive)
|
||||
*/
|
||||
private static fieldUsedInGroup(field: string, group: FilterGroup): boolean {
|
||||
if (!group || typeof group !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check filters in the group
|
||||
if (Array.isArray(group.filters)) {
|
||||
for (const filter of group.filters) {
|
||||
if (filter.field === field) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check nested conditions
|
||||
if (group.conditions) {
|
||||
return this.fieldUsedInCondition(field, group.conditions);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track events sequentially to avoid database deadlocks
|
||||
* Processes events one at a time with error handling
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private static async trackEventsSequentially(
|
||||
projectId: string,
|
||||
eventName: string,
|
||||
contactIds: string[],
|
||||
): Promise<void> {
|
||||
for (const contactId of contactIds) {
|
||||
try {
|
||||
await EventService.trackEvent(projectId, eventName, contactId);
|
||||
} catch (error) {
|
||||
// Log error but continue processing remaining events
|
||||
// Suppress logging in test environments to reduce noise from cleanup race conditions
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
console.error(`[ContactService] Failed to track event ${eventName} for contact ${contactId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import React from 'react';
|
||||
import signale from 'signale';
|
||||
import {DomainUnverifiedEmail, DomainVerifiedEmail, sendPlatformEmail} from '@plunk/email';
|
||||
import {DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis, wrapRedis} from '../database/redis.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {MembershipService} from './MembershipService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {
|
||||
deleteIdentity,
|
||||
disableFeedbackForwarding,
|
||||
getDomainVerificationAttributes,
|
||||
verifyDomain,
|
||||
} from './SESService.js';
|
||||
|
||||
export class DomainService {
|
||||
/**
|
||||
* Get a domain by ID
|
||||
*/
|
||||
public static async id(id: string) {
|
||||
return wrapRedis(Keys.Domain.id(id), async () => {
|
||||
return prisma.domain.findUnique({where: {id}});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all domains for a project
|
||||
*/
|
||||
public static async getProjectDomains(projectId: string) {
|
||||
return wrapRedis(Keys.Domain.project(projectId), async () => {
|
||||
return prisma.domain.findMany({
|
||||
where: {projectId},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new domain to a project and start verification
|
||||
*/
|
||||
public static async addDomain(projectId: string, domain: string) {
|
||||
// Start verification process with AWS SES
|
||||
const dkimTokens = await verifyDomain(domain);
|
||||
|
||||
// Create domain record
|
||||
const newDomain = await prisma.domain.create({
|
||||
data: {
|
||||
projectId,
|
||||
domain,
|
||||
verified: false,
|
||||
dkimTokens,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {name: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification about domain added
|
||||
await NtfyService.notifyDomainAdded(domain, newDomain.project.name, projectId);
|
||||
|
||||
return newDomain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check verification status for a domain
|
||||
*/
|
||||
public static async checkVerification(domainId: string) {
|
||||
const domain = await prisma.domain.findUnique({where: {id: domainId}});
|
||||
|
||||
if (!domain) {
|
||||
throw new Error('Domain not found');
|
||||
}
|
||||
|
||||
const attributes = await getDomainVerificationAttributes(domain.domain);
|
||||
|
||||
// If domain failed verification, retry
|
||||
if (attributes.status === 'Failed') {
|
||||
signale.warn(`[DOMAIN-SERVICE] Restarting verification for ${domain.domain}`);
|
||||
|
||||
let attempt = 0;
|
||||
const maxAttempts = 5;
|
||||
let success = false;
|
||||
let delay = 5000;
|
||||
|
||||
while (attempt < maxAttempts && !success) {
|
||||
try {
|
||||
await verifyDomain(domain.domain);
|
||||
success = true;
|
||||
signale.success(`[DOMAIN-SERVICE] Restarted verification for ${domain.domain}`);
|
||||
} catch (e: unknown) {
|
||||
const error = e as {Code?: string; name?: string; message?: string};
|
||||
if (error?.Code === 'Throttling' || error?.name === 'Throttling' || error?.message?.includes('Throttling')) {
|
||||
signale.warn(
|
||||
`[DOMAIN-SERVICE] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
|
||||
);
|
||||
await new Promise(r => setTimeout(r, delay));
|
||||
delay *= 2; // Exponential backoff
|
||||
attempt++;
|
||||
} else {
|
||||
signale.error(`[DOMAIN-SERVICE] Error restarting verification: ${error?.message || 'Unknown error'}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
signale.error(
|
||||
`[DOMAIN-SERVICE] Failed to verify ${domain.domain} after ${maxAttempts} attempts due to throttling`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Update domain if verification status changed
|
||||
if (attributes.status === 'Success' && !domain.verified) {
|
||||
const updatedDomain = await prisma.domain.update({
|
||||
where: {id: domainId},
|
||||
data: {verified: true},
|
||||
include: {
|
||||
project: {
|
||||
select: {name: true, id: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Disable feedback forwarding for verified domain
|
||||
try {
|
||||
await disableFeedbackForwarding(domain.domain);
|
||||
signale.info(`[DOMAIN-SERVICE] Disabled feedback forwarding for ${domain.domain}`);
|
||||
} catch (error) {
|
||||
signale.error(`[DOMAIN-SERVICE] Error disabling feedback forwarding for ${domain.domain}:`, error);
|
||||
}
|
||||
|
||||
// Send notification about domain verified
|
||||
await NtfyService.notifyDomainVerified(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
|
||||
|
||||
// Send email notification about domain verified
|
||||
try {
|
||||
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||
const cacheKey = Keys.Domain.verifiedEmail(domainId);
|
||||
const ttl = 604800; // 7 days
|
||||
|
||||
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||
if (wasSet) {
|
||||
const members = await MembershipService.getMembers(updatedDomain.project.id);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(DomainVerifiedEmail, {
|
||||
projectName: updatedDomain.project.name,
|
||||
projectId: updatedDomain.project.id,
|
||||
domain: domain.domain,
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)));
|
||||
}
|
||||
}
|
||||
} catch (emailError) {
|
||||
signale.error('[DOMAIN-EMAIL] Failed to send domain verified email:', emailError);
|
||||
}
|
||||
} else if (attributes.status !== 'Success' && domain.verified) {
|
||||
const updatedDomain = await prisma.domain.update({
|
||||
where: {id: domainId},
|
||||
data: {verified: false},
|
||||
include: {
|
||||
project: {
|
||||
select: {name: true, id: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Send notification about domain verification failed
|
||||
await NtfyService.notifyDomainVerificationFailed(
|
||||
domain.domain,
|
||||
updatedDomain.project.name,
|
||||
updatedDomain.project.id,
|
||||
);
|
||||
|
||||
// Send email notification about domain verification failed
|
||||
try {
|
||||
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const cacheKey = Keys.Domain.unverifiedEmail(domainId, year, month);
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||
|
||||
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||
if (wasSet) {
|
||||
const members = await MembershipService.getMembers(updatedDomain.project.id);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(DomainUnverifiedEmail, {
|
||||
projectName: updatedDomain.project.name,
|
||||
projectId: updatedDomain.project.id,
|
||||
domain: domain.domain,
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Domain Verification Failed', template)));
|
||||
}
|
||||
}
|
||||
} catch (emailError) {
|
||||
signale.error('[DOMAIN-EMAIL] Failed to send domain unverified email:', emailError);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
domain: domain.domain,
|
||||
tokens: attributes.tokens,
|
||||
status: attributes.status,
|
||||
verified: attributes.status === 'Success',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a domain from a project
|
||||
*/
|
||||
public static async removeDomain(domainId: string) {
|
||||
const domain = await prisma.domain.findUnique({
|
||||
where: {id: domainId},
|
||||
include: {
|
||||
project: {
|
||||
select: {name: true, id: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!domain) {
|
||||
throw new Error('Domain not found');
|
||||
}
|
||||
|
||||
// Extract domain name for checking usage
|
||||
const domainName = domain.domain;
|
||||
|
||||
// Check if domain is used in any templates
|
||||
const templatesUsingDomain = await prisma.template.count({
|
||||
where: {
|
||||
projectId: domain.projectId,
|
||||
from: {
|
||||
contains: `@${domainName}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (templatesUsingDomain > 0) {
|
||||
throw new HttpException(
|
||||
409,
|
||||
`Cannot delete domain: it is currently used in ${templatesUsingDomain} template(s). Update the templates first.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if domain is used in any workflow steps (via templates)
|
||||
const workflowStepsUsingDomain = await prisma.workflowStep.count({
|
||||
where: {
|
||||
workflow: {
|
||||
projectId: domain.projectId,
|
||||
},
|
||||
template: {
|
||||
from: {
|
||||
contains: `@${domainName}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (workflowStepsUsingDomain > 0) {
|
||||
throw new HttpException(
|
||||
409,
|
||||
`Cannot delete domain: it is currently used in ${workflowStepsUsingDomain} workflow step(s). Update the workflow templates first.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if domain is used in any active campaigns
|
||||
const campaignsUsingDomain = await prisma.campaign.count({
|
||||
where: {
|
||||
projectId: domain.projectId,
|
||||
from: {
|
||||
contains: `@${domainName}`,
|
||||
},
|
||||
status: {
|
||||
in: ['DRAFT', 'SCHEDULED', 'SENDING'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (campaignsUsingDomain > 0) {
|
||||
throw new HttpException(
|
||||
409,
|
||||
`Cannot delete domain: it is currently used in ${campaignsUsingDomain} active campaign(s). Update or complete the campaigns first.`,
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.domain.delete({where: {id: domainId}});
|
||||
|
||||
// Check if this domain is still attached to another project
|
||||
const domainExistsElsewhere = await prisma.domain.findFirst({
|
||||
where: {
|
||||
domain: domainName,
|
||||
},
|
||||
});
|
||||
|
||||
// If domain is not used by any other project, remove it from AWS SES
|
||||
if (!domainExistsElsewhere) {
|
||||
try {
|
||||
await deleteIdentity(domainName);
|
||||
signale.info(`[DOMAIN] Removed AWS SES identity for ${domainName} (no longer used by any project)`);
|
||||
} catch (error) {
|
||||
// Log error but don't fail the domain removal if AWS cleanup fails
|
||||
signale.error(`[DOMAIN] Failed to remove AWS SES identity for ${domainName}:`, error);
|
||||
}
|
||||
} else {
|
||||
signale.info(
|
||||
`[DOMAIN] Keeping AWS SES identity for ${domainName} (still used by project ${domainExistsElsewhere.projectId})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Send notification about domain removal
|
||||
await NtfyService.notifyDomainRemoved(domainName, domain.project.name, domain.project.id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get verified domains for a project
|
||||
*/
|
||||
public static async getVerifiedDomains(projectId: string) {
|
||||
return prisma.domain.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
verified: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that an email domain belongs to the specified project and is verified
|
||||
* @param email Full email address (e.g., "hello@example.com")
|
||||
* @param projectId Project ID to verify ownership
|
||||
* @returns The verified domain object
|
||||
* @throws HttpException if domain not found, not owned by project, or not verified
|
||||
*/
|
||||
public static async verifyEmailDomain(email: string, projectId: string) {
|
||||
// Extract domain from email
|
||||
const emailParts = email.split('@');
|
||||
if (emailParts.length !== 2) {
|
||||
throw new HttpException(400, 'Invalid email format');
|
||||
}
|
||||
|
||||
const domainName = emailParts[1];
|
||||
|
||||
// Find domain in database
|
||||
const domain = await prisma.domain.findFirst({
|
||||
where: {
|
||||
domain: domainName,
|
||||
},
|
||||
});
|
||||
|
||||
if (!domain) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
`Domain "${domainName}" is not registered. Please add and verify this domain in your project settings.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Verify domain belongs to the project
|
||||
if (domain.projectId !== projectId) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
`Domain "${domainName}" belongs to a different project. You cannot use this domain.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Verify domain is verified
|
||||
if (!domain.verified) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
`Domain "${domainName}" is not verified. Please complete the DNS verification process in your domain settings.`,
|
||||
);
|
||||
}
|
||||
|
||||
return domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain is already linked to another project
|
||||
* Used when adding a new domain to verify if the user has access to the existing project
|
||||
* @param domain Domain name to check
|
||||
* @param userId User ID to check membership
|
||||
* @returns Object with exists flag and membership info
|
||||
*/
|
||||
public static async checkDomainOwnership(domain: string, userId: string) {
|
||||
const existingDomain = await prisma.domain.findFirst({
|
||||
where: {domain},
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
members: {
|
||||
where: {userId},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingDomain) {
|
||||
return {exists: false};
|
||||
}
|
||||
|
||||
// Check if user is a member of the project that owns this domain
|
||||
const isMember = existingDomain.project.members.length > 0;
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
projectId: existingDomain.project.id,
|
||||
projectName: existingDomain.project.name,
|
||||
isMember,
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,265 @@
|
||||
import {promises as dns} from 'dns';
|
||||
import {run} from '@zootools/email-spell-checker';
|
||||
import type {EmailVerificationResult} from '@plunk/types';
|
||||
import {redis} from '../database/redis.js';
|
||||
|
||||
const DISPOSABLE_DOMAINS_URL =
|
||||
'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf';
|
||||
const DISPOSABLE_DOMAINS_CACHE_KEY = 'email:disposable_domains';
|
||||
const PERSONAL_DOMAINS_URL =
|
||||
'https://gist.githubusercontent.com/ammarshah/f5c2624d767f91a7cbdc4e54db8dd0bf/raw/660fd949eba09c0b86574d9d3aa0f2137161fc7c/all_email_provider_domains.txt';
|
||||
const PERSONAL_DOMAINS_CACHE_KEY = 'email:personal_domains';
|
||||
const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours (list updates daily)
|
||||
|
||||
// Known email forwarding/alias services
|
||||
const FORWARDING_DOMAINS = new Set([
|
||||
'privaterelay.appleid.com', // Apple Sign In
|
||||
'mozmail.com', // Firefox Relay
|
||||
'simplelogin.com', // SimpleLogin
|
||||
'simplelogin.fr',
|
||||
'simplelogin.co',
|
||||
'simplelogin.io',
|
||||
'aleeas.com',
|
||||
'slmail.me',
|
||||
'dralias.com',
|
||||
'8shield.net',
|
||||
'anonaddy.com', // Addy.io
|
||||
'anonaddy.me',
|
||||
'addy.io',
|
||||
'duck.com', // DuckDuckGo
|
||||
'33mail.com', // 33mail
|
||||
'33m.co',
|
||||
'passmail.com', // Proton Pass
|
||||
'passmail.net',
|
||||
'passinbox.com',
|
||||
'passfwd.com',
|
||||
'y.yo.fr',
|
||||
'opayq.com', // IronVest (formerly Blur)
|
||||
'cloak.id', // Cloaked
|
||||
'erine.email', // Erine
|
||||
'use.startmail.com', // StartMail
|
||||
]);
|
||||
|
||||
export class EmailVerificationService {
|
||||
private static disposableDomainsSet: Set<string> | null = null;
|
||||
private static personalDomainsSet: Set<string> | null = null;
|
||||
|
||||
/**
|
||||
* Verify an email address
|
||||
* - Checks for NS records (proves domain exists in DNS)
|
||||
* - Checks for MX records (required for receiving email)
|
||||
* - Checks for A/AAAA records (informational - indicates if domain has a website)
|
||||
* - Detects disposable email addresses
|
||||
* - Detects personal/free email providers (Gmail, Hotmail, etc.)
|
||||
* - Detects forwarding/alias email addresses
|
||||
* - Suggests corrections for common typos
|
||||
*/
|
||||
static async verifyEmail(email: string): Promise<EmailVerificationResult> {
|
||||
const result: EmailVerificationResult = {
|
||||
email,
|
||||
valid: true,
|
||||
isDisposable: false,
|
||||
isAlias: false,
|
||||
isTypo: false,
|
||||
isPlusAddressed: false,
|
||||
isPersonalEmail: false,
|
||||
domainExists: false,
|
||||
hasWebsite: false,
|
||||
hasMxRecords: false,
|
||||
reasons: [],
|
||||
};
|
||||
|
||||
// Extract domain from email
|
||||
const emailParts = email.split('@');
|
||||
if (emailParts.length !== 2) {
|
||||
result.valid = false;
|
||||
result.reasons.push('Invalid email format');
|
||||
return result;
|
||||
}
|
||||
|
||||
const domain = emailParts[1]!; // Safe to assert, we already validated length
|
||||
|
||||
// Check if email is from a disposable domain using GitHub list
|
||||
result.isDisposable = await this.isDisposableDomain(domain);
|
||||
|
||||
// Check if email is from a personal/free email provider
|
||||
result.isPersonalEmail = await this.isPersonalEmailDomain(domain);
|
||||
|
||||
// Check if email is from a known forwarding/alias service
|
||||
result.isAlias = this.isForwardingDomain(domain);
|
||||
|
||||
// Check for plus addressing
|
||||
result.isPlusAddressed = emailParts[0]!.includes('+');
|
||||
|
||||
// Check for common typos and suggest corrections
|
||||
const typoCheck = run({email});
|
||||
if (typoCheck && typoCheck.address && typoCheck.address !== email) {
|
||||
result.suggestedEmail = typoCheck.full;
|
||||
result.reasons.push(`Possible typo detected, did you mean ${typoCheck.domain}?`);
|
||||
result.isTypo = true;
|
||||
}
|
||||
|
||||
// Step 1: Check NS records - proves the domain exists in DNS
|
||||
try {
|
||||
const nsRecords = await dns.resolveNs(domain);
|
||||
result.domainExists = nsRecords && nsRecords.length > 0;
|
||||
} catch {
|
||||
result.domainExists = false;
|
||||
result.valid = false;
|
||||
result.reasons.push('Domain does not exist (no nameservers found)');
|
||||
// If domain doesn't exist, no point checking MX/A records
|
||||
return result;
|
||||
}
|
||||
|
||||
// Step 2: Check MX records - required for receiving email
|
||||
try {
|
||||
const mxRecords = await dns.resolveMx(domain);
|
||||
result.hasMxRecords = mxRecords && mxRecords.length > 0;
|
||||
if (!result.hasMxRecords) {
|
||||
result.valid = false;
|
||||
result.reasons.push('Domain cannot receive email (no MX records found)');
|
||||
}
|
||||
} catch {
|
||||
result.hasMxRecords = false;
|
||||
result.valid = false;
|
||||
result.reasons.push('Domain cannot receive email (no MX records found)');
|
||||
}
|
||||
|
||||
// Step 3: Check if domain has A/AAAA records - informational only
|
||||
// This indicates if the domain has a website/web server
|
||||
// Doesn't affect email validity since email only requires MX records
|
||||
try {
|
||||
await dns.resolve(domain, 'A');
|
||||
result.hasWebsite = true;
|
||||
} catch {
|
||||
// Try AAAA records if A records fail
|
||||
try {
|
||||
await dns.resolve(domain, 'AAAA');
|
||||
result.hasWebsite = true;
|
||||
} catch {
|
||||
// Domain doesn't have A/AAAA records (no website), but this is OK for email
|
||||
result.hasWebsite = false;
|
||||
}
|
||||
}
|
||||
|
||||
// If no issues were found, add a success reason
|
||||
if (result.valid && result.reasons.length === 0) {
|
||||
result.reasons.push('Email appears to be valid');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache the disposable domains list from GitHub
|
||||
* Uses Redis for caching with 24-hour TTL
|
||||
* Falls back to in-memory cache if Redis fails
|
||||
*/
|
||||
private static async getDisposableDomains(): Promise<Set<string>> {
|
||||
// Return in-memory cache if available
|
||||
if (this.disposableDomainsSet) {
|
||||
return this.disposableDomainsSet;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get from Redis cache first
|
||||
const cached = await redis.get(DISPOSABLE_DOMAINS_CACHE_KEY);
|
||||
if (cached) {
|
||||
const domains = JSON.parse(cached) as string[];
|
||||
this.disposableDomainsSet = new Set(domains);
|
||||
return this.disposableDomainsSet;
|
||||
}
|
||||
|
||||
// Fetch from GitHub if not in cache
|
||||
const response = await fetch(DISPOSABLE_DOMAINS_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch disposable domains: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const domains = text
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#')); // Filter empty lines and comments
|
||||
|
||||
// Cache in Redis
|
||||
await redis.set(DISPOSABLE_DOMAINS_CACHE_KEY, JSON.stringify(domains), 'EX', CACHE_TTL_SECONDS);
|
||||
|
||||
// Cache in memory
|
||||
this.disposableDomainsSet = new Set(domains);
|
||||
return this.disposableDomainsSet;
|
||||
} catch (error) {
|
||||
console.error('Error fetching disposable domains:', error);
|
||||
// Return empty set as fallback - don't block email verification
|
||||
return new Set<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain is disposable
|
||||
*/
|
||||
private static async isDisposableDomain(domain: string): Promise<boolean> {
|
||||
const disposableDomains = await this.getDisposableDomains();
|
||||
return disposableDomains.has(domain.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain is a known forwarding/alias service
|
||||
*/
|
||||
private static isForwardingDomain(domain: string): boolean {
|
||||
return FORWARDING_DOMAINS.has(domain.toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache the personal email domains list from GitHub
|
||||
* Uses Redis for caching with 24-hour TTL
|
||||
* Falls back to in-memory cache if Redis fails
|
||||
*/
|
||||
private static async getPersonalEmailDomains(): Promise<Set<string>> {
|
||||
// Return in-memory cache if available
|
||||
if (this.personalDomainsSet) {
|
||||
return this.personalDomainsSet;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get from Redis cache first
|
||||
const cached = await redis.get(PERSONAL_DOMAINS_CACHE_KEY);
|
||||
if (cached) {
|
||||
const domains = JSON.parse(cached) as string[];
|
||||
this.personalDomainsSet = new Set(domains);
|
||||
return this.personalDomainsSet;
|
||||
}
|
||||
|
||||
// Fetch from GitHub if not in cache
|
||||
const response = await fetch(PERSONAL_DOMAINS_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch personal email domains: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const domains = text
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !line.startsWith('#')); // Filter empty lines and comments
|
||||
|
||||
// Cache in Redis
|
||||
await redis.set(PERSONAL_DOMAINS_CACHE_KEY, JSON.stringify(domains), 'EX', CACHE_TTL_SECONDS);
|
||||
|
||||
// Cache in memory
|
||||
this.personalDomainsSet = new Set(domains);
|
||||
return this.personalDomainsSet;
|
||||
} catch (error) {
|
||||
console.error('Error fetching personal email domains:', error);
|
||||
// Return empty set as fallback - don't block email verification
|
||||
return new Set<string>();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain is a personal/free email provider
|
||||
*/
|
||||
private static async isPersonalEmailDomain(domain: string): Promise<boolean> {
|
||||
const personalDomains = await this.getPersonalEmailDomains();
|
||||
return personalDomains.has(domain.toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import type {Event} from '@plunk/db';
|
||||
import {Prisma} from '@plunk/db';
|
||||
import type {FilterCondition, FilterGroup} from '@plunk/types';
|
||||
import {toPrismaJson} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
|
||||
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
|
||||
|
||||
/**
|
||||
* Event Service
|
||||
* Handles event tracking and workflow triggering
|
||||
*/
|
||||
export class EventService {
|
||||
/**
|
||||
* Track an event
|
||||
* This can trigger workflows that are listening for this event
|
||||
*/
|
||||
public static async trackEvent(
|
||||
projectId: string,
|
||||
eventName: string,
|
||||
contactId?: string,
|
||||
emailId?: string,
|
||||
data?: Record<string, unknown>,
|
||||
): Promise<Event> {
|
||||
// Create event record
|
||||
const event = await prisma.event.create({
|
||||
data: {
|
||||
projectId,
|
||||
contactId,
|
||||
emailId,
|
||||
name: eventName,
|
||||
data: data ? toPrismaJson(data) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger workflows that are listening for this event
|
||||
await this.triggerWorkflows(projectId, eventName, contactId, data);
|
||||
|
||||
// Resume workflows waiting for this event
|
||||
await WorkflowExecutionService.handleEvent(projectId, eventName, contactId, data);
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the workflow cache for a project
|
||||
* Should be called when workflows are enabled/disabled or updated
|
||||
*/
|
||||
public static async invalidateWorkflowCache(projectId: string): Promise<void> {
|
||||
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||
try {
|
||||
await redis.del(cacheKey);
|
||||
} catch (error) {
|
||||
signale.warn('[EVENT] Failed to invalidate workflow cache:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events for a contact
|
||||
*/
|
||||
public static async getContactEvents(projectId: string, contactId: string, limit = 50): Promise<Event[]> {
|
||||
return prisma.event.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
contactId,
|
||||
},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events for a project
|
||||
*/
|
||||
public static async getProjectEvents(projectId: string, eventName?: string, limit = 100): Promise<Event[]> {
|
||||
return prisma.event.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
...(eventName ? {name: eventName} : {}),
|
||||
},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
take: limit,
|
||||
include: {
|
||||
contact: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event counts by type
|
||||
*/
|
||||
public static async getEventStats(projectId: string, startDate?: Date, endDate?: Date) {
|
||||
const where: Prisma.EventWhereInput = {
|
||||
projectId,
|
||||
...(startDate || endDate
|
||||
? {
|
||||
createdAt: {
|
||||
...(startDate ? {gte: startDate} : {}),
|
||||
...(endDate ? {lte: endDate} : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const events = await prisma.event.groupBy({
|
||||
by: ['name'],
|
||||
where,
|
||||
_count: true,
|
||||
orderBy: {
|
||||
_count: {
|
||||
name: 'desc',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return events.map(e => ({
|
||||
name: e.name,
|
||||
count: e._count,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unique event names for a project
|
||||
*/
|
||||
public static async getUniqueEventNames(projectId: string): Promise<string[]> {
|
||||
const events = await prisma.event.groupBy({
|
||||
by: ['name'],
|
||||
where: {projectId},
|
||||
orderBy: {
|
||||
_count: {
|
||||
name: 'desc',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return events.map(e => e.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available event data fields for a specific event name
|
||||
* Analyzes actual event data to discover which fields are present
|
||||
* This is optimized for large datasets - only samples recent events
|
||||
*/
|
||||
public static async getAvailableEventFields(projectId: string, eventName?: string): Promise<string[]> {
|
||||
// Query recent events to discover data fields (limit to 100 for performance)
|
||||
const events = await prisma.event.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
...(eventName ? {name: eventName} : {}),
|
||||
data: {
|
||||
not: Prisma.DbNull, // Only events with data (not null)
|
||||
},
|
||||
},
|
||||
select: {
|
||||
data: true,
|
||||
},
|
||||
orderBy: {createdAt: 'desc'},
|
||||
take: 100, // Sample recent events for performance
|
||||
});
|
||||
|
||||
// Extract all unique keys from event data
|
||||
const fieldSet = new Set<string>();
|
||||
|
||||
for (const event of events) {
|
||||
if (event.data && typeof event.data === 'object' && !Array.isArray(event.data)) {
|
||||
const data = event.data as Record<string, unknown>;
|
||||
for (const key of Object.keys(data)) {
|
||||
fieldSet.add(`event.${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(fieldSet).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event is used in any segments or workflows
|
||||
* Returns usage information including which segments/workflows use the event
|
||||
*
|
||||
* @param projectId - The project ID
|
||||
* @param eventName - The event name to check (e.g., "purchase.completed", "user.signup")
|
||||
* @returns Usage information
|
||||
*/
|
||||
public static async getEventUsage(
|
||||
projectId: string,
|
||||
eventName: string,
|
||||
): Promise<{
|
||||
usedInSegments: Array<{id: string; name: string}>;
|
||||
usedInWorkflows: Array<{id: string; name: string}>;
|
||||
totalCount: number;
|
||||
uniqueContacts: number;
|
||||
canDelete: boolean;
|
||||
}> {
|
||||
// Get all segments for the project
|
||||
const segments = await prisma.segment.findMany({
|
||||
where: {projectId},
|
||||
select: {id: true, name: true, condition: true},
|
||||
});
|
||||
|
||||
// Check which segments use this event
|
||||
const usedInSegments = segments.filter(segment => {
|
||||
const condition = segment.condition as FilterCondition | null;
|
||||
return this.eventUsedInCondition(eventName, condition);
|
||||
});
|
||||
|
||||
// Get workflows that use this event as a trigger or wait condition
|
||||
const workflows = await prisma.workflow.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
OR: [
|
||||
// Event as trigger
|
||||
{
|
||||
triggerType: 'EVENT',
|
||||
triggerConfig: {
|
||||
path: ['eventName'],
|
||||
equals: eventName,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {id: true, name: true},
|
||||
});
|
||||
|
||||
// Also check workflow steps that wait for events
|
||||
const workflowStepsWithEvent = await prisma.workflowStep.findMany({
|
||||
where: {
|
||||
workflow: {projectId},
|
||||
type: 'WAIT_FOR_EVENT',
|
||||
config: {
|
||||
path: ['eventName'],
|
||||
equals: eventName,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
workflow: {
|
||||
select: {id: true, name: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const usedInWorkflows = [...workflows, ...workflowStepsWithEvent.map(step => step.workflow)].reduce(
|
||||
(acc, workflow) => {
|
||||
// Deduplicate by id
|
||||
if (!acc.find((w: {id: string; name: string}) => w.id === workflow.id)) {
|
||||
acc.push(workflow);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as Array<{id: string; name: string}>,
|
||||
);
|
||||
|
||||
// Get event statistics
|
||||
const [totalCount, uniqueContacts] = await Promise.all([
|
||||
prisma.event.count({
|
||||
where: {projectId, name: eventName},
|
||||
}),
|
||||
prisma.event
|
||||
.groupBy({
|
||||
by: ['contactId'],
|
||||
where: {projectId, name: eventName, contactId: {not: null}},
|
||||
})
|
||||
.then(results => results.length),
|
||||
]);
|
||||
|
||||
const canDelete = usedInSegments.length === 0 && usedInWorkflows.length === 0;
|
||||
|
||||
return {
|
||||
usedInSegments,
|
||||
usedInWorkflows,
|
||||
totalCount,
|
||||
uniqueContacts,
|
||||
canDelete,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all events with a specific name
|
||||
* WARNING: This is destructive and cannot be undone
|
||||
* Should only be called after verifying the event is not in use
|
||||
*
|
||||
* @param projectId - The project ID
|
||||
* @param eventName - The event name to delete
|
||||
*/
|
||||
public static async deleteEvent(projectId: string, eventName: string): Promise<{deletedCount: number}> {
|
||||
// Prevent deletion of reserved system events
|
||||
if (this.isReservedEvent(eventName)) {
|
||||
throw new Error(`Cannot delete reserved system event: ${eventName}`);
|
||||
}
|
||||
|
||||
// Check if event is in use
|
||||
const usage = await this.getEventUsage(projectId, eventName);
|
||||
if (!usage.canDelete) {
|
||||
throw new Error(
|
||||
`Cannot delete event: used in ${usage.usedInSegments.length} segment(s) and ${usage.usedInWorkflows.length} workflow(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Delete all events with this name
|
||||
const result = await prisma.event.deleteMany({
|
||||
where: {
|
||||
projectId,
|
||||
name: eventName,
|
||||
},
|
||||
});
|
||||
|
||||
return {deletedCount: result.count};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an event name is reserved for system use
|
||||
* Reserved patterns:
|
||||
* - email.* (email.sent, email.delivery, email.open, email.click, email.bounce, email.complaint)
|
||||
* - contact.subscribed, contact.unsubscribed
|
||||
* - segment.*.entry, segment.*.exit
|
||||
*
|
||||
* @param eventName - The event name to check
|
||||
* @returns true if the event is reserved, false otherwise
|
||||
*/
|
||||
public static isReservedEvent(eventName: string): boolean {
|
||||
// Email events: email.*
|
||||
if (eventName.startsWith('email.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Contact events: contact.subscribed, contact.unsubscribed
|
||||
if (eventName === 'contact.subscribed' || eventName === 'contact.unsubscribed') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Segment events: segment.*.entry, segment.*.exit
|
||||
// Pattern: segment.<slug>.entry or segment.<slug>.exit
|
||||
if (eventName.startsWith('segment.') && (eventName.endsWith('.entry') || eventName.endsWith('.exit'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger workflows based on an event
|
||||
* Uses Redis caching for enabled workflows to improve performance
|
||||
*/
|
||||
private static async triggerWorkflows(
|
||||
projectId: string,
|
||||
eventName: string,
|
||||
contactId?: string,
|
||||
data?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
// Try to get workflows from cache
|
||||
const cacheKey = Keys.Workflow.enabled(projectId);
|
||||
let workflows;
|
||||
|
||||
try {
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
workflows = JSON.parse(cached);
|
||||
}
|
||||
} catch (error) {
|
||||
signale.warn('[EVENT] Failed to get workflows from cache:', error);
|
||||
}
|
||||
|
||||
// If not in cache, fetch from database
|
||||
if (!workflows) {
|
||||
workflows = await prisma.workflow.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
enabled: true,
|
||||
triggerType: 'EVENT',
|
||||
},
|
||||
include: {
|
||||
steps: {
|
||||
where: {type: 'TRIGGER'},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Cache for 5 minutes
|
||||
try {
|
||||
await redis.setex(cacheKey, 300, JSON.stringify(workflows));
|
||||
} catch (error) {
|
||||
signale.warn('[EVENT] Failed to cache workflows:', error);
|
||||
}
|
||||
}
|
||||
|
||||
for (const workflow of workflows) {
|
||||
const triggerConfig = workflow.triggerConfig;
|
||||
|
||||
// Check if this workflow is triggered by this event
|
||||
if (triggerConfig?.eventName === eventName) {
|
||||
// If event is for a specific contact, start workflow for that contact
|
||||
if (contactId) {
|
||||
await this.startWorkflowForContact(workflow.id, contactId, data);
|
||||
} else {
|
||||
// If event is not contact-specific, you might want different logic
|
||||
// For example, trigger for all contacts, or skip
|
||||
signale.info(`[EVENT] Event ${eventName} triggered workflow ${workflow.id}, but no contact specified`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a workflow execution for a contact
|
||||
*/
|
||||
private static async startWorkflowForContact(
|
||||
workflowId: string,
|
||||
contactId: string,
|
||||
context?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get workflow with steps and configuration
|
||||
const workflow = await prisma.workflow.findUnique({
|
||||
where: {id: workflowId},
|
||||
include: {
|
||||
steps: {
|
||||
where: {type: 'TRIGGER'},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!workflow || workflow.steps.length === 0) {
|
||||
signale.error(`[EVENT] Workflow ${workflowId} has no trigger step`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check re-entry rules
|
||||
if (!workflow.allowReentry) {
|
||||
// If re-entry is not allowed, check if contact has ANY execution (regardless of status)
|
||||
const existingExecution = await prisma.workflowExecution.findFirst({
|
||||
where: {
|
||||
workflowId,
|
||||
contactId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingExecution) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// If re-entry is allowed, only check if there's a currently RUNNING execution
|
||||
const runningExecution = await prisma.workflowExecution.findFirst({
|
||||
where: {
|
||||
workflowId,
|
||||
contactId,
|
||||
status: 'RUNNING',
|
||||
},
|
||||
});
|
||||
|
||||
if (runningExecution) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const triggerStep = workflow.steps[0];
|
||||
|
||||
if (!triggerStep) {
|
||||
signale.error(`[EVENT] Workflow ${workflowId} trigger step not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create workflow execution
|
||||
const execution = await prisma.workflowExecution.create({
|
||||
data: {
|
||||
workflowId,
|
||||
contactId,
|
||||
status: 'RUNNING',
|
||||
currentStepId: triggerStep.id,
|
||||
context: context ? toPrismaJson(context) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
signale.info(
|
||||
`[EVENT] Started workflow ${workflowId} execution ${execution.id} for contact ${contactId}${workflow.allowReentry ? ' (re-entry allowed)' : ''}`,
|
||||
);
|
||||
|
||||
// Start executing the workflow
|
||||
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
|
||||
} catch (error) {
|
||||
signale.error(`[EVENT] Error starting workflow ${workflowId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if an event is used in a filter condition (recursive)
|
||||
*/
|
||||
private static eventUsedInCondition(eventName: string, condition: FilterCondition | null): boolean {
|
||||
if (!condition || typeof condition !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check groups in the condition
|
||||
if (Array.isArray(condition.groups)) {
|
||||
for (const group of condition.groups) {
|
||||
if (this.eventUsedInGroup(eventName, group)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if an event is used in a filter group (recursive)
|
||||
*/
|
||||
private static eventUsedInGroup(eventName: string, group: FilterGroup): boolean {
|
||||
if (!group || typeof group !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check filters in the group
|
||||
if (Array.isArray(group.filters)) {
|
||||
for (const filter of group.filters) {
|
||||
// Event filters use field name like "event.eventName"
|
||||
if (filter.field === `event.${eventName}`) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check nested conditions
|
||||
if (group.conditions) {
|
||||
return this.eventUsedInCondition(eventName, group.conditions);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import type {Membership} from '@plunk/db';
|
||||
import type {DisabledProjectInfo, MemberWithEmail, OwnerInfo} from '@plunk/types';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis, REDIS_ONE_MINUTE, wrapRedis} from '../database/redis.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import {Keys} from './keys.js';
|
||||
|
||||
const FIVE_MINUTES_IN_SECONDS = 5 * 60;
|
||||
|
||||
/**
|
||||
* Service for managing project memberships
|
||||
* Centralizes all membership-related database queries with caching
|
||||
*/
|
||||
export class MembershipService {
|
||||
// ============================================
|
||||
// AUTHORIZATION METHODS (Cached)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Check if user has any access to a project (any role)
|
||||
* CACHED (1 min TTL) - called on every authenticated request
|
||||
*/
|
||||
public static async hasAccess(userId: string, projectId: string): Promise<boolean> {
|
||||
return wrapRedis(
|
||||
Keys.Membership.access(userId, projectId),
|
||||
async () => {
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
return membership !== null;
|
||||
},
|
||||
REDIS_ONE_MINUTE,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has admin or owner access to a project
|
||||
* CACHED (1 min TTL) - called before write operations
|
||||
*/
|
||||
public static async hasAdminAccess(userId: string, projectId: string): Promise<boolean> {
|
||||
return wrapRedis(
|
||||
Keys.Membership.admin(userId, projectId),
|
||||
async () => {
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
projectId,
|
||||
role: {
|
||||
in: ['ADMIN', 'OWNER'],
|
||||
},
|
||||
},
|
||||
});
|
||||
return membership !== null;
|
||||
},
|
||||
REDIS_ONE_MINUTE,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's membership with role info
|
||||
* CACHED (1 min TTL) - returns full membership or null
|
||||
*/
|
||||
public static async getMembership(userId: string, projectId: string): Promise<Membership | null> {
|
||||
return wrapRedis(
|
||||
Keys.Membership.full(userId, projectId),
|
||||
async () => {
|
||||
return prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
REDIS_ONE_MINUTE,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Require membership or throw 404
|
||||
* Uses cached getMembership internally
|
||||
*/
|
||||
public static async requireAccess(userId: string, projectId: string): Promise<Membership> {
|
||||
const membership = await this.getMembership(userId, projectId);
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpException(404, 'Project not found or you do not have access');
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require admin/owner access or throw 403
|
||||
* Uses cached hasAdminAccess internally
|
||||
*/
|
||||
public static async requireAdminAccess(userId: string, projectId: string): Promise<Membership> {
|
||||
const membership = await this.getMembership(userId, projectId);
|
||||
|
||||
if (!membership) {
|
||||
throw new HttpException(404, 'Project not found or you do not have access');
|
||||
}
|
||||
|
||||
if (membership.role !== 'ADMIN' && membership.role !== 'OWNER') {
|
||||
throw new HttpException(403, 'Insufficient permissions. Admin or owner access required.');
|
||||
}
|
||||
|
||||
return membership;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MEMBER LISTING (Not Cached - Dynamic Data)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get all members of a project with user info
|
||||
* NOT CACHED - returns fresh data for member management UI
|
||||
*/
|
||||
public static async getMembers(projectId: string): Promise<MemberWithEmail[]> {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
return memberships.map(m => ({
|
||||
userId: m.userId,
|
||||
email: m.user.email,
|
||||
role: m.role,
|
||||
createdAt: m.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project owner
|
||||
* CACHED (5 min TTL) - owner rarely changes
|
||||
*/
|
||||
public static async getOwner(projectId: string): Promise<OwnerInfo> {
|
||||
return wrapRedis(
|
||||
Keys.Membership.owner(projectId),
|
||||
async () => {
|
||||
const ownerMembership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
projectId,
|
||||
role: 'OWNER',
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!ownerMembership) {
|
||||
throw new HttpException(404, 'Project owner not found');
|
||||
}
|
||||
|
||||
return {
|
||||
userId: ownerMembership.userId,
|
||||
email: ownerMembership.user.email,
|
||||
};
|
||||
},
|
||||
FIVE_MINUTES_IN_SECONDS,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CRUD OPERATIONS (Invalidate Cache)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Add a member to a project
|
||||
* Invalidates cache for the project
|
||||
*/
|
||||
public static async addMember(projectId: string, userId: string, role: 'ADMIN' | 'MEMBER'): Promise<Membership> {
|
||||
// Check if membership already exists
|
||||
const existingMembership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingMembership) {
|
||||
throw new HttpException(409, 'User is already a member of this project');
|
||||
}
|
||||
|
||||
// Create new membership
|
||||
const newMembership = await prisma.membership.create({
|
||||
data: {
|
||||
userId,
|
||||
projectId,
|
||||
role,
|
||||
},
|
||||
});
|
||||
|
||||
// Invalidate cache
|
||||
await this.invalidateCache(projectId, userId);
|
||||
|
||||
return newMembership;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a member's role
|
||||
* Throws if trying to change OWNER role
|
||||
* Invalidates cache
|
||||
*/
|
||||
public static async updateRole(projectId: string, userId: string, newRole: 'ADMIN' | 'MEMBER'): Promise<Membership> {
|
||||
// Get existing membership
|
||||
const existingMembership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingMembership) {
|
||||
throw new HttpException(404, 'Membership not found');
|
||||
}
|
||||
|
||||
// Prevent changing OWNER role
|
||||
if (existingMembership.role === 'OWNER') {
|
||||
throw new HttpException(403, 'Cannot change the role of the project owner');
|
||||
}
|
||||
|
||||
// Update role
|
||||
const updatedMembership = await prisma.membership.update({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
role: newRole,
|
||||
},
|
||||
});
|
||||
|
||||
// Invalidate cache
|
||||
await this.invalidateCache(projectId, userId);
|
||||
|
||||
return updatedMembership;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from a project
|
||||
* Throws if trying to remove OWNER
|
||||
* Invalidates cache
|
||||
*/
|
||||
public static async removeMember(projectId: string, userId: string): Promise<void> {
|
||||
// Get existing membership
|
||||
const existingMembership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingMembership) {
|
||||
throw new HttpException(404, 'Membership not found');
|
||||
}
|
||||
|
||||
// Prevent removing OWNER
|
||||
if (existingMembership.role === 'OWNER') {
|
||||
throw new HttpException(403, 'Cannot remove the project owner');
|
||||
}
|
||||
|
||||
// Delete membership
|
||||
await prisma.membership.delete({
|
||||
where: {
|
||||
userId_projectId: {
|
||||
userId,
|
||||
projectId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Invalidate cache
|
||||
await this.invalidateCache(projectId, userId);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UTILITY METHODS
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Check if user is member of any disabled project
|
||||
* NOT CACHED - security-critical check
|
||||
*/
|
||||
public static async userHasDisabledProject(userId: string): Promise<DisabledProjectInfo> {
|
||||
const disabledMemberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
userId,
|
||||
project: {
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
hasDisabledProject: disabledMemberships.length > 0,
|
||||
disabledProjectNames: disabledMemberships.map(m => m.project.name),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PRIVATE CACHE MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Invalidate all caches for a project and user
|
||||
* Called after membership changes
|
||||
*/
|
||||
private static async invalidateCache(projectId: string, userId?: string): Promise<void> {
|
||||
const keysToDelete: string[] = [];
|
||||
|
||||
if (userId) {
|
||||
// Invalidate user-specific caches
|
||||
keysToDelete.push(
|
||||
Keys.Membership.access(userId, projectId),
|
||||
Keys.Membership.admin(userId, projectId),
|
||||
Keys.Membership.full(userId, projectId),
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate project-wide caches
|
||||
keysToDelete.push(Keys.Membership.owner(projectId));
|
||||
|
||||
// Delete all keys
|
||||
if (keysToDelete.length > 0) {
|
||||
await redis.del(...keysToDelete);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import signale from 'signale';
|
||||
|
||||
import {STRIPE_ENABLED, STRIPE_METER_EVENT_NAME} from '../app/constants.js';
|
||||
import {stripe} from '../app/stripe.js';
|
||||
|
||||
/**
|
||||
* Meter Service
|
||||
* Handles recording usage events to Stripe billing meters for pay-as-you-go pricing
|
||||
*/
|
||||
export class MeterService {
|
||||
/**
|
||||
* Record an email sent event to Stripe meter
|
||||
* This is used for usage-based billing where customers pay per email sent
|
||||
*
|
||||
* @param customerId - Stripe customer ID
|
||||
* @param value - Number of emails sent (default: 1)
|
||||
* @param idempotencyKey - Optional unique identifier to prevent duplicate recording
|
||||
*/
|
||||
public static async recordEmailSent(customerId: string, value = 1, idempotencyKey?: string): Promise<void> {
|
||||
// Skip if billing is disabled (self-hosted mode)
|
||||
if (!STRIPE_ENABLED || !stripe) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if no customer ID (not subscribed)
|
||||
if (!customerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await stripe.billing.meterEvents.create({
|
||||
event_name: STRIPE_METER_EVENT_NAME,
|
||||
payload: {
|
||||
stripe_customer_id: customerId,
|
||||
value: value.toString(), // Must be a string
|
||||
},
|
||||
...(idempotencyKey && {identifier: idempotencyKey}),
|
||||
});
|
||||
|
||||
signale.debug(`[METER] Recorded ${value} email(s) sent for customer ${customerId}`);
|
||||
} catch (error) {
|
||||
// Log error but don't throw - we don't want billing issues to break email sending
|
||||
signale.error('[METER] Failed to record email sent event:', error);
|
||||
|
||||
// If it's a rate limit error, we might want to retry
|
||||
if (error instanceof Error && error.message.includes('429')) {
|
||||
signale.warn('[METER] Rate limited - consider implementing queue-based meter recording');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record multiple emails in a single batch (for campaign sending)
|
||||
* More efficient than individual calls when sending bulk emails
|
||||
*
|
||||
* @param customerId - Stripe customer ID
|
||||
* @param count - Number of emails sent in this batch
|
||||
* @param batchId - Unique identifier for this batch
|
||||
*/
|
||||
public static async recordEmailBatch(customerId: string, count: number, batchId: string): Promise<void> {
|
||||
if (count <= 0) return;
|
||||
|
||||
await this.recordEmailSent(customerId, count, `batch_${batchId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current usage for a customer in the current billing period
|
||||
* Useful for displaying usage dashboards or implementing soft limits
|
||||
*
|
||||
* @param customerId - Stripe customer ID
|
||||
* @param meterId - The meter ID from Stripe dashboard
|
||||
* @param startTime - Start of period (Unix timestamp)
|
||||
* @param endTime - End of period (Unix timestamp)
|
||||
*/
|
||||
public static async getUsageSummary(
|
||||
meterId: string,
|
||||
customerId: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): Promise<unknown> {
|
||||
if (!STRIPE_ENABLED || !stripe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const summaries = await stripe.billing.meters.listEventSummaries(meterId, {
|
||||
customer: customerId,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
});
|
||||
|
||||
return summaries;
|
||||
} catch (error) {
|
||||
signale.error('[METER] Failed to get usage summary:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,819 @@
|
||||
import {type NtfyNotification, NtfyPriority, NtfyTag} from '@plunk/types';
|
||||
import signale from 'signale';
|
||||
|
||||
/**
|
||||
* Service for sending notifications via ntfy.sh
|
||||
* Supports configurable ntfy server URL via NTFY_URL environment variable
|
||||
*/
|
||||
export class NtfyService {
|
||||
private static ntfyUrl: string | null = null;
|
||||
private static isConfigured = false;
|
||||
|
||||
/**
|
||||
* Send a notification to ntfy
|
||||
* @param notification - The notification details
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
public static async send(notification: NtfyNotification): Promise<void> {
|
||||
this.initialize();
|
||||
|
||||
if (!this.ntfyUrl) {
|
||||
// Silently skip if not configured
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'text/plain',
|
||||
'Title': notification.title,
|
||||
};
|
||||
|
||||
if (notification.priority) {
|
||||
headers.Priority = notification.priority.toString();
|
||||
}
|
||||
|
||||
if (notification.tags && notification.tags.length > 0) {
|
||||
headers.Tags = notification.tags.join(',');
|
||||
}
|
||||
|
||||
const response = await fetch(this.ntfyUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: notification.message,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
signale.success(`[NTFY] Sent notification: ${notification.title}`);
|
||||
} catch (error) {
|
||||
signale.error('[NTFY] Failed to send notification:', error);
|
||||
// Don't throw - notifications should not break the main flow
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a high-priority notification
|
||||
*/
|
||||
public static async sendHigh(title: string, message: string, tags?: NtfyTag[]): Promise<void> {
|
||||
await this.send({
|
||||
title,
|
||||
message,
|
||||
priority: NtfyPriority.HIGH,
|
||||
tags,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a max-priority urgent notification
|
||||
*/
|
||||
public static async sendUrgent(title: string, message: string, tags?: NtfyTag[]): Promise<void> {
|
||||
await this.send({
|
||||
title,
|
||||
message,
|
||||
priority: NtfyPriority.MAX,
|
||||
tags,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a low-priority informational notification
|
||||
*/
|
||||
public static async sendInfo(title: string, message: string, tags?: NtfyTag[]): Promise<void> {
|
||||
await this.send({
|
||||
title,
|
||||
message,
|
||||
priority: NtfyPriority.LOW,
|
||||
tags,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a default priority notification
|
||||
*/
|
||||
public static async sendDefault(title: string, message: string, tags?: NtfyTag[]): Promise<void> {
|
||||
await this.send({
|
||||
title,
|
||||
message,
|
||||
priority: NtfyPriority.DEFAULT,
|
||||
tags,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about a new project creation
|
||||
*/
|
||||
public static async notifyProjectCreated(projectName: string, projectId: string, userId: string): Promise<void> {
|
||||
await this.sendDefault(
|
||||
'New Project Created',
|
||||
`Project "${projectName}" (${projectId}) was created by user ${userId}`,
|
||||
[NtfyTag.ROCKET, NtfyTag.SUCCESS],
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Event-specific notification helpers =====
|
||||
|
||||
/**
|
||||
* Notify about subscription started
|
||||
*/
|
||||
public static async notifySubscriptionStarted(
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
subscriptionId: string,
|
||||
): Promise<void> {
|
||||
await this.sendHigh(
|
||||
'Subscription Started',
|
||||
`Project "${projectName}" (${projectId}) started a paid subscription (${subscriptionId})`,
|
||||
[NtfyTag.MONEY, NtfyTag.SUCCESS],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about subscription cancelled
|
||||
*/
|
||||
public static async notifySubscriptionCancelled(
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
subscriptionId: string,
|
||||
): Promise<void> {
|
||||
await this.sendHigh(
|
||||
'Subscription Cancelled',
|
||||
`Project "${projectName}" (${projectId}) cancelled their subscription (${subscriptionId})`,
|
||||
[NtfyTag.WARNING, NtfyTag.MONEY],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about project disabled due to security risk
|
||||
*/
|
||||
public static async notifyProjectDisabledForSecurity(
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
violations: string[],
|
||||
): Promise<void> {
|
||||
const violationText = violations.join(', ');
|
||||
await this.sendUrgent(
|
||||
'Project Disabled - Security Risk',
|
||||
`Project "${projectName}" (${projectId}) was automatically disabled due to: ${violationText}`,
|
||||
[NtfyTag.SHIELD, NtfyTag.ERROR, NtfyTag.SKULL],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about payment failure
|
||||
*/
|
||||
public static async notifyPaymentFailed(projectName: string, projectId: string): Promise<void> {
|
||||
await this.sendHigh('Payment Failed', `Payment failed for project "${projectName}" (${projectId})`, [
|
||||
NtfyTag.WARNING,
|
||||
NtfyTag.MONEY,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about project disabled due to payment failure
|
||||
*/
|
||||
public static async notifyProjectDisabledForPayment(projectName: string, projectId: string): Promise<void> {
|
||||
await this.sendUrgent(
|
||||
'Project Disabled - Payment Failed',
|
||||
`Project "${projectName}" (${projectId}) was automatically disabled due to a failed recurring payment`,
|
||||
[NtfyTag.WARNING, NtfyTag.MONEY, NtfyTag.ERROR],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about successful invoice payment - MIN priority (routine)
|
||||
*/
|
||||
public static async notifyInvoicePaid(projectName: string, projectId: string): Promise<void> {
|
||||
await this.send({
|
||||
title: 'Invoice Paid',
|
||||
message: `Invoice paid for project "${projectName}" (${projectId})`,
|
||||
priority: NtfyPriority.MIN,
|
||||
tags: [NtfyTag.MONEY, NtfyTag.SUCCESS],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about subscription update - LOW priority (minor changes)
|
||||
*/
|
||||
public static async notifySubscriptionUpdated(projectName: string, projectId: string): Promise<void> {
|
||||
await this.send({
|
||||
title: 'Subscription Updated',
|
||||
message: `Subscription updated for project "${projectName}" (${projectId})`,
|
||||
priority: NtfyPriority.LOW,
|
||||
tags: [NtfyTag.MONEY, NtfyTag.INFO],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about project deletion - DEFAULT priority
|
||||
*/
|
||||
public static async notifyProjectDeleted(projectName: string, projectId: string, userId: string): Promise<void> {
|
||||
await this.sendDefault('Project Deleted', `Project "${projectName}" (${projectId}) was deleted by user ${userId}`, [
|
||||
NtfyTag.WARNING,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about security warning (non-critical)
|
||||
*/
|
||||
public static async notifySecurityWarning(projectName: string, projectId: string, warnings: string[]): Promise<void> {
|
||||
// Import redis at runtime to avoid circular dependencies
|
||||
const {redis} = await import('../database/redis.js');
|
||||
|
||||
const cacheKey = `ntfy:security:warning:${projectId}`;
|
||||
const ttl = 3600; // 1 hour
|
||||
|
||||
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||
if (!wasSet) {
|
||||
return;
|
||||
}
|
||||
|
||||
const warningText = warnings.join(', ');
|
||||
await this.sendDefault(
|
||||
'Security Warning',
|
||||
`Project "${projectName}" (${projectId}) has security warnings: ${warningText}`,
|
||||
[NtfyTag.WARNING, NtfyTag.SHIELD],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about email bounce - LOW priority (high volume)
|
||||
* Rate-limited to only send notification every 20 bounces with latest 20 bounce details
|
||||
*/
|
||||
public static async notifyEmailBounce(
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
recipientEmail: string,
|
||||
bounceType?: string,
|
||||
): Promise<void> {
|
||||
// Import redis at runtime to avoid circular dependencies
|
||||
const {redis} = await import('../database/redis.js');
|
||||
|
||||
// Use Redis counters to track bounce count globally
|
||||
const globalCountKey = `ntfy:bounce:count`;
|
||||
const bounceListKey = `ntfy:bounce:latest`;
|
||||
|
||||
// Increment global counter
|
||||
const globalCount = await redis.incr(globalCountKey);
|
||||
|
||||
// Store this bounce event in a list (keep latest 20)
|
||||
const bounceEvent = JSON.stringify({
|
||||
projectName,
|
||||
projectId,
|
||||
recipientEmail,
|
||||
bounceType: bounceType || 'Unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
await redis.lpush(bounceListKey, bounceEvent);
|
||||
await redis.ltrim(bounceListKey, 0, 19); // Keep only latest 20
|
||||
|
||||
// Set expiry on first increment (24 hour rolling window)
|
||||
if (globalCount === 1) {
|
||||
await redis.expire(globalCountKey, 86400);
|
||||
await redis.expire(bounceListKey, 86400);
|
||||
}
|
||||
|
||||
// Only send notification every 20 bounces
|
||||
if (globalCount % 20 === 0) {
|
||||
// Get the latest 20 bounces
|
||||
const latestBounces = await redis.lrange(bounceListKey, 0, 19);
|
||||
|
||||
// Parse and format the bounce events
|
||||
const bounceDetails = latestBounces
|
||||
.map(bounce => {
|
||||
try {
|
||||
const parsed = JSON.parse(bounce);
|
||||
return ` • ${parsed.recipientEmail} (${parsed.bounceType}) - ${parsed.projectName}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
await this.send({
|
||||
title: 'Email Bounces',
|
||||
message: `20 email bounces detected (total: ${globalCount})\n\nLatest 20 bounces:\n${bounceDetails}`,
|
||||
priority: NtfyPriority.LOW,
|
||||
tags: [NtfyTag.WARNING],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about email complaint - HIGH priority (reputation risk)
|
||||
*/
|
||||
public static async notifyEmailComplaint(
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
recipientEmail: string,
|
||||
): Promise<void> {
|
||||
await this.sendHigh(
|
||||
'Email Complaint',
|
||||
`Email complaint received from ${recipientEmail} in project "${projectName}" (${projectId})`,
|
||||
[NtfyTag.WARNING, NtfyTag.ERROR],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about new user account created via signup - LOW priority
|
||||
*/
|
||||
public static async notifyUserSignup(userEmail: string, userId: string): Promise<void> {
|
||||
await this.send({
|
||||
title: 'New User Signup',
|
||||
message: `New user registered: ${userEmail} (${userId})`,
|
||||
priority: NtfyPriority.LOW,
|
||||
tags: [NtfyTag.ROCKET, NtfyTag.SUCCESS],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about failed signup attempt with invalid email - LOW priority
|
||||
*/
|
||||
public static async notifyFailedSignupAttempt(email: string, reasons: string[]): Promise<void> {
|
||||
const reasonText = reasons.join(', ');
|
||||
await this.send({
|
||||
title: 'Failed Signup - Invalid Email',
|
||||
message: `Signup attempt blocked for email: ${email}\nReasons: ${reasonText}`,
|
||||
priority: NtfyPriority.LOW,
|
||||
tags: [NtfyTag.WARNING, NtfyTag.SHIELD],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about new user account created via OAuth - LOW priority
|
||||
*/
|
||||
public static async notifyUserOAuthSignup(
|
||||
userEmail: string,
|
||||
userId: string,
|
||||
provider: 'GitHub' | 'Google',
|
||||
): Promise<void> {
|
||||
await this.send({
|
||||
title: `New User - ${provider} OAuth`,
|
||||
message: `New user registered via ${provider}: ${userEmail} (${userId})`,
|
||||
priority: NtfyPriority.LOW,
|
||||
tags: [NtfyTag.ROCKET, NtfyTag.SUCCESS],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about campaign created (draft) - LOW priority
|
||||
*/
|
||||
public static async notifyCampaignCreated(
|
||||
campaignName: string,
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
await this.send({
|
||||
title: 'Campaign Created',
|
||||
message: `Campaign "${campaignName}" created in project "${projectName}" (${projectId})`,
|
||||
priority: NtfyPriority.LOW,
|
||||
tags: [NtfyTag.ROCKET],
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Campaign event notifications =====
|
||||
|
||||
/**
|
||||
* Notify about campaign scheduled - DEFAULT priority
|
||||
*/
|
||||
public static async notifyCampaignScheduled(
|
||||
campaignName: string,
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
scheduledFor: Date,
|
||||
recipientCount: number,
|
||||
): Promise<void> {
|
||||
await this.sendDefault(
|
||||
'Campaign Scheduled',
|
||||
`Campaign "${campaignName}" scheduled to send to ${recipientCount} recipients at ${scheduledFor.toISOString()} in project "${projectName}" (${projectId})`,
|
||||
[NtfyTag.ROCKET, NtfyTag.INFO],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about campaign sending started - LOW priority (informational)
|
||||
*/
|
||||
public static async notifyCampaignSendingStarted(
|
||||
campaignName: string,
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
recipientCount: number,
|
||||
): Promise<void> {
|
||||
await this.send({
|
||||
title: 'Campaign Sending Started',
|
||||
message: `Campaign "${campaignName}" started sending to ${recipientCount} recipients in project "${projectName}" (${projectId})`,
|
||||
priority: NtfyPriority.LOW,
|
||||
tags: [NtfyTag.ROCKET, NtfyTag.CHART],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about campaign send completed - DEFAULT priority
|
||||
*/
|
||||
public static async notifyCampaignSendCompleted(
|
||||
campaignName: string,
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
recipientCount: number,
|
||||
): Promise<void> {
|
||||
await this.sendDefault(
|
||||
'Campaign Send Completed',
|
||||
`Campaign "${campaignName}" successfully sent to ${recipientCount} recipients in project "${projectName}" (${projectId})`,
|
||||
[NtfyTag.ROCKET, NtfyTag.SUCCESS],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about campaign cancelled - LOW priority
|
||||
*/
|
||||
public static async notifyCampaignCancelled(
|
||||
campaignName: string,
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
await this.send({
|
||||
title: 'Campaign Cancelled',
|
||||
message: `Campaign "${campaignName}" was cancelled in project "${projectName}" (${projectId})`,
|
||||
priority: NtfyPriority.LOW,
|
||||
tags: [NtfyTag.WARNING],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about campaign deleted - LOW priority
|
||||
*/
|
||||
public static async notifyCampaignDeleted(
|
||||
campaignName: string,
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
await this.send({
|
||||
title: 'Campaign Deleted',
|
||||
message: `Campaign "${campaignName}" was deleted from project "${projectName}" (${projectId})`,
|
||||
priority: NtfyPriority.LOW,
|
||||
tags: [NtfyTag.INFO],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about workflow created - LOW priority
|
||||
*/
|
||||
public static async notifyWorkflowCreated(
|
||||
workflowName: string,
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
await this.send({
|
||||
title: 'Workflow Created',
|
||||
message: `Workflow "${workflowName}" created in project "${projectName}" (${projectId})`,
|
||||
priority: NtfyPriority.LOW,
|
||||
tags: [NtfyTag.ROCKET],
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Workflow event notifications =====
|
||||
|
||||
/**
|
||||
* Notify about workflow enabled
|
||||
*/
|
||||
public static async notifyWorkflowEnabled(
|
||||
workflowName: string,
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
await this.sendDefault(
|
||||
'Workflow Enabled',
|
||||
`Workflow "${workflowName}" is now active in project "${projectName}" (${projectId})`,
|
||||
[NtfyTag.SUCCESS],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about workflow disabled
|
||||
*/
|
||||
public static async notifyWorkflowDisabled(
|
||||
workflowName: string,
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
await this.sendDefault(
|
||||
'Workflow Disabled',
|
||||
`Workflow "${workflowName}" has been disabled in project "${projectName}" (${projectId})`,
|
||||
[NtfyTag.WARNING],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about workflow deleted - LOW priority
|
||||
*/
|
||||
public static async notifyWorkflowDeleted(
|
||||
workflowName: string,
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
await this.send({
|
||||
title: 'Workflow Deleted',
|
||||
message: `Workflow "${workflowName}" was deleted from project "${projectName}" (${projectId})`,
|
||||
priority: NtfyPriority.LOW,
|
||||
tags: [NtfyTag.INFO],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about workflow execution failed
|
||||
*/
|
||||
public static async notifyWorkflowExecutionFailed(
|
||||
workflowName: string,
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
contactEmail: string,
|
||||
error: string,
|
||||
): Promise<void> {
|
||||
await this.sendHigh(
|
||||
'Workflow Execution Failed',
|
||||
`Workflow "${workflowName}" failed for contact ${contactEmail} in project "${projectName}" (${projectId}). Error: ${error}`,
|
||||
[NtfyTag.ERROR, NtfyTag.WARNING],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about domain added
|
||||
*/
|
||||
public static async notifyDomainAdded(domain: string, projectName: string, projectId: string): Promise<void> {
|
||||
await this.sendDefault(
|
||||
'Domain Added',
|
||||
`Domain "${domain}" added for verification in project "${projectName}" (${projectId})`,
|
||||
[NtfyTag.INFO],
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Domain verification notifications =====
|
||||
|
||||
/**
|
||||
* Notify about domain verified
|
||||
*/
|
||||
public static async notifyDomainVerified(domain: string, projectName: string, projectId: string): Promise<void> {
|
||||
await this.sendDefault(
|
||||
'Domain Verified',
|
||||
`Domain "${domain}" is now verified and ready for use in project "${projectName}" (${projectId})`,
|
||||
[NtfyTag.SUCCESS, NtfyTag.SHIELD],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about domain verification failed
|
||||
*/
|
||||
public static async notifyDomainVerificationFailed(
|
||||
domain: string,
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
await this.sendHigh(
|
||||
'Domain Verification Failed',
|
||||
`Domain "${domain}" failed verification in project "${projectName}" (${projectId}). Email sending may be affected.`,
|
||||
[NtfyTag.WARNING, NtfyTag.SHIELD],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about domain removed
|
||||
*/
|
||||
public static async notifyDomainRemoved(domain: string, projectName: string, projectId: string): Promise<void> {
|
||||
await this.sendInfo(
|
||||
'Domain Removed',
|
||||
`Domain "${domain}" was removed from project "${projectName}" (${projectId})`,
|
||||
[NtfyTag.INFO],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about billing limit approaching (80% threshold)
|
||||
*/
|
||||
public static async notifyBillingLimitApproaching(
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
usage: number,
|
||||
limit: number,
|
||||
percentage: number,
|
||||
sourceType: string,
|
||||
): Promise<void> {
|
||||
// Import redis at runtime to avoid circular dependencies
|
||||
const {redis} = await import('../database/redis.js');
|
||||
|
||||
const cacheKey = `ntfy:billing:warning:${projectId}:${sourceType}`;
|
||||
const ttl = 86400; // 24 hours
|
||||
|
||||
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||
if (!wasSet) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendDefault(
|
||||
'Billing Limit Warning',
|
||||
`Email usage at ${Math.round(percentage)}% (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId})`,
|
||||
[NtfyTag.WARNING, NtfyTag.MONEY, NtfyTag.CHART],
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Billing and usage limit notifications =====
|
||||
|
||||
/**
|
||||
* Notify about billing limit exceeded - MAX priority (blocks operations)
|
||||
*/
|
||||
public static async notifyBillingLimitExceeded(
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
usage: number,
|
||||
limit: number,
|
||||
sourceType: string,
|
||||
): Promise<void> {
|
||||
// Import redis at runtime to avoid circular dependencies
|
||||
const {redis} = await import('../database/redis.js');
|
||||
|
||||
const cacheKey = `ntfy:billing:exceeded:${projectId}:${sourceType}`;
|
||||
const ttl = 86400; // 24 hours
|
||||
|
||||
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||
if (!wasSet) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendUrgent(
|
||||
'Billing Limit Exceeded',
|
||||
`Email usage limit reached (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId}). Further emails are blocked.`,
|
||||
[NtfyTag.ERROR, NtfyTag.MONEY, NtfyTag.SKULL],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about API keys regenerated
|
||||
*/
|
||||
public static async notifyApiKeysRegenerated(projectName: string, projectId: string, userId: string): Promise<void> {
|
||||
await this.sendHigh(
|
||||
'API Keys Regenerated',
|
||||
`API keys for project "${projectName}" (${projectId}) were regenerated by user ${userId}`,
|
||||
[NtfyTag.SHIELD, NtfyTag.WARNING],
|
||||
);
|
||||
}
|
||||
|
||||
// ===== API key notifications =====
|
||||
|
||||
/**
|
||||
* Notify about contact import started - MIN priority (routine, high volume)
|
||||
*/
|
||||
public static async notifyContactImportStarted(
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
filename: string,
|
||||
totalRows: number,
|
||||
): Promise<void> {
|
||||
await this.send({
|
||||
title: 'Contact Import Started',
|
||||
message: `Importing ${totalRows} contacts from "${filename}" into project "${projectName}" (${projectId})`,
|
||||
priority: NtfyPriority.MIN,
|
||||
tags: [NtfyTag.ROCKET],
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Contact import notifications =====
|
||||
|
||||
/**
|
||||
* Notify about contact import completed
|
||||
*/
|
||||
public static async notifyContactImportCompleted(
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
filename: string,
|
||||
successCount: number,
|
||||
createdCount: number,
|
||||
updatedCount: number,
|
||||
failureCount: number,
|
||||
): Promise<void> {
|
||||
await this.sendDefault(
|
||||
'Contact Import Completed',
|
||||
`Import "${filename}" completed for project "${projectName}" (${projectId}). Success: ${successCount} (${createdCount} new, ${updatedCount} updated), Errors: ${failureCount}`,
|
||||
[NtfyTag.SUCCESS, NtfyTag.CHART],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about contact import failed
|
||||
*/
|
||||
public static async notifyContactImportFailed(
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
filename: string,
|
||||
error: string,
|
||||
): Promise<void> {
|
||||
await this.sendHigh(
|
||||
'Contact Import Failed',
|
||||
`Contact import "${filename}" failed for project "${projectName}" (${projectId}). Error: ${error}`,
|
||||
[NtfyTag.ERROR],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about segment created - MIN priority
|
||||
*/
|
||||
public static async notifySegmentCreated(segmentName: string, projectName: string, projectId: string): Promise<void> {
|
||||
await this.send({
|
||||
title: 'Segment Created',
|
||||
message: `Segment "${segmentName}" created in project "${projectName}" (${projectId})`,
|
||||
priority: NtfyPriority.MIN,
|
||||
tags: [NtfyTag.INFO],
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Segment notifications =====
|
||||
|
||||
/**
|
||||
* Notify about segment membership computed - LOW priority
|
||||
*/
|
||||
public static async notifySegmentMembershipComputed(
|
||||
segmentName: string,
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
memberCount: number,
|
||||
added?: number,
|
||||
removed?: number,
|
||||
): Promise<void> {
|
||||
let message = `Segment "${segmentName}" in project "${projectName}" (${projectId}) now has ${memberCount} members`;
|
||||
|
||||
if (added !== undefined && removed !== undefined) {
|
||||
const changes: string[] = [];
|
||||
if (added > 0) changes.push(`+${added} added`);
|
||||
if (removed > 0) changes.push(`-${removed} removed`);
|
||||
if (changes.length > 0) {
|
||||
message += ` (${changes.join(', ')})`;
|
||||
}
|
||||
}
|
||||
|
||||
await this.send({
|
||||
title: 'Segment Membership Updated',
|
||||
message,
|
||||
priority: NtfyPriority.LOW,
|
||||
tags: [NtfyTag.CHART],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about bundled segment membership updates - LOW priority
|
||||
* Used when multiple segments are updated in a single processing cycle
|
||||
*/
|
||||
public static async notifySegmentMembershipBundled(
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
segmentCount: number,
|
||||
totalAdded: number,
|
||||
totalRemoved: number,
|
||||
): Promise<void> {
|
||||
const changes: string[] = [];
|
||||
if (totalAdded > 0) changes.push(`+${totalAdded} added`);
|
||||
if (totalRemoved > 0) changes.push(`-${totalRemoved} removed`);
|
||||
|
||||
const changesText = changes.length > 0 ? ` (${changes.join(', ')})` : '';
|
||||
const message = `${segmentCount} segment${segmentCount > 1 ? 's' : ''} updated in project "${projectName}" (${projectId})${changesText}`;
|
||||
|
||||
await this.send({
|
||||
title: 'Segment Memberships Updated',
|
||||
message,
|
||||
priority: NtfyPriority.LOW,
|
||||
tags: [NtfyTag.CHART],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify about segment deleted - MIN priority
|
||||
*/
|
||||
public static async notifySegmentDeleted(segmentName: string, projectName: string, projectId: string): Promise<void> {
|
||||
await this.send({
|
||||
title: 'Segment Deleted',
|
||||
message: `Segment "${segmentName}" was deleted from project "${projectName}" (${projectId})`,
|
||||
priority: NtfyPriority.MIN,
|
||||
tags: [NtfyTag.INFO],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the ntfy service with the configured URL
|
||||
*/
|
||||
private static initialize(): void {
|
||||
if (this.isConfigured) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.ntfyUrl = process.env.NTFY_URL || null;
|
||||
this.isConfigured = true;
|
||||
|
||||
if (!this.ntfyUrl) {
|
||||
signale.warn('[NTFY] NTFY_URL not configured - notifications will be skipped');
|
||||
} else {
|
||||
signale.info(`[NTFY] Initialized with URL: ${this.ntfyUrl}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {Keys} from './keys.js';
|
||||
import {wrapRedis} from '../database/redis.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
|
||||
export class ProjectService {
|
||||
public static async id(id: string) {
|
||||
return wrapRedis(Keys.Project.id(id), async () => {
|
||||
return prisma.project.findUnique({where: {id}});
|
||||
});
|
||||
}
|
||||
|
||||
public static async secret(key: string) {
|
||||
return wrapRedis(Keys.Project.secret(key), async () => {
|
||||
return prisma.project.findUnique({
|
||||
where: {
|
||||
secret: key,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static async public(key: string) {
|
||||
return wrapRedis(Keys.Project.public(key), async () => {
|
||||
return prisma.project.findUnique({
|
||||
where: {
|
||||
public: key,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
import {type Job, Queue} from 'bullmq';
|
||||
import type {RedisOptions} from 'ioredis';
|
||||
import signale from 'signale';
|
||||
import type {
|
||||
ApiRequestCleanupJobData,
|
||||
BulkContactActionJobData,
|
||||
CampaignBatchJobData,
|
||||
ContactImportJobData,
|
||||
DomainVerificationJobData,
|
||||
ScheduledCampaignJobData,
|
||||
SegmentCountJobData,
|
||||
SendEmailJobData,
|
||||
WorkflowStepJobData,
|
||||
} from '@plunk/types';
|
||||
|
||||
import {REDIS_URL} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
|
||||
/**
|
||||
* Queue Configuration
|
||||
*/
|
||||
|
||||
const redisConnection: RedisOptions = {
|
||||
maxRetriesPerRequest: null,
|
||||
enableReadyCheck: false,
|
||||
// Parse Redis URL
|
||||
...parseRedisUrl(REDIS_URL),
|
||||
};
|
||||
|
||||
function parseRedisUrl(url: string): {host: string; port: number; password?: string; db?: number} {
|
||||
const urlObj = new URL(url);
|
||||
return {
|
||||
host: urlObj.hostname,
|
||||
port: parseInt(urlObj.port || '6379', 10),
|
||||
password: urlObj.password || undefined,
|
||||
db: parseInt(urlObj.pathname.slice(1) || '0', 10),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue Instances
|
||||
*/
|
||||
|
||||
export const emailQueue = new Queue<SendEmailJobData>('email', {
|
||||
connection: redisConnection,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 2000,
|
||||
},
|
||||
removeOnComplete: 1000, // Keep last 1000 completed jobs
|
||||
removeOnFail: 5000, // Keep last 5000 failed jobs
|
||||
},
|
||||
});
|
||||
|
||||
export const campaignQueue = new Queue<CampaignBatchJobData>('campaign', {
|
||||
connection: redisConnection,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 5000,
|
||||
},
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 500,
|
||||
},
|
||||
});
|
||||
|
||||
export const workflowQueue = new Queue<WorkflowStepJobData>('workflow', {
|
||||
connection: redisConnection,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 2000,
|
||||
},
|
||||
removeOnComplete: 1000,
|
||||
removeOnFail: 5000,
|
||||
},
|
||||
});
|
||||
|
||||
export const scheduledQueue = new Queue<ScheduledCampaignJobData>('scheduled', {
|
||||
connection: redisConnection,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 10000,
|
||||
},
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 500,
|
||||
},
|
||||
});
|
||||
|
||||
export const importQueue = new Queue<ContactImportJobData>('import', {
|
||||
connection: redisConnection,
|
||||
defaultJobOptions: {
|
||||
attempts: 2, // Limited retries for imports
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 5000,
|
||||
},
|
||||
removeOnComplete: 50, // Keep last 50 completed imports
|
||||
removeOnFail: 100, // Keep last 100 failed imports
|
||||
},
|
||||
});
|
||||
|
||||
export const segmentCountQueue = new Queue<SegmentCountJobData>('segment-count', {
|
||||
connection: redisConnection,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 10000,
|
||||
},
|
||||
removeOnComplete: 10, // Keep last 10 completed jobs
|
||||
removeOnFail: 50, // Keep last 50 failed jobs
|
||||
},
|
||||
});
|
||||
|
||||
export const domainVerificationQueue = new Queue<DomainVerificationJobData>('domain-verification', {
|
||||
connection: redisConnection,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 10000,
|
||||
},
|
||||
removeOnComplete: 10, // Keep last 10 completed jobs
|
||||
removeOnFail: 50, // Keep last 50 failed jobs
|
||||
},
|
||||
});
|
||||
|
||||
export const apiRequestCleanupQueue = new Queue<ApiRequestCleanupJobData>('api-request-cleanup', {
|
||||
connection: redisConnection,
|
||||
defaultJobOptions: {
|
||||
attempts: 2,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 30000,
|
||||
},
|
||||
removeOnComplete: 5, // Keep last 5 completed jobs
|
||||
removeOnFail: 20, // Keep last 20 failed jobs
|
||||
},
|
||||
});
|
||||
|
||||
export const bulkContactQueue = new Queue<BulkContactActionJobData>('bulk-contact-actions', {
|
||||
connection: redisConnection,
|
||||
defaultJobOptions: {
|
||||
attempts: 2, // Limited retries for bulk operations
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 5000,
|
||||
},
|
||||
removeOnComplete: 50, // Keep last 50 completed bulk operations
|
||||
removeOnFail: 100, // Keep last 100 failed bulk operations
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Queue Service - Centralized queue management
|
||||
*/
|
||||
export class QueueService {
|
||||
/**
|
||||
* Add email to queue for sending
|
||||
*/
|
||||
public static async queueEmail(emailId: string, delay?: number): Promise<Job<SendEmailJobData>> {
|
||||
return emailQueue.add(
|
||||
'send-email',
|
||||
{emailId},
|
||||
{
|
||||
delay, // Optional delay in milliseconds
|
||||
jobId: `email-${emailId}`, // Prevent duplicate jobs
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add campaign batch to queue for processing
|
||||
*/
|
||||
public static async queueCampaignBatch(data: CampaignBatchJobData): Promise<Job<CampaignBatchJobData>> {
|
||||
return campaignQueue.add('process-batch', data, {
|
||||
jobId: `campaign-${data.campaignId}-batch-${data.batchNumber}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add workflow step to queue for execution
|
||||
*/
|
||||
public static async queueWorkflowStep(
|
||||
executionId: string,
|
||||
stepId: string,
|
||||
delay?: number,
|
||||
): Promise<Job<WorkflowStepJobData>> {
|
||||
return workflowQueue.add(
|
||||
'process-step',
|
||||
{executionId, stepId, type: 'process-step'},
|
||||
{
|
||||
delay,
|
||||
jobId: `workflow-${executionId}-${stepId}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a timeout handler for WAIT_FOR_EVENT steps
|
||||
*/
|
||||
public static async queueWorkflowTimeout(
|
||||
executionId: string,
|
||||
stepId: string,
|
||||
stepExecutionId: string,
|
||||
timeoutMs: number,
|
||||
): Promise<Job<WorkflowStepJobData>> {
|
||||
return workflowQueue.add(
|
||||
'timeout',
|
||||
{executionId, stepId, stepExecutionId, type: 'timeout'},
|
||||
{
|
||||
delay: timeoutMs,
|
||||
jobId: `workflow-timeout-${stepExecutionId}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a queued timeout job
|
||||
*/
|
||||
public static async cancelWorkflowTimeout(stepExecutionId: string): Promise<void> {
|
||||
const jobId = `workflow-timeout-${stepExecutionId}`;
|
||||
const job = await workflowQueue.getJob(jobId);
|
||||
|
||||
if (job) {
|
||||
await job.remove();
|
||||
signale.info(`[QUEUE] Cancelled timeout job ${jobId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule campaign for future sending
|
||||
*/
|
||||
public static async scheduleCampaign(campaignId: string, scheduledFor: Date): Promise<Job<ScheduledCampaignJobData>> {
|
||||
const delay = scheduledFor.getTime() - Date.now();
|
||||
|
||||
return scheduledQueue.add(
|
||||
'send-scheduled-campaign',
|
||||
{campaignId},
|
||||
{
|
||||
delay: Math.max(0, delay),
|
||||
jobId: `scheduled-campaign-${campaignId}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel scheduled campaign
|
||||
*/
|
||||
public static async cancelScheduledCampaign(campaignId: string): Promise<void> {
|
||||
const jobId = `scheduled-campaign-${campaignId}`;
|
||||
const job = await scheduledQueue.getJob(jobId);
|
||||
|
||||
if (job) {
|
||||
await job.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue contact import job
|
||||
*/
|
||||
public static async queueImport(
|
||||
projectId: string,
|
||||
csvData: string,
|
||||
filename: string,
|
||||
): Promise<Job<ContactImportJobData>> {
|
||||
return importQueue.add(
|
||||
'import-contacts',
|
||||
{projectId, csvData, filename},
|
||||
{
|
||||
jobId: `import-${projectId}-${Date.now()}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get import job status and progress
|
||||
* @param jobId - The job ID
|
||||
* @param projectId - The project ID to verify authorization
|
||||
* @returns Job status or null if not found or unauthorized
|
||||
*/
|
||||
public static async getImportJobStatus(jobId: string, projectId: string) {
|
||||
const job = await importQueue.getJob(jobId);
|
||||
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Security: Verify that the job belongs to the requesting project
|
||||
if (job.data.projectId !== projectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = await job.getState();
|
||||
const progress = job.progress;
|
||||
const returnValue = job.returnvalue;
|
||||
const failedReason = job.failedReason;
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
state,
|
||||
progress,
|
||||
result: returnValue,
|
||||
data: job.data,
|
||||
failedReason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue bulk contact action job
|
||||
*/
|
||||
public static async queueBulkContactAction(
|
||||
projectId: string,
|
||||
contactIds: string[],
|
||||
operation: 'subscribe' | 'unsubscribe' | 'delete',
|
||||
): Promise<Job<BulkContactActionJobData>> {
|
||||
return bulkContactQueue.add(
|
||||
'bulk-contact-action',
|
||||
{projectId, contactIds, operation},
|
||||
{
|
||||
jobId: `bulk-${operation}-${projectId}-${Date.now()}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bulk action job status and progress
|
||||
* @param jobId - The job ID
|
||||
* @param projectId - The project ID to verify authorization
|
||||
* @returns Job status or null if not found or unauthorized
|
||||
*/
|
||||
public static async getBulkActionJobStatus(jobId: string, projectId: string) {
|
||||
const job = await bulkContactQueue.getJob(jobId);
|
||||
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Security: Verify that the job belongs to the requesting project
|
||||
if (job.data.projectId !== projectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = await job.getState();
|
||||
const progress = job.progress;
|
||||
const returnValue = job.returnvalue;
|
||||
const failedReason = job.failedReason;
|
||||
|
||||
return {
|
||||
id: job.id,
|
||||
state,
|
||||
progress,
|
||||
result: returnValue,
|
||||
data: job.data,
|
||||
failedReason,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue segment count update job
|
||||
*/
|
||||
public static async queueSegmentCountUpdate(projectId?: string): Promise<Job<SegmentCountJobData>> {
|
||||
return segmentCountQueue.add(
|
||||
'update-segment-counts',
|
||||
{projectId},
|
||||
{
|
||||
jobId: projectId ? `segment-count-${projectId}-${Date.now()}` : `segment-count-all-${Date.now()}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue statistics
|
||||
*/
|
||||
public static async getStats() {
|
||||
const [
|
||||
emailCounts,
|
||||
campaignCounts,
|
||||
workflowCounts,
|
||||
scheduledCounts,
|
||||
importCounts,
|
||||
segmentCountCounts,
|
||||
domainVerificationCounts,
|
||||
apiRequestCleanupCounts,
|
||||
bulkContactCounts,
|
||||
] = await Promise.all([
|
||||
emailQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
campaignQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
workflowQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
scheduledQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
importQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
segmentCountQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
domainVerificationQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
apiRequestCleanupQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
bulkContactQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
]);
|
||||
|
||||
return {
|
||||
email: emailCounts,
|
||||
campaign: campaignCounts,
|
||||
workflow: workflowCounts,
|
||||
scheduled: scheduledCounts,
|
||||
import: importCounts,
|
||||
segmentCount: segmentCountCounts,
|
||||
domainVerification: domainVerificationCounts,
|
||||
apiRequestCleanup: apiRequestCleanupCounts,
|
||||
bulkContact: bulkContactCounts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause all queues (for maintenance)
|
||||
*/
|
||||
public static async pauseAll(): Promise<void> {
|
||||
await Promise.all([
|
||||
emailQueue.pause(),
|
||||
campaignQueue.pause(),
|
||||
workflowQueue.pause(),
|
||||
scheduledQueue.pause(),
|
||||
importQueue.pause(),
|
||||
segmentCountQueue.pause(),
|
||||
domainVerificationQueue.pause(),
|
||||
apiRequestCleanupQueue.pause(),
|
||||
bulkContactQueue.pause(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume all queues
|
||||
*/
|
||||
public static async resumeAll(): Promise<void> {
|
||||
await Promise.all([
|
||||
emailQueue.resume(),
|
||||
campaignQueue.resume(),
|
||||
workflowQueue.resume(),
|
||||
scheduledQueue.resume(),
|
||||
importQueue.resume(),
|
||||
segmentCountQueue.resume(),
|
||||
domainVerificationQueue.resume(),
|
||||
apiRequestCleanupQueue.resume(),
|
||||
bulkContactQueue.resume(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean old jobs (should be run periodically)
|
||||
*/
|
||||
public static async cleanOldJobs(): Promise<void> {
|
||||
const gracePeriod = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
await Promise.all([
|
||||
emailQueue.clean(gracePeriod, 1000, 'completed'),
|
||||
emailQueue.clean(gracePeriod * 7, 1000, 'failed'), // Keep failed jobs for 7 days
|
||||
campaignQueue.clean(gracePeriod, 100, 'completed'),
|
||||
campaignQueue.clean(gracePeriod * 7, 500, 'failed'),
|
||||
workflowQueue.clean(gracePeriod, 1000, 'completed'),
|
||||
workflowQueue.clean(gracePeriod * 7, 1000, 'failed'),
|
||||
scheduledQueue.clean(gracePeriod, 100, 'completed'),
|
||||
scheduledQueue.clean(gracePeriod * 7, 500, 'failed'),
|
||||
importQueue.clean(gracePeriod, 50, 'completed'),
|
||||
importQueue.clean(gracePeriod * 7, 100, 'failed'),
|
||||
segmentCountQueue.clean(gracePeriod, 10, 'completed'),
|
||||
segmentCountQueue.clean(gracePeriod * 7, 50, 'failed'),
|
||||
domainVerificationQueue.clean(gracePeriod, 10, 'completed'),
|
||||
domainVerificationQueue.clean(gracePeriod * 7, 50, 'failed'),
|
||||
bulkContactQueue.clean(gracePeriod, 50, 'completed'),
|
||||
bulkContactQueue.clean(gracePeriod * 7, 100, 'failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all pending jobs for a specific project
|
||||
* This should be called when a project is disabled
|
||||
*/
|
||||
public static async cancelAllProjectJobs(projectId: string): Promise<void> {
|
||||
signale.info(`[QUEUE] Cancelling all pending jobs for project ${projectId}`);
|
||||
|
||||
// Cancel all scheduled campaigns for this project
|
||||
const scheduledCampaigns = await scheduledQueue.getJobs(['waiting', 'delayed']);
|
||||
for (const job of scheduledCampaigns) {
|
||||
// We need to check if the campaign belongs to this project
|
||||
// by looking up the campaign in the database
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
where: {id: job.data.campaignId},
|
||||
select: {projectId: true},
|
||||
});
|
||||
|
||||
if (campaign?.projectId === projectId) {
|
||||
await job.remove();
|
||||
signale.info(`[QUEUE] Removed scheduled campaign job ${job.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel all pending emails for this project
|
||||
const pendingEmails = await emailQueue.getJobs(['waiting', 'delayed']);
|
||||
for (const job of pendingEmails) {
|
||||
const email = await prisma.email.findUnique({
|
||||
where: {id: job.data.emailId},
|
||||
select: {projectId: true},
|
||||
});
|
||||
|
||||
if (email?.projectId === projectId) {
|
||||
await job.remove();
|
||||
signale.info(`[QUEUE] Removed email job ${job.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel all pending campaign batches for this project
|
||||
const campaignBatches = await campaignQueue.getJobs(['waiting', 'delayed']);
|
||||
for (const job of campaignBatches) {
|
||||
const campaign = await prisma.campaign.findUnique({
|
||||
where: {id: job.data.campaignId},
|
||||
select: {projectId: true},
|
||||
});
|
||||
|
||||
if (campaign?.projectId === projectId) {
|
||||
await job.remove();
|
||||
signale.info(`[QUEUE] Removed campaign batch job ${job.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel all pending workflow steps for this project
|
||||
const workflowSteps = await workflowQueue.getJobs(['waiting', 'delayed']);
|
||||
for (const job of workflowSteps) {
|
||||
const execution = await prisma.workflowExecution.findUnique({
|
||||
where: {id: job.data.executionId},
|
||||
select: {workflow: {select: {projectId: true}}},
|
||||
});
|
||||
|
||||
if (execution?.workflow.projectId === projectId) {
|
||||
await job.remove();
|
||||
signale.info(`[QUEUE] Removed workflow step job ${job.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
signale.info(`[QUEUE] Finished cancelling jobs for project ${projectId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all queue connections
|
||||
*/
|
||||
public static async closeAll(): Promise<void> {
|
||||
await Promise.all([
|
||||
emailQueue.close(),
|
||||
campaignQueue.close(),
|
||||
workflowQueue.close(),
|
||||
scheduledQueue.close(),
|
||||
importQueue.close(),
|
||||
segmentCountQueue.close(),
|
||||
domainVerificationQueue.close(),
|
||||
apiRequestCleanupQueue.close(),
|
||||
bulkContactQueue.close(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
CreateBucketCommand,
|
||||
HeadBucketCommand,
|
||||
PutBucketPolicyCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import crypto from 'crypto';
|
||||
import signale from 'signale';
|
||||
|
||||
import {
|
||||
S3_ACCESS_KEY_ID,
|
||||
S3_ACCESS_KEY_SECRET,
|
||||
S3_BUCKET,
|
||||
S3_ENABLED,
|
||||
S3_ENDPOINT,
|
||||
S3_FORCE_PATH_STYLE,
|
||||
S3_PUBLIC_URL,
|
||||
} from '../app/constants.js';
|
||||
|
||||
/**
|
||||
* S3-compatible storage client for Minio
|
||||
*/
|
||||
let s3Client: S3Client | null = null;
|
||||
|
||||
if (S3_ENABLED) {
|
||||
s3Client = new S3Client({
|
||||
region: 'us-east-1', // Minio doesn't use regions, but AWS SDK requires this parameter
|
||||
endpoint: S3_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: S3_ACCESS_KEY_SECRET,
|
||||
},
|
||||
forcePathStyle: S3_FORCE_PATH_STYLE, // Required for Minio (uses path-style URLs)
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the S3 bucket if it doesn't exist
|
||||
*/
|
||||
export async function initializeBucket(): Promise<void> {
|
||||
if (!s3Client) {
|
||||
throw new Error('S3 is not enabled');
|
||||
}
|
||||
|
||||
let bucketExists = true;
|
||||
|
||||
try {
|
||||
await s3Client.send(
|
||||
new HeadBucketCommand({
|
||||
Bucket: S3_BUCKET,
|
||||
}),
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const isNotFoundError =
|
||||
error &&
|
||||
typeof error === 'object' &&
|
||||
(('name' in error && error.name === 'NotFound') ||
|
||||
('$metadata' in error &&
|
||||
error.$metadata &&
|
||||
typeof error.$metadata === 'object' &&
|
||||
'httpStatusCode' in error.$metadata &&
|
||||
error.$metadata.httpStatusCode === 404));
|
||||
if (isNotFoundError) {
|
||||
bucketExists = false;
|
||||
// Bucket doesn't exist, create it
|
||||
try {
|
||||
await s3Client.send(
|
||||
new CreateBucketCommand({
|
||||
Bucket: S3_BUCKET,
|
||||
}),
|
||||
);
|
||||
signale.info(`[S3] Created bucket: ${S3_BUCKET}`);
|
||||
} catch (createError) {
|
||||
signale.error('[S3] Failed to create bucket:', createError);
|
||||
throw createError;
|
||||
}
|
||||
} else {
|
||||
signale.error('[S3] Failed to check bucket:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Set public read policy for the bucket (both for new and existing buckets)
|
||||
try {
|
||||
const bucketPolicy = {
|
||||
Version: '2012-10-17',
|
||||
Statement: [
|
||||
{
|
||||
Sid: 'PublicReadGetObject',
|
||||
Effect: 'Allow',
|
||||
Principal: '*',
|
||||
Action: ['s3:GetObject'],
|
||||
Resource: [`arn:aws:s3:::${S3_BUCKET}/*`],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await s3Client.send(
|
||||
new PutBucketPolicyCommand({
|
||||
Bucket: S3_BUCKET,
|
||||
Policy: JSON.stringify(bucketPolicy),
|
||||
}),
|
||||
);
|
||||
|
||||
if (!bucketExists) {
|
||||
signale.info(`[S3] Set public read policy for bucket: ${S3_BUCKET}`);
|
||||
}
|
||||
} catch (policyError) {
|
||||
signale.error('[S3] Failed to set bucket policy:', policyError);
|
||||
// Don't throw - bucket was created but policy failed
|
||||
}
|
||||
}
|
||||
|
||||
interface UploadFileParams {
|
||||
file: Buffer;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
interface UploadFileResult {
|
||||
url: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to S3/Minio
|
||||
*/
|
||||
export async function uploadFile(params: UploadFileParams): Promise<UploadFileResult> {
|
||||
if (!s3Client) {
|
||||
throw new Error('S3 is not enabled');
|
||||
}
|
||||
|
||||
const {file, filename, contentType, projectId} = params;
|
||||
|
||||
// Generate a unique key for the file
|
||||
const timestamp = Date.now();
|
||||
const randomString = crypto.randomBytes(8).toString('hex');
|
||||
const extension = filename.split('.').pop();
|
||||
const key = `${projectId}/${timestamp}-${randomString}.${extension}`;
|
||||
|
||||
// Upload to S3/Minio
|
||||
await s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: S3_BUCKET,
|
||||
Key: key,
|
||||
Body: file,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
);
|
||||
|
||||
// Construct public URL
|
||||
const url = `${S3_PUBLIC_URL}/${key}`;
|
||||
|
||||
return {url, key};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if S3 is enabled and configured
|
||||
*/
|
||||
export function isS3Enabled(): boolean {
|
||||
return S3_ENABLED;
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import {SES} from '@aws-sdk/client-ses';
|
||||
import signale from 'signale';
|
||||
|
||||
import {
|
||||
AWS_SES_ACCESS_KEY_ID,
|
||||
AWS_SES_REGION,
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
DASHBOARD_URI,
|
||||
SES_CONFIGURATION_SET,
|
||||
SES_CONFIGURATION_SET_NO_TRACKING,
|
||||
TRACKING_TOGGLE_ENABLED,
|
||||
} from '../app/constants.js';
|
||||
|
||||
/**
|
||||
* AWS SES Client
|
||||
*/
|
||||
export const ses = new SES({
|
||||
apiVersion: '2010-12-01',
|
||||
region: AWS_SES_REGION,
|
||||
credentials: {
|
||||
accessKeyId: AWS_SES_ACCESS_KEY_ID,
|
||||
secretAccessKey: AWS_SES_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
interface SendRawEmailParams {
|
||||
from: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
to: string[] | {name?: string; email: string}[];
|
||||
content: {
|
||||
subject: string;
|
||||
html: string;
|
||||
};
|
||||
reply?: string;
|
||||
headers?: Record<string, string> | null;
|
||||
attachments?:
|
||||
| {
|
||||
filename: string;
|
||||
content: string; // Base64 encoded
|
||||
contentType: string;
|
||||
contentId?: string;
|
||||
disposition?: 'attachment' | 'inline';
|
||||
}[]
|
||||
| null;
|
||||
tracking?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Break long lines to comply with email RFC standards
|
||||
*/
|
||||
function breakLongLines(input: string, maxLineLength: number, isBase64 = false): string {
|
||||
if (isBase64) {
|
||||
// For base64 content, break at exact intervals without looking for spaces
|
||||
const result = [];
|
||||
for (let i = 0; i < input.length; i += maxLineLength) {
|
||||
result.push(input.substring(i, i + maxLineLength));
|
||||
}
|
||||
return result.join('\n');
|
||||
} else {
|
||||
// For text content, break at spaces when possible
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a raw email via AWS SES with full MIME formatting
|
||||
*/
|
||||
export async function sendRawEmail({
|
||||
from,
|
||||
to,
|
||||
content,
|
||||
reply,
|
||||
headers,
|
||||
attachments,
|
||||
tracking = true,
|
||||
}: SendRawEmailParams): Promise<{messageId: string}> {
|
||||
// Check if the body contains an unsubscribe link
|
||||
const regex = /unsubscribe\/([a-f\d-]+)"/;
|
||||
const containsUnsubscribeLink = regex.exec(content.html);
|
||||
|
||||
let unsubscribeHeader = '';
|
||||
if (containsUnsubscribeLink?.[1]) {
|
||||
const unsubscribeId = containsUnsubscribeLink[1];
|
||||
unsubscribeHeader = `List-Unsubscribe: <${DASHBOARD_URI}/unsubscribe/${unsubscribeId}>`;
|
||||
}
|
||||
|
||||
// Generate unique boundaries for multipart messages
|
||||
const altBoundary = `----=_AltPart_${Math.random().toString(36).substring(2)}`;
|
||||
const mixedBoundary = attachments?.some(a => (a.disposition ?? 'attachment') === 'attachment')
|
||||
? `----=_MixedPart_${Math.random().toString(36).substring(2)}`
|
||||
: null;
|
||||
const relatedBoundary = attachments?.some(a => a.disposition === 'inline')
|
||||
? `----=_RelatedPart_${Math.random().toString(36).substring(2)}`
|
||||
: null;
|
||||
|
||||
// Format To header with names if provided
|
||||
const toHeader = to
|
||||
.map(recipient => {
|
||||
if (typeof recipient === 'string') {
|
||||
return recipient;
|
||||
} else {
|
||||
return recipient.name ? `${recipient.name} <${recipient.email}>` : recipient.email;
|
||||
}
|
||||
})
|
||||
.join(', ');
|
||||
|
||||
// Extract just email addresses for Destinations (SES requirement)
|
||||
const destinations = to.map(recipient => (typeof recipient === 'string' ? recipient : recipient.email));
|
||||
|
||||
// Determine root content type
|
||||
let rootContentType = `multipart/alternative; boundary="${altBoundary}"`;
|
||||
if (mixedBoundary) {
|
||||
rootContentType = `multipart/mixed; boundary="${mixedBoundary}"`;
|
||||
} else if (relatedBoundary) {
|
||||
rootContentType = `multipart/related; boundary="${relatedBoundary}"`;
|
||||
}
|
||||
|
||||
// Build raw MIME message
|
||||
let rawMessage = `From: ${from.name} <${from.email}>
|
||||
To: ${toHeader}
|
||||
Reply-To: ${reply || from.email}
|
||||
Subject: ${content.subject}
|
||||
MIME-Version: 1.0
|
||||
Content-Type: ${rootContentType}
|
||||
${
|
||||
headers
|
||||
? Object.entries(headers)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n')
|
||||
: ''
|
||||
}
|
||||
${unsubscribeHeader}
|
||||
|
||||
`;
|
||||
|
||||
// building the body
|
||||
if (mixedBoundary) {
|
||||
rawMessage += `--${mixedBoundary}\n`;
|
||||
if (relatedBoundary) {
|
||||
rawMessage += `Content-Type: multipart/related; boundary="${relatedBoundary}"\n\n`;
|
||||
rawMessage += `--${relatedBoundary}\n`;
|
||||
}
|
||||
} else if (relatedBoundary) {
|
||||
rawMessage += `--${relatedBoundary}\n`;
|
||||
}
|
||||
|
||||
// If we are nested, we need to specify that this next part is the alternative container
|
||||
if (mixedBoundary || relatedBoundary) {
|
||||
rawMessage += `Content-Type: multipart/alternative; boundary="${altBoundary}"\n\n`;
|
||||
}
|
||||
|
||||
// The alternative part content (always contains HTML)
|
||||
rawMessage += `--${altBoundary}
|
||||
Content-Type: text/html; charset=utf-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
${breakLongLines(content.html, 500)}
|
||||
--${altBoundary}--
|
||||
`;
|
||||
|
||||
// Add inline attachments to the related container
|
||||
if (relatedBoundary) {
|
||||
const inlineAttachments = attachments?.filter(a => a.disposition === 'inline') ?? [];
|
||||
for (const attachment of inlineAttachments) {
|
||||
rawMessage += `\n--${relatedBoundary}
|
||||
Content-Type: ${attachment.contentType}
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-ID: <${attachment.contentId || attachment.filename}>
|
||||
Content-Disposition: inline; filename="${attachment.filename}"
|
||||
|
||||
${breakLongLines(attachment.content, 76, true)}`;
|
||||
}
|
||||
rawMessage += `\n--${relatedBoundary}--`;
|
||||
}
|
||||
|
||||
// Add regular attachments to the mixed container
|
||||
if (mixedBoundary) {
|
||||
const regularAttachments = attachments?.filter(a => (a.disposition ?? 'attachment') === 'attachment') ?? [];
|
||||
for (const attachment of regularAttachments) {
|
||||
rawMessage += `\n--${mixedBoundary}
|
||||
Content-Type: ${attachment.contentType}
|
||||
Content-Transfer-Encoding: base64
|
||||
Content-Disposition: attachment; filename="${attachment.filename}"
|
||||
|
||||
${breakLongLines(attachment.content, 76, true)}`;
|
||||
}
|
||||
rawMessage += `\n--${mixedBoundary}--`;
|
||||
}
|
||||
|
||||
// Determine which configuration set to use
|
||||
// Only use NO_TRACKING if tracking toggle is enabled AND tracking is disabled
|
||||
const configurationSetName =
|
||||
TRACKING_TOGGLE_ENABLED && !tracking ? SES_CONFIGURATION_SET_NO_TRACKING : SES_CONFIGURATION_SET;
|
||||
|
||||
// Send via SES
|
||||
const response = await ses.sendRawEmail({
|
||||
Destinations: destinations,
|
||||
ConfigurationSetName: configurationSetName,
|
||||
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};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get verification attributes for multiple domain identities
|
||||
*/
|
||||
export const getIdentities = async (domains: string[]): Promise<{domain: string; status: string}[]> => {
|
||||
const res = await ses.getIdentityVerificationAttributes({
|
||||
Identities: domains,
|
||||
});
|
||||
|
||||
const parsedResult = Object.entries(res.VerificationAttributes ?? {});
|
||||
return parsedResult.map(obj => {
|
||||
return {domain: obj[0], status: obj[1].VerificationStatus ?? 'NotStarted'};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a domain and get DKIM tokens for DNS configuration
|
||||
*/
|
||||
export const verifyDomain = async (domain: string): Promise<string[]> => {
|
||||
// Verify DKIM for the domain
|
||||
const DKIM = await ses.verifyDomainDkim({Domain: domain});
|
||||
|
||||
// Set custom MAIL FROM domain (plunk.yourdomain.com)
|
||||
await ses.setIdentityMailFromDomain({
|
||||
Identity: domain,
|
||||
MailFromDomain: `plunk.${domain}`,
|
||||
});
|
||||
|
||||
return DKIM.DkimTokens ?? [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Get DKIM verification attributes for a domain
|
||||
*/
|
||||
export const getDomainVerificationAttributes = async (domain: string) => {
|
||||
const attributes = await ses.getIdentityDkimAttributes({
|
||||
Identities: [domain],
|
||||
});
|
||||
|
||||
const parsedAttributes = Object.entries(attributes.DkimAttributes ?? {});
|
||||
|
||||
if (parsedAttributes.length === 0) {
|
||||
return {
|
||||
domain,
|
||||
tokens: [],
|
||||
status: 'NotStarted',
|
||||
};
|
||||
}
|
||||
|
||||
const firstAttribute = parsedAttributes[0];
|
||||
if (!firstAttribute) {
|
||||
return {
|
||||
domain,
|
||||
tokens: [],
|
||||
status: 'NotStarted',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
domain: firstAttribute[0],
|
||||
tokens: firstAttribute[1].DkimTokens ?? [],
|
||||
status: firstAttribute[1].DkimVerificationStatus ?? 'NotStarted',
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Disable bounce/complaint forwarding for a verified domain
|
||||
*/
|
||||
export const disableFeedbackForwarding = async (domain: string): Promise<void> => {
|
||||
await ses.setIdentityFeedbackForwardingEnabled({
|
||||
Identity: domain,
|
||||
ForwardingEnabled: false,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a verified domain identity from AWS SES
|
||||
*/
|
||||
export const deleteIdentity = async (domain: string): Promise<void> => {
|
||||
await ses.deleteIdentity({Identity: domain});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get AWS SES account sending quota and rate limit
|
||||
* @returns MaxSendRate (emails per second) or null if the call fails
|
||||
*/
|
||||
export const getSendingQuota = async (): Promise<{
|
||||
maxSendRate: number;
|
||||
max24HourSend: number;
|
||||
sentLast24Hours: number;
|
||||
} | null> => {
|
||||
try {
|
||||
const quota = await ses.getSendQuota({});
|
||||
|
||||
return {
|
||||
maxSendRate: quota.MaxSendRate ?? 14, // Default to sandbox limit if not provided
|
||||
max24HourSend: quota.Max24HourSend ?? 200, // Default sandbox daily limit
|
||||
sentLast24Hours: quota.SentLast24Hours ?? 0,
|
||||
};
|
||||
} catch (error) {
|
||||
signale.error('[SES] Failed to fetch sending quota:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,453 @@
|
||||
import {ProjectDisabledEmail, sendPlatformEmail} from '@plunk/email';
|
||||
import React from 'react';
|
||||
import signale from 'signale';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {MembershipService} from './MembershipService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {QueueService} from './QueueService.js';
|
||||
import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||
|
||||
/**
|
||||
* Security thresholds for bounce and complaint rates
|
||||
* These limits protect AWS SES reputation and prevent account suspension
|
||||
*/
|
||||
const SECURITY_THRESHOLDS = {
|
||||
// Minimum emails required before enforcing limits (prevents false positives)
|
||||
MIN_EMAILS_FOR_ENFORCEMENT: 100,
|
||||
|
||||
// Bounce rate thresholds (hard bounces only)
|
||||
BOUNCE_7DAY_WARNING: 5,
|
||||
BOUNCE_7DAY_CRITICAL: 10,
|
||||
BOUNCE_ALLTIME_WARNING: 4,
|
||||
BOUNCE_ALLTIME_CRITICAL: 8,
|
||||
|
||||
// Complaint rate thresholds (spam reports)
|
||||
COMPLAINT_7DAY_WARNING: 0.075,
|
||||
COMPLAINT_7DAY_CRITICAL: 0.15,
|
||||
COMPLAINT_ALLTIME_WARNING: 0.03,
|
||||
COMPLAINT_ALLTIME_CRITICAL: 0.12,
|
||||
|
||||
// Minimum absolute counts (prevents small sample size false positives)
|
||||
// Both percentage AND absolute count must be exceeded to trigger
|
||||
MIN_BOUNCES_FOR_CRITICAL: 10,
|
||||
MIN_BOUNCES_FOR_WARNING: 5,
|
||||
MIN_COMPLAINTS_FOR_CRITICAL: 5,
|
||||
MIN_COMPLAINTS_FOR_WARNING: 3,
|
||||
} as const;
|
||||
|
||||
interface RateData {
|
||||
total: number;
|
||||
bounces: number;
|
||||
complaints: number;
|
||||
bounceRate: number;
|
||||
complaintRate: number;
|
||||
}
|
||||
|
||||
interface SecurityStatus {
|
||||
projectId: string;
|
||||
isHealthy: boolean;
|
||||
shouldDisable: boolean;
|
||||
sevenDay: RateData;
|
||||
allTime: RateData;
|
||||
violations: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export class SecurityService {
|
||||
private static readonly CACHE_TTL = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* Get security status for a project (with caching)
|
||||
*/
|
||||
public static async getSecurityStatus(projectId: string): Promise<SecurityStatus> {
|
||||
try {
|
||||
// Try to get from cache first
|
||||
const cacheKey = Keys.Security.rates(projectId);
|
||||
const cached = await redis.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
// Calculate fresh data
|
||||
const status = await this.calculateSecurityStatus(projectId);
|
||||
|
||||
// Cache the result
|
||||
await redis.setex(cacheKey, this.CACHE_TTL, JSON.stringify(status));
|
||||
|
||||
return status;
|
||||
} catch (error) {
|
||||
signale.error('[SECURITY] Failed to get security status:', error);
|
||||
// Return safe defaults on error
|
||||
return {
|
||||
projectId,
|
||||
isHealthy: true,
|
||||
shouldDisable: false,
|
||||
sevenDay: {
|
||||
total: 0,
|
||||
bounces: 0,
|
||||
complaints: 0,
|
||||
bounceRate: 0,
|
||||
complaintRate: 0,
|
||||
},
|
||||
allTime: {
|
||||
total: 0,
|
||||
bounces: 0,
|
||||
complaints: 0,
|
||||
bounceRate: 0,
|
||||
complaintRate: 0,
|
||||
},
|
||||
violations: [],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check security status and auto-disable project if thresholds are exceeded
|
||||
* This should be called after bounce/complaint events are processed
|
||||
*/
|
||||
public static async checkAndEnforceSecurityLimits(projectId: string): Promise<void> {
|
||||
try {
|
||||
// Invalidate cache to get fresh data
|
||||
await this.invalidateCache(projectId);
|
||||
|
||||
// Get current security status
|
||||
const status = await this.getSecurityStatus(projectId);
|
||||
|
||||
// If project should be disabled, disable it (only if auto-disable is enabled)
|
||||
if (status.shouldDisable && AUTO_PROJECT_DISABLE) {
|
||||
await this.disableProject(projectId, status);
|
||||
} else if (status.shouldDisable && !AUTO_PROJECT_DISABLE) {
|
||||
// Log critical violations but don't auto-disable (self-hosted mode)
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {name: true},
|
||||
});
|
||||
|
||||
if (project) {
|
||||
signale.error(
|
||||
`[SECURITY] Project ${projectId} (${project.name}) has CRITICAL security violations but auto-disable is turned off:`,
|
||||
status.violations,
|
||||
);
|
||||
signale.info(
|
||||
`[SECURITY] 7-day stats: ${status.sevenDay.bounces} bounces, ${status.sevenDay.complaints} complaints out of ${status.sevenDay.total} emails`,
|
||||
);
|
||||
signale.info(
|
||||
`[SECURITY] All-time stats: ${status.allTime.bounces} bounces, ${status.allTime.complaints} complaints out of ${status.allTime.total} emails`,
|
||||
);
|
||||
|
||||
// Send notification about critical security violations
|
||||
await NtfyService.notifySecurityWarning(project.name, projectId, status.violations);
|
||||
}
|
||||
} else if (status.warnings.length > 0) {
|
||||
// Log warnings for monitoring
|
||||
signale.warn(`[SECURITY] Project ${projectId} has security warnings:`, status.warnings);
|
||||
|
||||
// Get project name for notification
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {name: true},
|
||||
});
|
||||
|
||||
if (project) {
|
||||
// Send notification about security warning
|
||||
await NtfyService.notifySecurityWarning(project.name, projectId, status.warnings);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log error but don't throw - we don't want security checks to break the webhook
|
||||
signale.error(`[SECURITY] Failed to check security limits for project ${projectId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached security data for a project
|
||||
* Should be called after bounce/complaint events
|
||||
*/
|
||||
public static async invalidateCache(projectId: string): Promise<void> {
|
||||
try {
|
||||
const cacheKey = Keys.Security.rates(projectId);
|
||||
await redis.del(cacheKey);
|
||||
} catch (error) {
|
||||
signale.error(`[SECURITY] Failed to invalidate cache for project ${projectId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is a member of any disabled project
|
||||
* Users with disabled projects cannot create new projects
|
||||
*/
|
||||
public static async userHasDisabledProject(userId: string): Promise<{
|
||||
hasDisabledProject: boolean;
|
||||
disabledProjectNames: string[];
|
||||
}> {
|
||||
return MembershipService.userHasDisabledProject(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific project is disabled
|
||||
*/
|
||||
public static async isProjectDisabled(projectId: string): Promise<boolean> {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {disabled: true},
|
||||
});
|
||||
|
||||
return project?.disabled ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a project's security metrics (for admin/dashboard display)
|
||||
*/
|
||||
public static async getProjectSecurityMetrics(projectId: string): Promise<{
|
||||
status: SecurityStatus;
|
||||
thresholds: typeof SECURITY_THRESHOLDS;
|
||||
isDisabled: boolean;
|
||||
}> {
|
||||
const [status, project] = await Promise.all([
|
||||
this.getSecurityStatus(projectId),
|
||||
prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {disabled: true},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
status,
|
||||
thresholds: SECURITY_THRESHOLDS,
|
||||
isDisabled: project?.disabled ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate bounce and complaint rates for a project
|
||||
*/
|
||||
private static async calculateRates(projectId: string, startDate?: Date): Promise<RateData> {
|
||||
const where = {
|
||||
projectId,
|
||||
...(startDate && {
|
||||
createdAt: {
|
||||
gte: startDate,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
// Get counts in parallel for performance
|
||||
const [total, bounces, complaints] = await Promise.all([
|
||||
prisma.email.count({where}),
|
||||
prisma.email.count({
|
||||
where: {
|
||||
...where,
|
||||
bouncedAt: {not: null},
|
||||
},
|
||||
}),
|
||||
prisma.email.count({
|
||||
where: {
|
||||
...where,
|
||||
complainedAt: {not: null},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const bounceRate = total > 0 ? (bounces / total) * 100 : 0;
|
||||
const complaintRate = total > 0 ? (complaints / total) * 100 : 0;
|
||||
|
||||
return {
|
||||
total,
|
||||
bounces,
|
||||
complaints,
|
||||
bounceRate,
|
||||
complaintRate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate security status without caching
|
||||
*/
|
||||
private static async calculateSecurityStatus(projectId: string): Promise<SecurityStatus> {
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Get 7-day and all-time rates in parallel
|
||||
const [sevenDay, allTime] = await Promise.all([
|
||||
this.calculateRates(projectId, sevenDaysAgo),
|
||||
this.calculateRates(projectId),
|
||||
]);
|
||||
|
||||
const violations: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Only enforce if minimum emails threshold is met
|
||||
const hasMinimumVolumeAllTime = allTime.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
|
||||
const hasMinimumVolume7Day = sevenDay.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
|
||||
|
||||
// Check 7-day bounce rate (only if 7-day volume is sufficient)
|
||||
if (hasMinimumVolume7Day) {
|
||||
// Critical: requires BOTH rate AND absolute count thresholds
|
||||
if (
|
||||
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL &&
|
||||
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
|
||||
) {
|
||||
violations.push(
|
||||
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%, ${sevenDay.bounces} bounces) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL} minimum)`,
|
||||
);
|
||||
} else if (
|
||||
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING &&
|
||||
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING
|
||||
) {
|
||||
warnings.push(
|
||||
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%, ${sevenDay.bounces} bounces) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING} minimum)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check 7-day complaint rate (only if 7-day volume is sufficient)
|
||||
if (hasMinimumVolume7Day) {
|
||||
// Critical: requires BOTH rate AND absolute count thresholds
|
||||
if (
|
||||
sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL &&
|
||||
sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
|
||||
) {
|
||||
violations.push(
|
||||
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%, ${sevenDay.complaints} complaints) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL} minimum)`,
|
||||
);
|
||||
} else if (
|
||||
sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING &&
|
||||
sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING
|
||||
) {
|
||||
warnings.push(
|
||||
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%, ${sevenDay.complaints} complaints) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING} minimum)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check all-time rates (only if all-time volume is sufficient)
|
||||
if (hasMinimumVolumeAllTime) {
|
||||
// Check all-time bounce rate - requires BOTH rate AND absolute count
|
||||
if (
|
||||
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL &&
|
||||
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
|
||||
) {
|
||||
violations.push(
|
||||
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%, ${allTime.bounces} bounces) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL} minimum)`,
|
||||
);
|
||||
} else if (
|
||||
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING &&
|
||||
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING
|
||||
) {
|
||||
warnings.push(
|
||||
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%, ${allTime.bounces} bounces) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING} minimum)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check all-time complaint rate - requires BOTH rate AND absolute count
|
||||
if (
|
||||
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL &&
|
||||
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
|
||||
) {
|
||||
violations.push(
|
||||
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%, ${allTime.complaints} complaints) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL} minimum)`,
|
||||
);
|
||||
} else if (
|
||||
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING &&
|
||||
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING
|
||||
) {
|
||||
warnings.push(
|
||||
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%, ${allTime.complaints} complaints) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING} minimum)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projectId,
|
||||
isHealthy: violations.length === 0,
|
||||
shouldDisable: violations.length > 0,
|
||||
sevenDay,
|
||||
allTime,
|
||||
violations,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a project due to security violations
|
||||
*/
|
||||
private static async disableProject(projectId: string, status: SecurityStatus): Promise<void> {
|
||||
try {
|
||||
// Check if already disabled to avoid duplicate logs
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {id: true, disabled: true, name: true},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
signale.error(`[SECURITY] Project ${projectId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (project.disabled) {
|
||||
// Already disabled, just log the current violations
|
||||
signale.warn(
|
||||
`[SECURITY] Project ${projectId} (${project.name}) already disabled. Current violations:`,
|
||||
status.violations,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable the project
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {disabled: true},
|
||||
});
|
||||
|
||||
// Log critical security event
|
||||
signale.error(
|
||||
`[SECURITY] Project ${projectId} (${project.name}) has been automatically disabled due to security violations:`,
|
||||
status.violations,
|
||||
);
|
||||
signale.info(
|
||||
`[SECURITY] 7-day stats: ${status.sevenDay.bounces} bounces, ${status.sevenDay.complaints} complaints out of ${status.sevenDay.total} emails`,
|
||||
);
|
||||
signale.info(
|
||||
`[SECURITY] All-time stats: ${status.allTime.bounces} bounces, ${status.allTime.complaints} complaints out of ${status.allTime.total} emails`,
|
||||
);
|
||||
|
||||
// Cancel all pending jobs for this project
|
||||
try {
|
||||
await QueueService.cancelAllProjectJobs(projectId);
|
||||
signale.info(`[SECURITY] Cancelled all pending jobs for project ${projectId}`);
|
||||
} catch (error) {
|
||||
signale.error(`[SECURITY] Failed to cancel pending jobs for project ${projectId}:`, error);
|
||||
}
|
||||
|
||||
// Send urgent notification about project suspension
|
||||
await NtfyService.notifyProjectDisabledForSecurity(project.name, projectId, status.violations);
|
||||
|
||||
// Send email notification to project members
|
||||
try {
|
||||
const members = await MembershipService.getMembers(projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(ProjectDisabledEmail, {
|
||||
projectName: project.name,
|
||||
projectId,
|
||||
violations: status.violations,
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
await Promise.all(
|
||||
emails.map(email => sendPlatformEmail(email, 'Project Disabled - Security Risk', template)),
|
||||
);
|
||||
}
|
||||
} catch (emailError) {
|
||||
signale.error(`[SECURITY] Failed to send project disabled email:`, emailError);
|
||||
}
|
||||
} catch (error) {
|
||||
signale.error(`[SECURITY] Failed to disable project ${projectId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,211 @@
|
||||
import type {Template} from '@plunk/db';
|
||||
import {Prisma} from '@plunk/db';
|
||||
import type {PaginatedResponse} from '@plunk/types';
|
||||
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {HttpException} from '../exceptions/index.js';
|
||||
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
|
||||
|
||||
export class TemplateService {
|
||||
/**
|
||||
* Get all templates for a project with pagination
|
||||
*/
|
||||
public static async list(
|
||||
projectId: string,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
search?: string,
|
||||
type?: Template['type'],
|
||||
): Promise<PaginatedResponse<Template>> {
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.TemplateWhereInput = {
|
||||
projectId,
|
||||
...(type ? {type} : {}),
|
||||
...(search
|
||||
? {
|
||||
OR: [
|
||||
{name: {contains: search, mode: 'insensitive' as const}},
|
||||
{description: {contains: search, mode: 'insensitive' as const}},
|
||||
{subject: {contains: search, mode: 'insensitive' as const}},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [templates, total] = await Promise.all([
|
||||
prisma.template.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: {createdAt: 'desc'},
|
||||
}),
|
||||
prisma.template.count({where}),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: templates,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single template by ID
|
||||
*/
|
||||
public static async get(projectId: string, templateId: string): Promise<Template> {
|
||||
const template = await prisma.template.findFirst({
|
||||
where: {
|
||||
id: templateId,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!template) {
|
||||
throw new HttpException(404, 'Template not found');
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new template
|
||||
*/
|
||||
public static async create(
|
||||
projectId: string,
|
||||
data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
from: string;
|
||||
fromName?: string | null;
|
||||
replyTo?: string | null;
|
||||
type?: Template['type'];
|
||||
},
|
||||
): Promise<Template> {
|
||||
return prisma.template.create({
|
||||
data: {
|
||||
projectId,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
subject: data.subject,
|
||||
body: data.body,
|
||||
from: data.from,
|
||||
fromName: data.fromName,
|
||||
replyTo: data.replyTo,
|
||||
type: data.type ?? 'MARKETING',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a template
|
||||
*/
|
||||
public static async update(
|
||||
projectId: string,
|
||||
templateId: string,
|
||||
data: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
subject?: string;
|
||||
body?: string;
|
||||
from?: string;
|
||||
fromName?: string | null;
|
||||
replyTo?: string | null;
|
||||
type?: Template['type'];
|
||||
},
|
||||
): Promise<Template> {
|
||||
// Verify template exists and belongs to project
|
||||
await this.get(projectId, templateId);
|
||||
|
||||
const updateData = {
|
||||
...buildEmailFieldsUpdate(data),
|
||||
...(data.type !== undefined ? {type: data.type} : {}),
|
||||
} as Prisma.TemplateUpdateInput;
|
||||
|
||||
return prisma.template.update({
|
||||
where: {id: templateId},
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a template
|
||||
*/
|
||||
public static async delete(projectId: string, templateId: string): Promise<void> {
|
||||
// Verify template exists and belongs to project
|
||||
await this.get(projectId, templateId);
|
||||
|
||||
// Check if template is used in any workflows
|
||||
const workflowSteps = await prisma.workflowStep.count({
|
||||
where: {
|
||||
templateId,
|
||||
workflow: {projectId},
|
||||
},
|
||||
});
|
||||
|
||||
if (workflowSteps > 0) {
|
||||
throw new HttpException(
|
||||
409,
|
||||
'Cannot delete template: it is currently used in workflow steps. Remove it from workflows first.',
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.template.delete({
|
||||
where: {id: templateId},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a template
|
||||
*/
|
||||
public static async duplicate(projectId: string, templateId: string): Promise<Template> {
|
||||
const template = await this.get(projectId, templateId);
|
||||
|
||||
// Create a new template with the same data
|
||||
return prisma.template.create({
|
||||
data: {
|
||||
projectId,
|
||||
name: `${template.name} (Copy)`,
|
||||
description: template.description,
|
||||
subject: template.subject,
|
||||
body: template.body,
|
||||
from: template.from,
|
||||
fromName: template.fromName,
|
||||
replyTo: template.replyTo,
|
||||
type: template.type,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get template usage statistics
|
||||
*/
|
||||
public static async getUsage(projectId: string, templateId: string) {
|
||||
// Verify template exists and belongs to project
|
||||
await this.get(projectId, templateId);
|
||||
|
||||
const [workflowStepsCount, emailsCount] = await Promise.all([
|
||||
prisma.workflowStep.count({
|
||||
where: {
|
||||
templateId,
|
||||
workflow: {projectId},
|
||||
},
|
||||
}),
|
||||
prisma.email.count({
|
||||
where: {
|
||||
templateId,
|
||||
projectId,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
workflowSteps: workflowStepsCount,
|
||||
emailsSent: emailsCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import {API_URI, NODE_ENV} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
import {wrapRedis} from '../database/redis.js';
|
||||
|
||||
import {Keys} from './keys.js';
|
||||
|
||||
/**
|
||||
* Extract base domain from URL for cookie sharing across subdomains
|
||||
* e.g., "http://api.example.com" -> ".example.com"
|
||||
* e.g., "http://api.localhost" -> ".localhost"
|
||||
* e.g., "http://app.plunk.local" -> ".plunk.local"
|
||||
*/
|
||||
function getCookieDomain(): string | undefined {
|
||||
if (NODE_ENV === 'development') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(API_URI);
|
||||
const hostname = url.hostname;
|
||||
|
||||
// For localhost or IP addresses, don't set a domain
|
||||
if (hostname === 'localhost' || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Extract base domain (last two parts for most domains, or .localhost)
|
||||
const parts = hostname.split('.');
|
||||
if (parts.length >= 2) {
|
||||
// For *.localhost, use .localhost (reserved TLD)
|
||||
if (hostname.endsWith('.localhost')) {
|
||||
return '.localhost';
|
||||
}
|
||||
// For *.local (mDNS TLD), use the actual base domain
|
||||
if (hostname.endsWith('.local')) {
|
||||
return `.${parts.slice(-2).join('.')}`;
|
||||
}
|
||||
// For other domains, use the last two parts (e.g., .example.com)
|
||||
return `.${parts.slice(-2).join('.')}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class UserService {
|
||||
public static readonly COOKIE_NAME = 'next_token';
|
||||
|
||||
public static async id(id: string) {
|
||||
return wrapRedis(Keys.User.id(id), async () => {
|
||||
return prisma.user.findUnique({where: {id}});
|
||||
});
|
||||
}
|
||||
|
||||
public static async email(email: string) {
|
||||
if (!email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return wrapRedis(Keys.User.email(email), async () => {
|
||||
return prisma.user.findFirst({
|
||||
where: {
|
||||
email: {
|
||||
equals: email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public static async projects(userId: string) {
|
||||
const memberships = await prisma.user.findUnique({where: {id: userId}}).memberships({
|
||||
include: {
|
||||
project: true,
|
||||
},
|
||||
});
|
||||
|
||||
return memberships ? memberships.map(({project}) => project) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates cookie options
|
||||
* @param expires An optional expiry for this cookie (useful for a logout)
|
||||
*/
|
||||
public static cookieOptions(expires?: Date) {
|
||||
// Check if using HTTPS from API_URI
|
||||
const isHttps = NODE_ENV === 'development' ? false : API_URI.startsWith('https://');
|
||||
|
||||
return {
|
||||
httpOnly: true,
|
||||
expires: expires ?? dayjs().add(7, 'days').toDate(),
|
||||
secure: isHttps,
|
||||
sameSite: isHttps ? 'none' : 'lax',
|
||||
path: '/',
|
||||
domain: getCookieDomain(),
|
||||
} as const;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,784 @@
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {EmailSourceType} from '@plunk/db';
|
||||
import {BillingLimitService} from '../BillingLimitService';
|
||||
import {EmailService} from '../EmailService';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
import {redis} from '../../database/redis';
|
||||
|
||||
// Mock STRIPE_ENABLED and STRIPE_SK for free tier tests
|
||||
vi.mock('../../app/constants.js', async () => {
|
||||
const actual = await vi.importActual('../../app/constants.js');
|
||||
return {
|
||||
...actual,
|
||||
STRIPE_ENABLED: true,
|
||||
STRIPE_SK: 'sk_test_mock_key_for_testing',
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the stripe client to avoid actual Stripe API calls
|
||||
vi.mock('../../app/stripe.js', () => ({
|
||||
stripe: {
|
||||
customers: {
|
||||
retrieve: vi.fn().mockResolvedValue({
|
||||
deleted: false,
|
||||
currency: 'usd',
|
||||
}),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('BillingLimitService - Critical Enforcement', () => {
|
||||
let projectId: string;
|
||||
let contactId: string;
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
beforeEach(async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
projectId = project.id;
|
||||
|
||||
const contact = await factories.createContact({projectId});
|
||||
contactId = contact.id;
|
||||
});
|
||||
|
||||
describe('Revenue Protection - Limit Enforcement', () => {
|
||||
it('should BLOCK transactional emails when limit exceeded', async () => {
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitTransactional: 5},
|
||||
});
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId,
|
||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||
});
|
||||
}
|
||||
|
||||
await BillingLimitService.invalidateCache(projectId);
|
||||
|
||||
// Attempt to send 6th email should fail
|
||||
await expect(
|
||||
EmailService.sendTransactionalEmail({
|
||||
projectId,
|
||||
contactId,
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
from: 'test@example.com',
|
||||
}),
|
||||
).rejects.toThrow(/billing limit/i);
|
||||
});
|
||||
|
||||
it('should BLOCK campaign emails when limit exceeded', async () => {
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 3},
|
||||
});
|
||||
|
||||
// Create 3 campaign emails
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId,
|
||||
sourceType: EmailSourceType.CAMPAIGN,
|
||||
});
|
||||
}
|
||||
|
||||
await BillingLimitService.invalidateCache(projectId);
|
||||
|
||||
// 4th should fail
|
||||
const campaign = await factories.createCampaign({projectId});
|
||||
await expect(
|
||||
EmailService.sendCampaignEmail({
|
||||
projectId,
|
||||
contactId,
|
||||
campaignId: campaign.id,
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
from: 'test@example.com',
|
||||
}),
|
||||
).rejects.toThrow(/billing limit/i);
|
||||
});
|
||||
|
||||
it('should BLOCK workflow emails when limit exceeded', async () => {
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitWorkflows: 2},
|
||||
});
|
||||
|
||||
// Create 2 workflow emails
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId,
|
||||
sourceType: EmailSourceType.WORKFLOW,
|
||||
});
|
||||
}
|
||||
|
||||
await BillingLimitService.invalidateCache(projectId);
|
||||
|
||||
// 3rd should fail
|
||||
await expect(
|
||||
EmailService.sendWorkflowEmail({
|
||||
projectId,
|
||||
contactId,
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
from: 'test@example.com',
|
||||
}),
|
||||
).rejects.toThrow(/billing limit/i);
|
||||
});
|
||||
|
||||
it('should ALLOW emails when limit is null (unlimited)', async () => {
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {
|
||||
billingLimitTransactional: null,
|
||||
// Set one other limit to trigger per-category mode (not free tier)
|
||||
billingLimitCampaigns: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
// Create 100 emails
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId,
|
||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||
});
|
||||
}
|
||||
|
||||
await BillingLimitService.invalidateCache(projectId);
|
||||
|
||||
// Should still allow more
|
||||
const result = await BillingLimitService.checkLimit(projectId, EmailSourceType.TRANSACTIONAL);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.limit).toBeNull();
|
||||
});
|
||||
|
||||
it('should enforce limits independently per source type', async () => {
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {
|
||||
billingLimitTransactional: 5,
|
||||
billingLimitCampaigns: 5,
|
||||
billingLimitWorkflows: 5,
|
||||
},
|
||||
});
|
||||
|
||||
// Max out transactional
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId,
|
||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||
});
|
||||
}
|
||||
|
||||
await BillingLimitService.invalidateCache(projectId);
|
||||
|
||||
// Transactional should be blocked
|
||||
const transactionalCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.TRANSACTIONAL);
|
||||
expect(transactionalCheck.allowed).toBe(false);
|
||||
|
||||
// Campaign should still be allowed
|
||||
const campaignCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
||||
expect(campaignCheck.allowed).toBe(true);
|
||||
|
||||
// Workflow should still be allowed
|
||||
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
|
||||
expect(workflowCheck.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Warning Threshold (80%)', () => {
|
||||
it('should warn when usage reaches 80% of limit', async () => {
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 10},
|
||||
});
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId,
|
||||
sourceType: EmailSourceType.CAMPAIGN,
|
||||
});
|
||||
}
|
||||
|
||||
await BillingLimitService.invalidateCache(projectId);
|
||||
|
||||
const result = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.warning).toBe(true);
|
||||
expect(result.percentage).toBeGreaterThanOrEqual(80);
|
||||
expect(result.message).toMatch(/warning/i);
|
||||
});
|
||||
|
||||
it('should not warn when usage is below 80%', async () => {
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 10},
|
||||
});
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId,
|
||||
sourceType: EmailSourceType.CAMPAIGN,
|
||||
});
|
||||
}
|
||||
|
||||
await BillingLimitService.invalidateCache(projectId);
|
||||
|
||||
const result = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.warning).toBe(false);
|
||||
expect(result.message).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cache Performance & Fallback', () => {
|
||||
it('should use cached usage counts to avoid DB queries', async () => {
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 100},
|
||||
});
|
||||
|
||||
// First call - should query DB and cache
|
||||
const usage1 = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
|
||||
|
||||
// Add email directly to DB (bypassing cache increment)
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId,
|
||||
sourceType: EmailSourceType.CAMPAIGN,
|
||||
});
|
||||
|
||||
// Second call within cache TTL - should return cached value (not reflect new email)
|
||||
const usage2 = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
|
||||
|
||||
expect(usage2).toBe(usage1); // Same as cached value
|
||||
});
|
||||
|
||||
it('should increment cache after successful email send', async () => {
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 100},
|
||||
});
|
||||
|
||||
await BillingLimitService.invalidateCache(projectId);
|
||||
|
||||
const initialUsage = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
|
||||
|
||||
// Send email (should increment cache)
|
||||
await EmailService.sendCampaignEmail({
|
||||
projectId,
|
||||
contactId,
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
from: 'test@example.com',
|
||||
});
|
||||
|
||||
const finalUsage = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
|
||||
|
||||
expect(finalUsage).toBe(initialUsage + 1);
|
||||
});
|
||||
|
||||
it('should handle Redis failure gracefully without blocking emails', async () => {
|
||||
// Mock Redis failure
|
||||
vi.spyOn(redis, 'get').mockRejectedValueOnce(new Error('Redis connection failed'));
|
||||
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 100},
|
||||
});
|
||||
|
||||
// Should fall back to DB and still work
|
||||
const result = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle invalid project ID gracefully', async () => {
|
||||
// Non-existent project should not crash, should allow email (fail-open)
|
||||
const result = await BillingLimitService.checkLimit('non-existent-project', EmailSourceType.CAMPAIGN);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.usage).toBe(0);
|
||||
expect(result.limit).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Monthly Reset Behavior', () => {
|
||||
it('should only count emails from current calendar month', async () => {
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 10},
|
||||
});
|
||||
|
||||
// Create a date in the previous month
|
||||
// Set day to 1 first to avoid month overflow issues (e.g., Jan 31 -> Feb 31 = Mar 3)
|
||||
const lastMonth = new Date();
|
||||
lastMonth.setDate(1);
|
||||
lastMonth.setMonth(lastMonth.getMonth() - 1);
|
||||
|
||||
await prisma.email.create({
|
||||
data: {
|
||||
projectId,
|
||||
contactId,
|
||||
subject: 'Old',
|
||||
body: 'Old',
|
||||
from: 'test@example.com',
|
||||
sourceType: EmailSourceType.CAMPAIGN,
|
||||
status: 'SENT',
|
||||
createdAt: lastMonth,
|
||||
},
|
||||
});
|
||||
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId,
|
||||
sourceType: EmailSourceType.CAMPAIGN,
|
||||
});
|
||||
|
||||
await BillingLimitService.invalidateCache(projectId);
|
||||
|
||||
const usage = await BillingLimitService.getUsage(projectId, EmailSourceType.CAMPAIGN);
|
||||
|
||||
// Should only count this month's email
|
||||
expect(usage).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complete Limits Overview', () => {
|
||||
it('should return all category limits and usage', async () => {
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {
|
||||
billingLimitWorkflows: 100,
|
||||
billingLimitCampaigns: 200,
|
||||
billingLimitTransactional: null, // Unlimited
|
||||
},
|
||||
});
|
||||
|
||||
// Create various emails
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId,
|
||||
sourceType: EmailSourceType.WORKFLOW,
|
||||
});
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId,
|
||||
sourceType: EmailSourceType.CAMPAIGN,
|
||||
});
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId,
|
||||
sourceType: EmailSourceType.CAMPAIGN,
|
||||
});
|
||||
|
||||
await BillingLimitService.invalidateCache(projectId);
|
||||
|
||||
const limits = await BillingLimitService.getLimitsAndUsage(projectId);
|
||||
|
||||
expect(limits.workflows.limit).toBe(100);
|
||||
expect(limits.workflows.usage).toBe(1);
|
||||
expect(limits.workflows.percentage).toBe(1);
|
||||
expect(limits.workflows.isWarning).toBe(false);
|
||||
expect(limits.workflows.isBlocked).toBe(false);
|
||||
|
||||
expect(limits.campaigns.limit).toBe(200);
|
||||
expect(limits.campaigns.usage).toBe(2);
|
||||
expect(limits.campaigns.percentage).toBe(1);
|
||||
|
||||
expect(limits.transactional.limit).toBeNull();
|
||||
expect(limits.transactional.usage).toBe(0);
|
||||
expect(limits.transactional.percentage).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Race Condition Behavior', () => {
|
||||
it('should check limits before each email send (may allow some concurrent sends)', async () => {
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 10},
|
||||
});
|
||||
|
||||
// Create 8 existing emails (80% of limit)
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId,
|
||||
sourceType: EmailSourceType.CAMPAIGN,
|
||||
});
|
||||
}
|
||||
|
||||
await BillingLimitService.invalidateCache(projectId);
|
||||
|
||||
// Try to send 5 emails simultaneously
|
||||
const promises = Array.from({length: 5}, () =>
|
||||
EmailService.sendCampaignEmail({
|
||||
projectId,
|
||||
contactId,
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
from: 'test@example.com',
|
||||
}),
|
||||
);
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
const successful = results.filter(r => r.status === 'fulfilled').length;
|
||||
|
||||
// Note: Due to race conditions, multiple may succeed before limit is enforced
|
||||
// The limit check happens at send time, not atomically
|
||||
// At least some should succeed (we're under limit when we start)
|
||||
expect(successful).toBeGreaterThan(0);
|
||||
|
||||
// Eventually, some should fail once limit is hit
|
||||
// Total emails created should not greatly exceed limit
|
||||
const totalEmails = await prisma.email.count({
|
||||
where: {projectId, sourceType: EmailSourceType.CAMPAIGN},
|
||||
});
|
||||
|
||||
// Should be close to limit (8 existing + some new <= ~13 due to races)
|
||||
expect(totalEmails).toBeLessThanOrEqual(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Free Tier Total Limit (1000 emails/month across all types)', () => {
|
||||
it('should enforce 1000 email total limit for free tier projects', async () => {
|
||||
// Create a contact for this test
|
||||
const contact = await factories.createContact({projectId});
|
||||
|
||||
// Free tier project has no subscription and no custom limits set
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {
|
||||
billingLimitWorkflows: null,
|
||||
billingLimitCampaigns: null,
|
||||
billingLimitTransactional: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Create 1000 emails total across different types
|
||||
for (let i = 0; i < 300; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: EmailSourceType.WORKFLOW,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 400; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: EmailSourceType.CAMPAIGN,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 300; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||
});
|
||||
}
|
||||
|
||||
await BillingLimitService.invalidateCache(projectId);
|
||||
|
||||
// All three types should now be blocked since total is 1000
|
||||
await expect(
|
||||
EmailService.sendWorkflowEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
from: 'test@example.com',
|
||||
}),
|
||||
).rejects.toThrow(/free tier limit/i);
|
||||
|
||||
await expect(
|
||||
EmailService.sendCampaignEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
from: 'test@example.com',
|
||||
}),
|
||||
).rejects.toThrow(/free tier limit/i);
|
||||
|
||||
await expect(
|
||||
EmailService.sendTransactionalEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
from: 'test@example.com',
|
||||
}),
|
||||
).rejects.toThrow(/free tier limit/i);
|
||||
});
|
||||
|
||||
it('should count all email types towards free tier total limit', async () => {
|
||||
// Create a contact for this test
|
||||
const contact = await factories.createContact({projectId});
|
||||
|
||||
// Free tier project
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {
|
||||
billingLimitWorkflows: null,
|
||||
billingLimitCampaigns: null,
|
||||
billingLimitTransactional: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Create 200 of each type (600 total)
|
||||
for (let i = 0; i < 200; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: EmailSourceType.WORKFLOW,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 200; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: EmailSourceType.CAMPAIGN,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 200; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||
});
|
||||
}
|
||||
|
||||
await BillingLimitService.invalidateCache(projectId);
|
||||
|
||||
// Check limits for each type - all should report 600 total usage
|
||||
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
|
||||
const campaignCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
||||
const transactionalCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.TRANSACTIONAL);
|
||||
|
||||
// All types should see the same total usage (600) and same limit (1000)
|
||||
expect(workflowCheck.usage).toBe(600);
|
||||
expect(workflowCheck.limit).toBe(1000);
|
||||
expect(workflowCheck.allowed).toBe(true);
|
||||
expect(workflowCheck.percentage).toBe(60);
|
||||
|
||||
expect(campaignCheck.usage).toBe(600);
|
||||
expect(campaignCheck.limit).toBe(1000);
|
||||
expect(campaignCheck.allowed).toBe(true);
|
||||
|
||||
expect(transactionalCheck.usage).toBe(600);
|
||||
expect(transactionalCheck.limit).toBe(1000);
|
||||
expect(transactionalCheck.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should show warning at 80% of free tier total limit (800 emails)', async () => {
|
||||
// Create a contact for this test
|
||||
const contact = await factories.createContact({projectId});
|
||||
|
||||
// Free tier project
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {
|
||||
billingLimitWorkflows: null,
|
||||
billingLimitCampaigns: null,
|
||||
billingLimitTransactional: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Create 800 emails spread across types
|
||||
for (let i = 0; i < 300; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: EmailSourceType.WORKFLOW,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 250; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: EmailSourceType.CAMPAIGN,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 250; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||
});
|
||||
}
|
||||
|
||||
await BillingLimitService.invalidateCache(projectId);
|
||||
|
||||
// All types should show warning
|
||||
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
|
||||
expect(workflowCheck.allowed).toBe(true);
|
||||
expect(workflowCheck.warning).toBe(true);
|
||||
expect(workflowCheck.usage).toBe(800);
|
||||
expect(workflowCheck.percentage).toBeGreaterThanOrEqual(80);
|
||||
expect(workflowCheck.message).toMatch(/warning/i);
|
||||
});
|
||||
|
||||
it('should allow free tier to send different distribution of email types', async () => {
|
||||
// Create a contact for this test
|
||||
const contact = await factories.createContact({projectId});
|
||||
|
||||
// Free tier project
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {
|
||||
billingLimitWorkflows: null,
|
||||
billingLimitCampaigns: null,
|
||||
billingLimitTransactional: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Create 900 transactional, 50 campaigns, 49 workflows (999 total - under limit)
|
||||
for (let i = 0; i < 900; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: EmailSourceType.CAMPAIGN,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 49; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: EmailSourceType.WORKFLOW,
|
||||
});
|
||||
}
|
||||
|
||||
await BillingLimitService.invalidateCache(projectId);
|
||||
|
||||
// Should still allow one more email of any type
|
||||
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
|
||||
expect(workflowCheck.allowed).toBe(true);
|
||||
expect(workflowCheck.usage).toBe(999);
|
||||
|
||||
// Send the 1000th email (should succeed)
|
||||
await EmailService.sendWorkflowEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
subject: 'Test',
|
||||
body: 'Test',
|
||||
from: 'test@example.com',
|
||||
});
|
||||
|
||||
await BillingLimitService.invalidateCache(projectId);
|
||||
|
||||
// Now all types should be blocked
|
||||
const campaignCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
||||
expect(campaignCheck.allowed).toBe(false);
|
||||
expect(campaignCheck.usage).toBe(1000);
|
||||
});
|
||||
|
||||
it('should distinguish free tier from paid tier with per-category limits', async () => {
|
||||
// Create a contact for this test
|
||||
const contact = await factories.createContact({projectId});
|
||||
|
||||
// Set custom per-category limits (paid tier behavior)
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {
|
||||
billingLimitWorkflows: 100,
|
||||
billingLimitCampaigns: 200,
|
||||
billingLimitTransactional: 300,
|
||||
},
|
||||
});
|
||||
|
||||
// Create 150 workflow emails (exceeds workflow limit but under total)
|
||||
for (let i = 0; i < 150; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: EmailSourceType.WORKFLOW,
|
||||
});
|
||||
}
|
||||
|
||||
await BillingLimitService.invalidateCache(projectId);
|
||||
|
||||
// Workflow should be blocked (150 > 100)
|
||||
const workflowCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.WORKFLOW);
|
||||
expect(workflowCheck.allowed).toBe(false);
|
||||
expect(workflowCheck.usage).toBe(150);
|
||||
expect(workflowCheck.limit).toBe(100);
|
||||
|
||||
// But campaigns should still be allowed (independent limit)
|
||||
const campaignCheck = await BillingLimitService.checkLimit(projectId, EmailSourceType.CAMPAIGN);
|
||||
expect(campaignCheck.allowed).toBe(true);
|
||||
expect(campaignCheck.usage).toBe(0);
|
||||
expect(campaignCheck.limit).toBe(200);
|
||||
});
|
||||
|
||||
it('should show shared limit for all categories in getLimitsAndUsage for free tier', async () => {
|
||||
// Create a contact for this test
|
||||
const contact = await factories.createContact({projectId});
|
||||
|
||||
// Free tier project
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {
|
||||
billingLimitWorkflows: null,
|
||||
billingLimitCampaigns: null,
|
||||
billingLimitTransactional: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Create various emails
|
||||
for (let i = 0; i < 100; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: EmailSourceType.WORKFLOW,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 200; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: EmailSourceType.CAMPAIGN,
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < 150; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||
});
|
||||
}
|
||||
|
||||
await BillingLimitService.invalidateCache(projectId);
|
||||
|
||||
const limits = await BillingLimitService.getLimitsAndUsage(projectId);
|
||||
|
||||
// All categories should show the same total usage (450) and limit (1000)
|
||||
expect(limits.workflows.limit).toBe(1000);
|
||||
expect(limits.workflows.usage).toBe(450);
|
||||
expect(limits.workflows.percentage).toBe(45);
|
||||
|
||||
expect(limits.campaigns.limit).toBe(1000);
|
||||
expect(limits.campaigns.usage).toBe(450);
|
||||
|
||||
expect(limits.transactional.limit).toBe(1000);
|
||||
expect(limits.transactional.usage).toBe(450);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,592 @@
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
||||
import {CampaignService} from '../CampaignService';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
// Mock STRIPE_ENABLED for billing limit tests
|
||||
vi.mock('../../app/constants.js', async () => {
|
||||
const actual = await vi.importActual('../../app/constants.js');
|
||||
return {
|
||||
...actual,
|
||||
STRIPE_ENABLED: true,
|
||||
STRIPE_SK: 'sk_test_mock_key_for_testing',
|
||||
};
|
||||
});
|
||||
|
||||
describe('CampaignService', () => {
|
||||
let projectId: string;
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
beforeEach(async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
projectId = project.id;
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a campaign with ALL audience type', async () => {
|
||||
const campaign = await CampaignService.create(projectId, {
|
||||
name: 'Test Campaign',
|
||||
subject: 'Test Subject',
|
||||
body: '<p>Test Body</p>',
|
||||
from: 'test@example.com',
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
});
|
||||
|
||||
expect(campaign).toBeDefined();
|
||||
expect(campaign.name).toBe('Test Campaign');
|
||||
expect(campaign.status).toBe(CampaignStatus.DRAFT);
|
||||
expect(campaign.audienceType).toBe(CampaignAudienceType.ALL);
|
||||
});
|
||||
|
||||
it('should create a campaign with SEGMENT audience type', async () => {
|
||||
const segment = await factories.createSegment(projectId, {name: 'VIP Users'});
|
||||
|
||||
const campaign = await CampaignService.create(projectId, {
|
||||
name: 'VIP Campaign',
|
||||
subject: 'Exclusive Offer',
|
||||
body: '<p>For VIP users only</p>',
|
||||
from: 'vip@example.com',
|
||||
audienceType: CampaignAudienceType.SEGMENT,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
|
||||
expect(campaign.segmentId).toBe(segment.id);
|
||||
});
|
||||
|
||||
it('should throw error when creating SEGMENT campaign without segmentId', async () => {
|
||||
await expect(
|
||||
CampaignService.create(projectId, {
|
||||
name: 'Invalid Campaign',
|
||||
subject: 'Test',
|
||||
body: '<p>Test</p>',
|
||||
from: 'test@example.com',
|
||||
audienceType: CampaignAudienceType.SEGMENT,
|
||||
}),
|
||||
).rejects.toThrow('Segment ID is required');
|
||||
});
|
||||
|
||||
it('should throw error when segment does not exist', async () => {
|
||||
await expect(
|
||||
CampaignService.create(projectId, {
|
||||
name: 'Invalid Campaign',
|
||||
subject: 'Test',
|
||||
body: '<p>Test</p>',
|
||||
from: 'test@example.com',
|
||||
audienceType: CampaignAudienceType.SEGMENT,
|
||||
segmentId: 'non-existent-segment',
|
||||
}),
|
||||
).rejects.toThrow('Segment not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update a draft campaign', async () => {
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
name: 'Original Name',
|
||||
status: CampaignStatus.DRAFT,
|
||||
});
|
||||
|
||||
const updated = await CampaignService.update(projectId, campaign.id, {
|
||||
name: 'Updated Name',
|
||||
subject: 'Updated Subject',
|
||||
});
|
||||
|
||||
expect(updated.name).toBe('Updated Name');
|
||||
expect(updated.subject).toBe('Updated Subject');
|
||||
});
|
||||
|
||||
it('should throw error when updating non-draft campaign', async () => {
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.SENT,
|
||||
});
|
||||
|
||||
await expect(CampaignService.update(projectId, campaign.id, {name: 'New Name'})).rejects.toThrow(
|
||||
'Cannot update campaign that is sending or has been sent',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete a draft campaign', async () => {
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
});
|
||||
|
||||
await CampaignService.delete(projectId, campaign.id);
|
||||
|
||||
const deleted = await prisma.campaign.findUnique({where: {id: campaign.id}});
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
|
||||
it('should not delete a non-draft campaign', async () => {
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.SENT,
|
||||
});
|
||||
|
||||
await expect(CampaignService.delete(projectId, campaign.id)).rejects.toThrow('Can only delete draft campaigns');
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicate', () => {
|
||||
it('should duplicate a campaign with (Copy) suffix', async () => {
|
||||
const original = await factories.createCampaign({
|
||||
projectId,
|
||||
name: 'Original Campaign',
|
||||
});
|
||||
|
||||
const duplicate = await CampaignService.duplicate(projectId, original.id);
|
||||
|
||||
expect(duplicate.name).toBe('Original Campaign (Copy)');
|
||||
expect(duplicate.subject).toBe(original.subject);
|
||||
expect(duplicate.body).toBe(original.body);
|
||||
expect(duplicate.status).toBe(CampaignStatus.DRAFT);
|
||||
expect(duplicate.id).not.toBe(original.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('should list campaigns with pagination', async () => {
|
||||
// Create 25 campaigns using bulk insert to avoid memory issues
|
||||
const campaignData = Array.from({length: 25}, (_, i) => ({
|
||||
projectId,
|
||||
name: `Campaign ${i}`,
|
||||
subject: 'Test Subject',
|
||||
body: '<p>Test Body</p>',
|
||||
from: 'test@example.com',
|
||||
status: 'DRAFT' as const,
|
||||
}));
|
||||
|
||||
await prisma.campaign.createMany({data: campaignData});
|
||||
|
||||
const result = await CampaignService.list(projectId, {page: 1, pageSize: 10});
|
||||
|
||||
expect(result.data).toHaveLength(10);
|
||||
expect(result.total).toBe(25);
|
||||
expect(result.totalPages).toBe(3);
|
||||
expect(result.page).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter campaigns by status', async () => {
|
||||
await factories.createCampaign({projectId, status: CampaignStatus.DRAFT});
|
||||
await factories.createCampaign({projectId, status: CampaignStatus.DRAFT});
|
||||
await factories.createCampaign({projectId, status: CampaignStatus.SENT});
|
||||
|
||||
const result = await CampaignService.list(projectId, {status: CampaignStatus.DRAFT});
|
||||
|
||||
expect(result.data).toHaveLength(2);
|
||||
expect(result.data.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should get a campaign by id', async () => {
|
||||
const campaign = await factories.createCampaign({projectId, name: 'Test Campaign'});
|
||||
|
||||
const retrieved = await CampaignService.get(projectId, campaign.id);
|
||||
|
||||
expect(retrieved.id).toBe(campaign.id);
|
||||
expect(retrieved.name).toBe('Test Campaign');
|
||||
});
|
||||
|
||||
it('should throw error when campaign does not exist', async () => {
|
||||
await expect(CampaignService.get(projectId, 'non-existent-id')).rejects.toThrow('Campaign not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Campaign + Segment Integration', () => {
|
||||
it('should create campaign targeting a segment', async () => {
|
||||
const segment = await factories.createSegment(projectId, {
|
||||
name: 'VIP Users',
|
||||
filters: [{field: 'data.vip', operator: 'equals', value: true}],
|
||||
});
|
||||
|
||||
const campaign = await CampaignService.create(projectId, {
|
||||
name: 'VIP Campaign',
|
||||
subject: 'Exclusive Offer',
|
||||
body: '<p>For VIP users only</p>',
|
||||
from: 'vip@example.com',
|
||||
audienceType: CampaignAudienceType.SEGMENT,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
|
||||
expect(campaign.audienceType).toBe(CampaignAudienceType.SEGMENT);
|
||||
expect(campaign.segmentId).toBe(segment.id);
|
||||
});
|
||||
|
||||
it('should only send to contacts matching segment criteria', async () => {
|
||||
// Create contacts - some match segment, some don't
|
||||
const vipContact1 = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
data: {vip: true},
|
||||
});
|
||||
const vipContact2 = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
data: {vip: true},
|
||||
});
|
||||
const regularContact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
data: {vip: false},
|
||||
});
|
||||
|
||||
const segment = await factories.createSegment(projectId, {
|
||||
name: 'VIP Users',
|
||||
filters: [{field: 'data.vip', operator: 'equals', value: true}],
|
||||
});
|
||||
|
||||
const _campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
audienceType: CampaignAudienceType.SEGMENT,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
|
||||
// In a real scenario, the campaign processor would create emails
|
||||
// For this test, we manually check which contacts match the segment filters
|
||||
const allContacts = await prisma.contact.findMany({
|
||||
where: {projectId},
|
||||
});
|
||||
const contacts = allContacts.filter(c => (c.data as Record<string, unknown>)?.vip === true);
|
||||
|
||||
// Should only include VIP contacts
|
||||
expect(contacts).toHaveLength(2);
|
||||
const contactIds = contacts.map(c => c.id);
|
||||
expect(contactIds).toContain(vipContact1.id);
|
||||
expect(contactIds).toContain(vipContact2.id);
|
||||
expect(contactIds).not.toContain(regularContact.id);
|
||||
});
|
||||
|
||||
it('should exclude unsubscribed contacts from segment campaigns', async () => {
|
||||
const subscribedVip = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
data: {vip: true},
|
||||
});
|
||||
const _unsubscribedVip = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: false,
|
||||
data: {vip: true},
|
||||
});
|
||||
|
||||
// Segment that requires BOTH vip AND subscribed
|
||||
const segment = await factories.createSegment(projectId, {
|
||||
name: 'Subscribed VIP Users',
|
||||
filters: [
|
||||
{field: 'data.vip', operator: 'equals', value: true},
|
||||
{field: 'subscribed', operator: 'equals', value: true},
|
||||
],
|
||||
});
|
||||
|
||||
const _campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
audienceType: CampaignAudienceType.SEGMENT,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
|
||||
// Verify only subscribed VIP is targeted
|
||||
const allContacts = await prisma.contact.findMany({
|
||||
where: {projectId},
|
||||
});
|
||||
const matching = allContacts.filter(
|
||||
c => (c.data as Record<string, unknown>)?.vip === true && c.subscribed === true,
|
||||
);
|
||||
|
||||
expect(matching).toHaveLength(1);
|
||||
expect(matching[0].id).toBe(subscribedVip.id);
|
||||
});
|
||||
|
||||
it('should handle campaigns for ALL audience type', async () => {
|
||||
// Create mix of contacts
|
||||
await factories.createContact({projectId, subscribed: true});
|
||||
await factories.createContact({projectId, subscribed: true});
|
||||
await factories.createContact({projectId, subscribed: false}); // Should be excluded
|
||||
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
});
|
||||
|
||||
expect(campaign.audienceType).toBe(CampaignAudienceType.ALL);
|
||||
expect(campaign.segmentId).toBeNull();
|
||||
|
||||
// Verify ALL campaigns should target subscribed contacts only
|
||||
const subscribedContacts = await prisma.contact.findMany({
|
||||
where: {projectId, subscribed: true},
|
||||
});
|
||||
|
||||
expect(subscribedContacts).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Campaign Audience Validation', () => {
|
||||
it('should calculate correct recipient count for segment campaigns', async () => {
|
||||
// Create 5 contacts matching segment
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
data: {plan: 'pro'},
|
||||
});
|
||||
}
|
||||
|
||||
// Create 3 contacts not matching
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
data: {plan: 'free'},
|
||||
});
|
||||
}
|
||||
|
||||
const segment = await factories.createSegment(projectId, {
|
||||
filters: [{field: 'data.plan', operator: 'equals', value: 'pro'}],
|
||||
});
|
||||
|
||||
await factories.createCampaign({
|
||||
projectId,
|
||||
audienceType: CampaignAudienceType.SEGMENT,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
|
||||
const matching = await prisma.contact.count({
|
||||
where: {
|
||||
projectId,
|
||||
data: {
|
||||
path: ['plan'],
|
||||
equals: 'pro',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(matching).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('send', () => {
|
||||
it('should throw error when campaign has no recipients', async () => {
|
||||
// Create campaign with no contacts in project
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
});
|
||||
|
||||
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow('Campaign has no recipients');
|
||||
});
|
||||
|
||||
it('should send campaign successfully when recipients are under billing limit', async () => {
|
||||
// Set billing limit for campaigns
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 100},
|
||||
});
|
||||
|
||||
// Create 5 subscribed contacts
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Create campaign targeting all contacts
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
});
|
||||
|
||||
// Should send successfully (5 recipients < 100 limit)
|
||||
const sentCampaign = await CampaignService.send(projectId, campaign.id);
|
||||
|
||||
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
|
||||
expect(sentCampaign.totalRecipients).toBe(5);
|
||||
});
|
||||
|
||||
it('should throw 403 error when campaign would exceed billing limit', async () => {
|
||||
// Set billing limit for campaigns to 10
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 10},
|
||||
});
|
||||
|
||||
// Create 5 existing campaign emails (usage = 5)
|
||||
const contact = await factories.createContact({projectId, subscribed: true});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: 'CAMPAIGN',
|
||||
});
|
||||
}
|
||||
|
||||
// Create campaign with 10 subscribed contacts (would result in 15 total)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
});
|
||||
|
||||
// Should throw error because 5 + 11 = 16 > 10 limit (11 contacts: 1 from email creation + 10 new)
|
||||
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow(/exceed billing limit/i);
|
||||
});
|
||||
|
||||
it('should throw 403 error when scheduled campaign would exceed billing limit', async () => {
|
||||
// Set billing limit for campaigns to 20
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 20},
|
||||
});
|
||||
|
||||
// Create 15 existing campaign emails
|
||||
const contact = await factories.createContact({projectId, subscribed: true});
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: 'CAMPAIGN',
|
||||
});
|
||||
}
|
||||
|
||||
// Create campaign with 10 subscribed contacts (would result in 25 total)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
});
|
||||
|
||||
const scheduledFor = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
|
||||
|
||||
// Should throw error when scheduling because 15 + 10 = 25 > 20 limit
|
||||
await expect(CampaignService.send(projectId, campaign.id, scheduledFor)).rejects.toThrow(
|
||||
/Cannot schedule campaign.*exceed billing limit/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should send campaign successfully when billing limit is null (unlimited)', async () => {
|
||||
// Set billing limit to null (unlimited)
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: null},
|
||||
});
|
||||
|
||||
// Create 1000 subscribed contacts
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
});
|
||||
|
||||
// Should send successfully (no limit)
|
||||
const sentCampaign = await CampaignService.send(projectId, campaign.id);
|
||||
|
||||
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
|
||||
expect(sentCampaign.totalRecipients).toBe(1000);
|
||||
});
|
||||
|
||||
it('should allow campaign that exactly reaches billing limit', async () => {
|
||||
// Set billing limit for campaigns to 10
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 10},
|
||||
});
|
||||
|
||||
// Create 5 existing campaign emails
|
||||
const contact = await factories.createContact({projectId, subscribed: true});
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await factories.createEmail({
|
||||
projectId,
|
||||
contactId: contact.id,
|
||||
sourceType: 'CAMPAIGN',
|
||||
});
|
||||
}
|
||||
|
||||
// Create 4 more subscribed contacts (total of 5 with the one above: 1 + 4 = 5)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
});
|
||||
|
||||
// Should send successfully because 5 + 5 = 10 (exactly at limit)
|
||||
const sentCampaign = await CampaignService.send(projectId, campaign.id);
|
||||
|
||||
expect(sentCampaign.status).toBe(CampaignStatus.SENDING);
|
||||
expect(sentCampaign.totalRecipients).toBe(5);
|
||||
});
|
||||
|
||||
it('should throw error when campaign has already been sent', async () => {
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.SENT,
|
||||
});
|
||||
|
||||
await expect(CampaignService.send(projectId, campaign.id)).rejects.toThrow(
|
||||
'Campaign has already been sent or is currently sending',
|
||||
);
|
||||
});
|
||||
|
||||
it('should schedule campaign successfully when recipients are under billing limit', async () => {
|
||||
// Set billing limit for campaigns
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {billingLimitCampaigns: 50},
|
||||
});
|
||||
|
||||
// Create 10 subscribed contacts
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const campaign = await factories.createCampaign({
|
||||
projectId,
|
||||
status: CampaignStatus.DRAFT,
|
||||
audienceType: CampaignAudienceType.ALL,
|
||||
});
|
||||
|
||||
const scheduledFor = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
|
||||
|
||||
// Should schedule successfully (10 recipients < 50 limit)
|
||||
const scheduledCampaign = await CampaignService.send(projectId, campaign.id, scheduledFor);
|
||||
|
||||
expect(scheduledCampaign.status).toBe(CampaignStatus.SCHEDULED);
|
||||
expect(scheduledCampaign.scheduledFor).toEqual(scheduledFor);
|
||||
expect(scheduledCampaign.totalRecipients).toBe(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,704 @@
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
import {ContactService} from '../ContactService';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
|
||||
describe('ContactService - Duplicate Prevention & Data Merging', () => {
|
||||
let projectId: string;
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
beforeEach(async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
projectId = project.id;
|
||||
});
|
||||
|
||||
describe('Duplicate Email Prevention', () => {
|
||||
it('should REJECT creating duplicate email in same project', async () => {
|
||||
const email = 'test@example.com';
|
||||
|
||||
await ContactService.create(projectId, {email});
|
||||
|
||||
await expect(ContactService.create(projectId, {email})).rejects.toThrow(/already exists/i);
|
||||
});
|
||||
|
||||
it('should ALLOW same email in different projects (multi-tenancy)', async () => {
|
||||
const {project: project1} = await factories.createUserWithProject();
|
||||
const {project: project2} = await factories.createUserWithProject();
|
||||
|
||||
const email = 'test@example.com';
|
||||
|
||||
const contact1 = await ContactService.create(project1.id, {email});
|
||||
const contact2 = await ContactService.create(project2.id, {email});
|
||||
|
||||
expect(contact1.id).not.toBe(contact2.id);
|
||||
expect(contact1.email).toBe(email);
|
||||
expect(contact2.email).toBe(email);
|
||||
});
|
||||
|
||||
it('should REJECT updating contact to duplicate email in same project', async () => {
|
||||
const email1 = 'user1@example.com';
|
||||
const email2 = 'user2@example.com';
|
||||
|
||||
await ContactService.create(projectId, {email: email1});
|
||||
const contact2 = await ContactService.create(projectId, {email: email2});
|
||||
|
||||
await expect(ContactService.update(projectId, contact2.id, {email: email1})).rejects.toThrow(/already exists/i);
|
||||
});
|
||||
|
||||
it('should ALLOW updating contact to same email (no-op)', async () => {
|
||||
const email = 'test@example.com';
|
||||
const contact = await ContactService.create(projectId, {email});
|
||||
|
||||
const updated = await ContactService.update(projectId, contact.id, {
|
||||
email,
|
||||
data: {firstName: 'John'},
|
||||
});
|
||||
|
||||
expect(updated.email).toBe(email);
|
||||
});
|
||||
|
||||
it('should handle race condition when creating same email simultaneously', async () => {
|
||||
const email = 'race@example.com';
|
||||
|
||||
const promises = Array.from({length: 10}, () => ContactService.create(projectId, {email}));
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
const successful = results.filter(r => r.status === 'fulfilled').length;
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
|
||||
expect(successful).toBe(1);
|
||||
expect(failed).toBe(9);
|
||||
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {projectId, email},
|
||||
});
|
||||
expect(contacts).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Upsert Data Merging Logic', () => {
|
||||
it('should merge new data with existing data without losing fields', async () => {
|
||||
const email = 'test@example.com';
|
||||
|
||||
const contact = await ContactService.upsert(projectId, email, {
|
||||
firstName: 'John',
|
||||
plan: 'free',
|
||||
signupDate: '2024-01-01',
|
||||
});
|
||||
|
||||
expect(contact.data).toMatchObject({
|
||||
firstName: 'John',
|
||||
plan: 'free',
|
||||
signupDate: '2024-01-01',
|
||||
});
|
||||
|
||||
const updated = await ContactService.upsert(projectId, email, {
|
||||
lastName: 'Doe',
|
||||
company: 'Acme Inc',
|
||||
});
|
||||
|
||||
expect(updated.data).toMatchObject({
|
||||
firstName: 'John',
|
||||
plan: 'free',
|
||||
signupDate: '2024-01-01',
|
||||
lastName: 'Doe',
|
||||
company: 'Acme Inc',
|
||||
});
|
||||
});
|
||||
|
||||
it('should overwrite existing fields with new values', async () => {
|
||||
const email = 'test@example.com';
|
||||
|
||||
await ContactService.upsert(projectId, email, {
|
||||
plan: 'free',
|
||||
credits: 100,
|
||||
});
|
||||
|
||||
const updated = await ContactService.upsert(projectId, email, {
|
||||
plan: 'pro',
|
||||
credits: 1000,
|
||||
});
|
||||
|
||||
expect(updated.data).toMatchObject({
|
||||
plan: 'pro',
|
||||
credits: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT persist non-persistent data', async () => {
|
||||
const email = 'test@example.com';
|
||||
|
||||
const contact = await ContactService.upsert(projectId, email, {
|
||||
firstName: 'John',
|
||||
tempToken: {value: 'abc123', persistent: false},
|
||||
oneTimeCode: {value: '123456', persistent: false},
|
||||
});
|
||||
|
||||
expect(contact.data).toHaveProperty('firstName', 'John');
|
||||
expect(contact.data).not.toHaveProperty('tempToken');
|
||||
expect(contact.data).not.toHaveProperty('oneTimeCode');
|
||||
});
|
||||
|
||||
it('should ignore reserved fields (plunk_id, plunk_email)', async () => {
|
||||
const email = 'test@example.com';
|
||||
|
||||
const contact = await ContactService.upsert(projectId, email, {
|
||||
firstName: 'John',
|
||||
plunk_id: 'malicious-id',
|
||||
plunk_email: 'hacker@evil.com',
|
||||
});
|
||||
|
||||
expect(contact.data).toHaveProperty('firstName', 'John');
|
||||
expect(contact.data).not.toHaveProperty('plunk_id');
|
||||
expect(contact.data).not.toHaveProperty('plunk_email');
|
||||
});
|
||||
|
||||
it('should handle null data gracefully', async () => {
|
||||
const email = 'test@example.com';
|
||||
|
||||
const contact = await ContactService.upsert(projectId, email, undefined);
|
||||
|
||||
expect(contact.email).toBe(email);
|
||||
});
|
||||
|
||||
it('should handle empty object data', async () => {
|
||||
const email = 'test@example.com';
|
||||
|
||||
const contact = await ContactService.upsert(projectId, email, {});
|
||||
|
||||
expect(contact.email).toBe(email);
|
||||
});
|
||||
|
||||
it('should update subscription status independently of data', async () => {
|
||||
const email = 'test@example.com';
|
||||
|
||||
await ContactService.upsert(projectId, email, {firstName: 'John'}, true);
|
||||
|
||||
const subscribed = await prisma.contact.findFirst({
|
||||
where: {projectId, email},
|
||||
});
|
||||
expect(subscribed?.subscribed).toBe(true);
|
||||
|
||||
await ContactService.upsert(projectId, email, {lastName: 'Doe'}, false);
|
||||
|
||||
const unsubscribed = await prisma.contact.findFirst({
|
||||
where: {projectId, email},
|
||||
});
|
||||
expect(unsubscribed?.subscribed).toBe(false);
|
||||
expect(unsubscribed?.data).toMatchObject({
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMergedData - Template Rendering', () => {
|
||||
it('should include reserved plunk_id and plunk_email fields', async () => {
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
const merged = ContactService.getMergedData(contact);
|
||||
|
||||
expect(merged.plunk_id).toBe(contact.id);
|
||||
expect(merged.plunk_email).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should merge persistent contact data', async () => {
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
data: {firstName: 'John', plan: 'pro'},
|
||||
});
|
||||
|
||||
const merged = ContactService.getMergedData(contact);
|
||||
|
||||
expect(merged.firstName).toBe('John');
|
||||
expect(merged.plan).toBe('pro');
|
||||
});
|
||||
|
||||
it('should merge temporary (non-persistent) data for rendering', async () => {
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
data: {firstName: 'John'},
|
||||
});
|
||||
|
||||
const temporaryData = {
|
||||
resetToken: {value: 'temp123', persistent: false},
|
||||
resetUrl: {value: 'https://app.com/reset?token=temp123', persistent: false},
|
||||
};
|
||||
|
||||
const merged = ContactService.getMergedData(contact, temporaryData);
|
||||
|
||||
expect(merged.firstName).toBe('John');
|
||||
expect(merged.resetToken).toBe('temp123');
|
||||
expect(merged.resetUrl).toBe('https://app.com/reset?token=temp123');
|
||||
});
|
||||
|
||||
it('should override persistent data with temporary data', async () => {
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
data: {name: 'Stored Name'},
|
||||
});
|
||||
|
||||
const temporaryData = {
|
||||
name: 'Override Name',
|
||||
};
|
||||
|
||||
const merged = ContactService.getMergedData(contact, temporaryData);
|
||||
|
||||
expect(merged.name).toBe('Override Name');
|
||||
});
|
||||
|
||||
it('should not allow overriding reserved fields via temporary data', async () => {
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
email: 'real@example.com',
|
||||
});
|
||||
|
||||
const temporaryData = {
|
||||
plunk_id: 'fake-id',
|
||||
plunk_email: 'fake@example.com',
|
||||
};
|
||||
|
||||
const merged = ContactService.getMergedData(contact, temporaryData);
|
||||
|
||||
expect(merged.plunk_id).toBe(contact.id);
|
||||
expect(merged.plunk_email).toBe('real@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data Integrity Edge Cases', () => {
|
||||
it('should handle deeply nested object data', async () => {
|
||||
const email = 'test@example.com';
|
||||
|
||||
const contact = await ContactService.upsert(projectId, email, {
|
||||
profile: {
|
||||
name: 'John',
|
||||
address: {
|
||||
city: 'NYC',
|
||||
zip: '10001',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(contact.data).toMatchObject({
|
||||
profile: {
|
||||
name: 'John',
|
||||
address: {
|
||||
city: 'NYC',
|
||||
zip: '10001',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle array data', async () => {
|
||||
const email = 'test@example.com';
|
||||
|
||||
const contact = await ContactService.upsert(projectId, email, {
|
||||
tags: ['vip', 'beta-tester', 'early-adopter'],
|
||||
});
|
||||
|
||||
expect(contact.data).toMatchObject({
|
||||
tags: ['vip', 'beta-tester', 'early-adopter'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle special characters in field names', async () => {
|
||||
const email = 'test@example.com';
|
||||
|
||||
const contact = await ContactService.upsert(projectId, email, {
|
||||
'custom-field': 'value',
|
||||
'field.with.dots': 'value2',
|
||||
'field with spaces': 'value3',
|
||||
});
|
||||
|
||||
expect(contact.data).toHaveProperty('custom-field', 'value');
|
||||
expect(contact.data).toHaveProperty('field.with.dots', 'value2');
|
||||
expect(contact.data).toHaveProperty('field with spaces', 'value3');
|
||||
});
|
||||
|
||||
it('should handle boolean, number, and string values', async () => {
|
||||
const email = 'test@example.com';
|
||||
|
||||
const contact = await ContactService.upsert(projectId, email, {
|
||||
isPremium: true,
|
||||
credits: 100,
|
||||
name: 'John Doe',
|
||||
discount: 0.15,
|
||||
});
|
||||
|
||||
expect(contact.data).toMatchObject({
|
||||
isPremium: true,
|
||||
credits: 100,
|
||||
name: 'John Doe',
|
||||
discount: 0.15,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null values in data', async () => {
|
||||
const email = 'test@example.com';
|
||||
|
||||
const contact = await ContactService.upsert(projectId, email, {
|
||||
firstName: 'John',
|
||||
middleName: null,
|
||||
lastName: 'Doe',
|
||||
});
|
||||
|
||||
expect(contact.data).toHaveProperty('firstName', 'John');
|
||||
expect(contact.data).toHaveProperty('middleName', null);
|
||||
expect(contact.data).toHaveProperty('lastName', 'Doe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Contact CRUD Operations', () => {
|
||||
it('should find contact by email', async () => {
|
||||
const email = 'find@example.com';
|
||||
const created = await ContactService.create(projectId, {email});
|
||||
|
||||
const found = await ContactService.findByEmail(projectId, email);
|
||||
|
||||
expect(found?.id).toBe(created.id);
|
||||
expect(found?.email).toBe(email);
|
||||
});
|
||||
|
||||
it('should return null when contact not found by email', async () => {
|
||||
const found = await ContactService.findByEmail(projectId, 'nonexistent@example.com');
|
||||
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('should get contact count for project', async () => {
|
||||
await factories.createContact({projectId});
|
||||
await factories.createContact({projectId});
|
||||
await factories.createContact({projectId});
|
||||
|
||||
const count = await ContactService.count(projectId);
|
||||
|
||||
expect(count).toBe(3);
|
||||
});
|
||||
|
||||
it('should delete contact', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
|
||||
await ContactService.delete(projectId, contact.id);
|
||||
|
||||
const deleted = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw 404 when deleting non-existent contact', async () => {
|
||||
await expect(ContactService.delete(projectId, 'non-existent')).rejects.toThrow(/not found/i);
|
||||
});
|
||||
|
||||
it('should throw 404 when getting non-existent contact', async () => {
|
||||
await expect(ContactService.get(projectId, 'non-existent')).rejects.toThrow(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Public Contact Operations (Unsubscribe)', () => {
|
||||
it('should get contact by ID without project authentication', async () => {
|
||||
const contact = await factories.createContact({projectId});
|
||||
|
||||
const fetched = await ContactService.getById(contact.id);
|
||||
|
||||
expect(fetched.id).toBe(contact.id);
|
||||
expect(fetched.email).toBe(contact.email);
|
||||
});
|
||||
|
||||
it('should subscribe contact', async () => {
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: false,
|
||||
});
|
||||
|
||||
await ContactService.subscribe(contact.id);
|
||||
|
||||
const subscribed = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
expect(subscribed?.subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should unsubscribe contact', async () => {
|
||||
const contact = await factories.createContact({
|
||||
projectId,
|
||||
subscribed: true,
|
||||
});
|
||||
|
||||
await ContactService.unsubscribe(contact.id);
|
||||
|
||||
const unsubscribed = await prisma.contact.findUnique({
|
||||
where: {id: contact.id},
|
||||
});
|
||||
|
||||
expect(unsubscribed?.subscribed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bulk Contact Operations', () => {
|
||||
describe('bulkSubscribe', () => {
|
||||
it('should subscribe multiple unsubscribed contacts', async () => {
|
||||
const contact1 = await factories.createContact({projectId, subscribed: false});
|
||||
const contact2 = await factories.createContact({projectId, subscribed: false});
|
||||
const contact3 = await factories.createContact({projectId, subscribed: false});
|
||||
|
||||
const result = await ContactService.bulkSubscribe(projectId, [contact1.id, contact2.id, contact3.id]);
|
||||
|
||||
expect(result.updated).toBe(3);
|
||||
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
|
||||
});
|
||||
|
||||
expect(contacts.every(c => c.subscribed)).toBe(true);
|
||||
});
|
||||
|
||||
it('should only update unsubscribed contacts, not already subscribed ones', async () => {
|
||||
const unsubscribed1 = await factories.createContact({projectId, subscribed: false});
|
||||
const unsubscribed2 = await factories.createContact({projectId, subscribed: false});
|
||||
const alreadySubscribed = await factories.createContact({projectId, subscribed: true});
|
||||
|
||||
const result = await ContactService.bulkSubscribe(projectId, [
|
||||
unsubscribed1.id,
|
||||
unsubscribed2.id,
|
||||
alreadySubscribed.id,
|
||||
]);
|
||||
|
||||
expect(result.updated).toBe(2);
|
||||
});
|
||||
|
||||
it('should return 0 if no contacts need updating', async () => {
|
||||
const contact1 = await factories.createContact({projectId, subscribed: true});
|
||||
const contact2 = await factories.createContact({projectId, subscribed: true});
|
||||
|
||||
const result = await ContactService.bulkSubscribe(projectId, [contact1.id, contact2.id]);
|
||||
|
||||
expect(result.updated).toBe(0);
|
||||
});
|
||||
|
||||
it('should only update contacts belonging to the specified project', async () => {
|
||||
const {project: otherProject} = await factories.createUserWithProject();
|
||||
const ownContact = await factories.createContact({projectId, subscribed: false});
|
||||
const otherContact = await factories.createContact({projectId: otherProject.id, subscribed: false});
|
||||
|
||||
const result = await ContactService.bulkSubscribe(projectId, [ownContact.id, otherContact.id]);
|
||||
|
||||
expect(result.updated).toBe(1);
|
||||
|
||||
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
|
||||
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
|
||||
|
||||
expect(ownContactAfter?.subscribed).toBe(true);
|
||||
expect(otherContactAfter?.subscribed).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle empty contact IDs array', async () => {
|
||||
const result = await ContactService.bulkSubscribe(projectId, []);
|
||||
|
||||
expect(result.updated).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle non-existent contact IDs gracefully', async () => {
|
||||
const result = await ContactService.bulkSubscribe(projectId, ['non-existent-1', 'non-existent-2']);
|
||||
|
||||
expect(result.updated).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle large batches efficiently', async () => {
|
||||
const contacts = await Promise.all(
|
||||
Array.from({length: 150}, () => factories.createContact({projectId, subscribed: false})),
|
||||
);
|
||||
const contactIds = contacts.map(c => c.id);
|
||||
|
||||
const result = await ContactService.bulkSubscribe(projectId, contactIds);
|
||||
|
||||
expect(result.updated).toBe(150);
|
||||
|
||||
const updatedContacts = await prisma.contact.findMany({
|
||||
where: {id: {in: contactIds}},
|
||||
});
|
||||
|
||||
expect(updatedContacts.every(c => c.subscribed)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkUnsubscribe', () => {
|
||||
it('should unsubscribe multiple subscribed contacts', async () => {
|
||||
const contact1 = await factories.createContact({projectId, subscribed: true});
|
||||
const contact2 = await factories.createContact({projectId, subscribed: true});
|
||||
const contact3 = await factories.createContact({projectId, subscribed: true});
|
||||
|
||||
const result = await ContactService.bulkUnsubscribe(projectId, [contact1.id, contact2.id, contact3.id]);
|
||||
|
||||
expect(result.updated).toBe(3);
|
||||
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
|
||||
});
|
||||
|
||||
expect(contacts.every(c => !c.subscribed)).toBe(true);
|
||||
});
|
||||
|
||||
it('should only update subscribed contacts, not already unsubscribed ones', async () => {
|
||||
const subscribed1 = await factories.createContact({projectId, subscribed: true});
|
||||
const subscribed2 = await factories.createContact({projectId, subscribed: true});
|
||||
const alreadyUnsubscribed = await factories.createContact({projectId, subscribed: false});
|
||||
|
||||
const result = await ContactService.bulkUnsubscribe(projectId, [
|
||||
subscribed1.id,
|
||||
subscribed2.id,
|
||||
alreadyUnsubscribed.id,
|
||||
]);
|
||||
|
||||
expect(result.updated).toBe(2);
|
||||
});
|
||||
|
||||
it('should return 0 if no contacts need updating', async () => {
|
||||
const contact1 = await factories.createContact({projectId, subscribed: false});
|
||||
const contact2 = await factories.createContact({projectId, subscribed: false});
|
||||
|
||||
const result = await ContactService.bulkUnsubscribe(projectId, [contact1.id, contact2.id]);
|
||||
|
||||
expect(result.updated).toBe(0);
|
||||
});
|
||||
|
||||
it('should only update contacts belonging to the specified project', async () => {
|
||||
const {project: otherProject} = await factories.createUserWithProject();
|
||||
const ownContact = await factories.createContact({projectId, subscribed: true});
|
||||
const otherContact = await factories.createContact({projectId: otherProject.id, subscribed: true});
|
||||
|
||||
const result = await ContactService.bulkUnsubscribe(projectId, [ownContact.id, otherContact.id]);
|
||||
|
||||
expect(result.updated).toBe(1);
|
||||
|
||||
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
|
||||
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
|
||||
|
||||
expect(ownContactAfter?.subscribed).toBe(false);
|
||||
expect(otherContactAfter?.subscribed).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle empty contact IDs array', async () => {
|
||||
const result = await ContactService.bulkUnsubscribe(projectId, []);
|
||||
|
||||
expect(result.updated).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle non-existent contact IDs gracefully', async () => {
|
||||
const result = await ContactService.bulkUnsubscribe(projectId, ['non-existent-1', 'non-existent-2']);
|
||||
|
||||
expect(result.updated).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkDelete', () => {
|
||||
it('should delete multiple contacts', async () => {
|
||||
const contact1 = await factories.createContact({projectId});
|
||||
const contact2 = await factories.createContact({projectId});
|
||||
const contact3 = await factories.createContact({projectId});
|
||||
|
||||
const result = await ContactService.bulkDelete(projectId, [contact1.id, contact2.id, contact3.id]);
|
||||
|
||||
expect(result.deleted).toBe(3);
|
||||
|
||||
const contacts = await prisma.contact.findMany({
|
||||
where: {id: {in: [contact1.id, contact2.id, contact3.id]}},
|
||||
});
|
||||
|
||||
expect(contacts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should only delete contacts belonging to the specified project', async () => {
|
||||
const {project: otherProject} = await factories.createUserWithProject();
|
||||
const ownContact = await factories.createContact({projectId});
|
||||
const otherContact = await factories.createContact({projectId: otherProject.id});
|
||||
|
||||
const result = await ContactService.bulkDelete(projectId, [ownContact.id, otherContact.id]);
|
||||
|
||||
expect(result.deleted).toBe(1);
|
||||
|
||||
const ownContactAfter = await prisma.contact.findUnique({where: {id: ownContact.id}});
|
||||
const otherContactAfter = await prisma.contact.findUnique({where: {id: otherContact.id}});
|
||||
|
||||
expect(ownContactAfter).toBeNull();
|
||||
expect(otherContactAfter).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty contact IDs array', async () => {
|
||||
const result = await ContactService.bulkDelete(projectId, []);
|
||||
|
||||
expect(result.deleted).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle non-existent contact IDs gracefully', async () => {
|
||||
const result = await ContactService.bulkDelete(projectId, ['non-existent-1', 'non-existent-2']);
|
||||
|
||||
expect(result.deleted).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle large batches efficiently', async () => {
|
||||
const contacts = await Promise.all(Array.from({length: 200}, () => factories.createContact({projectId})));
|
||||
const contactIds = contacts.map(c => c.id);
|
||||
|
||||
const result = await ContactService.bulkDelete(projectId, contactIds);
|
||||
|
||||
expect(result.deleted).toBe(200);
|
||||
|
||||
const remainingContacts = await prisma.contact.findMany({
|
||||
where: {id: {in: contactIds}},
|
||||
});
|
||||
|
||||
expect(remainingContacts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should delete both subscribed and unsubscribed contacts', async () => {
|
||||
const subscribed = await factories.createContact({projectId, subscribed: true});
|
||||
const unsubscribed = await factories.createContact({projectId, subscribed: false});
|
||||
|
||||
const result = await ContactService.bulkDelete(projectId, [subscribed.id, unsubscribed.id]);
|
||||
|
||||
expect(result.deleted).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle partial matches (some exist, some do not)', async () => {
|
||||
const existingContact = await factories.createContact({projectId});
|
||||
|
||||
const result = await ContactService.bulkDelete(projectId, [existingContact.id, 'non-existent-id']);
|
||||
|
||||
expect(result.deleted).toBe(1);
|
||||
|
||||
const contact = await prisma.contact.findUnique({where: {id: existingContact.id}});
|
||||
expect(contact).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bulk Operations - Project Isolation', () => {
|
||||
it('should never leak contacts between projects in bulk operations', async () => {
|
||||
const {project: project1} = await factories.createUserWithProject();
|
||||
const {project: project2} = await factories.createUserWithProject();
|
||||
|
||||
const p1Contact1 = await factories.createContact({projectId: project1.id, subscribed: false});
|
||||
const p1Contact2 = await factories.createContact({projectId: project1.id, subscribed: false});
|
||||
const p2Contact1 = await factories.createContact({projectId: project2.id, subscribed: false});
|
||||
const p2Contact2 = await factories.createContact({projectId: project2.id, subscribed: false});
|
||||
|
||||
await ContactService.bulkSubscribe(project1.id, [p1Contact1.id, p1Contact2.id, p2Contact1.id, p2Contact2.id]);
|
||||
|
||||
const p1ContactsAfter = await prisma.contact.findMany({
|
||||
where: {projectId: project1.id},
|
||||
});
|
||||
const p2ContactsAfter = await prisma.contact.findMany({
|
||||
where: {projectId: project2.id},
|
||||
});
|
||||
|
||||
expect(p1ContactsAfter.every(c => c.subscribed)).toBe(true);
|
||||
expect(p2ContactsAfter.every(c => !c.subscribed)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,546 @@
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||
import {DomainService} from '../DomainService.js';
|
||||
import {HttpException} from '../../exceptions/index.js';
|
||||
import * as SESService from '../SESService.js';
|
||||
|
||||
/**
|
||||
* Unit tests for DomainService
|
||||
* Focuses on business logic, validation, and edge cases
|
||||
*/
|
||||
describe('DomainService', () => {
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock SES service calls to avoid AWS API calls
|
||||
vi.spyOn(SESService, 'verifyDomain').mockResolvedValue(['token1', 'token2', 'token3']);
|
||||
vi.spyOn(SESService, 'getDomainVerificationAttributes').mockResolvedValue({
|
||||
status: 'Success',
|
||||
tokens: ['token1', 'token2', 'token3'],
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// ADD DOMAIN
|
||||
// ========================================
|
||||
describe('addDomain', () => {
|
||||
it('should add a domain and initiate verification', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
const domain = 'example.com';
|
||||
|
||||
const result = await DomainService.addDomain(project.id, domain);
|
||||
|
||||
expect(result.domain).toBe(domain);
|
||||
expect(result.projectId).toBe(project.id);
|
||||
expect(result.verified).toBe(false);
|
||||
expect(result.dkimTokens).toEqual(['token1', 'token2', 'token3']);
|
||||
expect(SESService.verifyDomain).toHaveBeenCalledWith(domain);
|
||||
});
|
||||
|
||||
it('should call AWS SES to initiate verification', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
const domain = 'test-domain.com';
|
||||
|
||||
await DomainService.addDomain(project.id, domain);
|
||||
|
||||
expect(SESService.verifyDomain).toHaveBeenCalledWith(domain);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// CHECK DOMAIN OWNERSHIP
|
||||
// ========================================
|
||||
describe('checkDomainOwnership', () => {
|
||||
it('should return exists: false for non-existent domain', async () => {
|
||||
const result = await DomainService.checkDomainOwnership('non-existent.com', 'user-id');
|
||||
|
||||
expect(result).toEqual({exists: false});
|
||||
});
|
||||
|
||||
it('should return project info when domain exists', async () => {
|
||||
const {user, project} = await factories.createUserWithProject();
|
||||
|
||||
await DomainService.addDomain(project.id, 'existing.com');
|
||||
|
||||
const result = await DomainService.checkDomainOwnership('existing.com', user.id);
|
||||
|
||||
expect(result.exists).toBe(true);
|
||||
expect(result.projectId).toBe(project.id);
|
||||
expect(result.projectName).toBe(project.name);
|
||||
expect(result.isMember).toBe(true);
|
||||
});
|
||||
|
||||
it('should indicate isMember: false when user is not a member', async () => {
|
||||
const {project: project1} = await factories.createUserWithProject();
|
||||
const {user: user2} = await factories.createUserWithProject();
|
||||
|
||||
await DomainService.addDomain(project1.id, 'restricted.com');
|
||||
|
||||
const result = await DomainService.checkDomainOwnership('restricted.com', user2.id);
|
||||
|
||||
expect(result.exists).toBe(true);
|
||||
expect(result.isMember).toBe(false);
|
||||
});
|
||||
|
||||
it('should indicate isMember: true when user is a member', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
const user2 = await factories.createUser();
|
||||
|
||||
await prisma.membership.create({
|
||||
data: {
|
||||
userId: user2.id,
|
||||
projectId: project.id,
|
||||
role: 'MEMBER',
|
||||
},
|
||||
});
|
||||
|
||||
await DomainService.addDomain(project.id, 'team-domain.com');
|
||||
|
||||
const result = await DomainService.checkDomainOwnership('team-domain.com', user2.id);
|
||||
|
||||
expect(result.exists).toBe(true);
|
||||
expect(result.isMember).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// VERIFY EMAIL DOMAIN
|
||||
// ========================================
|
||||
describe('verifyEmailDomain', () => {
|
||||
it('should throw error for invalid email format', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(HttpException);
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(
|
||||
/invalid email format/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when domain is not registered', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('sender@unregistered.com', project.id)).rejects.toThrow(
|
||||
HttpException,
|
||||
);
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('sender@unregistered.com', project.id)).rejects.toThrow(
|
||||
/not registered/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when domain belongs to different project', async () => {
|
||||
const {project: project1} = await factories.createUserWithProject();
|
||||
const {project: project2} = await factories.createUserWithProject();
|
||||
|
||||
const domain = await DomainService.addDomain(project1.id, 'project1.com');
|
||||
await prisma.domain.update({
|
||||
where: {id: domain.id},
|
||||
data: {verified: true},
|
||||
});
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('sender@project1.com', project2.id)).rejects.toThrow(HttpException);
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('sender@project1.com', project2.id)).rejects.toThrow(
|
||||
/belongs to a different project/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when domain is not verified', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await DomainService.addDomain(project.id, 'unverified.com');
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('sender@unverified.com', project.id)).rejects.toThrow(HttpException);
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('sender@unverified.com', project.id)).rejects.toThrow(
|
||||
/not verified/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return domain when all checks pass', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
const domain = await DomainService.addDomain(project.id, 'verified.com');
|
||||
await prisma.domain.update({
|
||||
where: {id: domain.id},
|
||||
data: {verified: true},
|
||||
});
|
||||
|
||||
const result = await DomainService.verifyEmailDomain('sender@verified.com', project.id);
|
||||
|
||||
expect(result.domain).toBe('verified.com');
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.projectId).toBe(project.id);
|
||||
});
|
||||
|
||||
it('should extract domain correctly from various email formats', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
const domain = await DomainService.addDomain(project.id, 'example.com');
|
||||
await prisma.domain.update({
|
||||
where: {id: domain.id},
|
||||
data: {verified: true},
|
||||
});
|
||||
|
||||
// Test various email formats
|
||||
const result1 = await DomainService.verifyEmailDomain('user@example.com', project.id);
|
||||
const result2 = await DomainService.verifyEmailDomain('admin@example.com', project.id);
|
||||
const result3 = await DomainService.verifyEmailDomain('support+tag@example.com', project.id);
|
||||
|
||||
expect(result1.domain).toBe('example.com');
|
||||
expect(result2.domain).toBe('example.com');
|
||||
expect(result3.domain).toBe('example.com');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// GET DOMAIN BY ID
|
||||
// ========================================
|
||||
describe('id', () => {
|
||||
it('should return domain by id', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
const domain = await DomainService.addDomain(project.id, 'test.com');
|
||||
const result = await DomainService.id(domain.id);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.id).toBe(domain.id);
|
||||
expect(result?.domain).toBe('test.com');
|
||||
});
|
||||
|
||||
it('should return null for non-existent id', async () => {
|
||||
const result = await DomainService.id('00000000-0000-0000-0000-000000000000');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// GET PROJECT DOMAINS
|
||||
// ========================================
|
||||
describe('getProjectDomains', () => {
|
||||
it('should return all domains for a project', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await DomainService.addDomain(project.id, 'domain1.com');
|
||||
await DomainService.addDomain(project.id, 'domain2.com');
|
||||
|
||||
const domains = await DomainService.getProjectDomains(project.id);
|
||||
|
||||
expect(domains).toHaveLength(2);
|
||||
expect(domains.map(d => d.domain).sort()).toEqual(['domain1.com', 'domain2.com']);
|
||||
});
|
||||
|
||||
it('should return domains ordered by creation date (newest first)', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
// Add domains with slight delay to ensure different timestamps
|
||||
const domain1 = await DomainService.addDomain(project.id, 'first.com');
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
const domain2 = await DomainService.addDomain(project.id, 'second.com');
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
const domain3 = await DomainService.addDomain(project.id, 'third.com');
|
||||
|
||||
const domains = await DomainService.getProjectDomains(project.id);
|
||||
|
||||
expect(domains[0].id).toBe(domain3.id); // Newest first
|
||||
expect(domains[1].id).toBe(domain2.id);
|
||||
expect(domains[2].id).toBe(domain1.id);
|
||||
});
|
||||
|
||||
it('should return empty array for project with no domains', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
const domains = await DomainService.getProjectDomains(project.id);
|
||||
|
||||
expect(domains).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// GET VERIFIED DOMAINS
|
||||
// ========================================
|
||||
describe('getVerifiedDomains', () => {
|
||||
it('should return only verified domains', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
const domain1 = await DomainService.addDomain(project.id, 'verified1.com');
|
||||
await DomainService.addDomain(project.id, 'unverified.com');
|
||||
const domain3 = await DomainService.addDomain(project.id, 'verified2.com');
|
||||
|
||||
// Mark two as verified
|
||||
await prisma.domain.update({
|
||||
where: {id: domain1.id},
|
||||
data: {verified: true},
|
||||
});
|
||||
await prisma.domain.update({
|
||||
where: {id: domain3.id},
|
||||
data: {verified: true},
|
||||
});
|
||||
|
||||
const verifiedDomains = await DomainService.getVerifiedDomains(project.id);
|
||||
|
||||
expect(verifiedDomains).toHaveLength(2);
|
||||
expect(verifiedDomains.map(d => d.domain).sort()).toEqual(['verified1.com', 'verified2.com']);
|
||||
});
|
||||
|
||||
it('should return empty array when no domains are verified', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await DomainService.addDomain(project.id, 'unverified1.com');
|
||||
await DomainService.addDomain(project.id, 'unverified2.com');
|
||||
|
||||
const verifiedDomains = await DomainService.getVerifiedDomains(project.id);
|
||||
|
||||
expect(verifiedDomains).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// CHECK VERIFICATION
|
||||
// ========================================
|
||||
describe('checkVerification', () => {
|
||||
it('should check verification status with AWS SES', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
const domain = await DomainService.addDomain(project.id, 'check-verification.com');
|
||||
|
||||
const result = await DomainService.checkVerification(domain.id);
|
||||
|
||||
expect(result.domain).toBe('check-verification.com');
|
||||
expect(result.tokens).toEqual(['token1', 'token2', 'token3']);
|
||||
expect(result.status).toBe('Success');
|
||||
expect(result.verified).toBe(true);
|
||||
|
||||
expect(SESService.getDomainVerificationAttributes).toHaveBeenCalledWith('check-verification.com');
|
||||
});
|
||||
|
||||
it('should update domain to verified when SES returns Success', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
const domain = await DomainService.addDomain(project.id, 'newly-verified.com');
|
||||
expect(domain.verified).toBe(false);
|
||||
|
||||
await DomainService.checkVerification(domain.id);
|
||||
|
||||
const updated = await prisma.domain.findUnique({where: {id: domain.id}});
|
||||
expect(updated?.verified).toBe(true);
|
||||
});
|
||||
|
||||
it('should update domain to unverified when SES returns Pending', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
const domain = await DomainService.addDomain(project.id, 'pending-domain.com');
|
||||
|
||||
// Manually mark as verified
|
||||
await prisma.domain.update({
|
||||
where: {id: domain.id},
|
||||
data: {verified: true},
|
||||
});
|
||||
|
||||
// Mock SES to return Pending
|
||||
vi.spyOn(SESService, 'getDomainVerificationAttributes').mockResolvedValueOnce({
|
||||
status: 'Pending',
|
||||
tokens: ['token1', 'token2', 'token3'],
|
||||
});
|
||||
|
||||
await DomainService.checkVerification(domain.id);
|
||||
|
||||
const updated = await prisma.domain.findUnique({where: {id: domain.id}});
|
||||
expect(updated?.verified).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw error for non-existent domain', async () => {
|
||||
await expect(DomainService.checkVerification('00000000-0000-0000-0000-000000000000')).rejects.toThrow(
|
||||
/domain not found/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// REMOVE DOMAIN
|
||||
// ========================================
|
||||
describe('removeDomain', () => {
|
||||
it('should remove domain when not in use', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
const domain = await DomainService.addDomain(project.id, 'remove-me.com');
|
||||
|
||||
await DomainService.removeDomain(domain.id);
|
||||
|
||||
const deleted = await prisma.domain.findUnique({where: {id: domain.id}});
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw error when domain is used in templates', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
const domain = await DomainService.addDomain(project.id, 'used-in-template.com');
|
||||
|
||||
await factories.createTemplate({
|
||||
projectId: project.id,
|
||||
from: 'sender@used-in-template.com',
|
||||
});
|
||||
|
||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException);
|
||||
|
||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*template/i);
|
||||
});
|
||||
|
||||
it('should throw error when domain is used in active campaigns', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
const domain = await DomainService.addDomain(project.id, 'used-in-campaign.com');
|
||||
|
||||
await factories.createCampaign({
|
||||
projectId: project.id,
|
||||
from: 'campaign@used-in-campaign.com',
|
||||
status: 'DRAFT',
|
||||
});
|
||||
|
||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException);
|
||||
|
||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*campaign/i);
|
||||
});
|
||||
|
||||
it('should allow removal when campaign is SENT (completed)', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
const domain = await DomainService.addDomain(project.id, 'completed-campaign.com');
|
||||
|
||||
await factories.createCampaign({
|
||||
projectId: project.id,
|
||||
from: 'campaign@completed-campaign.com',
|
||||
status: 'SENT',
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
await DomainService.removeDomain(domain.id);
|
||||
|
||||
const deleted = await prisma.domain.findUnique({where: {id: domain.id}});
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw error for non-existent domain', async () => {
|
||||
await expect(DomainService.removeDomain('00000000-0000-0000-0000-000000000000')).rejects.toThrow(
|
||||
/domain not found/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should check usage in multiple templates', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
const domain = await DomainService.addDomain(project.id, 'multi-use.com');
|
||||
|
||||
await factories.createTemplate({
|
||||
projectId: project.id,
|
||||
from: 'sender1@multi-use.com',
|
||||
});
|
||||
await factories.createTemplate({
|
||||
projectId: project.id,
|
||||
from: 'sender2@multi-use.com',
|
||||
});
|
||||
await factories.createTemplate({
|
||||
projectId: project.id,
|
||||
from: 'sender3@multi-use.com',
|
||||
});
|
||||
|
||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/3 template/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// EDGE CASES AND ERROR HANDLING
|
||||
// ========================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle emails with plus addressing', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
const domain = await DomainService.addDomain(project.id, 'example.com');
|
||||
await prisma.domain.update({
|
||||
where: {id: domain.id},
|
||||
data: {verified: true},
|
||||
});
|
||||
|
||||
const result = await DomainService.verifyEmailDomain('user+tag@example.com', project.id);
|
||||
|
||||
expect(result.domain).toBe('example.com');
|
||||
});
|
||||
|
||||
it('should handle subdomain correctly', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
const domain = await DomainService.addDomain(project.id, 'mail.example.com');
|
||||
await prisma.domain.update({
|
||||
where: {id: domain.id},
|
||||
data: {verified: true},
|
||||
});
|
||||
|
||||
const result = await DomainService.verifyEmailDomain('sender@mail.example.com', project.id);
|
||||
|
||||
expect(result.domain).toBe('mail.example.com');
|
||||
|
||||
// Different subdomain should fail
|
||||
await expect(DomainService.verifyEmailDomain('sender@other.example.com', project.id)).rejects.toThrow(
|
||||
/not registered/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle email with no @ sign', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow(/invalid email format/i);
|
||||
});
|
||||
|
||||
it('should handle email with multiple @ signs', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('user@@example.com', project.id)).rejects.toThrow(
|
||||
/invalid email format/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty email string', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow(/invalid email format/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// CONCURRENCY AND RACE CONDITIONS
|
||||
// ========================================
|
||||
describe('Concurrency', () => {
|
||||
it('should handle concurrent domain additions to same project', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
// Add multiple domains concurrently
|
||||
const results = await Promise.all([
|
||||
DomainService.addDomain(project.id, 'concurrent1.com'),
|
||||
DomainService.addDomain(project.id, 'concurrent2.com'),
|
||||
DomainService.addDomain(project.id, 'concurrent3.com'),
|
||||
]);
|
||||
|
||||
expect(results).toHaveLength(3);
|
||||
expect(results.map(d => d.domain).sort()).toEqual(['concurrent1.com', 'concurrent2.com', 'concurrent3.com']);
|
||||
});
|
||||
|
||||
it('should handle concurrent ownership checks', async () => {
|
||||
const {user, project} = await factories.createUserWithProject();
|
||||
|
||||
await DomainService.addDomain(project.id, 'concurrent-check.com');
|
||||
|
||||
// Multiple concurrent ownership checks
|
||||
const results = await Promise.all([
|
||||
DomainService.checkDomainOwnership('concurrent-check.com', user.id),
|
||||
DomainService.checkDomainOwnership('concurrent-check.com', user.id),
|
||||
DomainService.checkDomainOwnership('concurrent-check.com', user.id),
|
||||
]);
|
||||
|
||||
// All should return consistent results
|
||||
expect(results.every(r => r.exists)).toBe(true);
|
||||
expect(results.every(r => r.isMember)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user