Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
d55dda3127 | ||
|
|
d637147f25 | ||
|
|
c1ed23c967 | ||
|
|
46001619e8 | ||
|
|
7a6df7760b | ||
|
|
d2d779c68f | ||
|
|
bbc12e0029 | ||
|
|
125718bb94 | ||
|
|
35d67c6b42 | ||
|
|
14c471e4ca | ||
|
|
42ceb6edc0 | ||
|
|
5bce1d74ff | ||
|
|
b4404f698e | ||
|
|
e4625894c9 | ||
|
|
af87d80baa | ||
|
|
1f84c9113c | ||
|
|
0cf0a26a97 | ||
|
|
0d4b694208 | ||
|
|
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 |
+19
-4
@@ -27,10 +27,10 @@ WIKI_URI=http://localhost:1000
|
|||||||
|
|
||||||
# NEXT_PUBLIC_* variables are used for client-side code and sitemap generation
|
# NEXT_PUBLIC_* variables are used for client-side code and sitemap generation
|
||||||
# Use placeholder URLs that will be replaced at Docker container runtime
|
# Use placeholder URLs that will be replaced at Docker container runtime
|
||||||
NEXT_PUBLIC_API_URI=https://api.useplunk.com
|
NEXT_PUBLIC_API_URI=https://next-api.useplunk.com
|
||||||
NEXT_PUBLIC_DASHBOARD_URI=https://app.useplunk.com
|
NEXT_PUBLIC_DASHBOARD_URI=https://next-app.useplunk.com
|
||||||
NEXT_PUBLIC_LANDING_URI=https://www.useplunk.com
|
NEXT_PUBLIC_LANDING_URI=https://next.useplunk.com
|
||||||
NEXT_PUBLIC_WIKI_URI=https://docs.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)
|
# 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
|
# This affects how application URIs are auto-generated from domain names
|
||||||
@@ -145,6 +145,21 @@ SMTP_DOMAIN=smtp.example.com
|
|||||||
# Default: true
|
# Default: true
|
||||||
# AUTO_PROJECT_DISABLE=false
|
# 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)
|
# ADVANCED (rarely needed)
|
||||||
# ========================================
|
# ========================================
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -103,7 +103,7 @@ jobs:
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Build shared packages
|
- name: Build shared packages
|
||||||
run: yarn build --filter="@plunk/shared" --filter="@plunk/db"
|
run: yarn build --filter="@plunk/db" --filter="@plunk/types" --filter="@plunk/shared"
|
||||||
|
|
||||||
- name: Generate Prisma Client
|
- name: Generate Prisma Client
|
||||||
run: yarn workspace @plunk/db db:generate
|
run: yarn workspace @plunk/db db:generate
|
||||||
@@ -188,7 +188,7 @@ jobs:
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Build shared packages
|
- name: Build shared packages
|
||||||
run: yarn build --filter="@plunk/shared" --filter="@plunk/db"
|
run: yarn build --filter="@plunk/db" --filter="@plunk/types" --filter="@plunk/shared"
|
||||||
|
|
||||||
- name: Run linter
|
- name: Run linter
|
||||||
run: yarn lint
|
run: yarn lint
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.3.0"
|
".": "0.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ nodeLinker: node-modules
|
|||||||
|
|
||||||
# Support multiple architectures - download pre-built binaries instead of compiling
|
# Support multiple architectures - download pre-built binaries instead of compiling
|
||||||
supportedArchitectures:
|
supportedArchitectures:
|
||||||
os: [ "linux", "darwin" ]
|
os: [ "linux", "darwin", "win32" ]
|
||||||
cpu: [ "x64", "arm64" ]
|
cpu: [ "x64", "arm64" ]
|
||||||
libc: [ "glibc" ]
|
libc: [ "glibc" ]
|
||||||
|
|
||||||
|
|||||||
+102
@@ -1,5 +1,107 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [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)
|
## [0.3.0](https://github.com/useplunk/plunk/compare/v0.2.0...v0.3.0) (2025-12-29)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,12 +64,12 @@ to run them separately (e.g., for debugging), use `dev:server` and `dev:worker`
|
|||||||
### Applications (`apps/`)
|
### Applications (`apps/`)
|
||||||
|
|
||||||
- **api**: Express.js API server with TypeScript (ESM), uses @overnightjs/core
|
- **api**: Express.js API server with TypeScript (ESM), uses @overnightjs/core
|
||||||
- HTTP API endpoints for the platform
|
- HTTP API endpoints for the platform
|
||||||
- Background cron jobs (workflow processor, domain verification)
|
- Background cron jobs (workflow processor, domain verification)
|
||||||
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
|
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
|
||||||
- **web**: Next.js app (Pages Router) - Main platform (app.useplunk.com)
|
- **web**: Next.js app (Pages Router) - Main platform (next-app.useplunk.com)
|
||||||
- **landing**: Next.js app (Pages Router) - Marketing site (www.useplunk.com)
|
- **landing**: Next.js app (Pages Router) - Marketing site (next.useplunk.com)
|
||||||
- **wiki**: Next.js app - Documentation site (docs.useplunk.com)
|
- **wiki**: Next.js app - Documentation site (next-wiki.useplunk.com)
|
||||||
|
|
||||||
### Background Job Architecture
|
### Background Job Architecture
|
||||||
|
|
||||||
@@ -108,6 +108,7 @@ between groups.
|
|||||||
- Consistent type imports preferred: `import type { ... }`
|
- Consistent type imports preferred: `import type { ... }`
|
||||||
- Unused vars allowed with `_` prefix
|
- Unused vars allowed with `_` prefix
|
||||||
- Strict type checking enabled across all packages
|
- Strict type checking enabled across all packages
|
||||||
|
- Try to avoid inline types in favor of shared types in `@plunk/types`
|
||||||
|
|
||||||
### Component Structure
|
### Component Structure
|
||||||
|
|
||||||
@@ -147,6 +148,12 @@ Required for builds and deployment (see turbo.json and .env.example):
|
|||||||
- Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications)
|
- 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
|
- 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)
|
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:**
|
**Important Notes:**
|
||||||
|
|
||||||
@@ -155,4 +162,11 @@ Required for builds and deployment (see turbo.json and .env.example):
|
|||||||
allows the same Docker image to be used across different environments by simply changing environment variables at
|
allows the same Docker image to be used across different environments by simply changing environment variables at
|
||||||
runtime
|
runtime
|
||||||
- **Frontend Variables**: Next.js apps use `NEXT_PUBLIC_*` prefixed variables that are embedded at build time for
|
- **Frontend Variables**: Next.js apps use `NEXT_PUBLIC_*` prefixed variables that are embedded at build time for
|
||||||
client-side access
|
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.
|
||||||
|
|||||||
+3
-3
@@ -10,8 +10,8 @@ Plunk V2 is built as a modern Turborepo monorepo with the following structure:
|
|||||||
|
|
||||||
- **api**: Express.js API server with background worker process (BullMQ)
|
- **api**: Express.js API server with background worker process (BullMQ)
|
||||||
- **web**: Next.js dashboard application (app.useplunk.com)
|
- **web**: Next.js dashboard application (app.useplunk.com)
|
||||||
- **landing**: Next.js marketing site (www.useplunk.com)
|
- **landing**: Next.js marketing site (next.useplunk.com)
|
||||||
- **wiki**: Next.js documentation site (docs.useplunk.com)
|
- **wiki**: Next.js documentation site (next-wiki.useplunk.com)
|
||||||
|
|
||||||
### Shared Packages (`packages/`)
|
### Shared Packages (`packages/`)
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ between groups.
|
|||||||
|
|
||||||
## Need Help?
|
## Need Help?
|
||||||
|
|
||||||
- Check the [documentation](https://docs.useplunk.com)
|
- Check the [documentation](https://next-wiki.useplunk.com)
|
||||||
- Open an issue for bugs or feature requests
|
- Open an issue for bugs or feature requests
|
||||||
- Join our community discussions
|
- Join our community discussions
|
||||||
|
|
||||||
|
|||||||
+5
-6
@@ -95,10 +95,10 @@ ARG TARGETPLATFORM
|
|||||||
# Build-time arguments for URL configuration
|
# Build-time arguments for URL configuration
|
||||||
# These are only used during the build process (for wiki OpenAPI generation and static assets)
|
# 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
|
# Runtime URLs are configured via *_DOMAIN and USE_HTTPS environment variables at container startup
|
||||||
ARG API_URI=https://api.useplunk.com
|
ARG API_URI=https://next-api.useplunk.com
|
||||||
ARG DASHBOARD_URI=https://app.useplunk.com
|
ARG DASHBOARD_URI=https://next-app.useplunk.com
|
||||||
ARG LANDING_URI=https://www.useplunk.com
|
ARG LANDING_URI=https://next.useplunk.com
|
||||||
ARG WIKI_URI=https://docs.useplunk.com
|
ARG WIKI_URI=https://next-wiki.useplunk.com
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -273,8 +273,7 @@ COPY --from=builder --chown=plunk:nodejs /app/packages/shared/dist ./packages/sh
|
|||||||
COPY --from=builder --chown=plunk:nodejs /app/packages/shared/package.json ./packages/shared/package.json
|
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/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/email/package.json ./packages/email/package.json
|
||||||
# @plunk/types exports source TypeScript files directly (no build output)
|
COPY --from=builder --chown=plunk:nodejs /app/packages/types/dist ./packages/types/dist
|
||||||
COPY --from=builder --chown=plunk:nodejs /app/packages/types/src ./packages/types/src
|
|
||||||
COPY --from=builder --chown=plunk:nodejs /app/packages/types/package.json ./packages/types/package.json
|
COPY --from=builder --chown=plunk:nodejs /app/packages/types/package.json ./packages/types/package.json
|
||||||
|
|
||||||
# Copy Prisma schema (needed for migrations at runtime)
|
# Copy Prisma schema (needed for migrations at runtime)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ JWT_SECRET=hBx9Xh8J6KOMAGAsSjvcZJBT5TWyIkFX
|
|||||||
USE_HTTPS=false
|
USE_HTTPS=false
|
||||||
|
|
||||||
API_URI=http://localhost:8080
|
API_URI=http://localhost:8080
|
||||||
|
WIKI_URI=http://localhost:1000
|
||||||
DASHBOARD_URI=http://localhost:3000
|
DASHBOARD_URI=http://localhost:3000
|
||||||
LANDING_URI=http://localhost:4000
|
LANDING_URI=http://localhost:4000
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
NotAuthenticated,
|
NotAuthenticated,
|
||||||
NotFound,
|
NotFound,
|
||||||
RateLimitError,
|
RateLimitError,
|
||||||
ValidationError
|
ValidationError,
|
||||||
} from '../../exceptions/index.js';
|
} from '../../exceptions/index.js';
|
||||||
import {EmailService} from '../../services/EmailService.js';
|
import {EmailService} from '../../services/EmailService.js';
|
||||||
|
|
||||||
@@ -850,6 +850,17 @@ describe('Actions API Integration Tests', () => {
|
|||||||
expect(result.data.subscribed).toBeUndefined();
|
expect(result.data.subscribed).toBeUndefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should create new contacts as unsubscribed when subscribed is undefined', async () => {
|
||||||
|
const newEmail = '[email protected]';
|
||||||
|
|
||||||
|
// Send email to new contact without specifying subscribed
|
||||||
|
const {ContactService} = await import('../../services/ContactService.js');
|
||||||
|
const contact = await ContactService.upsert(projectId, newEmail, {name: 'Test'}, false);
|
||||||
|
|
||||||
|
// Transactional emails should create contacts as unsubscribed by default
|
||||||
|
expect(contact.subscribed).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('/v1/track endpoint', () => {
|
describe('/v1/track endpoint', () => {
|
||||||
@@ -905,9 +916,9 @@ describe('Actions API Integration Tests', () => {
|
|||||||
|
|
||||||
// Track event for new contact without specifying subscribed
|
// Track event for new contact without specifying subscribed
|
||||||
const {ContactService} = await import('../../services/ContactService.js');
|
const {ContactService} = await import('../../services/ContactService.js');
|
||||||
const contact = await ContactService.upsert(projectId, newEmail, {event: 'test'}, undefined);
|
const contact = await ContactService.upsert(projectId, newEmail, {event: 'test'}, true);
|
||||||
|
|
||||||
// New contacts should default to subscribed=true
|
// Event tracking should create contacts as subscribed by default
|
||||||
expect(contact.subscribed).toBe(true);
|
expect(contact.subscribed).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {describe, it, expect, beforeEach, beforeAll} from 'vitest';
|
import {beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||||
import {CampaignStatus, CampaignAudienceType} from '@plunk/db';
|
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
||||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||||
|
|
||||||
// Note: To run these integration tests, you need to:
|
// Note: To run these integration tests, you need to:
|
||||||
|
|||||||
+88
-10
@@ -20,7 +20,7 @@ import {
|
|||||||
SMTP_ENABLED,
|
SMTP_ENABLED,
|
||||||
STRIPE_ENABLED,
|
STRIPE_ENABLED,
|
||||||
TRACKING_TOGGLE_ENABLED,
|
TRACKING_TOGGLE_ENABLED,
|
||||||
WIKI_URI
|
WIKI_URI,
|
||||||
} from './app/constants.js';
|
} from './app/constants.js';
|
||||||
import {Actions} from './controllers/Actions.js';
|
import {Actions} from './controllers/Actions.js';
|
||||||
import {Activity} from './controllers/Activity.js';
|
import {Activity} from './controllers/Activity.js';
|
||||||
@@ -76,23 +76,73 @@ const server = new (class extends Server {
|
|||||||
// Log all requests to database for historical tracking and analytics
|
// Log all requests to database for historical tracking and analytics
|
||||||
this.app.use(databaseRequestLogger);
|
this.app.use(databaseRequestLogger);
|
||||||
|
|
||||||
this.app.use(['/v1', '/v1/track', '/v1/send'], (req, res, next) => {
|
|
||||||
res.set({'Access-Control-Allow-Origin': '*'});
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build allowed origins from environment variables
|
// Build allowed origins from environment variables
|
||||||
const allowedOrigins =
|
const allowedOrigins =
|
||||||
NODE_ENV === 'development'
|
NODE_ENV === 'development'
|
||||||
? [/.*\.localhost:1000/, 'http://localhost:3000', 'http://localhost:4000']
|
? [/.*\.localhost:1000/, 'http://localhost:3000', 'http://localhost:4000']
|
||||||
: [DASHBOARD_URI, LANDING_URI, WIKI_URI];
|
: [DASHBOARD_URI, LANDING_URI, WIKI_URI];
|
||||||
|
|
||||||
this.app.use(
|
// 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({
|
cors({
|
||||||
origin: allowedOrigins,
|
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,
|
credentials: true,
|
||||||
}),
|
})(req, res, next);
|
||||||
);
|
});
|
||||||
|
|
||||||
this.app.use(morgan(NODE_ENV === 'development' ? 'dev' : 'short'));
|
this.app.use(morgan(NODE_ENV === 'development' ? 'dev' : 'short'));
|
||||||
|
|
||||||
@@ -154,6 +204,34 @@ interface ErrorResponse {
|
|||||||
server.app.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
|
server.app.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
|
||||||
const requestId = res.locals.requestId as string | undefined;
|
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
|
// Handle Zod validation errors
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
const fieldErrors: FieldError[] = error.errors.map(err => ({
|
const fieldErrors: FieldError[] = error.errors.map(err => ({
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ export const PLUNK_ENABLED = PLUNK_API_KEY !== '' && PLUNK_FROM_ADDRESS !== '';
|
|||||||
// Useful for self-hosters who want to manage project status manually
|
// Useful for self-hosters who want to manage project status manually
|
||||||
export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true';
|
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
|
// Email Verification & Password Reset
|
||||||
export const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
|
export const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
|
||||||
export const EMAIL_VERIFICATION_RATE_LIMIT = 3; // Max 3 emails per hour
|
export const EMAIL_VERIFICATION_RATE_LIMIT = 3; // Max 3 emails per hour
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import {Controller, Middleware, Post} from '@overnightjs/core';
|
import {Controller, Middleware, Post} from '@overnightjs/core';
|
||||||
import {ActionSchemas} from '@plunk/shared';
|
import {ActionSchemas} from '@plunk/shared';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
|
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
|
||||||
import {requirePublicKey, requireSecretKey} from '../middleware/auth.js';
|
import {requirePublicKey, requireSecretKey} from '../middleware/auth.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {ContactService} from '../services/ContactService.js';
|
import {ContactService} from '../services/ContactService.js';
|
||||||
@@ -52,7 +50,7 @@ export class Actions {
|
|||||||
@Middleware([requirePublicKey])
|
@Middleware([requirePublicKey])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async track(req: Request, res: Response, _next: NextFunction) {
|
public async track(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
|
|
||||||
// Zod validation - errors automatically handled by global error handler
|
// Zod validation - errors automatically handled by global error handler
|
||||||
const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body);
|
const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body);
|
||||||
@@ -74,11 +72,12 @@ export class Actions {
|
|||||||
|
|
||||||
// Create or update contact with persistent data only
|
// Create or update contact with persistent data only
|
||||||
// ContactService.upsert will filter out non-persistent fields
|
// ContactService.upsert will filter out non-persistent fields
|
||||||
|
// Event tracking should subscribe contacts by default
|
||||||
const contact = await ContactService.upsert(
|
const contact = await ContactService.upsert(
|
||||||
auth.projectId,
|
auth.projectId,
|
||||||
email,
|
email,
|
||||||
data as Record<string, unknown> | undefined,
|
data as Record<string, unknown> | undefined,
|
||||||
subscribed,
|
subscribed ?? true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track the event with ALL data (persistent + non-persistent)
|
// Track the event with ALL data (persistent + non-persistent)
|
||||||
@@ -173,7 +172,7 @@ export class Actions {
|
|||||||
@Middleware([requireSecretKey])
|
@Middleware([requireSecretKey])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async send(req: Request, res: Response, _next: NextFunction) {
|
public async send(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
|
|
||||||
// Zod validation - errors automatically handled by global error handler
|
// Zod validation - errors automatically handled by global error handler
|
||||||
const {to, subject, body, subscribed, name, from, reply, headers, data, template, attachments} =
|
const {to, subject, body, subscribed, name, from, reply, headers, data, template, attachments} =
|
||||||
@@ -268,7 +267,8 @@ export class Actions {
|
|||||||
: (data as Record<string, unknown> | undefined);
|
: (data as Record<string, unknown> | undefined);
|
||||||
|
|
||||||
// Create or update contact with metadata
|
// Create or update contact with metadata
|
||||||
const contact = await ContactService.upsert(auth.projectId, recipient.email, recipientData, subscribed);
|
// Transactional emails should not subscribe contacts by default
|
||||||
|
const contact = await ContactService.upsert(auth.projectId, recipient.email, recipientData, subscribed ?? false);
|
||||||
|
|
||||||
// Get merged data including non-persistent fields for template rendering
|
// Get merged data including non-persistent fields for template rendering
|
||||||
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
|
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
|
||||||
@@ -277,6 +277,7 @@ export class Actions {
|
|||||||
// These are always available for template rendering
|
// These are always available for template rendering
|
||||||
const dataWithSystemVars = {
|
const dataWithSystemVars = {
|
||||||
...mergedData,
|
...mergedData,
|
||||||
|
id: contact.id,
|
||||||
email: contact.email,
|
email: contact.email,
|
||||||
data: mergedData, // Also available as nested data for {{data.fieldName}} syntax
|
data: mergedData, // Also available as nested data for {{data.fieldName}} syntax
|
||||||
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`,
|
unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`,
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import {Controller, Get, Middleware} from '@overnightjs/core';
|
import {Controller, Get, Middleware} from '@overnightjs/core';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
|
import {ActivityType} from '@plunk/types';
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
|
||||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||||
import {ActivityService, ActivityType} from '../services/ActivityService.js';
|
import {ActivityService} from '../services/ActivityService.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
|
|
||||||
@Controller('activity')
|
@Controller('activity')
|
||||||
@@ -24,7 +23,7 @@ export class Activity {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getActivities(req: Request, res: Response, _next: NextFunction) {
|
public async getActivities(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||||
const cursor = req.query.cursor as string | undefined;
|
const cursor = req.query.cursor as string | undefined;
|
||||||
const contactId = req.query.contactId as string | undefined;
|
const contactId = req.query.contactId as string | undefined;
|
||||||
@@ -65,7 +64,7 @@ export class Activity {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getStats(req: Request, res: Response, _next: NextFunction) {
|
public async getStats(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const startDate = req.query.startDate ? new Date(req.query.startDate 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;
|
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||||
|
|
||||||
@@ -85,7 +84,7 @@ export class Activity {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getRecentCount(req: Request, res: Response, _next: NextFunction) {
|
public async getRecentCount(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const minutes = Math.min(parseInt(req.query.minutes as string) || 5, 60); // Max 60 minutes
|
const minutes = Math.min(parseInt(req.query.minutes as string) || 5, 60); // Max 60 minutes
|
||||||
|
|
||||||
const count = await ActivityService.getRecentActivityCount(auth.projectId, minutes);
|
const count = await ActivityService.getRecentActivityCount(auth.projectId, minutes);
|
||||||
@@ -117,7 +116,7 @@ export class Activity {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getUpcoming(req: Request, res: Response, _next: NextFunction) {
|
public async getUpcoming(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
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 daysAhead = Math.min(parseInt(req.query.daysAhead as string) || 30, 90);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import {Controller, Get, Middleware} from '@overnightjs/core';
|
import {Controller, Get, Middleware} from '@overnightjs/core';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
|
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
|
||||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||||
import {AnalyticsService} from '../services/AnalyticsService.js';
|
import {AnalyticsService} from '../services/AnalyticsService.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
@@ -22,7 +20,7 @@ export class Analytics {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getTimeSeries(req: Request, res: Response, _next: NextFunction) {
|
public async getTimeSeries(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const startDate = req.query.startDate ? new Date(req.query.startDate 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;
|
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||||
|
|
||||||
@@ -44,7 +42,7 @@ export class Analytics {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getTopCampaigns(req: Request, res: Response, _next: NextFunction) {
|
public async getTopCampaigns(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const limit = Math.min(parseInt(req.query.limit as string) || 10, 50);
|
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 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 endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||||
@@ -68,7 +66,7 @@ export class Analytics {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getCampaignStats(req: Request, res: Response, _next: NextFunction) {
|
public async getCampaignStats(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const startDate = req.query.startDate ? new Date(req.query.startDate 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;
|
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||||
|
|
||||||
@@ -92,7 +90,7 @@ export class Analytics {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getTopEvents(req: Request, res: Response, _next: NextFunction) {
|
public async getTopEvents(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const limit = Math.min(parseInt(req.query.limit as string) || 5, 20);
|
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 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 endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as React from 'react';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
DASHBOARD_URI,
|
DASHBOARD_URI,
|
||||||
|
DISABLE_SIGNUPS,
|
||||||
EMAIL_VERIFICATION_RATE_LIMIT,
|
EMAIL_VERIFICATION_RATE_LIMIT,
|
||||||
EMAIL_VERIFICATION_RATE_WINDOW,
|
EMAIL_VERIFICATION_RATE_WINDOW,
|
||||||
GITHUB_OAUTH_ENABLED,
|
GITHUB_OAUTH_ENABLED,
|
||||||
@@ -15,12 +16,14 @@ import {
|
|||||||
PASSWORD_RESET_RATE_LIMIT,
|
PASSWORD_RESET_RATE_LIMIT,
|
||||||
PLUNK_ENABLED,
|
PLUNK_ENABLED,
|
||||||
TOKEN_EXPIRY_SECONDS,
|
TOKEN_EXPIRY_SECONDS,
|
||||||
|
VERIFY_EMAIL_ON_SIGNUP,
|
||||||
} from '../app/constants.js';
|
} from '../app/constants.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
|
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
|
||||||
import {BadRequest, NotAuthenticated, RateLimitError} from '../exceptions/index.js';
|
import {BadRequest, NotAuthenticated, RateLimitError} from '../exceptions/index.js';
|
||||||
import {jwt, parseJwt} from '../middleware/auth.js';
|
import {jwt, parseJwt} from '../middleware/auth.js';
|
||||||
import {AuthService} from '../services/AuthService.js';
|
import {AuthService} from '../services/AuthService.js';
|
||||||
|
import {EmailVerificationService} from '../services/EmailVerificationService.js';
|
||||||
import {NtfyService} from '../services/NtfyService.js';
|
import {NtfyService} from '../services/NtfyService.js';
|
||||||
import {UserService} from '../services/UserService.js';
|
import {UserService} from '../services/UserService.js';
|
||||||
import {Keys} from '../services/keys.js';
|
import {Keys} from '../services/keys.js';
|
||||||
@@ -62,8 +65,43 @@ export class Auth {
|
|||||||
@Post('signup')
|
@Post('signup')
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async signup(req: Request, res: Response, _next: NextFunction) {
|
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);
|
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);
|
const user = await UserService.email(email);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {CampaignSchemas, UtilitySchemas} from '@plunk/shared';
|
|||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
|
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
|
||||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||||
import {CampaignService} from '../services/CampaignService.js';
|
import {CampaignService} from '../services/CampaignService.js';
|
||||||
import {DomainService} from '../services/DomainService.js';
|
import {DomainService} from '../services/DomainService.js';
|
||||||
@@ -20,11 +19,10 @@ export class Campaigns {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async create(req: Request, res: Response, _next: NextFunction) {
|
private async create(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
|
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
|
||||||
CampaignSchemas.create.parse(req.body);
|
CampaignSchemas.create.parse(req.body);
|
||||||
|
|
||||||
// Validate audience-specific fields
|
|
||||||
if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) {
|
if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) {
|
||||||
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
|
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
|
||||||
}
|
}
|
||||||
@@ -63,7 +61,7 @@ export class Campaigns {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async list(req: Request, res: Response, _next: NextFunction) {
|
private async list(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const status = req.query.status as CampaignStatus | undefined;
|
const status = req.query.status as CampaignStatus | undefined;
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
const pageSize = parseInt(req.query.pageSize as string) || 20;
|
const pageSize = parseInt(req.query.pageSize as string) || 20;
|
||||||
@@ -79,13 +77,7 @@ export class Campaigns {
|
|||||||
pageSize,
|
pageSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json(result);
|
||||||
campaigns: result.campaigns,
|
|
||||||
page: result.page,
|
|
||||||
pageSize: result.pageSize,
|
|
||||||
total: result.total,
|
|
||||||
totalPages: result.totalPages,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,7 +88,7 @@ export class Campaigns {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async get(req: Request, res: Response, _next: NextFunction) {
|
private async get(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
const campaign = await CampaignService.get(auth.projectId, id!);
|
const campaign = await CampaignService.get(auth.projectId, id!);
|
||||||
@@ -115,7 +107,7 @@ export class Campaigns {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async update(req: Request, res: Response, _next: NextFunction) {
|
private async update(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
|
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
|
||||||
req.body;
|
req.body;
|
||||||
@@ -161,7 +153,7 @@ export class Campaigns {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async delete(req: Request, res: Response, _next: NextFunction) {
|
private async delete(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
await CampaignService.delete(auth.projectId, id!);
|
await CampaignService.delete(auth.projectId, id!);
|
||||||
@@ -180,7 +172,7 @@ export class Campaigns {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async duplicate(req: Request, res: Response, _next: NextFunction) {
|
private async duplicate(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
const campaign = await CampaignService.duplicate(auth.projectId, id!);
|
const campaign = await CampaignService.duplicate(auth.projectId, id!);
|
||||||
@@ -200,7 +192,7 @@ export class Campaigns {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async send(req: Request, res: Response, _next: NextFunction) {
|
private async send(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
const scheduledFor = req.body?.scheduledFor;
|
const scheduledFor = req.body?.scheduledFor;
|
||||||
|
|
||||||
@@ -231,7 +223,7 @@ export class Campaigns {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async cancel(req: Request, res: Response, _next: NextFunction) {
|
private async cancel(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
const campaign = await CampaignService.cancel(auth.projectId, id!);
|
const campaign = await CampaignService.cancel(auth.projectId, id!);
|
||||||
@@ -251,7 +243,7 @@ export class Campaigns {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async stats(req: Request, res: Response, _next: NextFunction) {
|
private async stats(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
const stats = await CampaignService.getStats(auth.projectId, id!);
|
const stats = await CampaignService.getStats(auth.projectId, id!);
|
||||||
@@ -270,7 +262,7 @@ export class Campaigns {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async sendTest(req: Request, res: Response, _next: NextFunction) {
|
private async sendTest(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
const {email} = CampaignSchemas.sendTest.parse(req.body);
|
const {email} = CampaignSchemas.sendTest.parse(req.body);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {Request, Response} from 'express';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
API_URI,
|
API_URI,
|
||||||
|
AWS_SES_REGION,
|
||||||
DASHBOARD_URI,
|
DASHBOARD_URI,
|
||||||
GITHUB_OAUTH_ENABLED,
|
GITHUB_OAUTH_ENABLED,
|
||||||
GOOGLE_OAUTH_ENABLED,
|
GOOGLE_OAUTH_ENABLED,
|
||||||
@@ -59,6 +60,9 @@ export class Config {
|
|||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
aws: {
|
||||||
|
sesRegion: AWS_SES_REGION,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/cor
|
|||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
|
||||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||||
import {ContactService} from '../services/ContactService.js';
|
import {ContactService} from '../services/ContactService.js';
|
||||||
import {QueueService} from '../services/QueueService.js';
|
import {QueueService} from '../services/QueueService.js';
|
||||||
@@ -16,7 +14,6 @@ const upload = multer({
|
|||||||
fileSize: 5 * 1024 * 1024, // 5MB max file size
|
fileSize: 5 * 1024 * 1024, // 5MB max file size
|
||||||
},
|
},
|
||||||
fileFilter: (_req, file, cb) => {
|
fileFilter: (_req, file, cb) => {
|
||||||
// Only accept CSV files
|
|
||||||
if (file.mimetype === 'text/csv' || file.originalname.endsWith('.csv')) {
|
if (file.mimetype === 'text/csv' || file.originalname.endsWith('.csv')) {
|
||||||
cb(null, true);
|
cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
@@ -35,7 +32,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async list(req: Request, res: Response, _next: NextFunction) {
|
public async list(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||||
const cursor = req.query.cursor as string | undefined;
|
const cursor = req.query.cursor as string | undefined;
|
||||||
const search = req.query.search as string | undefined;
|
const search = req.query.search as string | undefined;
|
||||||
@@ -54,7 +51,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
|
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fieldsWithTypes = await ContactService.getAvailableFields(auth.projectId!);
|
const fieldsWithTypes = await ContactService.getAvailableFields(auth.projectId!);
|
||||||
@@ -80,7 +77,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getFieldValues(req: Request, res: Response, _next: NextFunction) {
|
public async getFieldValues(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const field = req.params.field;
|
const field = req.params.field;
|
||||||
const limit = Math.min(parseInt(req.query.limit as string) || 100, 200);
|
const limit = Math.min(parseInt(req.query.limit as string) || 100, 200);
|
||||||
|
|
||||||
@@ -113,7 +110,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async get(req: Request, res: Response, _next: NextFunction) {
|
public async get(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const contactId = req.params.id;
|
const contactId = req.params.id;
|
||||||
|
|
||||||
if (!contactId) {
|
if (!contactId) {
|
||||||
@@ -133,7 +130,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {email, data, subscribed} = req.body;
|
const {email, data, subscribed} = req.body;
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
@@ -163,7 +160,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async update(req: Request, res: Response, _next: NextFunction) {
|
public async update(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const contactId = req.params.id;
|
const contactId = req.params.id;
|
||||||
const {email, data, subscribed} = req.body;
|
const {email, data, subscribed} = req.body;
|
||||||
|
|
||||||
@@ -184,7 +181,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async delete(req: Request, res: Response, _next: NextFunction) {
|
public async delete(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const contactId = req.params.id;
|
const contactId = req.params.id;
|
||||||
|
|
||||||
if (!contactId) {
|
if (!contactId) {
|
||||||
@@ -284,7 +281,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, upload.single('file')])
|
@Middleware([requireAuth, upload.single('file')])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async importCsv(req: Request, res: Response, _next: NextFunction) {
|
public async importCsv(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
|
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({error: 'CSV file is required'});
|
return res.status(400).json({error: 'CSV file is required'});
|
||||||
@@ -318,6 +315,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getImportStatus(req: Request, res: Response, _next: NextFunction) {
|
public async getImportStatus(req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const auth = res.locals.auth;
|
||||||
const jobId = req.params.jobId;
|
const jobId = req.params.jobId;
|
||||||
|
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
@@ -325,7 +323,7 @@ export class Contacts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await QueueService.getImportJobStatus(jobId);
|
const status = await QueueService.getImportJobStatus(jobId, auth.projectId!);
|
||||||
|
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return res.status(404).json({error: 'Import job not found'});
|
return res.status(404).json({error: 'Import job not found'});
|
||||||
@@ -349,7 +347,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getFieldUsage(req: Request, res: Response, _next: NextFunction) {
|
public async getFieldUsage(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const field = req.params.field;
|
const field = req.params.field;
|
||||||
|
|
||||||
if (!field) {
|
if (!field) {
|
||||||
@@ -376,7 +374,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async deleteField(req: Request, res: Response, _next: NextFunction) {
|
public async deleteField(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const field = req.params.field;
|
const field = req.params.field;
|
||||||
|
|
||||||
if (!field) {
|
if (!field) {
|
||||||
@@ -402,7 +400,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
|
public async bulkSubscribe(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {contactIds} = req.body;
|
const {contactIds} = req.body;
|
||||||
|
|
||||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||||
@@ -437,7 +435,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
|
public async bulkUnsubscribe(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {contactIds} = req.body;
|
const {contactIds} = req.body;
|
||||||
|
|
||||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||||
@@ -471,7 +469,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
|
public async bulkDelete(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {contactIds} = req.body;
|
const {contactIds} = req.body;
|
||||||
|
|
||||||
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
if (!Array.isArray(contactIds) || contactIds.length === 0) {
|
||||||
@@ -505,6 +503,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getBulkActionStatus(req: Request, res: Response, _next: NextFunction) {
|
public async getBulkActionStatus(req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const auth = res.locals.auth;
|
||||||
const jobId = req.params.jobId;
|
const jobId = req.params.jobId;
|
||||||
|
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
@@ -512,7 +511,7 @@ export class Contacts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await QueueService.getBulkActionJobStatus(jobId);
|
const status = await QueueService.getBulkActionJobStatus(jobId, auth.projectId!);
|
||||||
|
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return res.status(404).json({error: 'Bulk action job not found'});
|
return res.status(404).json({error: 'Bulk action job not found'});
|
||||||
|
|||||||
@@ -4,11 +4,10 @@ import type {NextFunction, Request, Response} from 'express';
|
|||||||
|
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
import {NotFound} from '../exceptions/index.js';
|
import {NotFound} from '../exceptions/index.js';
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
|
||||||
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
||||||
import {DomainService} from '../services/DomainService.js';
|
import {DomainService} from '../services/DomainService.js';
|
||||||
import {Keys} from '../services/keys.js';
|
import {Keys} from '../services/keys.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {MembershipService} from '../services/MembershipService.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
|
|
||||||
@Controller('domains')
|
@Controller('domains')
|
||||||
@@ -20,20 +19,11 @@ export class Domains {
|
|||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([isAuthenticated, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getProjectDomains(req: Request, res: Response, _next: NextFunction) {
|
public async getProjectDomains(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {projectId} = DomainSchemas.projectId.parse(req.params);
|
const {projectId} = DomainSchemas.projectId.parse(req.params);
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAccess(auth.userId!, projectId);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have access');
|
|
||||||
}
|
|
||||||
|
|
||||||
const domains = await DomainService.getProjectDomains(projectId);
|
const domains = await DomainService.getProjectDomains(projectId);
|
||||||
|
|
||||||
@@ -47,7 +37,7 @@ export class Domains {
|
|||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([isAuthenticated, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async addDomain(req: Request, res: Response, _next: NextFunction) {
|
public async addDomain(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {projectId, domain} = DomainSchemas.create.parse(req.body);
|
const {projectId, domain} = DomainSchemas.create.parse(req.body);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
@@ -55,19 +45,7 @@ export class Domains {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has admin access to this project
|
// Verify user has admin access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, projectId);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if domain is already linked to another project
|
// Check if domain is already linked to another project
|
||||||
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
|
const ownershipCheck = await DomainService.checkDomainOwnership(domain, auth.userId);
|
||||||
@@ -107,7 +85,7 @@ export class Domains {
|
|||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([isAuthenticated, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async checkVerification(req: Request, res: Response, _next: NextFunction) {
|
public async checkVerification(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
const domain = await DomainService.id(id);
|
const domain = await DomainService.id(id);
|
||||||
@@ -117,16 +95,7 @@ export class Domains {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has access to the project this domain belongs to
|
// Verify user has access to the project this domain belongs to
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAccess(auth.userId!, domain.projectId);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: domain.projectId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Domain not found or you do not have access');
|
|
||||||
}
|
|
||||||
|
|
||||||
const verificationStatus = await DomainService.checkVerification(id);
|
const verificationStatus = await DomainService.checkVerification(id);
|
||||||
|
|
||||||
@@ -144,7 +113,7 @@ export class Domains {
|
|||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([isAuthenticated, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async removeDomain(req: Request, res: Response, _next: NextFunction) {
|
public async removeDomain(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
const domain = await DomainService.id(id);
|
const domain = await DomainService.id(id);
|
||||||
@@ -154,19 +123,7 @@ export class Domains {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has admin access to the project this domain belongs to
|
// Verify user has admin access to the project this domain belongs to
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, domain.projectId);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: domain.projectId,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Domain not found or you do not have permission');
|
|
||||||
}
|
|
||||||
|
|
||||||
await DomainService.removeDomain(id);
|
await DomainService.removeDomain(id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
|
import {Controller, Delete, Get, Middleware, Post} from '@overnightjs/core';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
|
||||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||||
import {EventService} from '../services/EventService.js';
|
import {EventService} from '../services/EventService.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
@@ -17,7 +15,7 @@ export class Events {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async track(req: Request, res: Response, _next: NextFunction) {
|
public async track(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {name, contactId, emailId, data} = req.body;
|
const {name, contactId, emailId, data} = req.body;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -37,7 +35,7 @@ export class Events {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async list(req: Request, res: Response, _next: NextFunction) {
|
public async list(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const eventName = req.query.eventName as string | undefined;
|
const eventName = req.query.eventName as string | undefined;
|
||||||
const limit = parseInt(req.query.limit as string) || 100;
|
const limit = parseInt(req.query.limit as string) || 100;
|
||||||
|
|
||||||
@@ -54,7 +52,7 @@ export class Events {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async stats(req: Request, res: Response, _next: NextFunction) {
|
public async stats(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const startDate = req.query.startDate ? new Date(req.query.startDate 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;
|
const endDate = req.query.endDate ? new Date(req.query.endDate as string) : undefined;
|
||||||
|
|
||||||
@@ -71,7 +69,7 @@ export class Events {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getContactEvents(req: Request, res: Response, _next: NextFunction) {
|
public async getContactEvents(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const contactId = req.params.contactId;
|
const contactId = req.params.contactId;
|
||||||
const limit = parseInt(req.query.limit as string) || 50;
|
const limit = parseInt(req.query.limit as string) || 50;
|
||||||
|
|
||||||
@@ -92,7 +90,7 @@ export class Events {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getEventNames(req: Request, res: Response, _next: NextFunction) {
|
public async getEventNames(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
|
|
||||||
const eventNames = await EventService.getUniqueEventNames(auth.projectId!);
|
const eventNames = await EventService.getUniqueEventNames(auth.projectId!);
|
||||||
|
|
||||||
@@ -108,7 +106,7 @@ export class Events {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getEventUsage(req: Request, res: Response, _next: NextFunction) {
|
public async getEventUsage(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const eventName = req.params.eventName;
|
const eventName = req.params.eventName;
|
||||||
|
|
||||||
if (!eventName) {
|
if (!eventName) {
|
||||||
@@ -135,7 +133,7 @@ export class Events {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async deleteEvent(req: Request, res: Response, _next: NextFunction) {
|
public async deleteEvent(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const eventName = req.params.eventName;
|
const eventName = req.params.eventName;
|
||||||
|
|
||||||
if (!eventName) {
|
if (!eventName) {
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import type {NextFunction, Request, Response} from 'express';
|
|||||||
import {
|
import {
|
||||||
API_URI,
|
API_URI,
|
||||||
DASHBOARD_URI,
|
DASHBOARD_URI,
|
||||||
|
DISABLE_SIGNUPS,
|
||||||
GITHUB_OAUTH_CLIENT,
|
GITHUB_OAUTH_CLIENT,
|
||||||
GITHUB_OAUTH_ENABLED,
|
GITHUB_OAUTH_ENABLED,
|
||||||
GITHUB_OAUTH_SECRET,
|
GITHUB_OAUTH_SECRET,
|
||||||
} from '../../app/constants.js';
|
} from '../../app/constants.js';
|
||||||
import {prisma} from '../../database/prisma.js';
|
import {prisma} from '../../database/prisma.js';
|
||||||
|
import {BadRequest} from '../../exceptions/index.js';
|
||||||
import {jwt} from '../../middleware/auth.js';
|
import {jwt} from '../../middleware/auth.js';
|
||||||
import {NtfyService} from '../../services/NtfyService.js';
|
import {NtfyService} from '../../services/NtfyService.js';
|
||||||
import {UserService} from '../../services/UserService.js';
|
import {UserService} from '../../services/UserService.js';
|
||||||
@@ -81,6 +83,11 @@ export class Github {
|
|||||||
let isNewUser = false;
|
let isNewUser = false;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
// Check if signups are disabled
|
||||||
|
if (DISABLE_SIGNUPS) {
|
||||||
|
throw new BadRequest('New user signups are currently disabled');
|
||||||
|
}
|
||||||
|
|
||||||
user = await prisma.user.create({
|
user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import type {NextFunction, Request, Response} from 'express';
|
|||||||
import {
|
import {
|
||||||
API_URI,
|
API_URI,
|
||||||
DASHBOARD_URI,
|
DASHBOARD_URI,
|
||||||
|
DISABLE_SIGNUPS,
|
||||||
GOOGLE_OAUTH_CLIENT,
|
GOOGLE_OAUTH_CLIENT,
|
||||||
GOOGLE_OAUTH_ENABLED,
|
GOOGLE_OAUTH_ENABLED,
|
||||||
GOOGLE_OAUTH_SECRET,
|
GOOGLE_OAUTH_SECRET,
|
||||||
} from '../../app/constants.js';
|
} from '../../app/constants.js';
|
||||||
import {prisma} from '../../database/prisma.js';
|
import {prisma} from '../../database/prisma.js';
|
||||||
|
import {BadRequest} from '../../exceptions/index.js';
|
||||||
import {jwt} from '../../middleware/auth.js';
|
import {jwt} from '../../middleware/auth.js';
|
||||||
import {NtfyService} from '../../services/NtfyService.js';
|
import {NtfyService} from '../../services/NtfyService.js';
|
||||||
import {UserService} from '../../services/UserService.js';
|
import {UserService} from '../../services/UserService.js';
|
||||||
@@ -71,6 +73,11 @@ export class Google {
|
|||||||
let isNewUser = false;
|
let isNewUser = false;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
// Check if signups are disabled
|
||||||
|
if (DISABLE_SIGNUPS) {
|
||||||
|
throw new BadRequest('New user signups are currently disabled');
|
||||||
|
}
|
||||||
|
|
||||||
user = await prisma.user.create({
|
user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {MembershipSchemas, UtilitySchemas} from '@plunk/shared';
|
|||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
|
||||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||||
|
import {MembershipService} from '../services/MembershipService.js';
|
||||||
import {SecurityService} from '../services/SecurityService.js';
|
import {SecurityService} from '../services/SecurityService.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
|
|
||||||
@@ -19,20 +19,11 @@ export class Projects {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async getSetupState(req: Request, res: Response, _next: NextFunction) {
|
private async getSetupState(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new HttpException(404, 'Project not found or you do not have access');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get project with relevant data
|
// Get project with relevant data
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
@@ -92,20 +83,11 @@ export class Projects {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) {
|
private async getSecurityMetrics(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new HttpException(404, 'Project not found or you do not have access');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use existing SecurityService
|
// Use existing SecurityService
|
||||||
const metrics = await SecurityService.getProjectSecurityMetrics(id);
|
const metrics = await SecurityService.getProjectSecurityMetrics(id);
|
||||||
@@ -124,43 +106,18 @@ export class Projects {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async getMembers(req: Request, res: Response, _next: NextFunction) {
|
private async getMembers(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new HttpException(404, 'Project not found or you do not have access');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all members of the project
|
// Get all members of the project
|
||||||
const members = await prisma.membership.findMany({
|
const members = await MembershipService.getMembers(id);
|
||||||
where: {
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
user: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
email: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: members.map(m => ({
|
data: members,
|
||||||
userId: m.user.id,
|
|
||||||
email: m.user.email,
|
|
||||||
role: m.role,
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +130,7 @@ export class Projects {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async addMember(req: Request, res: Response, _next: NextFunction) {
|
private async addMember(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Validate params
|
// Validate params
|
||||||
@@ -190,19 +147,7 @@ export class Projects {
|
|||||||
const {email, role} = parseResult.data;
|
const {email, role} = parseResult.data;
|
||||||
|
|
||||||
// Verify current user is ADMIN or OWNER
|
// Verify current user is ADMIN or OWNER
|
||||||
const currentMembership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!currentMembership) {
|
|
||||||
throw new HttpException(403, 'Only project admins and owners can add members');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find user by email
|
// Find user by email
|
||||||
const userToAdd = await prisma.user.findUnique({
|
const userToAdd = await prisma.user.findUnique({
|
||||||
@@ -214,28 +159,8 @@ export class Projects {
|
|||||||
throw new HttpException(404, 'User with this email does not have an account');
|
throw new HttpException(404, 'User with this email does not have an account');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is already a member
|
// Add member to project
|
||||||
const existingMembership = await prisma.membership.findUnique({
|
const newMembership = await MembershipService.addMember(id, userToAdd.id, role);
|
||||||
where: {
|
|
||||||
userId_projectId: {
|
|
||||||
userId: userToAdd.id,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingMembership) {
|
|
||||||
throw new HttpException(409, 'User is already a member of this project');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create membership
|
|
||||||
const newMembership = await prisma.membership.create({
|
|
||||||
data: {
|
|
||||||
userId: userToAdd.id,
|
|
||||||
projectId: id,
|
|
||||||
role,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -256,7 +181,7 @@ export class Projects {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async updateMemberRole(req: Request, res: Response, _next: NextFunction) {
|
private async updateMemberRole(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id, userId} = req.params;
|
const {id, userId} = req.params;
|
||||||
|
|
||||||
// Validate params
|
// Validate params
|
||||||
@@ -276,38 +201,7 @@ export class Projects {
|
|||||||
const {role} = parseResult.data;
|
const {role} = parseResult.data;
|
||||||
|
|
||||||
// Verify current user is ADMIN or OWNER
|
// Verify current user is ADMIN or OWNER
|
||||||
const currentMembership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!currentMembership) {
|
|
||||||
throw new HttpException(403, 'Only project admins and owners can update member roles');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get target membership
|
|
||||||
const targetMembership = await prisma.membership.findUnique({
|
|
||||||
where: {
|
|
||||||
userId_projectId: {
|
|
||||||
userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!targetMembership) {
|
|
||||||
throw new HttpException(404, 'Member not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cannot change OWNER role
|
|
||||||
if (targetMembership.role === 'OWNER') {
|
|
||||||
throw new HttpException(403, 'Cannot change the role of the project owner');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user info
|
// Get user info
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
@@ -319,16 +213,8 @@ export class Projects {
|
|||||||
throw new HttpException(404, 'User not found');
|
throw new HttpException(404, 'User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update role
|
// Update role (service handles validation)
|
||||||
await prisma.membership.update({
|
await MembershipService.updateRole(id, userId, role);
|
||||||
where: {
|
|
||||||
userId_projectId: {
|
|
||||||
userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: {role},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -348,7 +234,7 @@ export class Projects {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
private async removeMember(req: Request, res: Response, _next: NextFunction) {
|
private async removeMember(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id, userId} = req.params;
|
const {id, userId} = req.params;
|
||||||
|
|
||||||
// Validate params
|
// Validate params
|
||||||
@@ -360,53 +246,15 @@ export class Projects {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify current user is ADMIN or OWNER
|
// Verify current user is ADMIN or OWNER
|
||||||
const currentMembership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!currentMembership) {
|
|
||||||
throw new HttpException(403, 'Only project admins and owners can remove members');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get target membership
|
|
||||||
const targetMembership = await prisma.membership.findUnique({
|
|
||||||
where: {
|
|
||||||
userId_projectId: {
|
|
||||||
userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!targetMembership) {
|
|
||||||
throw new HttpException(404, 'Member not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cannot remove OWNER
|
|
||||||
if (targetMembership.role === 'OWNER') {
|
|
||||||
throw new HttpException(403, 'Cannot remove the project owner');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cannot remove yourself
|
// Cannot remove yourself
|
||||||
if (userId === auth.userId) {
|
if (userId === auth.userId) {
|
||||||
throw new HttpException(403, 'You cannot remove yourself from the project');
|
throw new HttpException(403, 'You cannot remove yourself from the project');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete membership
|
// Remove member (service handles validation)
|
||||||
await prisma.membership.delete({
|
await MembershipService.removeMember(id, userId);
|
||||||
where: {
|
|
||||||
userId_projectId: {
|
|
||||||
userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
|
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
|
||||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||||
import {SegmentService} from '../services/SegmentService.js';
|
import {SegmentService} from '../services/SegmentService.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
@@ -16,7 +14,7 @@ export class Segments {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async list(req: Request, res: Response, _next: NextFunction) {
|
public async list(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
|
|
||||||
const segments = await SegmentService.list(auth.projectId!);
|
const segments = await SegmentService.list(auth.projectId!);
|
||||||
|
|
||||||
@@ -31,7 +29,7 @@ export class Segments {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async get(req: Request, res: Response, _next: NextFunction) {
|
public async get(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const segmentId = req.params.id;
|
const segmentId = req.params.id;
|
||||||
|
|
||||||
if (!segmentId) {
|
if (!segmentId) {
|
||||||
@@ -51,7 +49,7 @@ export class Segments {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getContacts(req: Request, res: Response, _next: NextFunction) {
|
public async getContacts(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const segmentId = req.params.id;
|
const segmentId = req.params.id;
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
|
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
|
||||||
@@ -73,7 +71,7 @@ export class Segments {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {name, description, condition, trackMembership} = req.body;
|
const {name, description, condition, trackMembership} = req.body;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -102,7 +100,7 @@ export class Segments {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async update(req: Request, res: Response, _next: NextFunction) {
|
public async update(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const segmentId = req.params.id;
|
const segmentId = req.params.id;
|
||||||
const {name, description, condition, trackMembership} = req.body;
|
const {name, description, condition, trackMembership} = req.body;
|
||||||
|
|
||||||
@@ -132,7 +130,7 @@ export class Segments {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async delete(req: Request, res: Response, _next: NextFunction) {
|
public async delete(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const segmentId = req.params.id;
|
const segmentId = req.params.id;
|
||||||
|
|
||||||
if (!segmentId) {
|
if (!segmentId) {
|
||||||
@@ -152,7 +150,7 @@ export class Segments {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async compute(req: Request, res: Response, _next: NextFunction) {
|
public async compute(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const segmentId = req.params.id;
|
const segmentId = req.params.id;
|
||||||
|
|
||||||
if (!segmentId) {
|
if (!segmentId) {
|
||||||
@@ -172,7 +170,7 @@ export class Segments {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async refresh(req: Request, res: Response, _next: NextFunction) {
|
public async refresh(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const segmentId = req.params.id;
|
const segmentId = req.params.id;
|
||||||
|
|
||||||
if (!segmentId) {
|
if (!segmentId) {
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/core';
|
||||||
import {TemplateType} from '@plunk/db';
|
import {TemplateType} from '@plunk/db';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
|
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
|
||||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||||
import {DomainService} from '../services/DomainService.js';
|
import {DomainService} from '../services/DomainService.js';
|
||||||
import {TemplateService} from '../services/TemplateService.js';
|
import {TemplateService} from '../services/TemplateService.js';
|
||||||
@@ -18,7 +16,7 @@ export class Templates {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async list(req: Request, res: Response, _next: NextFunction) {
|
public async list(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
|
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
|
||||||
const search = req.query.search as string | undefined;
|
const search = req.query.search as string | undefined;
|
||||||
@@ -37,7 +35,7 @@ export class Templates {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async get(req: Request, res: Response, _next: NextFunction) {
|
public async get(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const templateId = req.params.id;
|
const templateId = req.params.id;
|
||||||
|
|
||||||
if (!templateId) {
|
if (!templateId) {
|
||||||
@@ -57,7 +55,7 @@ export class Templates {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
|
const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -101,7 +99,7 @@ export class Templates {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async update(req: Request, res: Response, _next: NextFunction) {
|
public async update(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const templateId = req.params.id;
|
const templateId = req.params.id;
|
||||||
const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
|
const {name, description, subject, body, from, fromName, replyTo, type} = req.body;
|
||||||
|
|
||||||
@@ -136,7 +134,7 @@ export class Templates {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async delete(req: Request, res: Response, _next: NextFunction) {
|
public async delete(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const templateId = req.params.id;
|
const templateId = req.params.id;
|
||||||
|
|
||||||
if (!templateId) {
|
if (!templateId) {
|
||||||
@@ -156,7 +154,7 @@ export class Templates {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async duplicate(req: Request, res: Response, _next: NextFunction) {
|
public async duplicate(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const templateId = req.params.id;
|
const templateId = req.params.id;
|
||||||
|
|
||||||
if (!templateId) {
|
if (!templateId) {
|
||||||
@@ -176,7 +174,7 @@ export class Templates {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getUsage(req: Request, res: Response, _next: NextFunction) {
|
public async getUsage(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const templateId = req.params.id;
|
const templateId = req.params.id;
|
||||||
|
|
||||||
if (!templateId) {
|
if (!templateId) {
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import {Controller, Middleware, Post} from '@overnightjs/core';
|
|||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
|
||||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||||
import * as S3Service from '../services/S3Service.js';
|
import * as S3Service from '../services/S3Service.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
@@ -15,7 +13,6 @@ const upload = multer({
|
|||||||
fileSize: 10 * 1024 * 1024, // 10MB max file size
|
fileSize: 10 * 1024 * 1024, // 10MB max file size
|
||||||
},
|
},
|
||||||
fileFilter: (_req, file, cb) => {
|
fileFilter: (_req, file, cb) => {
|
||||||
// Only accept image files
|
|
||||||
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
|
||||||
|
|
||||||
if (allowedMimeTypes.includes(file.mimetype)) {
|
if (allowedMimeTypes.includes(file.mimetype)) {
|
||||||
@@ -36,7 +33,7 @@ export class Uploads {
|
|||||||
@Middleware([requireAuth, requireEmailVerified, upload.single('image')])
|
@Middleware([requireAuth, requireEmailVerified, upload.single('image')])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async uploadImage(req: Request, res: Response, _next: NextFunction) {
|
public async uploadImage(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!S3Service.isS3Enabled()) {
|
if (!S3Service.isS3Enabled()) {
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import {DASHBOARD_URI, STRIPE_ENABLED, STRIPE_PRICE_EMAIL_USAGE, STRIPE_PRICE_ON
|
|||||||
import {stripe} from '../app/stripe.js';
|
import {stripe} from '../app/stripe.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exceptions/index.js';
|
import {ErrorCode, HttpException, NotAuthenticated, NotFound} from '../exceptions/index.js';
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
|
||||||
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
||||||
import {BillingLimitService} from '../services/BillingLimitService.js';
|
import {BillingLimitService} from '../services/BillingLimitService.js';
|
||||||
|
import {MembershipService} from '../services/MembershipService.js';
|
||||||
import {NtfyService} from '../services/NtfyService.js';
|
import {NtfyService} from '../services/NtfyService.js';
|
||||||
import {SecurityService} from '../services/SecurityService.js';
|
import {SecurityService} from '../services/SecurityService.js';
|
||||||
import {UserService} from '../services/UserService.js';
|
import {UserService} from '../services/UserService.js';
|
||||||
@@ -23,7 +23,7 @@ export class Users {
|
|||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([isAuthenticated, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async me(req: Request, res: Response, _next: NextFunction) {
|
public async me(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
@@ -42,7 +42,7 @@ export class Users {
|
|||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([isAuthenticated, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async meProjects(req: Request, res: Response, _next: NextFunction) {
|
public async meProjects(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
@@ -57,7 +57,7 @@ export class Users {
|
|||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([isAuthenticated, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async createProject(req: Request, res: Response, _next: NextFunction) {
|
public async createProject(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
@@ -104,24 +104,12 @@ export class Users {
|
|||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([isAuthenticated, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async updateProject(req: Request, res: Response, _next: NextFunction) {
|
public async updateProject(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
const data = ProjectSchemas.update.parse(req.body);
|
const data = ProjectSchemas.update.parse(req.body);
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has admin/owner access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission to update it');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the project
|
// Update the project
|
||||||
const project = await prisma.project.update({
|
const project = await prisma.project.update({
|
||||||
@@ -136,23 +124,11 @@ export class Users {
|
|||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([isAuthenticated, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) {
|
public async regenerateProjectKeys(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Verify user has admin/owner access to this project
|
// Verify user has admin/owner access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission to regenerate keys');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new unique API keys
|
// Generate new unique API keys
|
||||||
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
|
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
|
||||||
@@ -188,7 +164,7 @@ export class Users {
|
|||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([isAuthenticated, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
|
public async createCheckoutSession(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
const {currency} = req.query;
|
const {currency} = req.query;
|
||||||
|
|
||||||
@@ -197,20 +173,8 @@ export class Users {
|
|||||||
return res.status(404).json({error: 'Billing is not enabled'});
|
return res.status(404).json({error: 'Billing is not enabled'});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has admin/owner access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission to manage billing');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the project
|
// Get the project
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
@@ -280,7 +244,7 @@ export class Users {
|
|||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([isAuthenticated, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) {
|
public async createBillingPortalSession(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Check if billing is enabled
|
// Check if billing is enabled
|
||||||
@@ -288,20 +252,8 @@ export class Users {
|
|||||||
return res.status(404).json({error: 'Billing is not enabled'});
|
return res.status(404).json({error: 'Billing is not enabled'});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has admin/owner access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission to manage billing');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the project
|
// Get the project
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
@@ -330,7 +282,7 @@ export class Users {
|
|||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([isAuthenticated, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
public async getBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
@@ -342,16 +294,7 @@ export class Users {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission to view billing limits');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get billing limits and usage
|
// Get billing limits and usage
|
||||||
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
|
const limitsAndUsage = await BillingLimitService.getLimitsAndUsage(id);
|
||||||
@@ -363,7 +306,7 @@ export class Users {
|
|||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([isAuthenticated, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
public async updateBillingLimits(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
@@ -377,19 +320,7 @@ export class Users {
|
|||||||
const data = BillingLimitSchemas.update.parse(req.body);
|
const data = BillingLimitSchemas.update.parse(req.body);
|
||||||
|
|
||||||
// Verify user has admin/owner access to this project
|
// Verify user has admin/owner access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission to update billing limits');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the project with current limits
|
// Get the project with current limits
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
@@ -399,6 +330,7 @@ export class Users {
|
|||||||
billingLimitWorkflows: true,
|
billingLimitWorkflows: true,
|
||||||
billingLimitCampaigns: true,
|
billingLimitCampaigns: true,
|
||||||
billingLimitTransactional: true,
|
billingLimitTransactional: true,
|
||||||
|
billingLimitInbound: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -418,6 +350,7 @@ export class Users {
|
|||||||
billingLimitWorkflows: data.workflows,
|
billingLimitWorkflows: data.workflows,
|
||||||
billingLimitCampaigns: data.campaigns,
|
billingLimitCampaigns: data.campaigns,
|
||||||
billingLimitTransactional: data.transactional,
|
billingLimitTransactional: data.transactional,
|
||||||
|
billingLimitInbound: data.inbound,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -429,6 +362,7 @@ export class Users {
|
|||||||
workflows: project.billingLimitWorkflows,
|
workflows: project.billingLimitWorkflows,
|
||||||
campaigns: project.billingLimitCampaigns,
|
campaigns: project.billingLimitCampaigns,
|
||||||
transactional: project.billingLimitTransactional,
|
transactional: project.billingLimitTransactional,
|
||||||
|
inbound: project.billingLimitInbound,
|
||||||
},
|
},
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
@@ -442,7 +376,7 @@ export class Users {
|
|||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([isAuthenticated, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) {
|
public async getBillingConsumption(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Check if billing is enabled
|
// Check if billing is enabled
|
||||||
@@ -459,16 +393,7 @@ export class Users {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission to view billing');
|
|
||||||
}
|
|
||||||
|
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
where: {id},
|
where: {id},
|
||||||
@@ -568,7 +493,7 @@ export class Users {
|
|||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([isAuthenticated, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) {
|
public async getBillingInvoices(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
// Check if billing is enabled
|
// Check if billing is enabled
|
||||||
@@ -585,16 +510,7 @@ export class Users {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission to view billing');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the project
|
// Get the project
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
@@ -656,7 +572,7 @@ export class Users {
|
|||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([isAuthenticated, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) {
|
public async getSecurityHealth(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
@@ -668,16 +584,7 @@ export class Users {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has access to this project
|
// Verify user has access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission to view security metrics');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get security metrics
|
// Get security metrics
|
||||||
const metrics = await SecurityService.getProjectSecurityMetrics(id);
|
const metrics = await SecurityService.getProjectSecurityMetrics(id);
|
||||||
@@ -689,7 +596,7 @@ export class Users {
|
|||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([isAuthenticated, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async resetProject(req: Request, res: Response, _next: NextFunction) {
|
public async resetProject(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
@@ -701,19 +608,7 @@ export class Users {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has admin/owner access to this project
|
// Verify user has admin/owner access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
role: {
|
|
||||||
in: ['ADMIN', 'OWNER'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound('Project not found or you do not have permission to reset it');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if project is disabled - block reset operation
|
// Check if project is disabled - block reset operation
|
||||||
const isDisabled = await SecurityService.isProjectDisabled(id);
|
const isDisabled = await SecurityService.isProjectDisabled(id);
|
||||||
@@ -775,7 +670,7 @@ export class Users {
|
|||||||
@Middleware([isAuthenticated, requireEmailVerified])
|
@Middleware([isAuthenticated, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async deleteProject(req: Request, res: Response, _next: NextFunction) {
|
public async deleteProject(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {id} = UtilitySchemas.id.parse(req.params);
|
const {id} = UtilitySchemas.id.parse(req.params);
|
||||||
|
|
||||||
if (!auth.userId) {
|
if (!auth.userId) {
|
||||||
@@ -787,21 +682,7 @@ export class Users {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has owner or admin access to this project
|
// Verify user has owner or admin access to this project
|
||||||
const membership = await prisma.membership.findFirst({
|
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||||
where: {
|
|
||||||
userId: auth.userId,
|
|
||||||
projectId: id,
|
|
||||||
role: {
|
|
||||||
in: ['OWNER', 'ADMIN'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new NotFound(
|
|
||||||
'Project not found or you do not have permission to delete it. Only project owners and admins can delete projects.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get project to check for active subscription and disabled status
|
// Get project to check for active subscription and disabled status
|
||||||
const project = await prisma.project.findUnique({
|
const project = await prisma.project.findUnique({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Controller, Post} from '@overnightjs/core';
|
import {Controller, Post} from '@overnightjs/core';
|
||||||
import type {Prisma} from '@plunk/db';
|
import type {Prisma} from '@plunk/db';
|
||||||
import {EmailStatus} from '@plunk/db';
|
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||||
import type {Request, Response} from 'express';
|
import type {Request, Response} from 'express';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
@@ -8,7 +8,10 @@ import type Stripe from 'stripe';
|
|||||||
import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
|
import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
|
||||||
import {stripe} from '../app/stripe.js';
|
import {stripe} from '../app/stripe.js';
|
||||||
import {prisma} from '../database/prisma.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 {EventService} from '../services/EventService.js';
|
||||||
|
import {MeterService} from '../services/MeterService.js';
|
||||||
import {NtfyService} from '../services/NtfyService.js';
|
import {NtfyService} from '../services/NtfyService.js';
|
||||||
import {SecurityService} from '../services/SecurityService.js';
|
import {SecurityService} from '../services/SecurityService.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
@@ -21,7 +24,8 @@ import {CatchAsync} from '../utils/asyncHandler.js';
|
|||||||
export class Webhooks {
|
export class Webhooks {
|
||||||
/**
|
/**
|
||||||
* Receive SNS webhook notifications from AWS SES
|
* Receive SNS webhook notifications from AWS SES
|
||||||
* Handles email events: delivery, open, click, bounce, complaint
|
* Handles outbound email events: delivery, open, click, bounce, complaint
|
||||||
|
* Handles inbound email notifications: received emails via SES receiving
|
||||||
*/
|
*/
|
||||||
@Post('sns')
|
@Post('sns')
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
@@ -67,6 +71,150 @@ export class Webhooks {
|
|||||||
|
|
||||||
// Parse the nested SES event from the Message field
|
// Parse the nested SES event from the Message field
|
||||||
const body = JSON.parse(req.body.Message);
|
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 eventType = body.eventType as 'Bounce' | 'Delivery' | 'Open' | 'Complaint' | 'Click';
|
||||||
const messageId = body.mail?.messageId;
|
const messageId = body.mail?.messageId;
|
||||||
|
|
||||||
@@ -152,29 +300,62 @@ export class Webhooks {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Bounce':
|
case 'Bounce': {
|
||||||
signale.warn(`[WEBHOOK] Bounce received for ${email.contact.email} from ${email.project.name}`);
|
const bounceType = body.bounce?.bounceType;
|
||||||
updateData.status = EmailStatus.BOUNCED;
|
const isPermanentBounce = bounceType === 'Permanent';
|
||||||
updateData.bouncedAt = now;
|
const isTransientBounce = bounceType === 'Transient';
|
||||||
// Unsubscribe contact on bounce
|
|
||||||
await prisma.contact.update({
|
|
||||||
where: {id: email.contactId},
|
|
||||||
data: {subscribed: false},
|
|
||||||
});
|
|
||||||
eventData = {
|
|
||||||
...baseEventData,
|
|
||||||
bounceType: body.bounce?.bounceType,
|
|
||||||
bouncedAt: now.toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send notification about bounce
|
if (isPermanentBounce) {
|
||||||
await NtfyService.notifyEmailBounce(
|
// Hard bounce - counts toward bounce rate and unsubscribes contact
|
||||||
email.project.name,
|
signale.warn(`[WEBHOOK] Permanent bounce received for ${email.contact.email} from ${email.project.name}`);
|
||||||
email.projectId,
|
updateData.status = EmailStatus.BOUNCED;
|
||||||
email.contact.email,
|
updateData.bouncedAt = now;
|
||||||
body.bounce?.bounceType,
|
// 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;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'Complaint':
|
case 'Complaint':
|
||||||
signale.warn(`[WEBHOOK] Complaint received for ${email.contact.email} from ${email.project.name}`);
|
signale.warn(`[WEBHOOK] Complaint received for ${email.contact.email} from ${email.project.name}`);
|
||||||
@@ -208,8 +389,10 @@ export class Webhooks {
|
|||||||
// Track event (this will trigger workflows)
|
// Track event (this will trigger workflows)
|
||||||
await EventService.trackEvent(email.projectId, eventName, email.contactId, email.id, eventData);
|
await EventService.trackEvent(email.projectId, eventName, email.contactId, email.id, eventData);
|
||||||
|
|
||||||
// Check security limits for bounce and complaint events
|
// Check security limits only for permanent bounces and complaints
|
||||||
if (eventType === 'Bounce' || eventType === 'Complaint') {
|
// 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);
|
await SecurityService.checkAndEnforceSecurityLimits(email.projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import {Controller, Delete, Get, Middleware, Patch, Post} from '@overnightjs/cor
|
|||||||
import {WorkflowExecutionStatus} from '@plunk/db';
|
import {WorkflowExecutionStatus} from '@plunk/db';
|
||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import type {AuthResponse} from '../middleware/auth.js';
|
|
||||||
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
import {requireAuth, requireEmailVerified} from '../middleware/auth.js';
|
||||||
import {WorkflowService} from '../services/WorkflowService.js';
|
import {WorkflowService} from '../services/WorkflowService.js';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
@@ -18,7 +16,7 @@ export class Workflows {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async list(req: Request, res: Response, _next: NextFunction) {
|
public async list(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
|
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
|
||||||
const search = req.query.search as string | undefined;
|
const search = req.query.search as string | undefined;
|
||||||
@@ -38,7 +36,7 @@ export class Workflows {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
|
public async getAvailableFields(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const eventName = req.query.eventName as string | undefined;
|
const eventName = req.query.eventName as string | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -61,7 +59,7 @@ export class Workflows {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async get(req: Request, res: Response, _next: NextFunction) {
|
public async get(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const workflowId = req.params.id;
|
const workflowId = req.params.id;
|
||||||
|
|
||||||
if (!workflowId) {
|
if (!workflowId) {
|
||||||
@@ -81,7 +79,7 @@ export class Workflows {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async create(req: Request, res: Response, _next: NextFunction) {
|
public async create(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const {name, description, eventName, enabled, allowReentry} = req.body;
|
const {name, description, eventName, enabled, allowReentry} = req.body;
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -111,7 +109,7 @@ export class Workflows {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async update(req: Request, res: Response, _next: NextFunction) {
|
public async update(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const workflowId = req.params.id;
|
const workflowId = req.params.id;
|
||||||
const {name, description, triggerType, triggerConfig, enabled, allowReentry} = req.body;
|
const {name, description, triggerType, triggerConfig, enabled, allowReentry} = req.body;
|
||||||
|
|
||||||
@@ -139,7 +137,7 @@ export class Workflows {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async delete(req: Request, res: Response, _next: NextFunction) {
|
public async delete(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const workflowId = req.params.id;
|
const workflowId = req.params.id;
|
||||||
|
|
||||||
if (!workflowId) {
|
if (!workflowId) {
|
||||||
@@ -159,7 +157,7 @@ export class Workflows {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async addStep(req: Request, res: Response, _next: NextFunction) {
|
public async addStep(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const workflowId = req.params.id;
|
const workflowId = req.params.id;
|
||||||
const {type, name, position, config, templateId, autoConnect} = req.body;
|
const {type, name, position, config, templateId, autoConnect} = req.body;
|
||||||
|
|
||||||
@@ -191,7 +189,7 @@ export class Workflows {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async updateStep(req: Request, res: Response, _next: NextFunction) {
|
public async updateStep(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const workflowId = req.params.id;
|
const workflowId = req.params.id;
|
||||||
const stepId = req.params.stepId;
|
const stepId = req.params.stepId;
|
||||||
const {name, position, config, templateId} = req.body;
|
const {name, position, config, templateId} = req.body;
|
||||||
@@ -218,7 +216,7 @@ export class Workflows {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async deleteStep(req: Request, res: Response, _next: NextFunction) {
|
public async deleteStep(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const workflowId = req.params.id;
|
const workflowId = req.params.id;
|
||||||
const stepId = req.params.stepId;
|
const stepId = req.params.stepId;
|
||||||
|
|
||||||
@@ -239,7 +237,7 @@ export class Workflows {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async createTransition(req: Request, res: Response, _next: NextFunction) {
|
public async createTransition(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const workflowId = req.params.id;
|
const workflowId = req.params.id;
|
||||||
const {fromStepId, toStepId, condition, priority} = req.body;
|
const {fromStepId, toStepId, condition, priority} = req.body;
|
||||||
|
|
||||||
@@ -269,7 +267,7 @@ export class Workflows {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async deleteTransition(req: Request, res: Response, _next: NextFunction) {
|
public async deleteTransition(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const workflowId = req.params.id;
|
const workflowId = req.params.id;
|
||||||
const transitionId = req.params.transitionId;
|
const transitionId = req.params.transitionId;
|
||||||
|
|
||||||
@@ -290,7 +288,7 @@ export class Workflows {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async startExecution(req: Request, res: Response, _next: NextFunction) {
|
public async startExecution(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const workflowId = req.params.id;
|
const workflowId = req.params.id;
|
||||||
const {contactId, context} = req.body;
|
const {contactId, context} = req.body;
|
||||||
|
|
||||||
@@ -315,7 +313,7 @@ export class Workflows {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async listExecutions(req: Request, res: Response, _next: NextFunction) {
|
public async listExecutions(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const workflowId = req.params.id;
|
const workflowId = req.params.id;
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
|
const pageSize = Math.min(parseInt(req.query.pageSize as string) || 20, 100);
|
||||||
@@ -338,7 +336,7 @@ export class Workflows {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getExecution(req: Request, res: Response, _next: NextFunction) {
|
public async getExecution(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const workflowId = req.params.id;
|
const workflowId = req.params.id;
|
||||||
const executionId = req.params.executionId;
|
const executionId = req.params.executionId;
|
||||||
|
|
||||||
@@ -359,7 +357,7 @@ export class Workflows {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async cancelExecution(req: Request, res: Response, _next: NextFunction) {
|
public async cancelExecution(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const workflowId = req.params.id;
|
const workflowId = req.params.id;
|
||||||
const executionId = req.params.executionId;
|
const executionId = req.params.executionId;
|
||||||
|
|
||||||
@@ -380,7 +378,7 @@ export class Workflows {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async cancelAllExecutions(req: Request, res: Response, _next: NextFunction) {
|
public async cancelAllExecutions(req: Request, res: Response, _next: NextFunction) {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
const workflowId = req.params.id;
|
const workflowId = req.params.id;
|
||||||
|
|
||||||
if (!workflowId) {
|
if (!workflowId) {
|
||||||
|
|||||||
@@ -74,9 +74,7 @@ export class NotFound extends HttpException {
|
|||||||
* @param id Optional resource identifier to include in the message
|
* @param id Optional resource identifier to include in the message
|
||||||
*/
|
*/
|
||||||
public constructor(resource: string, id?: string) {
|
public constructor(resource: string, id?: string) {
|
||||||
const message = id
|
const message = id ? `${resource} with ID "${id}" was not found` : `That ${resource.toLowerCase()} was not found`;
|
||||||
? `${resource} with ID "${id}" was not found`
|
|
||||||
: `That ${resource.toLowerCase()} was not found`;
|
|
||||||
|
|
||||||
// Map common resources to specific error codes
|
// Map common resources to specific error codes
|
||||||
const errorCodeMap: Record<string, ErrorCode> = {
|
const errorCodeMap: Record<string, ErrorCode> = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import type {Prisma} from '@plunk/db';
|
|
||||||
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
|
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
|
||||||
|
import {toPrismaJson} from '@plunk/types';
|
||||||
import {createServiceMocks, factories, getPrismaClient} from '../../../../../test/helpers';
|
import {createServiceMocks, factories, getPrismaClient} from '../../../../../test/helpers';
|
||||||
|
|
||||||
// Mock MeterService
|
// Mock MeterService
|
||||||
@@ -29,12 +29,6 @@ describe('Email Processor', () => {
|
|||||||
status: EmailStatus.PENDING,
|
status: EmailStatus.PENDING,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock the email processor logic
|
|
||||||
// In a real implementation, you would:
|
|
||||||
// 1. Create job tester
|
|
||||||
// 2. Mock SES service
|
|
||||||
// 3. Process the job
|
|
||||||
// 4. Verify status changes
|
|
||||||
|
|
||||||
// Simulate processing
|
// Simulate processing
|
||||||
await prisma.email.update({
|
await prisma.email.update({
|
||||||
@@ -261,13 +255,13 @@ describe('Email Processor', () => {
|
|||||||
from: '[email protected]',
|
from: '[email protected]',
|
||||||
status: EmailStatus.PENDING,
|
status: EmailStatus.PENDING,
|
||||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||||
attachments: [
|
attachments: toPrismaJson([
|
||||||
{
|
{
|
||||||
filename: 'document.pdf',
|
filename: 'document.pdf',
|
||||||
content: 'base64encodedcontent',
|
content: 'base64encodedcontent',
|
||||||
contentType: 'application/pdf',
|
contentType: 'application/pdf',
|
||||||
},
|
},
|
||||||
] as unknown as Prisma.InputJsonValue,
|
]),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -306,9 +300,7 @@ describe('Email Processor', () => {
|
|||||||
from: '[email protected]',
|
from: '[email protected]',
|
||||||
status: EmailStatus.PENDING,
|
status: EmailStatus.PENDING,
|
||||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||||
attachments: [
|
attachments: toPrismaJson([{filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'}]),
|
||||||
{filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'},
|
|
||||||
] as unknown as Prisma.InputJsonValue,
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
project: true,
|
project: true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {describe, it, expect, beforeEach, afterEach} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||||
import {CampaignStatus} from '@plunk/db';
|
import {CampaignStatus} from '@plunk/db';
|
||||||
import {factories, getPrismaClient, createTimeControl} from '../../../../../test/helpers';
|
import {createTimeControl, factories, getPrismaClient} from '../../../../../test/helpers';
|
||||||
|
|
||||||
describe('Scheduled Campaign Processor', () => {
|
describe('Scheduled Campaign Processor', () => {
|
||||||
let projectId: string;
|
let projectId: string;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type {ApiRequestCleanupJobData} from '@plunk/types';
|
||||||
import type {Job} from 'bullmq';
|
import type {Job} from 'bullmq';
|
||||||
import {Worker} from 'bullmq';
|
import {Worker} from 'bullmq';
|
||||||
import type {RedisOptions} from 'ioredis';
|
import type {RedisOptions} from 'ioredis';
|
||||||
@@ -5,7 +6,6 @@ import signale from 'signale';
|
|||||||
|
|
||||||
import {REDIS_URL} from '../app/constants.js';
|
import {REDIS_URL} from '../app/constants.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import type {ApiRequestCleanupJobData} from '../services/QueueService.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API Request Cleanup Worker
|
* API Request Cleanup Worker
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
* Processes bulk subscribe, unsubscribe, and delete operations
|
* Processes bulk subscribe, unsubscribe, and delete operations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type {BulkContactActionJobData} from '@plunk/types';
|
||||||
import {type Job, Worker} from 'bullmq';
|
import {type Job, Worker} from 'bullmq';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import {ContactService} from '../services/ContactService.js';
|
import {ContactService} from '../services/ContactService.js';
|
||||||
import {type BulkContactActionJobData, bulkContactQueue} from '../services/QueueService.js';
|
import {bulkContactQueue} from '../services/QueueService.js';
|
||||||
|
|
||||||
const BATCH_SIZE = 100; // Process contacts in batches of 100
|
const BATCH_SIZE = 100; // Process contacts in batches of 100
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,12 @@
|
|||||||
* Processes campaign batches (queues emails for each contact in the batch)
|
* Processes campaign batches (queues emails for each contact in the batch)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type {CampaignBatchJobData} from '@plunk/types';
|
||||||
import {type Job, Worker} from 'bullmq';
|
import {type Job, Worker} from 'bullmq';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import {CampaignService} from '../services/CampaignService.js';
|
import {CampaignService} from '../services/CampaignService.js';
|
||||||
import {type CampaignBatchJobData, campaignQueue} from '../services/QueueService.js';
|
import {campaignQueue} from '../services/QueueService.js';
|
||||||
|
|
||||||
export function createCampaignWorker() {
|
export function createCampaignWorker() {
|
||||||
const worker = new Worker<CampaignBatchJobData>(
|
const worker = new Worker<CampaignBatchJobData>(
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
* Processes domain verification jobs from the BullMQ queue
|
* Processes domain verification jobs from the BullMQ queue
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type {DomainVerificationJobData} from '@plunk/types';
|
||||||
import {type Job, Worker} from 'bullmq';
|
import {type Job, Worker} from 'bullmq';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import {type DomainVerificationJobData, domainVerificationQueue} from '../services/QueueService.js';
|
import {domainVerificationQueue} from '../services/QueueService.js';
|
||||||
|
|
||||||
import {checkDomainVerifications} from './domain-verification.js';
|
import {checkDomainVerifications} from './domain-verification.js';
|
||||||
|
|
||||||
|
|||||||
@@ -6,10 +6,14 @@
|
|||||||
* Scheduled to run every 5 minutes via repeatable jobs
|
* Scheduled to run every 5 minutes via repeatable jobs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
import signale from 'signale';
|
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 {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
|
import {MembershipService} from '../services/MembershipService.js';
|
||||||
import {disableFeedbackForwarding, getIdentities, verifyDomain} from '../services/SESService.js';
|
import {disableFeedbackForwarding, getIdentities, verifyDomain} from '../services/SESService.js';
|
||||||
import {Keys} from '../services/keys.js';
|
import {Keys} from '../services/keys.js';
|
||||||
|
|
||||||
@@ -26,7 +30,15 @@ export async function checkDomainVerifications() {
|
|||||||
// Process domains in batches of 99 (AWS SES limit is 100)
|
// Process domains in batches of 99 (AWS SES limit is 100)
|
||||||
for (let i = 0; i < count; i += 99) {
|
for (let i = 0; i < count; i += 99) {
|
||||||
const domains = await prisma.domain.findMany({
|
const domains = await prisma.domain.findMany({
|
||||||
select: {id: true, domain: true, projectId: true, verified: true},
|
select: {
|
||||||
|
id: true,
|
||||||
|
domain: true,
|
||||||
|
projectId: true,
|
||||||
|
verified: true,
|
||||||
|
project: {
|
||||||
|
select: {name: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
skip: i,
|
skip: i,
|
||||||
take: 99,
|
take: 99,
|
||||||
});
|
});
|
||||||
@@ -64,7 +76,11 @@ export async function checkDomainVerifications() {
|
|||||||
signale.success(`[DOMAIN-VERIFICATION] Restarted verification for ${sesIdentity.domain}`);
|
signale.success(`[DOMAIN-VERIFICATION] Restarted verification for ${sesIdentity.domain}`);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const error = e as {Code?: string; name?: string; message?: string};
|
const error = e as {Code?: string; name?: string; message?: string};
|
||||||
if (error?.Code === 'Throttling' || error?.name === 'Throttling' || error?.message?.includes('Throttling')) {
|
if (
|
||||||
|
error?.Code === 'Throttling' ||
|
||||||
|
error?.name === 'Throttling' ||
|
||||||
|
error?.message?.includes('Throttling')
|
||||||
|
) {
|
||||||
signale.warn(
|
signale.warn(
|
||||||
`[DOMAIN-VERIFICATION] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
|
`[DOMAIN-VERIFICATION] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
|
||||||
);
|
);
|
||||||
@@ -72,7 +88,9 @@ export async function checkDomainVerifications() {
|
|||||||
delay *= 2; // Exponential backoff
|
delay *= 2; // Exponential backoff
|
||||||
attempt++;
|
attempt++;
|
||||||
} else {
|
} else {
|
||||||
signale.error(`[DOMAIN-VERIFICATION] Error restarting verification: ${error?.message || 'Unknown error'}`);
|
signale.error(
|
||||||
|
`[DOMAIN-VERIFICATION] Error restarting verification: ${error?.message || 'Unknown error'}`,
|
||||||
|
);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,6 +120,34 @@ export async function checkDomainVerifications() {
|
|||||||
signale.error(`[DOMAIN-VERIFICATION] Error disabling feedback forwarding: ${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
|
// Invalidate cache
|
||||||
await redis.del(Keys.Domain.id(dbDomain.id));
|
await redis.del(Keys.Domain.id(dbDomain.id));
|
||||||
await redis.del(Keys.Domain.project(dbDomain.projectId));
|
await redis.del(Keys.Domain.project(dbDomain.projectId));
|
||||||
@@ -111,6 +157,38 @@ export async function checkDomainVerifications() {
|
|||||||
if (dbDomain.verified && !isVerified) {
|
if (dbDomain.verified && !isVerified) {
|
||||||
signale.warn(`[DOMAIN-VERIFICATION] Domain ${sesIdentity.domain} is no longer verified`);
|
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.id(dbDomain.id));
|
||||||
await redis.del(Keys.Domain.project(dbDomain.projectId));
|
await redis.del(Keys.Domain.project(dbDomain.projectId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {CampaignStatus, EmailSourceType, EmailStatus} from '@plunk/db';
|
import {CampaignStatus, EmailSourceType, EmailStatus} from '@plunk/db';
|
||||||
|
import type {SendEmailJobData} from '@plunk/types';
|
||||||
import {type Job, Worker} from 'bullmq';
|
import {type Job, Worker} from 'bullmq';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
|
import {DASHBOARD_URI, EMAIL_RATE_LIMIT_PER_SECOND} from '../app/constants.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {EmailService} from '../services/EmailService.js';
|
import {EmailService} from '../services/EmailService.js';
|
||||||
import {EventService} from '../services/EventService.js';
|
import {EventService} from '../services/EventService.js';
|
||||||
import {MeterService} from '../services/MeterService.js';
|
import {MeterService} from '../services/MeterService.js';
|
||||||
import {emailQueue, type SendEmailJobData} from '../services/QueueService.js';
|
import {emailQueue} from '../services/QueueService.js';
|
||||||
import {getSendingQuota, sendRawEmail} from '../services/SESService.js';
|
import {getSendingQuota, sendRawEmail} from '../services/SESService.js';
|
||||||
import {DASHBOARD_URI, EMAIL_RATE_LIMIT_PER_SECOND} from '../app/constants.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine the email sending rate limit (emails per second)
|
* Determine the email sending rate limit (emails per second)
|
||||||
@@ -116,10 +117,25 @@ export async function createEmailWorker() {
|
|||||||
const fromName = email.fromName || email.project.name;
|
const fromName = email.fromName || email.project.name;
|
||||||
const fromEmail = email.from;
|
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
|
// Build recipient with name if available
|
||||||
const recipient: {name?: string; email: string} | string = email.toName
|
const recipient: {name?: string; email: string} | string = email.toName
|
||||||
? {name: email.toName, email: email.contact.email}
|
? {name: email.toName, email: recipientEmail}
|
||||||
: email.contact.email;
|
: recipientEmail;
|
||||||
|
|
||||||
// Determine tracking based on project settings and email type
|
// Determine tracking based on project settings and email type
|
||||||
const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType);
|
const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType);
|
||||||
@@ -136,6 +152,7 @@ export async function createEmailWorker() {
|
|||||||
html: compiledHtml,
|
html: compiledHtml,
|
||||||
},
|
},
|
||||||
reply: email.replyTo || undefined,
|
reply: email.replyTo || undefined,
|
||||||
|
headers: publicHeaders,
|
||||||
tracking: shouldTrack,
|
tracking: shouldTrack,
|
||||||
attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null,
|
attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null,
|
||||||
});
|
});
|
||||||
@@ -203,6 +220,7 @@ export async function createEmailWorker() {
|
|||||||
where: {id: email.campaignId},
|
where: {id: email.campaignId},
|
||||||
data: {
|
data: {
|
||||||
status: CampaignStatus.SENT,
|
status: CampaignStatus.SENT,
|
||||||
|
sentCount,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* Processes CSV contact imports with validation and batch processing
|
* Processes CSV contact imports with validation and batch processing
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type {ContactImportJobData} from '@plunk/types';
|
||||||
import {type Job, Worker} from 'bullmq';
|
import {type Job, Worker} from 'bullmq';
|
||||||
import {parse} from 'csv-parse/sync';
|
import {parse} from 'csv-parse/sync';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
@@ -10,7 +11,7 @@ import signale from 'signale';
|
|||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {ContactService} from '../services/ContactService.js';
|
import {ContactService} from '../services/ContactService.js';
|
||||||
import {NtfyService} from '../services/NtfyService.js';
|
import {NtfyService} from '../services/NtfyService.js';
|
||||||
import {type ContactImportJobData, importQueue} from '../services/QueueService.js';
|
import {importQueue} from '../services/QueueService.js';
|
||||||
|
|
||||||
const BATCH_SIZE = 100; // Process contacts in batches of 100
|
const BATCH_SIZE = 100; // Process contacts in batches of 100
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {CampaignStatus} from '@plunk/db';
|
import {CampaignStatus} from '@plunk/db';
|
||||||
|
import type {ScheduledCampaignJobData} from '@plunk/types';
|
||||||
import {type Job, Worker} from 'bullmq';
|
import {type Job, Worker} from 'bullmq';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {CampaignService} from '../services/CampaignService.js';
|
import {CampaignService} from '../services/CampaignService.js';
|
||||||
import {type ScheduledCampaignJobData, scheduledQueue} from '../services/QueueService.js';
|
import {scheduledQueue} from '../services/QueueService.js';
|
||||||
|
|
||||||
export function createScheduledCampaignWorker() {
|
export function createScheduledCampaignWorker() {
|
||||||
const worker = new Worker<ScheduledCampaignJobData>(
|
const worker = new Worker<ScheduledCampaignJobData>(
|
||||||
|
|||||||
@@ -3,12 +3,13 @@
|
|||||||
* Processes segment count update jobs from the BullMQ queue
|
* Processes segment count update jobs from the BullMQ queue
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type {SegmentCountJobData} from '@plunk/types';
|
||||||
import {type Job, Worker} from 'bullmq';
|
import {type Job, Worker} from 'bullmq';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {NtfyService} from '../services/NtfyService.js';
|
import {NtfyService} from '../services/NtfyService.js';
|
||||||
import {type SegmentCountJobData, segmentCountQueue} from '../services/QueueService.js';
|
import {segmentCountQueue} from '../services/QueueService.js';
|
||||||
import {SegmentService} from '../services/SegmentService.js';
|
import {SegmentService} from '../services/SegmentService.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,8 +18,6 @@ import {SegmentService} from '../services/SegmentService.js';
|
|||||||
* - For segments without trackMembership: only update counts
|
* - For segments without trackMembership: only update counts
|
||||||
*/
|
*/
|
||||||
async function processProjectSegments(projectId: string, projectName?: string): Promise<void> {
|
async function processProjectSegments(projectId: string, projectName?: string): Promise<void> {
|
||||||
const logPrefix = projectName ? `${projectName} (${projectId})` : projectId;
|
|
||||||
|
|
||||||
// Get all segments for this project, separating tracked vs non-tracked
|
// Get all segments for this project, separating tracked vs non-tracked
|
||||||
const segments = await prisma.segment.findMany({
|
const segments = await prisma.segment.findMany({
|
||||||
where: {projectId},
|
where: {projectId},
|
||||||
@@ -28,45 +27,44 @@ async function processProjectSegments(projectId: string, projectName?: string):
|
|||||||
const trackedSegments = segments.filter(s => s.trackMembership);
|
const trackedSegments = segments.filter(s => s.trackMembership);
|
||||||
const nonTrackedSegments = segments.filter(s => !s.trackMembership);
|
const nonTrackedSegments = segments.filter(s => !s.trackMembership);
|
||||||
|
|
||||||
signale.info(
|
|
||||||
`[SEGMENT-COUNT-WORKER] Project ${logPrefix}: ${trackedSegments.length} tracked, ${nonTrackedSegments.length} non-tracked segments`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Process tracked segments with full membership computation (creates events)
|
// Process tracked segments with full membership computation (creates events)
|
||||||
|
let updatedSegmentCount = 0;
|
||||||
|
let totalAdded = 0;
|
||||||
|
let totalRemoved = 0;
|
||||||
|
|
||||||
if (trackedSegments.length > 0) {
|
if (trackedSegments.length > 0) {
|
||||||
for (const segment of trackedSegments) {
|
for (const segment of trackedSegments) {
|
||||||
try {
|
try {
|
||||||
signale.info(
|
|
||||||
`[SEGMENT-COUNT-WORKER] Computing membership for tracked segment "${segment.name}" (${segment.id})`,
|
|
||||||
);
|
|
||||||
const result = await SegmentService.computeMembership(projectId, segment.id);
|
const result = await SegmentService.computeMembership(projectId, segment.id);
|
||||||
signale.success(
|
|
||||||
`[SEGMENT-COUNT-WORKER] Segment "${segment.name}": +${result.added} entries, -${result.removed} exits, ${result.total} total members`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Notify about segment membership update only if there were actual changes
|
// Track segments with actual changes for bundled notification
|
||||||
if (projectName && (result.added > 0 || result.removed > 0)) {
|
if (result.added > 0 || result.removed > 0) {
|
||||||
await NtfyService.notifySegmentMembershipComputed(
|
updatedSegmentCount++;
|
||||||
segment.name,
|
totalAdded += result.added;
|
||||||
projectName,
|
totalRemoved += result.removed;
|
||||||
projectId,
|
|
||||||
result.total,
|
|
||||||
result.added,
|
|
||||||
result.removed,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[SEGMENT-COUNT-WORKER] Failed to compute membership for segment ${segment.id}:`, error);
|
signale.error(`[SEGMENT-COUNT-WORKER] Failed to compute membership for segment ${segment.id}:`, error);
|
||||||
// Continue with other segments
|
// 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)
|
// Process non-tracked segments with count-only update (lightweight)
|
||||||
if (nonTrackedSegments.length > 0) {
|
if (nonTrackedSegments.length > 0) {
|
||||||
try {
|
try {
|
||||||
await SegmentService.refreshAllMemberCounts(projectId);
|
await SegmentService.refreshAllMemberCounts(projectId);
|
||||||
signale.info(`[SEGMENT-COUNT-WORKER] Updated counts for ${nonTrackedSegments.length} non-tracked segments`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[SEGMENT-COUNT-WORKER] Failed to update counts for non-tracked segments:`, error);
|
signale.error(`[SEGMENT-COUNT-WORKER] Failed to update counts for non-tracked segments:`, error);
|
||||||
}
|
}
|
||||||
@@ -79,14 +77,10 @@ async function processProjectSegments(projectId: string, projectName?: string):
|
|||||||
async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise<void> {
|
async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise<void> {
|
||||||
const {projectId} = job.data;
|
const {projectId} = job.data;
|
||||||
|
|
||||||
signale.info(`[SEGMENT-COUNT-WORKER] Starting segment count update job ${job.id}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
// Process specific project
|
// Process specific project
|
||||||
signale.info(`[SEGMENT-COUNT-WORKER] Processing segments for project ${projectId}`);
|
|
||||||
await processProjectSegments(projectId);
|
await processProjectSegments(projectId);
|
||||||
signale.success(`[SEGMENT-COUNT-WORKER] Completed segments for project ${projectId}`);
|
|
||||||
} else {
|
} else {
|
||||||
// Process all active projects
|
// Process all active projects
|
||||||
const projects = await prisma.project.findMany({
|
const projects = await prisma.project.findMany({
|
||||||
@@ -94,7 +88,7 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
|
|||||||
select: {id: true, name: true},
|
select: {id: true, name: true},
|
||||||
});
|
});
|
||||||
|
|
||||||
signale.info(`[SEGMENT-COUNT-WORKER] Found ${projects.length} active projects`);
|
signale.info(`[SEGMENT-COUNT-WORKER] Processing ${projects.length} active projects`);
|
||||||
|
|
||||||
// Process projects in batches to avoid overwhelming the database
|
// Process projects in batches to avoid overwhelming the database
|
||||||
const PROJECT_BATCH_SIZE = 10;
|
const PROJECT_BATCH_SIZE = 10;
|
||||||
@@ -104,9 +98,7 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
batch.map(async project => {
|
batch.map(async project => {
|
||||||
try {
|
try {
|
||||||
signale.info(`[SEGMENT-COUNT-WORKER] Processing project ${project.name} (${project.id})`);
|
|
||||||
await processProjectSegments(project.id, project.name);
|
await processProjectSegments(project.id, project.name);
|
||||||
signale.success(`[SEGMENT-COUNT-WORKER] Completed project ${project.name}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[SEGMENT-COUNT-WORKER] Failed to process project ${project.id}:`, error);
|
signale.error(`[SEGMENT-COUNT-WORKER] Failed to process project ${project.id}:`, error);
|
||||||
// Don't throw - continue with other projects
|
// Don't throw - continue with other projects
|
||||||
@@ -119,8 +111,6 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
|
|||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
signale.success(`[SEGMENT-COUNT-WORKER] Completed all segment updates`);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[SEGMENT-COUNT-WORKER] Error processing job ${job.id}:`, error);
|
signale.error(`[SEGMENT-COUNT-WORKER] Error processing job ${job.id}:`, error);
|
||||||
@@ -147,10 +137,6 @@ export function createSegmentCountWorker(): Worker {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
worker.on('completed', job => {
|
|
||||||
signale.success(`[SEGMENT-COUNT-WORKER] Job ${job.id} completed`);
|
|
||||||
});
|
|
||||||
|
|
||||||
worker.on('failed', (job, error) => {
|
worker.on('failed', (job, error) => {
|
||||||
signale.error(`[SEGMENT-COUNT-WORKER] Job ${job?.id} failed:`, error);
|
signale.error(`[SEGMENT-COUNT-WORKER] Job ${job?.id} failed:`, error);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
* Processes workflow steps from the queue (for delayed steps)
|
* Processes workflow steps from the queue (for delayed steps)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type {WorkflowStepJobData} from '@plunk/types';
|
||||||
import {type Job, Worker} from 'bullmq';
|
import {type Job, Worker} from 'bullmq';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import {workflowQueue, type WorkflowStepJobData} from '../services/QueueService.js';
|
import {workflowQueue} from '../services/QueueService.js';
|
||||||
import {WorkflowExecutionService} from '../services/WorkflowExecutionService.js';
|
import {WorkflowExecutionService} from '../services/WorkflowExecutionService.js';
|
||||||
|
|
||||||
export function createWorkflowWorker() {
|
export function createWorkflowWorker() {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ describe('Request Logger Middleware', () => {
|
|||||||
projectId = project.id;
|
projectId = project.id;
|
||||||
userId = user.id;
|
userId = user.id;
|
||||||
|
|
||||||
// Mock request object
|
|
||||||
req = {
|
req = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/v1/send',
|
path: '/v1/send',
|
||||||
@@ -334,12 +333,7 @@ describe('Request Logger Middleware', () => {
|
|||||||
await res.json!({success: true});
|
await res.json!({success: true});
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
// TODO: Add assertion to verify request was NOT logged when disabled
|
|
||||||
// const loggedRequest = await prisma.apiRequest.findUnique({
|
|
||||||
// where: {id: 'test-request-id-123'},
|
|
||||||
// });
|
|
||||||
// expect(loggedRequest).toBeNull();
|
|
||||||
|
|
||||||
// Restore original value
|
// Restore original value
|
||||||
if (originalEnv !== undefined) {
|
if (originalEnv !== undefined) {
|
||||||
process.env.REQUEST_LOGGING = originalEnv;
|
process.env.REQUEST_LOGGING = originalEnv;
|
||||||
|
|||||||
+28
-109
@@ -2,15 +2,13 @@ import dayjs from 'dayjs';
|
|||||||
import type {NextFunction, Request, Response} from 'express';
|
import type {NextFunction, Request, Response} from 'express';
|
||||||
import jsonwebtoken from 'jsonwebtoken';
|
import jsonwebtoken from 'jsonwebtoken';
|
||||||
|
|
||||||
import {JWT_SECRET, PLUNK_ENABLED} from '../app/constants.js';
|
import type {AuthResponse} from '@plunk/types';
|
||||||
import {prisma} from '../database/prisma.js';
|
|
||||||
import {ErrorCode, HttpException, NotAuthenticated} from '../exceptions/index.js';
|
|
||||||
|
|
||||||
export interface AuthResponse {
|
import {JWT_SECRET, PLUNK_ENABLED} from '../app/constants.js';
|
||||||
type: 'jwt' | 'apiKey';
|
import {ErrorCode, HttpException, NotAuthenticated} from '../exceptions/index.js';
|
||||||
userId?: string;
|
import {MembershipService} from '../services/MembershipService.js';
|
||||||
projectId: string;
|
import {ProjectService} from '../services/ProjectService.js';
|
||||||
}
|
import {UserService} from '../services/UserService.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware to check if this unsubscribe is authenticated on the dashboard
|
* Middleware to check if this unsubscribe is authenticated on the dashboard
|
||||||
@@ -80,72 +78,6 @@ export function parseJwt(request: Request): string {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware to require project access
|
|
||||||
* Validates that the user is authenticated and has access to the project specified in X-Project-Id header
|
|
||||||
* @param req
|
|
||||||
* @param res
|
|
||||||
* @param next
|
|
||||||
*/
|
|
||||||
export const requireProjectAccess = async (req: Request, res: Response, next: NextFunction) => {
|
|
||||||
try {
|
|
||||||
// First authenticate the user
|
|
||||||
const userId = parseJwt(req);
|
|
||||||
|
|
||||||
// Get project ID from header
|
|
||||||
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([
|
|
||||||
prisma.membership.findUnique({
|
|
||||||
where: {
|
|
||||||
userId_projectId: {
|
|
||||||
userId,
|
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.project.findUnique({
|
|
||||||
where: {id: projectId},
|
|
||||||
select: {disabled: true},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
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,
|
|
||||||
} 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware to require public API key authentication (for /v1/track endpoint only)
|
* 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
|
* Validates that the request has a valid public key and sets the project
|
||||||
@@ -174,12 +106,12 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu
|
|||||||
|
|
||||||
const apiKey = parts[1];
|
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
|
// Look up project by public key only
|
||||||
const project = await prisma.project.findFirst({
|
const project = await ProjectService.public(apiKey);
|
||||||
where: {
|
|
||||||
public: apiKey,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@@ -193,7 +125,7 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu
|
|||||||
res.locals.auth = {
|
res.locals.auth = {
|
||||||
type: 'apiKey',
|
type: 'apiKey',
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
} as AuthResponse;
|
};
|
||||||
|
|
||||||
// Check if project is disabled - block write operations
|
// Check if project is disabled - block write operations
|
||||||
if (project.disabled) {
|
if (project.disabled) {
|
||||||
@@ -243,12 +175,12 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
|
|||||||
|
|
||||||
const apiKey = parts[1];
|
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
|
// Look up project by secret key only
|
||||||
const project = await prisma.project.findFirst({
|
const project = await ProjectService.secret(apiKey);
|
||||||
where: {
|
|
||||||
secret: apiKey,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@@ -262,7 +194,7 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
|
|||||||
res.locals.auth = {
|
res.locals.auth = {
|
||||||
type: 'apiKey',
|
type: 'apiKey',
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
} as AuthResponse;
|
};
|
||||||
|
|
||||||
// Check if project is disabled - block write operations
|
// Check if project is disabled - block write operations
|
||||||
if (project.disabled) {
|
if (project.disabled) {
|
||||||
@@ -310,12 +242,12 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
|
|||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = parts[1];
|
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)
|
// Look up project by secret key only (public keys not allowed)
|
||||||
const project = await prisma.project.findFirst({
|
const project = await ProjectService.secret(apiKey);
|
||||||
where: {
|
|
||||||
secret: apiKey,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
@@ -360,18 +292,8 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
|
|||||||
|
|
||||||
// Verify user has access to this project and get project status
|
// Verify user has access to this project and get project status
|
||||||
const [membership, project] = await Promise.all([
|
const [membership, project] = await Promise.all([
|
||||||
prisma.membership.findUnique({
|
MembershipService.getMembership(userId, projectId),
|
||||||
where: {
|
ProjectService.id(projectId),
|
||||||
userId_projectId: {
|
|
||||||
userId,
|
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
prisma.project.findUnique({
|
|
||||||
where: {id: projectId},
|
|
||||||
select: {disabled: true},
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
@@ -383,7 +305,7 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
|
|||||||
type: 'jwt',
|
type: 'jwt',
|
||||||
userId,
|
userId,
|
||||||
projectId,
|
projectId,
|
||||||
} as AuthResponse;
|
};
|
||||||
|
|
||||||
// Check if project is disabled - block write operations
|
// Check if project is disabled - block write operations
|
||||||
if (project?.disabled) {
|
if (project?.disabled) {
|
||||||
@@ -414,7 +336,7 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
|
|||||||
*/
|
*/
|
||||||
export const requireEmailVerified = async (req: Request, res: Response, next: NextFunction) => {
|
export const requireEmailVerified = async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const auth = res.locals.auth as AuthResponse;
|
const auth = res.locals.auth;
|
||||||
|
|
||||||
if (auth.type === 'apiKey') {
|
if (auth.type === 'apiKey') {
|
||||||
return next();
|
return next();
|
||||||
@@ -424,10 +346,7 @@ export const requireEmailVerified = async (req: Request, res: Response, next: Ne
|
|||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await UserService.id(auth.userId);
|
||||||
where: {id: auth.userId},
|
|
||||||
select: {emailVerified: true, type: true},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotAuthenticated();
|
throw new NotAuthenticated();
|
||||||
|
|||||||
@@ -1,61 +1,12 @@
|
|||||||
import type {Prisma} from '@plunk/db';
|
import type {Prisma} from '@plunk/db';
|
||||||
|
import type {Activity, ActivityStats, CursorPaginatedResponse} from '@plunk/types';
|
||||||
|
import {ActivityType} from '@plunk/types';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
import {Keys} from './keys.js';
|
import {Keys} from './keys.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Activity types that can be tracked
|
|
||||||
*/
|
|
||||||
export enum ActivityType {
|
|
||||||
EVENT_TRIGGERED = 'event.triggered',
|
|
||||||
EMAIL_SENT = 'email.sent',
|
|
||||||
EMAIL_DELIVERED = 'email.delivered',
|
|
||||||
EMAIL_OPENED = 'email.opened',
|
|
||||||
EMAIL_CLICKED = 'email.clicked',
|
|
||||||
EMAIL_BOUNCED = 'email.bounced',
|
|
||||||
CAMPAIGN_SENT = 'campaign.sent',
|
|
||||||
CAMPAIGN_SCHEDULED = 'campaign.scheduled',
|
|
||||||
WORKFLOW_STARTED = 'workflow.started',
|
|
||||||
WORKFLOW_COMPLETED = 'workflow.completed',
|
|
||||||
WORKFLOW_EMAIL_SCHEDULED = 'workflow.email.scheduled',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified activity item
|
|
||||||
*/
|
|
||||||
export interface Activity {
|
|
||||||
id: string;
|
|
||||||
type: ActivityType;
|
|
||||||
timestamp: Date;
|
|
||||||
contactEmail?: string;
|
|
||||||
contactId?: string;
|
|
||||||
metadata: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Paginated activity response
|
|
||||||
*/
|
|
||||||
export interface PaginatedActivities {
|
|
||||||
activities: Activity[];
|
|
||||||
nextCursor?: string;
|
|
||||||
hasMore: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Activity stats for dashboard
|
|
||||||
*/
|
|
||||||
export interface ActivityStats {
|
|
||||||
totalEvents: number;
|
|
||||||
totalEmailsSent: number;
|
|
||||||
totalEmailsOpened: number;
|
|
||||||
totalEmailsClicked: number;
|
|
||||||
totalWorkflowsStarted: number;
|
|
||||||
openRate: number;
|
|
||||||
clickRate: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Activity Service
|
* Activity Service
|
||||||
*
|
*
|
||||||
@@ -107,7 +58,7 @@ export class ActivityService {
|
|||||||
contactId?: string,
|
contactId?: string,
|
||||||
startDate?: Date,
|
startDate?: Date,
|
||||||
endDate?: Date,
|
endDate?: Date,
|
||||||
): Promise<PaginatedActivities> {
|
): Promise<CursorPaginatedResponse<Activity>> {
|
||||||
// Cap limit to prevent abuse
|
// Cap limit to prevent abuse
|
||||||
const effectiveLimit = Math.min(limit, this.MAX_LIMIT);
|
const effectiveLimit = Math.min(limit, this.MAX_LIMIT);
|
||||||
|
|
||||||
@@ -158,8 +109,8 @@ export class ActivityService {
|
|||||||
const nextCursor = hasMore && lastActivity ? `${lastActivity.timestamp.getTime()}_${lastActivity.id}` : undefined;
|
const nextCursor = hasMore && lastActivity ? `${lastActivity.timestamp.getTime()}_${lastActivity.id}` : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activities: results,
|
data: results,
|
||||||
nextCursor,
|
cursor: nextCursor,
|
||||||
hasMore,
|
hasMore,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -415,15 +366,41 @@ export class ActivityService {
|
|||||||
...(contactId ? {contactId} : {}),
|
...(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({
|
const emails = await prisma.email.findMany({
|
||||||
where: {
|
where: {
|
||||||
...where,
|
...where,
|
||||||
createdAt: cursorTimestamp
|
OR: orConditions,
|
||||||
? {
|
|
||||||
...dateFilter,
|
|
||||||
lt: cursorTimestamp,
|
|
||||||
}
|
|
||||||
: dateFilter,
|
|
||||||
},
|
},
|
||||||
orderBy: {createdAt: 'desc'},
|
orderBy: {createdAt: 'desc'},
|
||||||
take: limit,
|
take: limit,
|
||||||
@@ -450,6 +427,27 @@ export class ActivityService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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
|
// Convert each email into multiple activities based on its state
|
||||||
for (const email of emails) {
|
for (const email of emails) {
|
||||||
const baseMetadata = {
|
const baseMetadata = {
|
||||||
@@ -464,7 +462,7 @@ export class ActivityService {
|
|||||||
workflowName: email.workflowExecution?.workflow?.name,
|
workflowName: email.workflowExecution?.workflow?.name,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (email.sentAt && (!types || types.includes(ActivityType.EMAIL_SENT))) {
|
if (email.sentAt && (!types || types.includes(ActivityType.EMAIL_SENT)) && isInDateRange(email.sentAt)) {
|
||||||
activities.push({
|
activities.push({
|
||||||
id: `${email.id}_sent`,
|
id: `${email.id}_sent`,
|
||||||
type: ActivityType.EMAIL_SENT,
|
type: ActivityType.EMAIL_SENT,
|
||||||
@@ -475,7 +473,11 @@ export class ActivityService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (email.deliveredAt && (!types || types.includes(ActivityType.EMAIL_DELIVERED))) {
|
if (
|
||||||
|
email.deliveredAt &&
|
||||||
|
(!types || types.includes(ActivityType.EMAIL_DELIVERED)) &&
|
||||||
|
isInDateRange(email.deliveredAt)
|
||||||
|
) {
|
||||||
activities.push({
|
activities.push({
|
||||||
id: `${email.id}_delivered`,
|
id: `${email.id}_delivered`,
|
||||||
type: ActivityType.EMAIL_DELIVERED,
|
type: ActivityType.EMAIL_DELIVERED,
|
||||||
@@ -486,7 +488,23 @@ export class ActivityService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (email.openedAt && (!types || types.includes(ActivityType.EMAIL_OPENED))) {
|
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({
|
activities.push({
|
||||||
id: `${email.id}_opened`,
|
id: `${email.id}_opened`,
|
||||||
type: ActivityType.EMAIL_OPENED,
|
type: ActivityType.EMAIL_OPENED,
|
||||||
@@ -501,7 +519,7 @@ export class ActivityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Email clicked
|
// Email clicked
|
||||||
if (email.clickedAt && (!types || types.includes(ActivityType.EMAIL_CLICKED))) {
|
if (email.clickedAt && (!types || types.includes(ActivityType.EMAIL_CLICKED)) && isInDateRange(email.clickedAt)) {
|
||||||
activities.push({
|
activities.push({
|
||||||
id: `${email.id}_clicked`,
|
id: `${email.id}_clicked`,
|
||||||
type: ActivityType.EMAIL_CLICKED,
|
type: ActivityType.EMAIL_CLICKED,
|
||||||
@@ -516,7 +534,7 @@ export class ActivityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Email bounced
|
// Email bounced
|
||||||
if (email.bouncedAt && (!types || types.includes(ActivityType.EMAIL_BOUNCED))) {
|
if (email.bouncedAt && (!types || types.includes(ActivityType.EMAIL_BOUNCED)) && isInDateRange(email.bouncedAt)) {
|
||||||
activities.push({
|
activities.push({
|
||||||
id: `${email.id}_bounced`,
|
id: `${email.id}_bounced`,
|
||||||
type: ActivityType.EMAIL_BOUNCED,
|
type: ActivityType.EMAIL_BOUNCED,
|
||||||
@@ -529,6 +547,25 @@ export class ActivityService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
return activities;
|
||||||
|
|||||||
@@ -58,14 +58,16 @@ export class AnalyticsService {
|
|||||||
const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate;
|
const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate;
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
const cacheKey = Keys.Analytics.timeseries(projectId, limitedStartDate.toISOString(), effectiveEndDate.toISOString());
|
const cacheKey = Keys.Analytics.timeseries(
|
||||||
|
projectId,
|
||||||
|
limitedStartDate.toISOString(),
|
||||||
|
effectiveEndDate.toISOString(),
|
||||||
|
);
|
||||||
const cached = await redis.get(cacheKey);
|
const cached = await redis.get(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return JSON.parse(cached);
|
return JSON.parse(cached);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw SQL query for efficient daily aggregation
|
|
||||||
// Using raw SQL because Prisma's groupBy is less efficient for date truncation
|
|
||||||
const result = await prisma.$queryRaw<
|
const result = await prisma.$queryRaw<
|
||||||
{
|
{
|
||||||
date: Date;
|
date: Date;
|
||||||
@@ -191,7 +193,11 @@ export class AnalyticsService {
|
|||||||
const effectiveEndDate = endDate || now;
|
const effectiveEndDate = endDate || now;
|
||||||
|
|
||||||
// Check cache
|
// Check cache
|
||||||
const cacheKey = Keys.Analytics.campaignStats(projectId, effectiveStartDate.toISOString(), effectiveEndDate.toISOString());
|
const cacheKey = Keys.Analytics.campaignStats(
|
||||||
|
projectId,
|
||||||
|
effectiveStartDate.toISOString(),
|
||||||
|
effectiveEndDate.toISOString(),
|
||||||
|
);
|
||||||
const cached = await redis.get(cacheKey);
|
const cached = await redis.get(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return JSON.parse(cached);
|
return JSON.parse(cached);
|
||||||
@@ -295,7 +301,12 @@ export class AnalyticsService {
|
|||||||
const effectiveEndDate = endDate || now;
|
const effectiveEndDate = endDate || now;
|
||||||
|
|
||||||
// Check cache
|
// Check cache
|
||||||
const cacheKey = Keys.Analytics.topEvents(projectId, limit, effectiveStartDate.toISOString(), effectiveEndDate.toISOString());
|
const cacheKey = Keys.Analytics.topEvents(
|
||||||
|
projectId,
|
||||||
|
limit,
|
||||||
|
effectiveStartDate.toISOString(),
|
||||||
|
effectiveEndDate.toISOString(),
|
||||||
|
);
|
||||||
const cached = await redis.get(cacheKey);
|
const cached = await redis.get(cacheKey);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
return JSON.parse(cached);
|
return JSON.parse(cached);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {EmailSourceType} from '@plunk/db';
|
import {EmailSourceType} from '@plunk/db';
|
||||||
|
import type {BillingLimitsResponse, CategoryUsage, LimitCheckResult} from '@plunk/types';
|
||||||
import {BillingLimitExceededEmail, BillingLimitWarningEmail, sendPlatformEmail} from '@plunk/email';
|
import {BillingLimitExceededEmail, BillingLimitWarningEmail, sendPlatformEmail} from '@plunk/email';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
@@ -8,41 +9,9 @@ import {stripe} from '../app/stripe.js';
|
|||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
import {Keys} from './keys.js';
|
import {Keys} from './keys.js';
|
||||||
|
import {MembershipService} from './MembershipService.js';
|
||||||
import {NtfyService} from './NtfyService.js';
|
import {NtfyService} from './NtfyService.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Usage information for a specific email category
|
|
||||||
*/
|
|
||||||
export interface CategoryUsage {
|
|
||||||
limit: number | null; // null = unlimited
|
|
||||||
usage: number;
|
|
||||||
percentage: number; // 0-100
|
|
||||||
isWarning: boolean; // true if >= 80%
|
|
||||||
isBlocked: boolean; // true if >= 100%
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Complete billing limits and usage for a project
|
|
||||||
*/
|
|
||||||
export interface BillingLimitsResponse {
|
|
||||||
workflows: CategoryUsage;
|
|
||||||
campaigns: CategoryUsage;
|
|
||||||
transactional: CategoryUsage;
|
|
||||||
currency: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of limit check
|
|
||||||
*/
|
|
||||||
export interface LimitCheckResult {
|
|
||||||
allowed: boolean;
|
|
||||||
warning: boolean; // true if >= 80% but < 100%
|
|
||||||
usage: number;
|
|
||||||
limit: number | null;
|
|
||||||
percentage: number;
|
|
||||||
message?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Billing Limit Service
|
* Billing Limit Service
|
||||||
* Handles usage tracking and enforcement of billing limits per email category
|
* Handles usage tracking and enforcement of billing limits per email category
|
||||||
@@ -188,6 +157,7 @@ export class BillingLimitService {
|
|||||||
billingLimitWorkflows: true,
|
billingLimitWorkflows: true,
|
||||||
billingLimitCampaigns: true,
|
billingLimitCampaigns: true,
|
||||||
billingLimitTransactional: true,
|
billingLimitTransactional: true,
|
||||||
|
billingLimitInbound: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,6 +184,9 @@ export class BillingLimitService {
|
|||||||
case EmailSourceType.TRANSACTIONAL:
|
case EmailSourceType.TRANSACTIONAL:
|
||||||
limit = project.billingLimitTransactional;
|
limit = project.billingLimitTransactional;
|
||||||
break;
|
break;
|
||||||
|
case EmailSourceType.INBOUND:
|
||||||
|
limit = project.billingLimitInbound;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
limit = null;
|
limit = null;
|
||||||
}
|
}
|
||||||
@@ -222,7 +195,8 @@ export class BillingLimitService {
|
|||||||
const hasCustomLimits =
|
const hasCustomLimits =
|
||||||
project.billingLimitWorkflows !== null ||
|
project.billingLimitWorkflows !== null ||
|
||||||
project.billingLimitCampaigns !== null ||
|
project.billingLimitCampaigns !== null ||
|
||||||
project.billingLimitTransactional !== null;
|
project.billingLimitTransactional !== null ||
|
||||||
|
project.billingLimitInbound !== null;
|
||||||
|
|
||||||
// Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit
|
// Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit
|
||||||
// Only enforce free tier limits if billing is enabled
|
// Only enforce free tier limits if billing is enabled
|
||||||
@@ -399,6 +373,7 @@ export class BillingLimitService {
|
|||||||
billingLimitWorkflows: true,
|
billingLimitWorkflows: true,
|
||||||
billingLimitCampaigns: true,
|
billingLimitCampaigns: true,
|
||||||
billingLimitTransactional: true,
|
billingLimitTransactional: true,
|
||||||
|
billingLimitInbound: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -421,10 +396,11 @@ export class BillingLimitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get usage for all categories in parallel
|
// Get usage for all categories in parallel
|
||||||
const [workflowUsage, campaignUsage, transactionalUsage] = await Promise.all([
|
const [workflowUsage, campaignUsage, transactionalUsage, inboundUsage] = await Promise.all([
|
||||||
this.getUsage(projectId, EmailSourceType.WORKFLOW),
|
this.getUsage(projectId, EmailSourceType.WORKFLOW),
|
||||||
this.getUsage(projectId, EmailSourceType.CAMPAIGN),
|
this.getUsage(projectId, EmailSourceType.CAMPAIGN),
|
||||||
this.getUsage(projectId, EmailSourceType.TRANSACTIONAL),
|
this.getUsage(projectId, EmailSourceType.TRANSACTIONAL),
|
||||||
|
this.getUsage(projectId, EmailSourceType.INBOUND),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Helper to calculate category usage
|
// Helper to calculate category usage
|
||||||
@@ -443,18 +419,19 @@ export class BillingLimitService {
|
|||||||
const hasCustomLimits =
|
const hasCustomLimits =
|
||||||
project.billingLimitWorkflows !== null ||
|
project.billingLimitWorkflows !== null ||
|
||||||
project.billingLimitCampaigns !== null ||
|
project.billingLimitCampaigns !== null ||
|
||||||
project.billingLimitTransactional !== null;
|
project.billingLimitTransactional !== null ||
|
||||||
|
project.billingLimitInbound !== null;
|
||||||
|
|
||||||
// Free tier projects (no subscription and no custom limits): show total usage with shared limit
|
// Free tier projects (no subscription and no custom limits): show total usage with shared limit
|
||||||
// Only show free tier limits if billing is enabled
|
// Only show free tier limits if billing is enabled
|
||||||
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
|
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
|
||||||
const totalUsage = workflowUsage + campaignUsage + transactionalUsage;
|
const totalUsage = workflowUsage + campaignUsage + transactionalUsage + inboundUsage;
|
||||||
const limit = this.FREE_TIER_TOTAL_LIMIT;
|
const limit = this.FREE_TIER_TOTAL_LIMIT;
|
||||||
const percentage = (totalUsage / limit) * 100;
|
const percentage = (totalUsage / limit) * 100;
|
||||||
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
|
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
|
||||||
const isBlocked = totalUsage >= limit;
|
const isBlocked = totalUsage >= limit;
|
||||||
|
|
||||||
// For free tier, show the same limit and total usage for all three categories
|
// 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
|
// This makes it clear in the UI that it's a shared limit
|
||||||
const sharedUsageInfo: CategoryUsage = {
|
const sharedUsageInfo: CategoryUsage = {
|
||||||
limit,
|
limit,
|
||||||
@@ -468,6 +445,7 @@ export class BillingLimitService {
|
|||||||
workflows: sharedUsageInfo,
|
workflows: sharedUsageInfo,
|
||||||
campaigns: sharedUsageInfo,
|
campaigns: sharedUsageInfo,
|
||||||
transactional: sharedUsageInfo,
|
transactional: sharedUsageInfo,
|
||||||
|
inbound: sharedUsageInfo,
|
||||||
currency,
|
currency,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -477,6 +455,7 @@ export class BillingLimitService {
|
|||||||
workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows),
|
workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows),
|
||||||
campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns),
|
campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns),
|
||||||
transactional: calculateCategoryUsage(transactionalUsage, project.billingLimitTransactional),
|
transactional: calculateCategoryUsage(transactionalUsage, project.billingLimitTransactional),
|
||||||
|
inbound: calculateCategoryUsage(inboundUsage, project.billingLimitInbound),
|
||||||
currency,
|
currency,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -501,6 +480,7 @@ export class BillingLimitService {
|
|||||||
this.getCacheKey(projectId, EmailSourceType.WORKFLOW),
|
this.getCacheKey(projectId, EmailSourceType.WORKFLOW),
|
||||||
this.getCacheKey(projectId, EmailSourceType.CAMPAIGN),
|
this.getCacheKey(projectId, EmailSourceType.CAMPAIGN),
|
||||||
this.getCacheKey(projectId, EmailSourceType.TRANSACTIONAL),
|
this.getCacheKey(projectId, EmailSourceType.TRANSACTIONAL),
|
||||||
|
this.getCacheKey(projectId, EmailSourceType.INBOUND),
|
||||||
];
|
];
|
||||||
|
|
||||||
await Promise.all(keys.map(key => redis.del(key)));
|
await Promise.all(keys.map(key => redis.del(key)));
|
||||||
@@ -524,11 +504,13 @@ export class BillingLimitService {
|
|||||||
workflows: number | null;
|
workflows: number | null;
|
||||||
campaigns: number | null;
|
campaigns: number | null;
|
||||||
transactional: number | null;
|
transactional: number | null;
|
||||||
|
inbound: number | null;
|
||||||
},
|
},
|
||||||
newLimits: {
|
newLimits: {
|
||||||
workflows: number | null;
|
workflows: number | null;
|
||||||
campaigns: number | null;
|
campaigns: number | null;
|
||||||
transactional: number | null;
|
transactional: number | null;
|
||||||
|
inbound: number | null;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -562,6 +544,14 @@ export class BillingLimitService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
if (keysToDelete.length > 0) {
|
||||||
await Promise.all(keysToDelete.map(key => redis.del(key)));
|
await Promise.all(keysToDelete.map(key => redis.del(key)));
|
||||||
signale.debug(
|
signale.debug(
|
||||||
@@ -611,16 +601,20 @@ export class BillingLimitService {
|
|||||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
const cacheKey = Keys.Billing.warningEmail(projectId, sourceType, year, month);
|
const cacheKey = Keys.Billing.warningEmail(projectId, sourceType, year, month);
|
||||||
|
|
||||||
const alreadySent = await redis.get(cacheKey);
|
// Use SETNX (SET if Not eXists) to atomically check and set the flag
|
||||||
if (alreadySent === '1') {
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const members = await prisma.membership.findMany({
|
const members = await MembershipService.getMembers(projectId);
|
||||||
where: {projectId},
|
const emails = members.map(m => m.email);
|
||||||
include: {user: {select: {email: true}}},
|
|
||||||
});
|
|
||||||
const emails = members.map(m => m.user.email);
|
|
||||||
if (emails.length === 0) {
|
if (emails.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -637,11 +631,6 @@ export class BillingLimitService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Warning', template)));
|
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Warning', template)));
|
||||||
|
|
||||||
// Mark that we've sent the warning email (expires at end of month)
|
|
||||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
||||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
|
||||||
await redis.setex(cacheKey, ttl, '1');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[BILLING_LIMIT] Failed to send warning email:`, error);
|
signale.error(`[BILLING_LIMIT] Failed to send warning email:`, error);
|
||||||
}
|
}
|
||||||
@@ -664,16 +653,20 @@ export class BillingLimitService {
|
|||||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
const cacheKey = Keys.Billing.limitEmail(projectId, sourceType, year, month);
|
const cacheKey = Keys.Billing.limitEmail(projectId, sourceType, year, month);
|
||||||
|
|
||||||
const alreadySent = await redis.get(cacheKey);
|
// Use SETNX (SET if Not eXists) to atomically check and set the flag
|
||||||
if (alreadySent === '1') {
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const members = await prisma.membership.findMany({
|
const members = await MembershipService.getMembers(projectId);
|
||||||
where: {projectId},
|
const emails = members.map(m => m.email);
|
||||||
include: {user: {select: {email: true}}},
|
|
||||||
});
|
|
||||||
const emails = members.map(m => m.user.email);
|
|
||||||
if (emails.length === 0) {
|
if (emails.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -689,11 +682,6 @@ export class BillingLimitService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Exceeded', template)));
|
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Exceeded', template)));
|
||||||
|
|
||||||
// Mark that we've sent the limit email (expires at end of month)
|
|
||||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
||||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
|
||||||
await redis.setex(cacheKey, ttl, '1');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[BILLING_LIMIT] Failed to send limit exceeded email:`, error);
|
signale.error(`[BILLING_LIMIT] Failed to send limit exceeded email:`, error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {Campaign, Contact, Prisma} from '@plunk/db';
|
import type {Campaign, Contact, Prisma} from '@plunk/db';
|
||||||
import {CampaignAudienceType, CampaignStatus, EmailSourceType} from '@plunk/db';
|
import {CampaignAudienceType, CampaignStatus, EmailSourceType} from '@plunk/db';
|
||||||
import type {FilterCondition} from '@plunk/types';
|
import type {CreateCampaignData, FilterCondition, PaginatedResponse, UpdateCampaignData} from '@plunk/types';
|
||||||
|
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
@@ -18,32 +19,6 @@ import {sendRawEmail} from './SESService.js';
|
|||||||
|
|
||||||
const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance)
|
const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance)
|
||||||
|
|
||||||
export interface CreateCampaignData {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
subject: string;
|
|
||||||
body: string;
|
|
||||||
from: string;
|
|
||||||
fromName?: string | null;
|
|
||||||
replyTo?: string | null;
|
|
||||||
audienceType: CampaignAudienceType;
|
|
||||||
audienceCondition?: FilterCondition;
|
|
||||||
segmentId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateCampaignData {
|
|
||||||
name?: string;
|
|
||||||
description?: string;
|
|
||||||
subject?: string;
|
|
||||||
body?: string;
|
|
||||||
from?: string;
|
|
||||||
fromName?: string | null;
|
|
||||||
replyTo?: string | null;
|
|
||||||
audienceType?: CampaignAudienceType;
|
|
||||||
audienceCondition?: FilterCondition;
|
|
||||||
segmentId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CampaignService {
|
export class CampaignService {
|
||||||
/**
|
/**
|
||||||
* Create a new campaign
|
* Create a new campaign
|
||||||
@@ -85,7 +60,7 @@ export class CampaignService {
|
|||||||
fromName: data.fromName,
|
fromName: data.fromName,
|
||||||
replyTo: data.replyTo,
|
replyTo: data.replyTo,
|
||||||
audienceType: data.audienceType,
|
audienceType: data.audienceType,
|
||||||
audienceCondition: (data.audienceCondition || null) as unknown as Prisma.InputJsonValue,
|
audienceCondition: toPrismaJson(data.audienceCondition || null),
|
||||||
segmentId: data.segmentId,
|
segmentId: data.segmentId,
|
||||||
status: CampaignStatus.DRAFT,
|
status: CampaignStatus.DRAFT,
|
||||||
totalRecipients: 0, // Will be updated below
|
totalRecipients: 0, // Will be updated below
|
||||||
@@ -133,7 +108,7 @@ export class CampaignService {
|
|||||||
if (data.audienceCondition) {
|
if (data.audienceCondition) {
|
||||||
SegmentService.validateCondition(data.audienceCondition);
|
SegmentService.validateCondition(data.audienceCondition);
|
||||||
}
|
}
|
||||||
updateData.audienceCondition = (data.audienceCondition || null) as unknown as Prisma.InputJsonValue;
|
updateData.audienceCondition = toPrismaJson(data.audienceCondition || null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.segmentId !== undefined) {
|
if (data.segmentId !== undefined) {
|
||||||
@@ -209,7 +184,7 @@ export class CampaignService {
|
|||||||
page?: number;
|
page?: number;
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<{campaigns: Campaign[]; total: number; page: number; pageSize: number; totalPages: number}> {
|
): Promise<PaginatedResponse<Campaign>> {
|
||||||
const {status, page = 1, pageSize = 20} = options;
|
const {status, page = 1, pageSize = 20} = options;
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
@@ -232,7 +207,7 @@ export class CampaignService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
campaigns,
|
data: campaigns,
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -476,6 +451,7 @@ export class CampaignService {
|
|||||||
const contactData =
|
const contactData =
|
||||||
contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data) ? contact.data : {};
|
contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data) ? contact.data : {};
|
||||||
const variables = {
|
const variables = {
|
||||||
|
id: contact.id,
|
||||||
email: contact.email,
|
email: contact.email,
|
||||||
...contactData,
|
...contactData,
|
||||||
data: contactData,
|
data: contactData,
|
||||||
@@ -589,6 +565,7 @@ export class CampaignService {
|
|||||||
await prisma.campaign.update({
|
await prisma.campaign.update({
|
||||||
where: {id: campaignId},
|
where: {id: campaignId},
|
||||||
data: {
|
data: {
|
||||||
|
sentCount: sentEmails,
|
||||||
deliveredCount: deliveredEmails,
|
deliveredCount: deliveredEmails,
|
||||||
openedCount: openedEmails,
|
openedCount: openedEmails,
|
||||||
clickedCount: clickedEmails,
|
clickedCount: clickedEmails,
|
||||||
@@ -747,7 +724,7 @@ export class CampaignService {
|
|||||||
return this.buildSegmentWhereAsync(projectId, campaign.segmentId, baseWhere);
|
return this.buildSegmentWhereAsync(projectId, campaign.segmentId, baseWhere);
|
||||||
|
|
||||||
case CampaignAudienceType.FILTERED: {
|
case CampaignAudienceType.FILTERED: {
|
||||||
const condition = campaign.audienceCondition as unknown as FilterCondition;
|
const condition = fromPrismaJson<FilterCondition>(campaign.audienceCondition);
|
||||||
if (!condition) {
|
if (!condition) {
|
||||||
throw new HttpException(400, 'Audience condition is required for FILTERED audience type');
|
throw new HttpException(400, 'Audience condition is required for FILTERED audience type');
|
||||||
}
|
}
|
||||||
@@ -783,7 +760,7 @@ export class CampaignService {
|
|||||||
throw new HttpException(404, 'Segment not found');
|
throw new HttpException(404, 'Segment not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const condition = segment.condition as unknown as FilterCondition;
|
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||||
const segmentWhere = SegmentService.buildConditionClause(condition);
|
const segmentWhere = SegmentService.buildConditionClause(condition);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
import {type Contact, Prisma} from '@plunk/db';
|
import {type Contact, Prisma} from '@plunk/db';
|
||||||
import {isValidLanguageCode} from '@plunk/shared';
|
import {isValidLanguageCode} from '@plunk/shared';
|
||||||
import type {FilterCondition, FilterGroup} from '@plunk/types';
|
import type {CursorPaginatedResponse, FilterCondition, FilterGroup} from '@plunk/types';
|
||||||
|
import {toPrismaJson} from '@plunk/types';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
import {EventService} from './EventService.js';
|
import {EventService} from './EventService.js';
|
||||||
|
|
||||||
export interface PaginatedContacts {
|
|
||||||
contacts: Contact[];
|
|
||||||
total: number;
|
|
||||||
cursor?: string;
|
|
||||||
hasMore: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ContactService {
|
export class ContactService {
|
||||||
/**
|
/**
|
||||||
* Get all contacts for a project with cursor-based pagination
|
* Get all contacts for a project with cursor-based pagination
|
||||||
@@ -23,7 +17,7 @@ export class ContactService {
|
|||||||
limit = 20,
|
limit = 20,
|
||||||
cursor?: string,
|
cursor?: string,
|
||||||
search?: string,
|
search?: string,
|
||||||
): Promise<PaginatedContacts> {
|
): Promise<CursorPaginatedResponse<Contact>> {
|
||||||
const where: Prisma.ContactWhereInput = {
|
const where: Prisma.ContactWhereInput = {
|
||||||
projectId,
|
projectId,
|
||||||
...(search
|
...(search
|
||||||
@@ -58,7 +52,7 @@ export class ContactService {
|
|||||||
const total = !cursor ? await prisma.contact.count({where}) : 0;
|
const total = !cursor ? await prisma.contact.count({where}) : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contacts: results,
|
data: results,
|
||||||
total,
|
total,
|
||||||
cursor: nextCursor,
|
cursor: nextCursor,
|
||||||
hasMore,
|
hasMore,
|
||||||
@@ -227,7 +221,15 @@ export class ContactService {
|
|||||||
for (const [key, value] of Object.entries(data)) {
|
for (const [key, value] of Object.entries(data)) {
|
||||||
// Skip reserved system-generated fields
|
// Skip reserved system-generated fields
|
||||||
// These fields are dynamically added during template rendering and cannot be overridden
|
// These fields are dynamically added during template rendering and cannot be overridden
|
||||||
const reservedFields = ['plunk_id', 'plunk_email', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl'];
|
const reservedFields = [
|
||||||
|
'plunk_id',
|
||||||
|
'plunk_email',
|
||||||
|
'id',
|
||||||
|
'email',
|
||||||
|
'unsubscribeUrl',
|
||||||
|
'subscribeUrl',
|
||||||
|
'manageUrl',
|
||||||
|
];
|
||||||
if (reservedFields.includes(key)) {
|
if (reservedFields.includes(key)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -236,10 +238,7 @@ export class ContactService {
|
|||||||
if (key === 'locale') {
|
if (key === 'locale') {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
if (!isValidLanguageCode(value)) {
|
if (!isValidLanguageCode(value)) {
|
||||||
throw new HttpException(
|
throw new HttpException(400, `Invalid locale code: ${value}. Must be one of: en, nl, fr, hi, de`);
|
||||||
400,
|
|
||||||
`Invalid locale code: ${value}. Must be one of: en, nl, fr, hi, de`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else if (value !== null && value !== undefined) {
|
} else if (value !== null && value !== undefined) {
|
||||||
throw new HttpException(400, 'Locale must be a string');
|
throw new HttpException(400, 'Locale must be a string');
|
||||||
@@ -272,7 +271,7 @@ export class ContactService {
|
|||||||
const updated = await prisma.contact.update({
|
const updated = await prisma.contact.update({
|
||||||
where: {id: existing.id},
|
where: {id: existing.id},
|
||||||
data: {
|
data: {
|
||||||
data: Object.keys(mergedData).length > 0 ? (mergedData as Prisma.InputJsonValue) : Prisma.JsonNull,
|
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
|
||||||
...(subscribed !== undefined ? {subscribed} : {}),
|
...(subscribed !== undefined ? {subscribed} : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -292,7 +291,7 @@ export class ContactService {
|
|||||||
data: {
|
data: {
|
||||||
projectId,
|
projectId,
|
||||||
email,
|
email,
|
||||||
data: Object.keys(mergedData).length > 0 ? (mergedData as Prisma.InputJsonValue) : Prisma.JsonNull,
|
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
|
||||||
subscribed: subscribed ?? true,
|
subscribed: subscribed ?? true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -678,59 +677,11 @@ export class ContactService {
|
|||||||
return {deletedFrom: result};
|
return {deletedFrom: result};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk subscribe contacts
|
* Bulk subscribe contacts
|
||||||
* Updates multiple contacts to subscribed=true in batches
|
* Updates multiple contacts to subscribed=true in batches
|
||||||
*/
|
*/
|
||||||
public static async bulkSubscribe(
|
public static async bulkSubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
|
||||||
projectId: string,
|
|
||||||
contactIds: string[],
|
|
||||||
): Promise<{updated: number}> {
|
|
||||||
// Verify all contacts belong to this project
|
// Verify all contacts belong to this project
|
||||||
const contacts = await prisma.contact.findMany({
|
const contacts = await prisma.contact.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -779,10 +730,7 @@ export class ContactService {
|
|||||||
/**
|
/**
|
||||||
* Bulk unsubscribe contacts
|
* Bulk unsubscribe contacts
|
||||||
*/
|
*/
|
||||||
public static async bulkUnsubscribe(
|
public static async bulkUnsubscribe(projectId: string, contactIds: string[]): Promise<{updated: number}> {
|
||||||
projectId: string,
|
|
||||||
contactIds: string[],
|
|
||||||
): Promise<{updated: number}> {
|
|
||||||
const contacts = await prisma.contact.findMany({
|
const contacts = await prisma.contact.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: {in: contactIds},
|
id: {in: contactIds},
|
||||||
@@ -829,10 +777,7 @@ export class ContactService {
|
|||||||
/**
|
/**
|
||||||
* Bulk delete contacts
|
* Bulk delete contacts
|
||||||
*/
|
*/
|
||||||
public static async bulkDelete(
|
public static async bulkDelete(projectId: string, contactIds: string[]): Promise<{deleted: number}> {
|
||||||
projectId: string,
|
|
||||||
contactIds: string[],
|
|
||||||
): Promise<{deleted: number}> {
|
|
||||||
const result = await prisma.contact.deleteMany({
|
const result = await prisma.contact.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
id: {in: contactIds},
|
id: {in: contactIds},
|
||||||
@@ -843,6 +788,51 @@ export class ContactService {
|
|||||||
return {deleted: result.count};
|
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
|
* Track events sequentially to avoid database deadlocks
|
||||||
* Processes events one at a time with error handling
|
* Processes events one at a time with error handling
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
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 {prisma} from '../database/prisma.js';
|
||||||
import {wrapRedis} from '../database/redis.js';
|
import {redis, wrapRedis} from '../database/redis.js';
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
import {Keys} from './keys.js';
|
import {Keys} from './keys.js';
|
||||||
|
import {MembershipService} from './MembershipService.js';
|
||||||
import {NtfyService} from './NtfyService.js';
|
import {NtfyService} from './NtfyService.js';
|
||||||
import {getDomainVerificationAttributes, verifyDomain} from './SESService.js';
|
import {deleteIdentity, getDomainVerificationAttributes, verifyDomain} from './SESService.js';
|
||||||
|
|
||||||
export class DomainService {
|
export class DomainService {
|
||||||
/**
|
/**
|
||||||
@@ -81,6 +86,32 @@ export class DomainService {
|
|||||||
|
|
||||||
// Send notification about domain verified
|
// Send notification about domain verified
|
||||||
await NtfyService.notifyDomainVerified(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
|
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) {
|
} else if (attributes.status !== 'Success' && domain.verified) {
|
||||||
const updatedDomain = await prisma.domain.update({
|
const updatedDomain = await prisma.domain.update({
|
||||||
where: {id: domainId},
|
where: {id: domainId},
|
||||||
@@ -93,7 +124,41 @@ export class DomainService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send notification about domain verification failed
|
// Send notification about domain verification failed
|
||||||
await NtfyService.notifyDomainVerificationFailed(domain.domain, updatedDomain.project.name, updatedDomain.project.id);
|
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 {
|
return {
|
||||||
@@ -184,6 +249,28 @@ export class DomainService {
|
|||||||
|
|
||||||
await prisma.domain.delete({where: {id: domainId}});
|
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
|
// Send notification about domain removal
|
||||||
await NtfyService.notifyDomainRemoved(domainName, domain.project.name, domain.project.id);
|
await NtfyService.notifyDomainRemoved(domainName, domain.project.name, domain.project.id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type {Contact, Email, Prisma, Project} from '@plunk/db';
|
import type {Contact, Email, Prisma, Project} from '@plunk/db';
|
||||||
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
|
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
|
||||||
|
import {toPrismaJson} from '@plunk/types';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
|
import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
import {renderTemplate, createTranslatorSync} from '@plunk/shared';
|
import {createTranslatorSync, renderTemplate} from '@plunk/shared';
|
||||||
|
|
||||||
import {BillingLimitService} from './BillingLimitService.js';
|
import {BillingLimitService} from './BillingLimitService.js';
|
||||||
import {DomainService} from './DomainService.js';
|
import {DomainService} from './DomainService.js';
|
||||||
@@ -34,6 +35,7 @@ interface SendEmailParams {
|
|||||||
campaignId?: string;
|
campaignId?: string;
|
||||||
workflowExecutionId?: string;
|
workflowExecutionId?: string;
|
||||||
workflowStepExecutionId?: string;
|
workflowStepExecutionId?: string;
|
||||||
|
recipientEmail?: string; // Optional custom recipient email (overrides contact.email)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,8 +93,8 @@ export class EmailService {
|
|||||||
fromName: params.fromName,
|
fromName: params.fromName,
|
||||||
toName: params.toName,
|
toName: params.toName,
|
||||||
replyTo: params.replyTo,
|
replyTo: params.replyTo,
|
||||||
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
|
headers: params.headers ? toPrismaJson(params.headers) : undefined,
|
||||||
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
|
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
|
||||||
sourceType: EmailSourceType.TRANSACTIONAL,
|
sourceType: EmailSourceType.TRANSACTIONAL,
|
||||||
templateId: params.templateId,
|
templateId: params.templateId,
|
||||||
status: EmailStatus.PENDING,
|
status: EmailStatus.PENDING,
|
||||||
@@ -152,8 +154,8 @@ export class EmailService {
|
|||||||
from: params.from,
|
from: params.from,
|
||||||
fromName: params.fromName,
|
fromName: params.fromName,
|
||||||
replyTo: params.replyTo,
|
replyTo: params.replyTo,
|
||||||
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
|
headers: params.headers ? toPrismaJson(params.headers) : undefined,
|
||||||
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
|
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
|
||||||
sourceType,
|
sourceType,
|
||||||
templateId: params.templateId,
|
templateId: params.templateId,
|
||||||
campaignId: params.campaignId,
|
campaignId: params.campaignId,
|
||||||
@@ -192,7 +194,8 @@ export class EmailService {
|
|||||||
|
|
||||||
// Check subscription status for marketing emails
|
// Check subscription status for marketing emails
|
||||||
// Transactional emails should always be sent regardless of subscription status
|
// Transactional emails should always be sent regardless of subscription status
|
||||||
if (sourceType !== EmailSourceType.TRANSACTIONAL) {
|
// Custom recipient emails also bypass subscription checks (they're not in the contact list)
|
||||||
|
if (sourceType !== EmailSourceType.TRANSACTIONAL && !params.recipientEmail) {
|
||||||
const contact = await prisma.contact.findUnique({
|
const contact = await prisma.contact.findUnique({
|
||||||
where: {id: params.contactId},
|
where: {id: params.contactId},
|
||||||
select: {subscribed: true},
|
select: {subscribed: true},
|
||||||
@@ -213,8 +216,8 @@ export class EmailService {
|
|||||||
from: params.from,
|
from: params.from,
|
||||||
fromName: params.fromName,
|
fromName: params.fromName,
|
||||||
replyTo: params.replyTo,
|
replyTo: params.replyTo,
|
||||||
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
|
headers: params.headers ? toPrismaJson(params.headers) : undefined,
|
||||||
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
|
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
|
||||||
sourceType,
|
sourceType,
|
||||||
templateId: params.templateId,
|
templateId: params.templateId,
|
||||||
workflowExecutionId: params.workflowExecutionId,
|
workflowExecutionId: params.workflowExecutionId,
|
||||||
@@ -241,6 +244,12 @@ export class EmailService {
|
|||||||
signale.warn(`[BILLING_LIMIT] ${limitCheck.message}`);
|
signale.warn(`[BILLING_LIMIT] ${limitCheck.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If custom recipient email is provided, store it in headers for later use
|
||||||
|
const emailHeaders = params.headers ? {...params.headers} : {};
|
||||||
|
if (params.recipientEmail) {
|
||||||
|
emailHeaders['X-Plunk-Recipient-Override'] = params.recipientEmail;
|
||||||
|
}
|
||||||
|
|
||||||
const email = await prisma.email.create({
|
const email = await prisma.email.create({
|
||||||
data: {
|
data: {
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
@@ -250,8 +259,8 @@ export class EmailService {
|
|||||||
from: params.from,
|
from: params.from,
|
||||||
fromName: params.fromName,
|
fromName: params.fromName,
|
||||||
replyTo: params.replyTo,
|
replyTo: params.replyTo,
|
||||||
headers: params.headers ? (params.headers as Prisma.InputJsonValue) : undefined,
|
headers: Object.keys(emailHeaders).length > 0 ? toPrismaJson(emailHeaders) : undefined,
|
||||||
attachments: params.attachments ? (params.attachments as unknown as Prisma.InputJsonValue) : undefined,
|
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
|
||||||
sourceType,
|
sourceType,
|
||||||
templateId: params.templateId,
|
templateId: params.templateId,
|
||||||
workflowExecutionId: params.workflowExecutionId,
|
workflowExecutionId: params.workflowExecutionId,
|
||||||
@@ -332,6 +341,7 @@ export class EmailService {
|
|||||||
subject: email.subject,
|
subject: email.subject,
|
||||||
body: email.body,
|
body: email.body,
|
||||||
data: {
|
data: {
|
||||||
|
id: email.contact.id,
|
||||||
email: email.contact.email,
|
email: email.contact.email,
|
||||||
...contactData,
|
...contactData,
|
||||||
data: contactData,
|
data: contactData,
|
||||||
@@ -359,6 +369,15 @@ export class EmailService {
|
|||||||
? (email.headers as Record<string, string>)
|
? (email.headers as Record<string, string>)
|
||||||
: undefined;
|
: 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'];
|
||||||
|
}
|
||||||
|
|
||||||
// Parse attachments from JSON
|
// Parse attachments from JSON
|
||||||
const attachments =
|
const attachments =
|
||||||
email.attachments && Array.isArray(email.attachments)
|
email.attachments && Array.isArray(email.attachments)
|
||||||
@@ -374,13 +393,13 @@ export class EmailService {
|
|||||||
name: fromName,
|
name: fromName,
|
||||||
email: fromEmail,
|
email: fromEmail,
|
||||||
},
|
},
|
||||||
to: [email.contact.email],
|
to: [recipientEmail],
|
||||||
content: {
|
content: {
|
||||||
subject: formattedEmail.subject,
|
subject: formattedEmail.subject,
|
||||||
html: compiledHtml,
|
html: compiledHtml,
|
||||||
},
|
},
|
||||||
reply: email.replyTo || undefined,
|
reply: email.replyTo || undefined,
|
||||||
headers: customHeaders,
|
headers: publicHeaders,
|
||||||
attachments: attachments,
|
attachments: attachments,
|
||||||
tracking: shouldTrack,
|
tracking: shouldTrack,
|
||||||
});
|
});
|
||||||
@@ -502,6 +521,42 @@ export class EmailService {
|
|||||||
data: updateData,
|
data: updateData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update campaign stats if applicable
|
||||||
|
if (email.campaignId) {
|
||||||
|
const campaignUpdate: Prisma.CampaignUpdateInput = {};
|
||||||
|
|
||||||
|
switch (eventType) {
|
||||||
|
case 'delivered':
|
||||||
|
campaignUpdate.deliveredCount = {increment: 1};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'opened':
|
||||||
|
// Only increment unique opens to match getStats logic
|
||||||
|
if (!email.openedAt) {
|
||||||
|
campaignUpdate.openedCount = {increment: 1};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'clicked':
|
||||||
|
// Only increment unique clicks to match getStats logic
|
||||||
|
if (!email.clickedAt) {
|
||||||
|
campaignUpdate.clickedCount = {increment: 1};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'bounced':
|
||||||
|
campaignUpdate.bouncedCount = {increment: 1};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(campaignUpdate).length > 0) {
|
||||||
|
await prisma.campaign.update({
|
||||||
|
where: {id: email.campaignId},
|
||||||
|
data: campaignUpdate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Track event
|
// Track event
|
||||||
await prisma.event.create({
|
await prisma.event.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -509,7 +564,7 @@ export class EmailService {
|
|||||||
contactId: email.contactId,
|
contactId: email.contactId,
|
||||||
emailId: email.id,
|
emailId: email.id,
|
||||||
name: `email.${eventType}`,
|
name: `email.${eventType}`,
|
||||||
data: metadata ? (metadata as Prisma.InputJsonValue) : undefined,
|
data: metadata ? toPrismaJson(metadata) : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -530,10 +585,11 @@ export class EmailService {
|
|||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const [total, sent, delivered, opened, clicked, bounced, failed] = await Promise.all([
|
const [total, sent, delivered, received, opened, clicked, bounced, failed] = await Promise.all([
|
||||||
prisma.email.count({where}),
|
prisma.email.count({where}),
|
||||||
prisma.email.count({where: {...where, status: EmailStatus.SENT}}),
|
prisma.email.count({where: {...where, status: EmailStatus.SENT}}),
|
||||||
prisma.email.count({where: {...where, status: EmailStatus.DELIVERED}}),
|
prisma.email.count({where: {...where, status: EmailStatus.DELIVERED}}),
|
||||||
|
prisma.email.count({where: {...where, status: EmailStatus.RECEIVED}}),
|
||||||
prisma.email.count({where: {...where, status: EmailStatus.OPENED}}),
|
prisma.email.count({where: {...where, status: EmailStatus.OPENED}}),
|
||||||
prisma.email.count({where: {...where, status: EmailStatus.CLICKED}}),
|
prisma.email.count({where: {...where, status: EmailStatus.CLICKED}}),
|
||||||
prisma.email.count({where: {...where, status: EmailStatus.BOUNCED}}),
|
prisma.email.count({where: {...where, status: EmailStatus.BOUNCED}}),
|
||||||
@@ -544,6 +600,7 @@ export class EmailService {
|
|||||||
total,
|
total,
|
||||||
sent,
|
sent,
|
||||||
delivered,
|
delivered,
|
||||||
|
received,
|
||||||
opened,
|
opened,
|
||||||
clicked,
|
clicked,
|
||||||
bounced,
|
bounced,
|
||||||
|
|||||||
@@ -1,26 +1,154 @@
|
|||||||
import {promises as dns} from 'dns';
|
import {promises as dns} from 'dns';
|
||||||
import {run} from '@zootools/email-spell-checker';
|
import {run} from '@zootools/email-spell-checker';
|
||||||
|
import type {EmailVerificationResult} from '@plunk/types';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
|
|
||||||
export interface EmailVerificationResult {
|
|
||||||
email: string;
|
|
||||||
valid: boolean;
|
|
||||||
isDisposable: boolean;
|
|
||||||
isTypo: boolean;
|
|
||||||
isPlusAddressed: boolean;
|
|
||||||
domainExists: boolean;
|
|
||||||
hasMxRecords: boolean;
|
|
||||||
suggestedEmail?: string;
|
|
||||||
reasons: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const DISPOSABLE_DOMAINS_URL =
|
const DISPOSABLE_DOMAINS_URL =
|
||||||
'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf';
|
'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf';
|
||||||
const DISPOSABLE_DOMAINS_CACHE_KEY = 'email:disposable_domains';
|
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)
|
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 {
|
export class EmailVerificationService {
|
||||||
private static disposableDomainsSet: Set<string> | null = null;
|
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
|
* Fetch and cache the disposable domains list from GitHub
|
||||||
@@ -76,85 +204,62 @@ export class EmailVerificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify an email address
|
* Check if a domain is a known forwarding/alias service
|
||||||
* - Checks if domain exists (DNS A/AAAA records)
|
|
||||||
* - Checks for MX records
|
|
||||||
* - Detects disposable email addresses
|
|
||||||
* - Suggests corrections for common typos
|
|
||||||
*/
|
*/
|
||||||
static async verifyEmail(email: string): Promise<EmailVerificationResult> {
|
private static isForwardingDomain(domain: string): boolean {
|
||||||
const result: EmailVerificationResult = {
|
return FORWARDING_DOMAINS.has(domain.toLowerCase());
|
||||||
email,
|
}
|
||||||
valid: true,
|
|
||||||
isDisposable: false,
|
|
||||||
isTypo: false,
|
|
||||||
isPlusAddressed: false,
|
|
||||||
domainExists: false,
|
|
||||||
hasMxRecords: false,
|
|
||||||
reasons: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract domain from email
|
/**
|
||||||
const emailParts = email.split('@');
|
* Fetch and cache the personal email domains list from GitHub
|
||||||
if (emailParts.length !== 2) {
|
* Uses Redis for caching with 24-hour TTL
|
||||||
result.valid = false;
|
* Falls back to in-memory cache if Redis fails
|
||||||
result.reasons.push('Invalid email format');
|
*/
|
||||||
return result;
|
private static async getPersonalEmailDomains(): Promise<Set<string>> {
|
||||||
|
// Return in-memory cache if available
|
||||||
|
if (this.personalDomainsSet) {
|
||||||
|
return this.personalDomainsSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if domain exists (has any DNS records)
|
|
||||||
try {
|
try {
|
||||||
await dns.resolve(domain, 'A');
|
// Try to get from Redis cache first
|
||||||
result.domainExists = true;
|
const cached = await redis.get(PERSONAL_DOMAINS_CACHE_KEY);
|
||||||
} catch {
|
if (cached) {
|
||||||
// Try AAAA records if A records fail
|
const domains = JSON.parse(cached) as string[];
|
||||||
try {
|
this.personalDomainsSet = new Set(domains);
|
||||||
await dns.resolve(domain, 'AAAA');
|
return this.personalDomainsSet;
|
||||||
result.domainExists = true;
|
|
||||||
} catch {
|
|
||||||
result.domainExists = false;
|
|
||||||
result.valid = false;
|
|
||||||
result.reasons.push('Domain does not exist');
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check MX records (only if domain exists)
|
// Fetch from GitHub if not in cache
|
||||||
if (result.domainExists) {
|
const response = await fetch(PERSONAL_DOMAINS_URL);
|
||||||
try {
|
if (!response.ok) {
|
||||||
const mxRecords = await dns.resolveMx(domain);
|
throw new Error(`Failed to fetch personal email domains: ${response.statusText}`);
|
||||||
result.hasMxRecords = mxRecords && mxRecords.length > 0;
|
|
||||||
if (!result.hasMxRecords) {
|
|
||||||
result.valid = false;
|
|
||||||
result.reasons.push('No MX records found for domain');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
result.hasMxRecords = false;
|
|
||||||
result.valid = false;
|
|
||||||
result.reasons.push('No MX records found for domain');
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// If no issues were found, add a success reason
|
const text = await response.text();
|
||||||
if (result.valid && result.reasons.length === 0) {
|
const domains = text
|
||||||
result.reasons.push('Email appears to be valid');
|
.split('\n')
|
||||||
}
|
.map(line => line.trim())
|
||||||
|
.filter(line => line && !line.startsWith('#')); // Filter empty lines and comments
|
||||||
|
|
||||||
return result;
|
// 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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {Event} from '@plunk/db';
|
import type {Event} from '@plunk/db';
|
||||||
import {Prisma} from '@plunk/db';
|
import {Prisma} from '@plunk/db';
|
||||||
import type {FilterCondition, FilterGroup} from '@plunk/types';
|
import type {FilterCondition, FilterGroup} from '@plunk/types';
|
||||||
|
import {toPrismaJson} from '@plunk/types';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
@@ -32,7 +33,7 @@ export class EventService {
|
|||||||
contactId,
|
contactId,
|
||||||
emailId,
|
emailId,
|
||||||
name: eventName,
|
name: eventName,
|
||||||
data: data ? (data as Prisma.InputJsonValue) : undefined,
|
data: data ? toPrismaJson(data) : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -472,7 +473,7 @@ export class EventService {
|
|||||||
contactId,
|
contactId,
|
||||||
status: 'RUNNING',
|
status: 'RUNNING',
|
||||||
currentStepId: triggerStep.id,
|
currentStepId: triggerStep.id,
|
||||||
context: context ? (context as Prisma.InputJsonValue) : undefined,
|
context: context ? toPrismaJson(context) : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,40 +1,6 @@
|
|||||||
|
import {type NtfyNotification, NtfyPriority, NtfyTag} from '@plunk/types';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
/**
|
|
||||||
* Priority levels for ntfy notifications
|
|
||||||
* Based on ntfy.sh documentation
|
|
||||||
*/
|
|
||||||
export enum NtfyPriority {
|
|
||||||
MIN = 1, // No vibration/sound, relegated to "Other notifications"
|
|
||||||
LOW = 2, // No vibration/sound, hidden until drawer opened
|
|
||||||
DEFAULT = 3, // Short vibration and sound (standard)
|
|
||||||
HIGH = 4, // Long vibration, pop-over notification
|
|
||||||
MAX = 5, // Long vibration bursts, pop-over notification
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tags for ntfy notifications (emoji shortcuts)
|
|
||||||
*/
|
|
||||||
export enum NtfyTag {
|
|
||||||
WARNING = 'warning',
|
|
||||||
ERROR = 'rotating_light',
|
|
||||||
SUCCESS = 'white_check_mark',
|
|
||||||
MONEY = 'money_with_wings',
|
|
||||||
SHIELD = 'shield',
|
|
||||||
ROCKET = 'rocket',
|
|
||||||
BELL = 'bell',
|
|
||||||
CHART = 'chart_with_upwards_trend',
|
|
||||||
SKULL = 'skull',
|
|
||||||
INFO = 'information_source',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NtfyNotification {
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
priority?: NtfyPriority;
|
|
||||||
tags?: NtfyTag[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service for sending notifications via ntfy.sh
|
* Service for sending notifications via ntfy.sh
|
||||||
* Supports configurable ntfy server URL via NTFY_URL environment variable
|
* Supports configurable ntfy server URL via NTFY_URL environment variable
|
||||||
@@ -241,6 +207,18 @@ export class NtfyService {
|
|||||||
* Notify about security warning (non-critical)
|
* Notify about security warning (non-critical)
|
||||||
*/
|
*/
|
||||||
public static async notifySecurityWarning(projectName: string, projectId: string, warnings: string[]): Promise<void> {
|
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(', ');
|
const warningText = warnings.join(', ');
|
||||||
await this.sendDefault(
|
await this.sendDefault(
|
||||||
'Security Warning',
|
'Security Warning',
|
||||||
@@ -251,7 +229,7 @@ export class NtfyService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Notify about email bounce - LOW priority (high volume)
|
* Notify about email bounce - LOW priority (high volume)
|
||||||
* Rate-limited to only send notification every 20 bounces with per-project breakdown
|
* Rate-limited to only send notification every 20 bounces with latest 20 bounce details
|
||||||
*/
|
*/
|
||||||
public static async notifyEmailBounce(
|
public static async notifyEmailBounce(
|
||||||
projectName: string,
|
projectName: string,
|
||||||
@@ -262,63 +240,51 @@ export class NtfyService {
|
|||||||
// Import redis at runtime to avoid circular dependencies
|
// Import redis at runtime to avoid circular dependencies
|
||||||
const {redis} = await import('../database/redis.js');
|
const {redis} = await import('../database/redis.js');
|
||||||
|
|
||||||
// Use Redis counters to track bounce count globally and per-project
|
// Use Redis counters to track bounce count globally
|
||||||
const globalCountKey = `ntfy:bounce:count`;
|
const globalCountKey = `ntfy:bounce:count`;
|
||||||
const projectCountKey = `ntfy:bounce:count:${projectId}`;
|
const bounceListKey = `ntfy:bounce:latest`;
|
||||||
const projectListKey = `ntfy:bounce:projects`;
|
|
||||||
|
|
||||||
// Increment global counter
|
// Increment global counter
|
||||||
const globalCount = await redis.incr(globalCountKey);
|
const globalCount = await redis.incr(globalCountKey);
|
||||||
|
|
||||||
// Increment project-specific counter
|
// Store this bounce event in a list (keep latest 20)
|
||||||
await redis.incr(projectCountKey);
|
const bounceEvent = JSON.stringify({
|
||||||
|
projectName,
|
||||||
// Add project to the set of projects with bounces (for tracking)
|
projectId,
|
||||||
await redis.sadd(projectListKey, 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)
|
// Set expiry on first increment (24 hour rolling window)
|
||||||
if (globalCount === 1) {
|
if (globalCount === 1) {
|
||||||
await redis.expire(globalCountKey, 86400);
|
await redis.expire(globalCountKey, 86400);
|
||||||
await redis.expire(projectListKey, 86400);
|
await redis.expire(bounceListKey, 86400);
|
||||||
}
|
}
|
||||||
// Always refresh project counter expiry to match global window
|
|
||||||
await redis.expire(projectCountKey, 86400);
|
|
||||||
|
|
||||||
// Only send notification every 20 bounces
|
// Only send notification every 20 bounces
|
||||||
if (globalCount % 20 === 0) {
|
if (globalCount % 20 === 0) {
|
||||||
// Get all projects with bounces
|
// Get the latest 20 bounces
|
||||||
const projectIds = await redis.smembers(projectListKey);
|
const latestBounces = await redis.lrange(bounceListKey, 0, 19);
|
||||||
|
|
||||||
// Get bounce counts for each project
|
// Parse and format the bounce events
|
||||||
const projectCounts: Array<{projectId: string; count: number; name: string}> = [];
|
const bounceDetails = latestBounces
|
||||||
for (const pid of projectIds) {
|
.map(bounce => {
|
||||||
const count = await redis.get(`ntfy:bounce:count:${pid}`);
|
try {
|
||||||
if (count) {
|
const parsed = JSON.parse(bounce);
|
||||||
// Fetch project name from database
|
return ` • ${parsed.recipientEmail} (${parsed.bounceType}) - ${parsed.projectName}`;
|
||||||
const {prisma} = await import('../database/prisma.js');
|
} catch {
|
||||||
const project = await prisma.project.findUnique({
|
return null;
|
||||||
where: {id: pid},
|
}
|
||||||
select: {name: true},
|
})
|
||||||
});
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
projectCounts.push({
|
|
||||||
projectId: pid,
|
|
||||||
count: parseInt(count, 10),
|
|
||||||
name: project?.name || 'Unknown',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by count descending
|
|
||||||
projectCounts.sort((a, b) => b.count - a.count);
|
|
||||||
|
|
||||||
// Build breakdown message
|
|
||||||
const breakdown = projectCounts.map(p => ` • ${p.name} (${p.projectId}): ${p.count}`).join('\n');
|
|
||||||
|
|
||||||
const bounceInfo = bounceType ? ` (${bounceType})` : '';
|
|
||||||
await this.send({
|
await this.send({
|
||||||
title: 'Email Bounces',
|
title: 'Email Bounces',
|
||||||
message: `20 email bounces detected (total: ${globalCount})\n\nBreakdown by project:\n${breakdown}\n\nLatest: ${recipientEmail}${bounceInfo} in "${projectName}"`,
|
message: `20 email bounces detected (total: ${globalCount})\n\nLatest 20 bounces:\n${bounceDetails}`,
|
||||||
priority: NtfyPriority.LOW,
|
priority: NtfyPriority.LOW,
|
||||||
tags: [NtfyTag.WARNING],
|
tags: [NtfyTag.WARNING],
|
||||||
});
|
});
|
||||||
@@ -352,6 +318,19 @@ export class NtfyService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Notify about new user account created via OAuth - LOW priority
|
||||||
*/
|
*/
|
||||||
@@ -610,6 +589,18 @@ export class NtfyService {
|
|||||||
percentage: number,
|
percentage: number,
|
||||||
sourceType: string,
|
sourceType: string,
|
||||||
): Promise<void> {
|
): 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(
|
await this.sendDefault(
|
||||||
'Billing Limit Warning',
|
'Billing Limit Warning',
|
||||||
`Email usage at ${Math.round(percentage)}% (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId})`,
|
`Email usage at ${Math.round(percentage)}% (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId})`,
|
||||||
@@ -629,6 +620,18 @@ export class NtfyService {
|
|||||||
limit: number,
|
limit: number,
|
||||||
sourceType: string,
|
sourceType: string,
|
||||||
): Promise<void> {
|
): 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(
|
await this.sendUrgent(
|
||||||
'Billing Limit Exceeded',
|
'Billing Limit Exceeded',
|
||||||
`Email usage limit reached (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId}). Further emails are blocked.`,
|
`Email usage limit reached (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId}). Further emails are blocked.`,
|
||||||
@@ -747,6 +750,32 @@ export class NtfyService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Notify about segment deleted - MIN priority
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,63 +1,21 @@
|
|||||||
import {type Job, Queue} from 'bullmq';
|
import {type Job, Queue} from 'bullmq';
|
||||||
import type {RedisOptions} from 'ioredis';
|
import type {RedisOptions} from 'ioredis';
|
||||||
import signale from 'signale';
|
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 {REDIS_URL} from '../app/constants.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Queue Job Data Types
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface SendEmailJobData {
|
|
||||||
emailId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CampaignBatchJobData {
|
|
||||||
campaignId: string;
|
|
||||||
batchNumber: number;
|
|
||||||
offset: number;
|
|
||||||
limit: number;
|
|
||||||
cursor?: string; // For cursor-based pagination
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowStepJobData {
|
|
||||||
executionId: string;
|
|
||||||
stepId: string;
|
|
||||||
type?: 'process-step' | 'timeout'; // Job type for different handling
|
|
||||||
stepExecutionId?: string; // For timeout jobs, reference to the step execution
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScheduledCampaignJobData {
|
|
||||||
campaignId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContactImportJobData {
|
|
||||||
projectId: string;
|
|
||||||
csvData: string; // Base64 encoded CSV content
|
|
||||||
filename: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SegmentCountJobData {
|
|
||||||
projectId?: string; // Optional: if provided, only update this project's segments
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
||||||
export interface DomainVerificationJobData {
|
|
||||||
// Empty for now - processes all domains
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
||||||
export interface ApiRequestCleanupJobData {
|
|
||||||
// Empty - cleans up old API request logs
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BulkContactActionJobData {
|
|
||||||
projectId: string;
|
|
||||||
contactIds: string[];
|
|
||||||
operation: 'subscribe' | 'unsubscribe' | 'delete';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue Configuration
|
* Queue Configuration
|
||||||
*/
|
*/
|
||||||
@@ -324,14 +282,22 @@ export class QueueService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get import job status and progress
|
* 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) {
|
public static async getImportJobStatus(jobId: string, projectId: string) {
|
||||||
const job = await importQueue.getJob(jobId);
|
const job = await importQueue.getJob(jobId);
|
||||||
|
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return null;
|
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 state = await job.getState();
|
||||||
const progress = job.progress;
|
const progress = job.progress;
|
||||||
const returnValue = job.returnvalue;
|
const returnValue = job.returnvalue;
|
||||||
@@ -366,14 +332,22 @@ export class QueueService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get bulk action job status and progress
|
* 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) {
|
public static async getBulkActionJobStatus(jobId: string, projectId: string) {
|
||||||
const job = await bulkContactQueue.getJob(jobId);
|
const job = await bulkContactQueue.getJob(jobId);
|
||||||
|
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return null;
|
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 state = await job.getState();
|
||||||
const progress = job.progress;
|
const progress = job.progress;
|
||||||
const returnValue = job.returnvalue;
|
const returnValue = job.returnvalue;
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import {
|
import {
|
||||||
S3Client,
|
|
||||||
PutObjectCommand,
|
|
||||||
CreateBucketCommand,
|
CreateBucketCommand,
|
||||||
HeadBucketCommand,
|
HeadBucketCommand,
|
||||||
PutBucketPolicyCommand,
|
PutBucketPolicyCommand,
|
||||||
|
PutObjectCommand,
|
||||||
|
S3Client,
|
||||||
} from '@aws-sdk/client-s3';
|
} from '@aws-sdk/client-s3';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
S3_ENDPOINT,
|
|
||||||
S3_ACCESS_KEY_ID,
|
S3_ACCESS_KEY_ID,
|
||||||
S3_ACCESS_KEY_SECRET,
|
S3_ACCESS_KEY_SECRET,
|
||||||
S3_BUCKET,
|
S3_BUCKET,
|
||||||
S3_PUBLIC_URL,
|
|
||||||
S3_FORCE_PATH_STYLE,
|
|
||||||
S3_ENABLED,
|
S3_ENABLED,
|
||||||
|
S3_ENDPOINT,
|
||||||
|
S3_FORCE_PATH_STYLE,
|
||||||
|
S3_PUBLIC_URL,
|
||||||
} from '../app/constants.js';
|
} from '../app/constants.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,7 +46,6 @@ export async function initializeBucket(): Promise<void> {
|
|||||||
let bucketExists = true;
|
let bucketExists = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if bucket exists
|
|
||||||
await s3Client.send(
|
await s3Client.send(
|
||||||
new HeadBucketCommand({
|
new HeadBucketCommand({
|
||||||
Bucket: S3_BUCKET,
|
Bucket: S3_BUCKET,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
DASHBOARD_URI,
|
DASHBOARD_URI,
|
||||||
SES_CONFIGURATION_SET,
|
SES_CONFIGURATION_SET,
|
||||||
SES_CONFIGURATION_SET_NO_TRACKING,
|
SES_CONFIGURATION_SET_NO_TRACKING,
|
||||||
TRACKING_TOGGLE_ENABLED
|
TRACKING_TOGGLE_ENABLED,
|
||||||
} from '../app/constants.js';
|
} from '../app/constants.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -258,6 +258,13 @@ export const disableFeedbackForwarding = async (domain: string): Promise<void> =
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Get AWS SES account sending quota and rate limit
|
||||||
* @returns MaxSendRate (emails per second) or null if the call fails
|
* @returns MaxSendRate (emails per second) or null if the call fails
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import signale from 'signale';
|
|||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis} from '../database/redis.js';
|
import {redis} from '../database/redis.js';
|
||||||
import {Keys} from './keys.js';
|
import {Keys} from './keys.js';
|
||||||
|
import {MembershipService} from './MembershipService.js';
|
||||||
import {NtfyService} from './NtfyService.js';
|
import {NtfyService} from './NtfyService.js';
|
||||||
import {QueueService} from './QueueService.js';
|
import {QueueService} from './QueueService.js';
|
||||||
import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||||
@@ -18,16 +19,23 @@ const SECURITY_THRESHOLDS = {
|
|||||||
MIN_EMAILS_FOR_ENFORCEMENT: 100,
|
MIN_EMAILS_FOR_ENFORCEMENT: 100,
|
||||||
|
|
||||||
// Bounce rate thresholds (hard bounces only)
|
// Bounce rate thresholds (hard bounces only)
|
||||||
BOUNCE_7DAY_WARNING: 3,
|
BOUNCE_7DAY_WARNING: 5,
|
||||||
BOUNCE_7DAY_CRITICAL: 8,
|
BOUNCE_7DAY_CRITICAL: 10,
|
||||||
BOUNCE_ALLTIME_WARNING: 2,
|
BOUNCE_ALLTIME_WARNING: 4,
|
||||||
BOUNCE_ALLTIME_CRITICAL: 5,
|
BOUNCE_ALLTIME_CRITICAL: 8,
|
||||||
|
|
||||||
// Complaint rate thresholds (spam reports)
|
// Complaint rate thresholds (spam reports)
|
||||||
COMPLAINT_7DAY_WARNING: 0.05,
|
COMPLAINT_7DAY_WARNING: 0.075,
|
||||||
COMPLAINT_7DAY_CRITICAL: 0.1,
|
COMPLAINT_7DAY_CRITICAL: 0.15,
|
||||||
COMPLAINT_ALLTIME_WARNING: 0.02,
|
COMPLAINT_ALLTIME_WARNING: 0.03,
|
||||||
COMPLAINT_ALLTIME_CRITICAL: 0.08,
|
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;
|
} as const;
|
||||||
|
|
||||||
interface RateData {
|
interface RateData {
|
||||||
@@ -177,26 +185,7 @@ export class SecurityService {
|
|||||||
hasDisabledProject: boolean;
|
hasDisabledProject: boolean;
|
||||||
disabledProjectNames: string[];
|
disabledProjectNames: string[];
|
||||||
}> {
|
}> {
|
||||||
const disabledMemberships = await prisma.membership.findMany({
|
return MembershipService.userHasDisabledProject(userId);
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
project: {
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
project: {
|
|
||||||
select: {
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
hasDisabledProject: disabledMemberships.length > 0,
|
|
||||||
disabledProjectNames: disabledMemberships.map(m => m.project.name),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -293,50 +282,82 @@ export class SecurityService {
|
|||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
// Only enforce if minimum emails threshold is met
|
// Only enforce if minimum emails threshold is met
|
||||||
const hasMinimumVolume = allTime.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
|
const hasMinimumVolumeAllTime = allTime.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
|
||||||
|
const hasMinimumVolume7Day = sevenDay.total >= SECURITY_THRESHOLDS.MIN_EMAILS_FOR_ENFORCEMENT;
|
||||||
|
|
||||||
if (hasMinimumVolume) {
|
// Check 7-day bounce rate (only if 7-day volume is sufficient)
|
||||||
// Check 7-day bounce rate
|
if (hasMinimumVolume7Day) {
|
||||||
if (sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL) {
|
// 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(
|
violations.push(
|
||||||
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%)`,
|
`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) {
|
} else if (
|
||||||
|
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING &&
|
||||||
|
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING
|
||||||
|
) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING}%)`,
|
`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 bounce rate
|
// Check all-time complaint rate - requires BOTH rate AND absolute count
|
||||||
if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL) {
|
if (
|
||||||
|
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL &&
|
||||||
|
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
|
||||||
|
) {
|
||||||
violations.push(
|
violations.push(
|
||||||
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL}%)`,
|
`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.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING) {
|
} else if (
|
||||||
|
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING &&
|
||||||
|
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING
|
||||||
|
) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING}%)`,
|
`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)`,
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check 7-day complaint rate
|
|
||||||
if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL) {
|
|
||||||
violations.push(
|
|
||||||
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL}%)`,
|
|
||||||
);
|
|
||||||
} else if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING) {
|
|
||||||
warnings.push(
|
|
||||||
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING}%)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check all-time complaint rate
|
|
||||||
if (allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL) {
|
|
||||||
violations.push(
|
|
||||||
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL}%)`,
|
|
||||||
);
|
|
||||||
} else if (allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING) {
|
|
||||||
warnings.push(
|
|
||||||
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING}%)`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -408,11 +429,8 @@ export class SecurityService {
|
|||||||
|
|
||||||
// Send email notification to project members
|
// Send email notification to project members
|
||||||
try {
|
try {
|
||||||
const members = await prisma.membership.findMany({
|
const members = await MembershipService.getMembers(projectId);
|
||||||
where: {projectId},
|
const emails = members.map(m => m.email);
|
||||||
include: {user: {select: {email: true}}},
|
|
||||||
});
|
|
||||||
const emails = members.map(m => m.user.email);
|
|
||||||
if (emails.length > 0) {
|
if (emails.length > 0) {
|
||||||
const template = React.createElement(ProjectDisabledEmail, {
|
const template = React.createElement(ProjectDisabledEmail, {
|
||||||
projectName: project.name,
|
projectName: project.name,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {type Contact, Prisma, type Segment} from '@plunk/db';
|
import {type Contact, Prisma, type Segment} from '@plunk/db';
|
||||||
import type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
|
import type {FilterCondition, FilterGroup, PaginatedResponse, SegmentFilter} from '@plunk/types';
|
||||||
|
import {fromPrismaJson, toPrismaJson} from '@plunk/types';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
@@ -8,17 +9,8 @@ import {HttpException} from '../exceptions/index.js';
|
|||||||
import {EventService} from './EventService.js';
|
import {EventService} from './EventService.js';
|
||||||
import {NtfyService} from './NtfyService.js';
|
import {NtfyService} from './NtfyService.js';
|
||||||
|
|
||||||
// Re-export types for use in other services
|
|
||||||
export type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
|
export type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
|
||||||
|
|
||||||
export interface PaginatedContacts {
|
|
||||||
contacts: Contact[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
totalPages: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert segment name to a URL-safe slug for event names
|
* Convert segment name to a URL-safe slug for event names
|
||||||
* Example: "VIP Customers" -> "vip-customers"
|
* Example: "VIP Customers" -> "vip-customers"
|
||||||
@@ -72,9 +64,9 @@ export class SegmentService {
|
|||||||
segmentId: string,
|
segmentId: string,
|
||||||
page = 1,
|
page = 1,
|
||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
): Promise<PaginatedContacts> {
|
): Promise<PaginatedResponse<Contact>> {
|
||||||
const segment = await this.get(projectId, segmentId);
|
const segment = await this.get(projectId, segmentId);
|
||||||
const condition = segment.condition as unknown as FilterCondition;
|
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||||
|
|
||||||
const where = this.buildWhereClause(projectId, condition);
|
const where = this.buildWhereClause(projectId, condition);
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
@@ -90,7 +82,7 @@ export class SegmentService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contacts,
|
data: contacts,
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -122,7 +114,7 @@ export class SegmentService {
|
|||||||
projectId,
|
projectId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
condition: data.condition as unknown as Prisma.InputJsonValue,
|
condition: toPrismaJson(data.condition),
|
||||||
trackMembership: data.trackMembership ?? false,
|
trackMembership: data.trackMembership ?? false,
|
||||||
memberCount,
|
memberCount,
|
||||||
},
|
},
|
||||||
@@ -169,7 +161,7 @@ export class SegmentService {
|
|||||||
updateData.description = data.description;
|
updateData.description = data.description;
|
||||||
}
|
}
|
||||||
if (data.condition !== undefined) {
|
if (data.condition !== undefined) {
|
||||||
updateData.condition = data.condition as unknown as Prisma.InputJsonValue;
|
updateData.condition = toPrismaJson(data.condition);
|
||||||
|
|
||||||
// Recompute member count when condition changes
|
// Recompute member count when condition changes
|
||||||
const where = this.buildWhereClause(projectId, data.condition);
|
const where = this.buildWhereClause(projectId, data.condition);
|
||||||
@@ -237,7 +229,7 @@ export class SegmentService {
|
|||||||
*/
|
*/
|
||||||
public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> {
|
public static async refreshMemberCount(projectId: string, segmentId: string): Promise<number> {
|
||||||
const segment = await this.get(projectId, segmentId);
|
const segment = await this.get(projectId, segmentId);
|
||||||
const condition = segment.condition as unknown as FilterCondition;
|
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||||
const where = this.buildWhereClause(projectId, condition);
|
const where = this.buildWhereClause(projectId, condition);
|
||||||
|
|
||||||
const memberCount = await prisma.contact.count({where});
|
const memberCount = await prisma.contact.count({where});
|
||||||
@@ -268,7 +260,7 @@ export class SegmentService {
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
batch.map(async segment => {
|
batch.map(async segment => {
|
||||||
try {
|
try {
|
||||||
const condition = segment.condition as unknown as FilterCondition;
|
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||||
const where = this.buildWhereClause(projectId, condition);
|
const where = this.buildWhereClause(projectId, condition);
|
||||||
const memberCount = await prisma.contact.count({where});
|
const memberCount = await prisma.contact.count({where});
|
||||||
|
|
||||||
@@ -298,7 +290,7 @@ export class SegmentService {
|
|||||||
throw new HttpException(400, 'Segment does not have membership tracking enabled');
|
throw new HttpException(400, 'Segment does not have membership tracking enabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
const condition = segment.condition as unknown as FilterCondition;
|
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||||
const where = this.buildWhereClause(projectId, condition);
|
const where = this.buildWhereClause(projectId, condition);
|
||||||
|
|
||||||
// Get all matching contacts using cursor-based pagination to avoid memory issues
|
// Get all matching contacts using cursor-based pagination to avoid memory issues
|
||||||
@@ -364,15 +356,43 @@ export class SegmentService {
|
|||||||
for (let i = 0; i < toAdd.length; i += ADD_BATCH_SIZE) {
|
for (let i = 0; i < toAdd.length; i += ADD_BATCH_SIZE) {
|
||||||
const batch = toAdd.slice(i, i + ADD_BATCH_SIZE);
|
const batch = toAdd.slice(i, i + ADD_BATCH_SIZE);
|
||||||
|
|
||||||
await prisma.segmentMembership.createMany({
|
// Check which contacts already have a membership record (inactive)
|
||||||
data: batch.map(contactId => ({
|
const existingMemberships = await prisma.segmentMembership.findMany({
|
||||||
|
where: {
|
||||||
segmentId,
|
segmentId,
|
||||||
contactId,
|
contactId: {in: batch},
|
||||||
enteredAt: new Date(),
|
},
|
||||||
})),
|
select: {contactId: true},
|
||||||
skipDuplicates: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const existingContactIds = new Set(existingMemberships.map(m => m.contactId));
|
||||||
|
const newEntries = batch.filter(id => !existingContactIds.has(id));
|
||||||
|
const reEntries = batch.filter(id => existingContactIds.has(id));
|
||||||
|
|
||||||
|
if (newEntries.length > 0) {
|
||||||
|
await prisma.segmentMembership.createMany({
|
||||||
|
data: newEntries.map(contactId => ({
|
||||||
|
segmentId,
|
||||||
|
contactId,
|
||||||
|
enteredAt: new Date(),
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reEntries.length > 0) {
|
||||||
|
await prisma.segmentMembership.updateMany({
|
||||||
|
where: {
|
||||||
|
segmentId,
|
||||||
|
contactId: {in: reEntries},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
exitedAt: null,
|
||||||
|
enteredAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Create segment-specific entry events for each contact in the batch
|
// Create segment-specific entry events for each contact in the batch
|
||||||
for (const contactId of batch) {
|
for (const contactId of batch) {
|
||||||
try {
|
try {
|
||||||
@@ -496,6 +516,19 @@ export class SegmentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build Prisma clause from filter condition (recursive)
|
||||||
|
*/
|
||||||
|
public static buildConditionClause(condition: FilterCondition): Prisma.ContactWhereInput {
|
||||||
|
const groupClauses = condition.groups.map(group => this.buildGroupClause(group));
|
||||||
|
|
||||||
|
if (condition.logic === 'AND') {
|
||||||
|
return {AND: groupClauses};
|
||||||
|
} else {
|
||||||
|
return {OR: groupClauses};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate filter group (recursive)
|
* Validate filter group (recursive)
|
||||||
*/
|
*/
|
||||||
@@ -551,8 +584,10 @@ export class SegmentService {
|
|||||||
'exists',
|
'exists',
|
||||||
'notExists',
|
'notExists',
|
||||||
'within',
|
'within',
|
||||||
|
'olderThan',
|
||||||
'triggered',
|
'triggered',
|
||||||
'triggeredWithin',
|
'triggeredWithin',
|
||||||
|
'triggeredOlderThan',
|
||||||
'notTriggered',
|
'notTriggered',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -571,7 +606,9 @@ export class SegmentService {
|
|||||||
'greaterThanOrEqual',
|
'greaterThanOrEqual',
|
||||||
'lessThanOrEqual',
|
'lessThanOrEqual',
|
||||||
'within',
|
'within',
|
||||||
|
'olderThan',
|
||||||
'triggeredWithin',
|
'triggeredWithin',
|
||||||
|
'triggeredOlderThan',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
|
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
|
||||||
@@ -579,7 +616,7 @@ export class SegmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate unit for time-based operators
|
// Validate unit for time-based operators
|
||||||
if (['within', 'triggeredWithin'].includes(filter.operator) && !filter.unit) {
|
if (['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(filter.operator) && !filter.unit) {
|
||||||
throw new HttpException(400, `"${filter.operator}" operator requires a unit (days, hours, or minutes)`);
|
throw new HttpException(400, `"${filter.operator}" operator requires a unit (days, hours, or minutes)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -594,19 +631,6 @@ export class SegmentService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Build Prisma clause from filter condition (recursive)
|
|
||||||
*/
|
|
||||||
public static buildConditionClause(condition: FilterCondition): Prisma.ContactWhereInput {
|
|
||||||
const groupClauses = condition.groups.map(group => this.buildGroupClause(group));
|
|
||||||
|
|
||||||
if (condition.logic === 'AND') {
|
|
||||||
return {AND: groupClauses};
|
|
||||||
} else {
|
|
||||||
return {OR: groupClauses};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build Prisma clause from filter group (recursive)
|
* Build Prisma clause from filter group (recursive)
|
||||||
*/
|
*/
|
||||||
@@ -709,6 +733,20 @@ export class SegmentService {
|
|||||||
// Use ISO string for lexicographic comparison in JSON
|
// Use ISO string for lexicographic comparison in JSON
|
||||||
return {data: {path, gte: since.toISOString() as Prisma.InputJsonValue}};
|
return {data: {path, gte: since.toISOString() as Prisma.InputJsonValue}};
|
||||||
}
|
}
|
||||||
|
case 'olderThan': {
|
||||||
|
// Note: Requires JSON date fields in ISO 8601 format for proper comparison
|
||||||
|
if (!unit) {
|
||||||
|
throw new HttpException(400, 'Unit is required for "olderThan" operator');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the "before" date (X time units ago from now)
|
||||||
|
const now = new Date();
|
||||||
|
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||||
|
const before = new Date(now.getTime() - milliseconds);
|
||||||
|
|
||||||
|
// Use ISO string for lexicographic comparison in JSON
|
||||||
|
return {data: {path, lt: before.toISOString() as Prisma.InputJsonValue}};
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`);
|
throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`);
|
||||||
}
|
}
|
||||||
@@ -802,6 +840,18 @@ export class SegmentService {
|
|||||||
|
|
||||||
return {[field]: {gte: since}};
|
return {[field]: {gte: since}};
|
||||||
}
|
}
|
||||||
|
case 'olderThan': {
|
||||||
|
// "olderThan X days/hours/minutes" means more than X time units ago
|
||||||
|
if (!unit) {
|
||||||
|
throw new HttpException(400, 'Unit is required for "olderThan" operator');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||||
|
const before = new Date(now.getTime() - milliseconds);
|
||||||
|
|
||||||
|
return {[field]: {lt: before}};
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new HttpException(400, `Unsupported operator for date field: ${operator}`);
|
throw new HttpException(400, `Unsupported operator for date field: ${operator}`);
|
||||||
}
|
}
|
||||||
@@ -901,6 +951,42 @@ export class SegmentService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'triggeredOlderThan': {
|
||||||
|
// Contact triggered this event, but only more than X time ago (not recently)
|
||||||
|
// This means: has event AND all occurrences are before the cutoff
|
||||||
|
if (!unit) {
|
||||||
|
throw new HttpException(400, 'Unit is required for "triggeredOlderThan" operator');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||||
|
const before = new Date(now.getTime() - milliseconds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
AND: [
|
||||||
|
// Must have triggered the event at some point
|
||||||
|
{
|
||||||
|
events: {
|
||||||
|
some: {
|
||||||
|
name: eventName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// But NOT within the recent timeframe
|
||||||
|
{
|
||||||
|
events: {
|
||||||
|
none: {
|
||||||
|
name: eventName,
|
||||||
|
createdAt: {
|
||||||
|
gte: before,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case 'notTriggered':
|
case 'notTriggered':
|
||||||
// Contact has never triggered this event
|
// Contact has never triggered this event
|
||||||
return {
|
return {
|
||||||
@@ -975,6 +1061,42 @@ export class SegmentService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'triggeredOlderThan': {
|
||||||
|
// Contact had this email activity, but only more than X time ago (not recently)
|
||||||
|
if (!unit) {
|
||||||
|
throw new HttpException(400, 'Unit is required for "triggeredOlderThan" operator');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||||
|
const before = new Date(now.getTime() - milliseconds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
AND: [
|
||||||
|
// Must have the email activity at some point
|
||||||
|
{
|
||||||
|
emails: {
|
||||||
|
some: {
|
||||||
|
[field]: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// But NOT within the recent timeframe
|
||||||
|
{
|
||||||
|
emails: {
|
||||||
|
none: {
|
||||||
|
[field]: {
|
||||||
|
gte: before,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case 'notTriggered':
|
case 'notTriggered':
|
||||||
// Contact has never had this email activity
|
// Contact has never had this email activity
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
import type {Template} from '@plunk/db';
|
import type {Template} from '@plunk/db';
|
||||||
import {Prisma} from '@plunk/db';
|
import {Prisma} from '@plunk/db';
|
||||||
|
import type {PaginatedResponse} from '@plunk/types';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {HttpException} from '../exceptions/index.js';
|
import {HttpException} from '../exceptions/index.js';
|
||||||
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
|
import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js';
|
||||||
|
|
||||||
export interface PaginatedTemplates {
|
|
||||||
templates: Template[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
totalPages: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TemplateService {
|
export class TemplateService {
|
||||||
/**
|
/**
|
||||||
* Get all templates for a project with pagination
|
* Get all templates for a project with pagination
|
||||||
@@ -23,7 +16,7 @@ export class TemplateService {
|
|||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
search?: string,
|
search?: string,
|
||||||
type?: Template['type'],
|
type?: Template['type'],
|
||||||
): Promise<PaginatedTemplates> {
|
): Promise<PaginatedResponse<Template>> {
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
const where: Prisma.TemplateWhereInput = {
|
const where: Prisma.TemplateWhereInput = {
|
||||||
@@ -51,7 +44,7 @@ export class TemplateService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
templates,
|
data: templates,
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import type {
|
import type {
|
||||||
Contact,
|
Contact,
|
||||||
Prisma,
|
Prisma,
|
||||||
|
Template,
|
||||||
|
Workflow,
|
||||||
WorkflowExecution,
|
WorkflowExecution,
|
||||||
WorkflowStep,
|
WorkflowStep,
|
||||||
WorkflowStepExecution,
|
WorkflowStepExecution,
|
||||||
Template,
|
|
||||||
Workflow,
|
|
||||||
} from '@plunk/db';
|
} from '@plunk/db';
|
||||||
import {StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db';
|
import {StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db';
|
||||||
import {WorkflowStepConfigSchemas, renderTemplate} from '@plunk/shared';
|
import {toPrismaJson} from '@plunk/types';
|
||||||
|
import {renderTemplate, WorkflowStepConfigSchemas} from '@plunk/shared';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
@@ -19,7 +20,6 @@ import {EmailService} from './EmailService.js';
|
|||||||
import {NtfyService} from './NtfyService.js';
|
import {NtfyService} from './NtfyService.js';
|
||||||
import {QueueService} from './QueueService.js';
|
import {QueueService} from './QueueService.js';
|
||||||
|
|
||||||
// Type aliases for workflow execution context
|
|
||||||
type StepConfig = Prisma.JsonValue;
|
type StepConfig = Prisma.JsonValue;
|
||||||
type StepResult = Record<string, unknown>;
|
type StepResult = Record<string, unknown>;
|
||||||
type WorkflowExecutionWithRelations = WorkflowExecution & {contact: Contact; workflow: Workflow};
|
type WorkflowExecutionWithRelations = WorkflowExecution & {contact: Contact; workflow: Workflow};
|
||||||
@@ -80,7 +80,9 @@ export class WorkflowExecutionService {
|
|||||||
signale.info(`[WORKFLOW] Execution ${executionId} is WAITING, resuming from delay`);
|
signale.info(`[WORKFLOW] Execution ${executionId} is WAITING, resuming from delay`);
|
||||||
// This is a delayed step - continue with execution
|
// This is a delayed step - continue with execution
|
||||||
} else if (initialExecution.status !== WorkflowExecutionStatus.RUNNING) {
|
} else if (initialExecution.status !== WorkflowExecutionStatus.RUNNING) {
|
||||||
signale.info(`[WORKFLOW] Execution ${executionId} already completed or cancelled with status ${initialExecution.status}, skipping`);
|
signale.info(
|
||||||
|
`[WORKFLOW] Execution ${executionId} already completed or cancelled with status ${initialExecution.status}, skipping`,
|
||||||
|
);
|
||||||
return; // Already completed or cancelled
|
return; // Already completed or cancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +243,7 @@ export class WorkflowExecutionService {
|
|||||||
data: {
|
data: {
|
||||||
status: StepExecutionStatus.COMPLETED,
|
status: StepExecutionStatus.COMPLETED,
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
output: result ? (result as Prisma.InputJsonValue) : undefined,
|
output: result ? toPrismaJson(result) : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -440,11 +442,11 @@ export class WorkflowExecutionService {
|
|||||||
data: {
|
data: {
|
||||||
status: StepExecutionStatus.COMPLETED,
|
status: StepExecutionStatus.COMPLETED,
|
||||||
completedAt: new Date(),
|
completedAt: new Date(),
|
||||||
output: {
|
output: toPrismaJson({
|
||||||
eventName,
|
eventName,
|
||||||
eventData: data ? (data as Prisma.InputJsonValue) : undefined,
|
eventData: data ? toPrismaJson(data) : undefined,
|
||||||
receivedAt: new Date().toISOString(),
|
receivedAt: new Date().toISOString(),
|
||||||
} as Prisma.InputJsonValue,
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -517,18 +519,25 @@ export class WorkflowExecutionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SEND_EMAIL step - Send an email to the contact
|
* SEND_EMAIL step - Send an email to the contact or a custom recipient
|
||||||
*/
|
*/
|
||||||
private static async executeSendEmail(
|
private static async executeSendEmail(
|
||||||
step: WorkflowStepWithTemplate,
|
step: WorkflowStepWithTemplate,
|
||||||
execution: WorkflowExecutionWithRelations,
|
execution: WorkflowExecutionWithRelations,
|
||||||
stepExecution: WorkflowStepExecution,
|
stepExecution: WorkflowStepExecution,
|
||||||
_config: StepConfig,
|
config: StepConfig,
|
||||||
): Promise<StepResult> {
|
): Promise<StepResult> {
|
||||||
if (!step.template) {
|
if (!step.template) {
|
||||||
throw new Error('No template configured for SEND_EMAIL step');
|
throw new Error('No template configured for SEND_EMAIL step');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse step config to get recipient configuration
|
||||||
|
const stepConfig = config && typeof config === 'object' && !Array.isArray(config) ? config : {};
|
||||||
|
const recipientConfig =
|
||||||
|
stepConfig.recipient && typeof stepConfig.recipient === 'object' && !Array.isArray(stepConfig.recipient)
|
||||||
|
? (stepConfig.recipient as {type?: string; customEmail?: string})
|
||||||
|
: {type: 'CONTACT'};
|
||||||
|
|
||||||
// Get contact data for variable substitution
|
// Get contact data for variable substitution
|
||||||
const contact = execution.contact;
|
const contact = execution.contact;
|
||||||
const contactData =
|
const contactData =
|
||||||
@@ -543,6 +552,7 @@ export class WorkflowExecutionService {
|
|||||||
: {};
|
: {};
|
||||||
|
|
||||||
const variables = {
|
const variables = {
|
||||||
|
id: contact.id,
|
||||||
email: contact.email,
|
email: contact.email,
|
||||||
...contactData,
|
...contactData,
|
||||||
...executionContext,
|
...executionContext,
|
||||||
@@ -555,10 +565,20 @@ export class WorkflowExecutionService {
|
|||||||
const renderedSubject = this.renderTemplate(step.template.subject, variables);
|
const renderedSubject = this.renderTemplate(step.template.subject, variables);
|
||||||
const renderedBody = this.renderTemplate(step.template.body, variables);
|
const renderedBody = this.renderTemplate(step.template.body, variables);
|
||||||
|
|
||||||
|
// Determine recipient email
|
||||||
|
let recipientEmail = contact.email;
|
||||||
|
let recipientContactId = contact.id;
|
||||||
|
|
||||||
|
if (recipientConfig.type === 'CUSTOM' && recipientConfig.customEmail) {
|
||||||
|
recipientEmail = recipientConfig.customEmail;
|
||||||
|
// For custom recipients, we don't associate with a contact
|
||||||
|
recipientContactId = contact.id; // Keep original contact for tracking
|
||||||
|
}
|
||||||
|
|
||||||
// Send email via EmailService
|
// Send email via EmailService
|
||||||
const email = await EmailService.sendWorkflowEmail({
|
const email = await EmailService.sendWorkflowEmail({
|
||||||
projectId: execution.workflow.projectId,
|
projectId: execution.workflow.projectId,
|
||||||
contactId: contact.id,
|
contactId: recipientContactId,
|
||||||
workflowExecutionId: execution.id,
|
workflowExecutionId: execution.id,
|
||||||
workflowStepExecutionId: stepExecution.id, // Use stepExecution.id, not step.id
|
workflowStepExecutionId: stepExecution.id, // Use stepExecution.id, not step.id
|
||||||
templateId: step.template.id,
|
templateId: step.template.id,
|
||||||
@@ -567,11 +587,15 @@ export class WorkflowExecutionService {
|
|||||||
from: step.template.from,
|
from: step.template.from,
|
||||||
fromName: step.template.fromName || undefined,
|
fromName: step.template.fromName || undefined,
|
||||||
replyTo: step.template.replyTo || undefined,
|
replyTo: step.template.replyTo || undefined,
|
||||||
|
// Pass custom recipient email if specified
|
||||||
|
recipientEmail: recipientConfig.type === 'CUSTOM' ? recipientEmail : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
emailId: email.id,
|
emailId: email.id,
|
||||||
sentAt: email.createdAt,
|
sentAt: email.createdAt,
|
||||||
|
recipientType: recipientConfig.type,
|
||||||
|
recipientEmail,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -866,7 +890,7 @@ export class WorkflowExecutionService {
|
|||||||
await prisma.contact.update({
|
await prisma.contact.update({
|
||||||
where: {id: contact.id},
|
where: {id: contact.id},
|
||||||
data: {
|
data: {
|
||||||
data: newData ? (newData as Prisma.InputJsonValue) : undefined,
|
data: newData ? toPrismaJson(newData) : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowStepExecution, WorkflowTransition} from '@plunk/db';
|
import type {Workflow, WorkflowExecution, WorkflowStep, WorkflowTransition} from '@plunk/db';
|
||||||
import {Prisma, WorkflowExecutionStatus} from '@plunk/db';
|
import {Prisma, WorkflowExecutionStatus} from '@plunk/db';
|
||||||
|
import type {PaginatedResponse, WorkflowExecutionWithDetails, WorkflowWithDetails} from '@plunk/types';
|
||||||
|
import {toPrismaJson} from '@plunk/types';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
|
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
@@ -10,34 +12,16 @@ import {EventService} from './EventService.js';
|
|||||||
import {NtfyService} from './NtfyService.js';
|
import {NtfyService} from './NtfyService.js';
|
||||||
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
|
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
|
||||||
|
|
||||||
export interface PaginatedWorkflows {
|
|
||||||
workflows: Workflow[];
|
|
||||||
total: number;
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
totalPages: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowWithDetails extends Workflow {
|
|
||||||
steps: (WorkflowStep & {
|
|
||||||
template?: {id: string; name: string} | null;
|
|
||||||
outgoingTransitions: WorkflowTransition[];
|
|
||||||
incomingTransitions: WorkflowTransition[];
|
|
||||||
})[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowExecutionWithDetails extends WorkflowExecution {
|
|
||||||
workflow: Workflow;
|
|
||||||
contact: {id: string; email: string};
|
|
||||||
currentStep?: WorkflowStep | null;
|
|
||||||
stepExecutions: WorkflowStepExecution[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class WorkflowService {
|
export class WorkflowService {
|
||||||
/**
|
/**
|
||||||
* Get all workflows for a project with pagination
|
* Get all workflows for a project with pagination
|
||||||
*/
|
*/
|
||||||
public static async list(projectId: string, page = 1, pageSize = 20, search?: string): Promise<PaginatedWorkflows> {
|
public static async list(
|
||||||
|
projectId: string,
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
search?: string,
|
||||||
|
): Promise<PaginatedResponse<Workflow>> {
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
const where: Prisma.WorkflowWhereInput = {
|
const where: Prisma.WorkflowWhereInput = {
|
||||||
@@ -71,7 +55,7 @@ export class WorkflowService {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workflows: workflows as Workflow[],
|
data: workflows as Workflow[],
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -210,25 +194,57 @@ export class WorkflowService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateData: Prisma.WorkflowUpdateInput = {};
|
// Use transaction to update workflow and TRIGGER step atomically
|
||||||
|
const updated = await prisma.$transaction(async tx => {
|
||||||
|
const updateData: Prisma.WorkflowUpdateInput = {};
|
||||||
|
|
||||||
if (data.name !== undefined) updateData.name = data.name;
|
if (data.name !== undefined) updateData.name = data.name;
|
||||||
if (data.description !== undefined) updateData.description = data.description;
|
if (data.description !== undefined) updateData.description = data.description;
|
||||||
if (data.triggerType !== undefined) updateData.triggerType = data.triggerType;
|
if (data.triggerType !== undefined) updateData.triggerType = data.triggerType;
|
||||||
if (data.triggerConfig !== undefined) {
|
if (data.triggerConfig !== undefined) {
|
||||||
updateData.triggerConfig = data.triggerConfig === null ? Prisma.JsonNull : data.triggerConfig;
|
updateData.triggerConfig = data.triggerConfig === null ? Prisma.JsonNull : data.triggerConfig;
|
||||||
}
|
}
|
||||||
if (data.enabled !== undefined) updateData.enabled = data.enabled;
|
if (data.enabled !== undefined) updateData.enabled = data.enabled;
|
||||||
if (data.allowReentry !== undefined) updateData.allowReentry = data.allowReentry;
|
if (data.allowReentry !== undefined) updateData.allowReentry = data.allowReentry;
|
||||||
|
|
||||||
const updated = await prisma.workflow.update({
|
const updatedWorkflow = await tx.workflow.update({
|
||||||
where: {id: workflowId},
|
where: {id: workflowId},
|
||||||
data: updateData,
|
data: updateData,
|
||||||
include: {
|
include: {
|
||||||
project: {
|
project: {
|
||||||
select: {name: true},
|
select: {name: true},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
|
|
||||||
|
// If triggerConfig changed and it's an EVENT trigger, update the TRIGGER step
|
||||||
|
if (data.triggerConfig !== undefined && updatedWorkflow.triggerType === 'EVENT') {
|
||||||
|
const newTriggerConfig = data.triggerConfig as {eventName?: string} | null;
|
||||||
|
const eventName = newTriggerConfig?.eventName;
|
||||||
|
|
||||||
|
if (eventName) {
|
||||||
|
// Find the TRIGGER step
|
||||||
|
const triggerStep = await tx.workflowStep.findFirst({
|
||||||
|
where: {
|
||||||
|
workflowId: workflowId,
|
||||||
|
type: 'TRIGGER',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (triggerStep) {
|
||||||
|
// Update TRIGGER step config and name to match
|
||||||
|
await tx.workflowStep.update({
|
||||||
|
where: {id: triggerStep.id},
|
||||||
|
data: {
|
||||||
|
name: `Trigger: ${eventName}`,
|
||||||
|
config: {eventName},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedWorkflow;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Invalidate workflow cache if enabled status changed or workflow is enabled
|
// Invalidate workflow cache if enabled status changed or workflow is enabled
|
||||||
@@ -236,6 +252,11 @@ export class WorkflowService {
|
|||||||
await EventService.invalidateWorkflowCache(projectId);
|
await EventService.invalidateWorkflowCache(projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also invalidate cache if triggerConfig changed on an enabled workflow
|
||||||
|
if (data.triggerConfig !== undefined && updated.enabled) {
|
||||||
|
await EventService.invalidateWorkflowCache(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
// Send notification if enabled status changed
|
// Send notification if enabled status changed
|
||||||
if (data.enabled !== undefined && data.enabled !== workflow.enabled) {
|
if (data.enabled !== undefined && data.enabled !== workflow.enabled) {
|
||||||
if (data.enabled) {
|
if (data.enabled) {
|
||||||
@@ -321,8 +342,8 @@ export class WorkflowService {
|
|||||||
workflowId,
|
workflowId,
|
||||||
type: data.type,
|
type: data.type,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
position: data.position as Prisma.InputJsonValue,
|
position: toPrismaJson(data.position),
|
||||||
config: data.config as Prisma.InputJsonValue,
|
config: toPrismaJson(data.config),
|
||||||
templateId: data.templateId,
|
templateId: data.templateId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -403,8 +424,8 @@ export class WorkflowService {
|
|||||||
const updateData: Prisma.WorkflowStepUpdateInput = {};
|
const updateData: Prisma.WorkflowStepUpdateInput = {};
|
||||||
|
|
||||||
if (data.name !== undefined) updateData.name = data.name;
|
if (data.name !== undefined) updateData.name = data.name;
|
||||||
if (data.position !== undefined) updateData.position = data.position as Prisma.InputJsonValue;
|
if (data.position !== undefined) updateData.position = toPrismaJson(data.position);
|
||||||
if (data.config !== undefined) updateData.config = data.config as Prisma.InputJsonValue;
|
if (data.config !== undefined) updateData.config = toPrismaJson(data.config);
|
||||||
if (data.templateId !== undefined) {
|
if (data.templateId !== undefined) {
|
||||||
if (data.templateId === null) {
|
if (data.templateId === null) {
|
||||||
updateData.template = {disconnect: true};
|
updateData.template = {disconnect: true};
|
||||||
@@ -596,7 +617,7 @@ export class WorkflowService {
|
|||||||
fromStepId: data.fromStepId,
|
fromStepId: data.fromStepId,
|
||||||
condition: {
|
condition: {
|
||||||
path: ['branch'],
|
path: ['branch'],
|
||||||
equals: conditionObj.branch as Prisma.InputJsonValue,
|
equals: toPrismaJson(conditionObj.branch),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {describe, it, expect, beforeEach, vi} from 'vitest';
|
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {EmailSourceType} from '@plunk/db';
|
import {EmailSourceType} from '@plunk/db';
|
||||||
import {BillingLimitService} from '../BillingLimitService';
|
import {BillingLimitService} from '../BillingLimitService';
|
||||||
import {EmailService} from '../EmailService';
|
import {EmailService} from '../EmailService';
|
||||||
@@ -320,7 +320,10 @@ describe('BillingLimitService - Critical Enforcement', () => {
|
|||||||
data: {billingLimitCampaigns: 10},
|
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();
|
const lastMonth = new Date();
|
||||||
|
lastMonth.setDate(1);
|
||||||
lastMonth.setMonth(lastMonth.getMonth() - 1);
|
lastMonth.setMonth(lastMonth.getMonth() - 1);
|
||||||
|
|
||||||
await prisma.email.create({
|
await prisma.email.create({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {describe, it, expect, beforeEach, vi} from 'vitest';
|
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {CampaignStatus, CampaignAudienceType} from '@plunk/db';
|
import {CampaignAudienceType, CampaignStatus} from '@plunk/db';
|
||||||
import {CampaignService} from '../CampaignService';
|
import {CampaignService} from '../CampaignService';
|
||||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||||
|
|
||||||
@@ -164,7 +164,7 @@ describe('CampaignService', () => {
|
|||||||
|
|
||||||
const result = await CampaignService.list(projectId, {page: 1, pageSize: 10});
|
const result = await CampaignService.list(projectId, {page: 1, pageSize: 10});
|
||||||
|
|
||||||
expect(result.campaigns).toHaveLength(10);
|
expect(result.data).toHaveLength(10);
|
||||||
expect(result.total).toBe(25);
|
expect(result.total).toBe(25);
|
||||||
expect(result.totalPages).toBe(3);
|
expect(result.totalPages).toBe(3);
|
||||||
expect(result.page).toBe(1);
|
expect(result.page).toBe(1);
|
||||||
@@ -177,8 +177,8 @@ describe('CampaignService', () => {
|
|||||||
|
|
||||||
const result = await CampaignService.list(projectId, {status: CampaignStatus.DRAFT});
|
const result = await CampaignService.list(projectId, {status: CampaignStatus.DRAFT});
|
||||||
|
|
||||||
expect(result.campaigns).toHaveLength(2);
|
expect(result.data).toHaveLength(2);
|
||||||
expect(result.campaigns.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
|
expect(result.data.every(c => c.status === CampaignStatus.DRAFT)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {describe, it, expect, beforeEach} from 'vitest';
|
import {beforeEach, describe, expect, it} from 'vitest';
|
||||||
import {ContactService} from '../ContactService';
|
import {ContactService} from '../ContactService';
|
||||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||||
|
|
||||||
@@ -687,12 +687,7 @@ describe('ContactService - Duplicate Prevention & Data Merging', () => {
|
|||||||
const p2Contact1 = await factories.createContact({projectId: project2.id, subscribed: false});
|
const p2Contact1 = await factories.createContact({projectId: project2.id, subscribed: false});
|
||||||
const p2Contact2 = await factories.createContact({projectId: project2.id, subscribed: false});
|
const p2Contact2 = await factories.createContact({projectId: project2.id, subscribed: false});
|
||||||
|
|
||||||
await ContactService.bulkSubscribe(project1.id, [
|
await ContactService.bulkSubscribe(project1.id, [p1Contact1.id, p1Contact2.id, p2Contact1.id, p2Contact2.id]);
|
||||||
p1Contact1.id,
|
|
||||||
p1Contact2.id,
|
|
||||||
p2Contact1.id,
|
|
||||||
p2Contact2.id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const p1ContactsAfter = await prisma.contact.findMany({
|
const p1ContactsAfter = await prisma.contact.findMany({
|
||||||
where: {projectId: project1.id},
|
where: {projectId: project1.id},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {describe, it, expect, beforeEach, vi} from 'vitest';
|
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||||
import {DomainService} from '../DomainService.js';
|
import {DomainService} from '../DomainService.js';
|
||||||
import {HttpException} from '../../exceptions/index.js';
|
import {HttpException} from '../../exceptions/index.js';
|
||||||
@@ -110,9 +110,7 @@ describe('DomainService', () => {
|
|||||||
it('should throw error for invalid email format', async () => {
|
it('should throw error for invalid email format', async () => {
|
||||||
const {project} = await factories.createUserWithProject();
|
const {project} = await factories.createUserWithProject();
|
||||||
|
|
||||||
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(
|
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(HttpException);
|
||||||
HttpException,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(
|
await expect(DomainService.verifyEmailDomain('invalid-email', project.id)).rejects.toThrow(
|
||||||
/invalid email format/i,
|
/invalid email format/i,
|
||||||
@@ -122,13 +120,13 @@ describe('DomainService', () => {
|
|||||||
it('should throw error when domain is not registered', async () => {
|
it('should throw error when domain is not registered', async () => {
|
||||||
const {project} = await factories.createUserWithProject();
|
const {project} = await factories.createUserWithProject();
|
||||||
|
|
||||||
await expect(
|
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
|
||||||
DomainService.verifyEmailDomain('[email protected]', project.id),
|
HttpException,
|
||||||
).rejects.toThrow(HttpException);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
|
||||||
DomainService.verifyEmailDomain('[email protected]', project.id),
|
/not registered/i,
|
||||||
).rejects.toThrow(/not registered/i);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error when domain belongs to different project', async () => {
|
it('should throw error when domain belongs to different project', async () => {
|
||||||
@@ -141,13 +139,11 @@ describe('DomainService', () => {
|
|||||||
data: {verified: true},
|
data: {verified: true},
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(DomainService.verifyEmailDomain('[email protected]', project2.id)).rejects.toThrow(HttpException);
|
||||||
DomainService.verifyEmailDomain('[email protected]', project2.id),
|
|
||||||
).rejects.toThrow(HttpException);
|
|
||||||
|
|
||||||
await expect(
|
await expect(DomainService.verifyEmailDomain('[email protected]', project2.id)).rejects.toThrow(
|
||||||
DomainService.verifyEmailDomain('[email protected]', project2.id),
|
/belongs to a different project/i,
|
||||||
).rejects.toThrow(/belongs to a different project/i);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error when domain is not verified', async () => {
|
it('should throw error when domain is not verified', async () => {
|
||||||
@@ -155,13 +151,11 @@ describe('DomainService', () => {
|
|||||||
|
|
||||||
await DomainService.addDomain(project.id, 'unverified.com');
|
await DomainService.addDomain(project.id, 'unverified.com');
|
||||||
|
|
||||||
await expect(
|
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(HttpException);
|
||||||
DomainService.verifyEmailDomain('[email protected]', project.id),
|
|
||||||
).rejects.toThrow(HttpException);
|
|
||||||
|
|
||||||
await expect(
|
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
|
||||||
DomainService.verifyEmailDomain('[email protected]', project.id),
|
/not verified/i,
|
||||||
).rejects.toThrow(/not verified/i);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return domain when all checks pass', async () => {
|
it('should return domain when all checks pass', async () => {
|
||||||
@@ -358,9 +352,9 @@ describe('DomainService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for non-existent domain', async () => {
|
it('should throw error for non-existent domain', async () => {
|
||||||
await expect(
|
await expect(DomainService.checkVerification('00000000-0000-0000-0000-000000000000')).rejects.toThrow(
|
||||||
DomainService.checkVerification('00000000-0000-0000-0000-000000000000'),
|
/domain not found/i,
|
||||||
).rejects.toThrow(/domain not found/i);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -391,9 +385,7 @@ describe('DomainService', () => {
|
|||||||
|
|
||||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException);
|
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException);
|
||||||
|
|
||||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(
|
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*template/i);
|
||||||
/used in.*template/i,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error when domain is used in active campaigns', async () => {
|
it('should throw error when domain is used in active campaigns', async () => {
|
||||||
@@ -409,9 +401,7 @@ describe('DomainService', () => {
|
|||||||
|
|
||||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException);
|
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(HttpException);
|
||||||
|
|
||||||
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(
|
await expect(DomainService.removeDomain(domain.id)).rejects.toThrow(/used in.*campaign/i);
|
||||||
/used in.*campaign/i,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow removal when campaign is SENT (completed)', async () => {
|
it('should allow removal when campaign is SENT (completed)', async () => {
|
||||||
@@ -433,9 +423,9 @@ describe('DomainService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw error for non-existent domain', async () => {
|
it('should throw error for non-existent domain', async () => {
|
||||||
await expect(
|
await expect(DomainService.removeDomain('00000000-0000-0000-0000-000000000000')).rejects.toThrow(
|
||||||
DomainService.removeDomain('00000000-0000-0000-0000-000000000000'),
|
/domain not found/i,
|
||||||
).rejects.toThrow(/domain not found/i);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should check usage in multiple templates', async () => {
|
it('should check usage in multiple templates', async () => {
|
||||||
@@ -492,17 +482,15 @@ describe('DomainService', () => {
|
|||||||
expect(result.domain).toBe('mail.example.com');
|
expect(result.domain).toBe('mail.example.com');
|
||||||
|
|
||||||
// Different subdomain should fail
|
// Different subdomain should fail
|
||||||
await expect(
|
await expect(DomainService.verifyEmailDomain('[email protected]', project.id)).rejects.toThrow(
|
||||||
DomainService.verifyEmailDomain('[email protected]', project.id),
|
/not registered/i,
|
||||||
).rejects.toThrow(/not registered/i);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle email with no @ sign', async () => {
|
it('should handle email with no @ sign', async () => {
|
||||||
const {project} = await factories.createUserWithProject();
|
const {project} = await factories.createUserWithProject();
|
||||||
|
|
||||||
await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow(
|
await expect(DomainService.verifyEmailDomain('nodomain', project.id)).rejects.toThrow(/invalid email format/i);
|
||||||
/invalid email format/i,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle email with multiple @ signs', async () => {
|
it('should handle email with multiple @ signs', async () => {
|
||||||
@@ -516,9 +504,7 @@ describe('DomainService', () => {
|
|||||||
it('should handle empty email string', async () => {
|
it('should handle empty email string', async () => {
|
||||||
const {project} = await factories.createUserWithProject();
|
const {project} = await factories.createUserWithProject();
|
||||||
|
|
||||||
await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow(
|
await expect(DomainService.verifyEmailDomain('', project.id)).rejects.toThrow(/invalid email format/i);
|
||||||
/invalid email format/i,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -537,11 +523,7 @@ describe('DomainService', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
expect(results).toHaveLength(3);
|
expect(results).toHaveLength(3);
|
||||||
expect(results.map(d => d.domain).sort()).toEqual([
|
expect(results.map(d => d.domain).sort()).toEqual(['concurrent1.com', 'concurrent2.com', 'concurrent3.com']);
|
||||||
'concurrent1.com',
|
|
||||||
'concurrent2.com',
|
|
||||||
'concurrent3.com',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle concurrent ownership checks', async () => {
|
it('should handle concurrent ownership checks', async () => {
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match exact string values in standard fields (case-insensitive)', async () => {
|
it('should match exact string values in standard fields (case-insensitive)', async () => {
|
||||||
@@ -61,8 +61,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match boolean values', async () => {
|
it('should match boolean values', async () => {
|
||||||
@@ -80,8 +80,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match numeric values as strings in JSON fields', async () => {
|
it('should match numeric values as strings in JSON fields', async () => {
|
||||||
@@ -99,8 +99,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,8 +120,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should exclude boolean false values', async () => {
|
it('should exclude boolean false values', async () => {
|
||||||
@@ -139,8 +139,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT include contacts where field does not exist (only excludes matching values)', async () => {
|
it('should NOT include contacts where field does not exist (only excludes matching values)', async () => {
|
||||||
@@ -162,7 +162,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
const ids = result.contacts.map(c => c.id);
|
const ids = result.data.map(c => c.id);
|
||||||
// notEquals only matches where field exists and has different value
|
// notEquals only matches where field exists and has different value
|
||||||
expect(ids).toContain(withDifferentValue.id);
|
expect(ids).toContain(withDifferentValue.id);
|
||||||
expect(ids).not.toContain(withMatchingField.id);
|
expect(ids).not.toContain(withMatchingField.id);
|
||||||
@@ -186,8 +186,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match substring in email field (case-insensitive)', async () => {
|
it('should match substring in email field (case-insensitive)', async () => {
|
||||||
@@ -209,10 +209,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
const ids = result.contacts.map(c => c.id);
|
const ids = result.data.map(c => c.id);
|
||||||
expect(ids).toContain(match1.id);
|
expect(ids).toContain(match1.id);
|
||||||
expect(ids).toContain(match2.id);
|
expect(ids).toContain(match2.id);
|
||||||
expect(result.contacts).toHaveLength(2);
|
expect(result.data).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not match when field does not exist', async () => {
|
it('should not match when field does not exist', async () => {
|
||||||
@@ -226,7 +226,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(0);
|
expect(result.data).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match partial domain in email', async () => {
|
it('should match partial domain in email', async () => {
|
||||||
@@ -244,8 +244,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(gmailUser.id);
|
expect(result.data[0].id).toBe(gmailUser.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -265,8 +265,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT include contacts where field does not exist (only excludes matching substrings)', async () => {
|
it('should NOT include contacts where field does not exist (only excludes matching substrings)', async () => {
|
||||||
@@ -288,7 +288,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
const ids = result.contacts.map(c => c.id);
|
const ids = result.data.map(c => c.id);
|
||||||
// notContains only matches where field exists and doesn't contain substring
|
// notContains only matches where field exists and doesn't contain substring
|
||||||
expect(ids).toContain(withDifferentValue.id);
|
expect(ids).toContain(withDifferentValue.id);
|
||||||
expect(ids).not.toContain(withMatchingSubstring.id);
|
expect(ids).not.toContain(withMatchingSubstring.id);
|
||||||
@@ -314,8 +314,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -344,10 +344,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
const ids = result.contacts.map(c => c.id);
|
const ids = result.data.map(c => c.id);
|
||||||
expect(ids).toContain(high.id);
|
expect(ids).toContain(high.id);
|
||||||
expect(ids).toContain(veryHigh.id);
|
expect(ids).toContain(veryHigh.id);
|
||||||
expect(result.contacts).toHaveLength(2);
|
expect(result.data).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should exclude values equal to threshold', async () => {
|
it('should exclude values equal to threshold', async () => {
|
||||||
@@ -361,7 +361,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(0);
|
expect(result.data).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work with negative numbers', async () => {
|
it('should work with negative numbers', async () => {
|
||||||
@@ -379,8 +379,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work with decimal values', async () => {
|
it('should work with decimal values', async () => {
|
||||||
@@ -398,8 +398,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -423,10 +423,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
const ids = result.contacts.map(c => c.id);
|
const ids = result.data.map(c => c.id);
|
||||||
expect(ids).toContain(equal.id);
|
expect(ids).toContain(equal.id);
|
||||||
expect(ids).toContain(greater.id);
|
expect(ids).toContain(greater.id);
|
||||||
expect(result.contacts).toHaveLength(2);
|
expect(result.data).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -450,10 +450,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
const ids = result.contacts.map(c => c.id);
|
const ids = result.data.map(c => c.id);
|
||||||
expect(ids).toContain(low.id);
|
expect(ids).toContain(low.id);
|
||||||
expect(ids).toContain(veryLow.id);
|
expect(ids).toContain(veryLow.id);
|
||||||
expect(result.contacts).toHaveLength(2);
|
expect(result.data).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should exclude values equal to threshold', async () => {
|
it('should exclude values equal to threshold', async () => {
|
||||||
@@ -467,7 +467,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(0);
|
expect(result.data).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -491,10 +491,10 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
const ids = result.contacts.map(c => c.id);
|
const ids = result.data.map(c => c.id);
|
||||||
expect(ids).toContain(equal.id);
|
expect(ids).toContain(equal.id);
|
||||||
expect(ids).toContain(less.id);
|
expect(ids).toContain(less.id);
|
||||||
expect(result.contacts).toHaveLength(2);
|
expect(result.data).toHaveLength(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -515,8 +515,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(positive.id);
|
expect(result.data[0].id).toBe(positive.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle very large numbers', async () => {
|
it('should handle very large numbers', async () => {
|
||||||
@@ -534,8 +534,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -560,8 +560,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(withField.id);
|
expect(result.data[0].id).toBe(withField.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should exclude contacts where field is null', async () => {
|
it('should exclude contacts where field is null', async () => {
|
||||||
@@ -579,8 +579,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(withValue.id);
|
expect(result.data[0].id).toBe(withValue.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match fields with empty string values', async () => {
|
it('should match fields with empty string values', async () => {
|
||||||
@@ -594,8 +594,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(withEmptyString.id);
|
expect(result.data[0].id).toBe(withEmptyString.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match fields with zero values', async () => {
|
it('should match fields with zero values', async () => {
|
||||||
@@ -609,8 +609,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(withZero.id);
|
expect(result.data[0].id).toBe(withZero.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match fields with boolean false values', async () => {
|
it('should match fields with boolean false values', async () => {
|
||||||
@@ -624,8 +624,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(withFalse.id);
|
expect(result.data[0].id).toBe(withFalse.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -645,8 +645,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(withoutField.id);
|
expect(result.data[0].id).toBe(withoutField.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match contacts where field is null', async () => {
|
it('should match contacts where field is null', async () => {
|
||||||
@@ -664,8 +664,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(withNull.id);
|
expect(result.data[0].id).toBe(withNull.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should exclude fields with empty string values', async () => {
|
it('should exclude fields with empty string values', async () => {
|
||||||
@@ -679,7 +679,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(0);
|
expect(result.data).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should exclude fields with zero values', async () => {
|
it('should exclude fields with zero values', async () => {
|
||||||
@@ -693,7 +693,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(0);
|
expect(result.data).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -718,7 +718,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
const ids = result.contacts.map(c => c.id);
|
const ids = result.data.map(c => c.id);
|
||||||
expect(ids).toContain(recent.id);
|
expect(ids).toContain(recent.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -737,7 +737,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
const ids = result.contacts.map(c => c.id);
|
const ids = result.data.map(c => c.id);
|
||||||
expect(ids).toContain(veryRecent.id);
|
expect(ids).toContain(veryRecent.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -756,7 +756,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
const ids = result.contacts.map(c => c.id);
|
const ids = result.data.map(c => c.id);
|
||||||
expect(ids).toContain(justNow.id);
|
expect(ids).toContain(justNow.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -794,8 +794,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match contacts with JSON date field within specified hours', async () => {
|
it('should match contacts with JSON date field within specified hours', async () => {
|
||||||
@@ -819,8 +819,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match contacts with JSON date field within specified minutes', async () => {
|
it('should match contacts with JSON date field within specified minutes', async () => {
|
||||||
@@ -844,8 +844,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT match contacts with JSON date field outside the time range', async () => {
|
it('should NOT match contacts with JSON date field outside the time range', async () => {
|
||||||
@@ -870,7 +870,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(0);
|
expect(result.data).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT match contacts where JSON date field does not exist', async () => {
|
it('should NOT match contacts where JSON date field does not exist', async () => {
|
||||||
@@ -893,7 +893,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(0);
|
expect(result.data).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle null values in JSON date fields gracefully', async () => {
|
it('should handle null values in JSON date fields gracefully', async () => {
|
||||||
@@ -916,7 +916,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(0);
|
expect(result.data).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work correctly with combined filters (AND logic)', async () => {
|
it('should work correctly with combined filters (AND logic)', async () => {
|
||||||
@@ -953,8 +953,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should require unit parameter for within operator', async () => {
|
it('should require unit parameter for within operator', async () => {
|
||||||
@@ -1002,7 +1002,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
const ids = result.contacts.map(c => c.id);
|
const ids = result.data.map(c => c.id);
|
||||||
expect(ids).toContain(newer.id);
|
expect(ids).toContain(newer.id);
|
||||||
expect(ids).not.toContain(older.id);
|
expect(ids).not.toContain(older.id);
|
||||||
});
|
});
|
||||||
@@ -1025,7 +1025,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
const ids = result.contacts.map(c => c.id);
|
const ids = result.data.map(c => c.id);
|
||||||
expect(ids).toContain(first.id);
|
expect(ids).toContain(first.id);
|
||||||
expect(ids).toContain(second.id);
|
expect(ids).toContain(second.id);
|
||||||
expect(ids).not.toContain(third.id);
|
expect(ids).not.toContain(third.id);
|
||||||
@@ -1069,7 +1069,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
const ids = result.contacts.map(c => c.id);
|
const ids = result.data.map(c => c.id);
|
||||||
expect(ids).toContain(match1.id);
|
expect(ids).toContain(match1.id);
|
||||||
expect(ids).toContain(match2.id);
|
expect(ids).toContain(match2.id);
|
||||||
expect(ids).not.toContain(noMatch.id);
|
expect(ids).not.toContain(noMatch.id);
|
||||||
@@ -1107,7 +1107,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
const ids = result.contacts.map(c => c.id);
|
const ids = result.data.map(c => c.id);
|
||||||
expect(ids).toContain(match1.id);
|
expect(ids).toContain(match1.id);
|
||||||
expect(ids).toContain(match2.id);
|
expect(ids).toContain(match2.id);
|
||||||
expect(ids).not.toContain(noMatch.id);
|
expect(ids).not.toContain(noMatch.id);
|
||||||
@@ -1133,7 +1133,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
const ids = result.contacts.map(c => c.id);
|
const ids = result.data.map(c => c.id);
|
||||||
expect(ids).toContain(match.id);
|
expect(ids).toContain(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1162,8 +1162,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1205,7 +1205,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
const ids = result.contacts.map(c => c.id);
|
const ids = result.data.map(c => c.id);
|
||||||
expect(ids).toContain(match1.id);
|
expect(ids).toContain(match1.id);
|
||||||
expect(ids).toContain(match2.id);
|
expect(ids).toContain(match2.id);
|
||||||
expect(ids).not.toContain(noMatch.id);
|
expect(ids).not.toContain(noMatch.id);
|
||||||
@@ -1247,7 +1247,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
const ids = result.contacts.map(c => c.id);
|
const ids = result.data.map(c => c.id);
|
||||||
expect(ids).toContain(match1.id);
|
expect(ids).toContain(match1.id);
|
||||||
expect(ids).toContain(match2.id);
|
expect(ids).toContain(match2.id);
|
||||||
expect(ids).not.toContain(noMatch.id);
|
expect(ids).not.toContain(noMatch.id);
|
||||||
@@ -1298,8 +1298,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should combine existence checks with value comparisons', async () => {
|
it('should combine existence checks with value comparisons', async () => {
|
||||||
@@ -1329,8 +1329,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1357,8 +1357,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not match contacts who have not triggered the event', async () => {
|
it('should not match contacts who have not triggered the event', async () => {
|
||||||
@@ -1379,7 +1379,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(0);
|
expect(result.data).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match contacts with multiple occurrences of the same event', async () => {
|
it('should match contacts with multiple occurrences of the same event', async () => {
|
||||||
@@ -1400,8 +1400,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1440,8 +1440,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match contacts with event within time range (hours)', async () => {
|
it('should match contacts with event within time range (hours)', async () => {
|
||||||
@@ -1476,8 +1476,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match contacts with event within time range (minutes)', async () => {
|
it('should match contacts with event within time range (minutes)', async () => {
|
||||||
@@ -1512,8 +1512,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle events at exact boundary', async () => {
|
it('should handle events at exact boundary', async () => {
|
||||||
@@ -1538,7 +1538,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
// Should not match because events at exact boundary are excluded
|
// Should not match because events at exact boundary are excluded
|
||||||
expect(result.contacts).toHaveLength(0);
|
expect(result.data).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not match contacts with event outside time range', async () => {
|
it('should not match contacts with event outside time range', async () => {
|
||||||
@@ -1562,7 +1562,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(0);
|
expect(result.data).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match contact if any of their events is within range', async () => {
|
it('should match contact if any of their events is within range', async () => {
|
||||||
@@ -1598,8 +1598,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1623,8 +1623,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not match contacts who have triggered the event', async () => {
|
it('should not match contacts who have triggered the event', async () => {
|
||||||
@@ -1644,7 +1644,7 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(0);
|
expect(result.data).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should match contacts with other events but not the target event', async () => {
|
it('should match contacts with other events but not the target event', async () => {
|
||||||
@@ -1664,8 +1664,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1697,8 +1697,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should combine multiple event filters', async () => {
|
it('should combine multiple event filters', async () => {
|
||||||
@@ -1727,8 +1727,8 @@ describe('SegmentService - Comprehensive Operator Tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ describe('SegmentService', () => {
|
|||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
|
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(subscribed.id);
|
expect(result.data[0].id).toBe(subscribed.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should filter contacts by custom data fields', async () => {
|
it('should filter contacts by custom data fields', async () => {
|
||||||
@@ -53,8 +53,8 @@ describe('SegmentService', () => {
|
|||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
|
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(proUser.id);
|
expect(result.data[0].id).toBe(proUser.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should filter contacts with multiple conditions', async () => {
|
it('should filter contacts with multiple conditions', async () => {
|
||||||
@@ -85,8 +85,8 @@ describe('SegmentService', () => {
|
|||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
|
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(target.id);
|
expect(result.data[0].id).toBe(target.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should support notEquals operator', async () => {
|
it('should support notEquals operator', async () => {
|
||||||
@@ -106,8 +106,8 @@ describe('SegmentService', () => {
|
|||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
|
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(pro.id);
|
expect(result.data[0].id).toBe(pro.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should support contains operator for strings', async () => {
|
it('should support contains operator for strings', async () => {
|
||||||
@@ -127,8 +127,8 @@ describe('SegmentService', () => {
|
|||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
|
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(match.id);
|
expect(result.data[0].id).toBe(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should support exists operator for custom fields', async () => {
|
it('should support exists operator for custom fields', async () => {
|
||||||
@@ -148,8 +148,8 @@ describe('SegmentService', () => {
|
|||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
|
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(withField.id);
|
expect(result.data[0].id).toBe(withField.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle empty segments', async () => {
|
it('should handle empty segments', async () => {
|
||||||
@@ -165,7 +165,7 @@ describe('SegmentService', () => {
|
|||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
|
|
||||||
expect(result.contacts).toHaveLength(0);
|
expect(result.data).toHaveLength(0);
|
||||||
expect(result.total).toBe(0);
|
expect(result.total).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -192,7 +192,7 @@ describe('SegmentService', () => {
|
|||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
|
|
||||||
expect(result.total).toBe(2);
|
expect(result.total).toBe(2);
|
||||||
expect(result.contacts).toHaveLength(2);
|
expect(result.data).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should support pagination', async () => {
|
it('should support pagination', async () => {
|
||||||
@@ -209,15 +209,15 @@ describe('SegmentService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const page1 = await SegmentService.getContacts(projectId, segment.id, 1, 10);
|
const page1 = await SegmentService.getContacts(projectId, segment.id, 1, 10);
|
||||||
expect(page1.contacts).toHaveLength(10);
|
expect(page1.data).toHaveLength(10);
|
||||||
expect(page1.total).toBe(25);
|
expect(page1.total).toBe(25);
|
||||||
expect(page1.totalPages).toBe(3);
|
expect(page1.totalPages).toBe(3);
|
||||||
|
|
||||||
const page2 = await SegmentService.getContacts(projectId, segment.id, 2, 10);
|
const page2 = await SegmentService.getContacts(projectId, segment.id, 2, 10);
|
||||||
expect(page2.contacts).toHaveLength(10);
|
expect(page2.data).toHaveLength(10);
|
||||||
|
|
||||||
const page3 = await SegmentService.getContacts(projectId, segment.id, 3, 10);
|
const page3 = await SegmentService.getContacts(projectId, segment.id, 3, 10);
|
||||||
expect(page3.contacts).toHaveLength(5);
|
expect(page3.data).toHaveLength(5);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ describe('SegmentService', () => {
|
|||||||
|
|
||||||
// Initially not in segment
|
// Initially not in segment
|
||||||
let result = await SegmentService.getContacts(projectId, proSegment.id);
|
let result = await SegmentService.getContacts(projectId, proSegment.id);
|
||||||
expect(result.contacts).toHaveLength(0);
|
expect(result.data).toHaveLength(0);
|
||||||
|
|
||||||
// Update contact to pro plan
|
// Update contact to pro plan
|
||||||
await prisma.contact.update({
|
await prisma.contact.update({
|
||||||
@@ -288,8 +288,8 @@ describe('SegmentService', () => {
|
|||||||
|
|
||||||
// Should now be in segment
|
// Should now be in segment
|
||||||
result = await SegmentService.getContacts(projectId, proSegment.id);
|
result = await SegmentService.getContacts(projectId, proSegment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
expect(result.contacts[0].id).toBe(contact.id);
|
expect(result.data[0].id).toBe(contact.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be removed from segment when criteria no longer met', async () => {
|
it('should be removed from segment when criteria no longer met', async () => {
|
||||||
@@ -304,7 +304,7 @@ describe('SegmentService', () => {
|
|||||||
|
|
||||||
// Initially in segment
|
// Initially in segment
|
||||||
let result = await SegmentService.getContacts(projectId, segment.id);
|
let result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(1);
|
expect(result.data).toHaveLength(1);
|
||||||
|
|
||||||
// Unsubscribe contact
|
// Unsubscribe contact
|
||||||
await prisma.contact.update({
|
await prisma.contact.update({
|
||||||
@@ -314,7 +314,7 @@ describe('SegmentService', () => {
|
|||||||
|
|
||||||
// Should no longer be in segment
|
// Should no longer be in segment
|
||||||
result = await SegmentService.getContacts(projectId, segment.id);
|
result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
expect(result.contacts).toHaveLength(0);
|
expect(result.data).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -506,8 +506,8 @@ describe('SegmentService', () => {
|
|||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
|
|
||||||
expect(result.contacts.map(c => c.id).sort()).toEqual([other.id].sort());
|
expect(result.data.map(c => c.id).sort()).toEqual([other.id].sort());
|
||||||
expect(result.contacts.map(c => c.id)).not.toContain(match.id);
|
expect(result.data.map(c => c.id)).not.toContain(match.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should support case-insensitive equals/contains for email strings', async () => {
|
it('should support case-insensitive equals/contains for email strings', async () => {
|
||||||
@@ -526,7 +526,7 @@ describe('SegmentService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const equalsResult = await SegmentService.getContacts(projectId, equalsSegment.id);
|
const equalsResult = await SegmentService.getContacts(projectId, equalsSegment.id);
|
||||||
const equalsIds = equalsResult.contacts.map(c => c.id);
|
const equalsIds = equalsResult.data.map(c => c.id);
|
||||||
expect(equalsIds).toContain(lower.id);
|
expect(equalsIds).toContain(lower.id);
|
||||||
expect(equalsIds).toContain(upper.id);
|
expect(equalsIds).toContain(upper.id);
|
||||||
|
|
||||||
@@ -536,7 +536,7 @@ describe('SegmentService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const containsResult = await SegmentService.getContacts(projectId, containsSegment.id);
|
const containsResult = await SegmentService.getContacts(projectId, containsSegment.id);
|
||||||
const containsIds = containsResult.contacts.map(c => c.id);
|
const containsIds = containsResult.data.map(c => c.id);
|
||||||
expect(containsIds).toContain(lower.id);
|
expect(containsIds).toContain(lower.id);
|
||||||
expect(containsIds).toContain(upper.id);
|
expect(containsIds).toContain(upper.id);
|
||||||
});
|
});
|
||||||
@@ -557,7 +557,7 @@ describe('SegmentService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
const ids = result.contacts.map(c => c.id);
|
const ids = result.data.map(c => c.id);
|
||||||
|
|
||||||
expect(ids).toContain(unsubscribed.id);
|
expect(ids).toContain(unsubscribed.id);
|
||||||
expect(ids).not.toContain(subscribed.id);
|
expect(ids).not.toContain(subscribed.id);
|
||||||
@@ -579,7 +579,7 @@ describe('SegmentService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const notContainsResult = await SegmentService.getContacts(projectId, notContainsSegment.id);
|
const notContainsResult = await SegmentService.getContacts(projectId, notContainsSegment.id);
|
||||||
const notContainsIds = notContainsResult.contacts.map(c => c.id);
|
const notContainsIds = notContainsResult.data.map(c => c.id);
|
||||||
expect(notContainsIds).toContain(other.id);
|
expect(notContainsIds).toContain(other.id);
|
||||||
expect(notContainsIds).not.toContain(acme.id);
|
expect(notContainsIds).not.toContain(acme.id);
|
||||||
|
|
||||||
@@ -589,7 +589,7 @@ describe('SegmentService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const notEqualsResult = await SegmentService.getContacts(projectId, notEqualsSegment.id);
|
const notEqualsResult = await SegmentService.getContacts(projectId, notEqualsSegment.id);
|
||||||
const notEqualsIds = notEqualsResult.contacts.map(c => c.id);
|
const notEqualsIds = notEqualsResult.data.map(c => c.id);
|
||||||
expect(notEqualsIds).toContain(other.id);
|
expect(notEqualsIds).toContain(other.id);
|
||||||
expect(notEqualsIds).not.toContain(acme.id);
|
expect(notEqualsIds).not.toContain(acme.id);
|
||||||
});
|
});
|
||||||
@@ -610,7 +610,7 @@ describe('SegmentService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const existsResult = await SegmentService.getContacts(projectId, existsSegment.id);
|
const existsResult = await SegmentService.getContacts(projectId, existsSegment.id);
|
||||||
const existsIds = new Set(existsResult.contacts.map(c => c.id));
|
const existsIds = new Set(existsResult.data.map(c => c.id));
|
||||||
expect(existsIds.has(withCompany.id)).toBe(true);
|
expect(existsIds.has(withCompany.id)).toBe(true);
|
||||||
expect(existsIds.has(withNullCompany.id)).toBe(false);
|
expect(existsIds.has(withNullCompany.id)).toBe(false);
|
||||||
|
|
||||||
@@ -620,7 +620,7 @@ describe('SegmentService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const notExistsResult = await SegmentService.getContacts(projectId, notExistsSegment.id);
|
const notExistsResult = await SegmentService.getContacts(projectId, notExistsSegment.id);
|
||||||
const notExistsIds = new Set(notExistsResult.contacts.map(c => c.id));
|
const notExistsIds = new Set(notExistsResult.data.map(c => c.id));
|
||||||
expect(notExistsIds.has(withCompany.id)).toBe(false);
|
expect(notExistsIds.has(withCompany.id)).toBe(false);
|
||||||
expect(notExistsIds.has(withNullCompany.id)).toBe(true);
|
expect(notExistsIds.has(withNullCompany.id)).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -645,7 +645,7 @@ describe('SegmentService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const greaterThanResult = await SegmentService.getContacts(projectId, greaterThanSegment.id);
|
const greaterThanResult = await SegmentService.getContacts(projectId, greaterThanSegment.id);
|
||||||
const gtIds = greaterThanResult.contacts.map(c => c.id);
|
const gtIds = greaterThanResult.data.map(c => c.id);
|
||||||
expect(gtIds).toContain(mid.id);
|
expect(gtIds).toContain(mid.id);
|
||||||
expect(gtIds).toContain(high.id);
|
expect(gtIds).toContain(high.id);
|
||||||
expect(gtIds).not.toContain(low.id);
|
expect(gtIds).not.toContain(low.id);
|
||||||
@@ -656,7 +656,7 @@ describe('SegmentService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const lteResult = await SegmentService.getContacts(projectId, lessThanOrEqualSegment.id);
|
const lteResult = await SegmentService.getContacts(projectId, lessThanOrEqualSegment.id);
|
||||||
const lteIds = lteResult.contacts.map(c => c.id);
|
const lteIds = lteResult.data.map(c => c.id);
|
||||||
expect(lteIds).toContain(low.id);
|
expect(lteIds).toContain(low.id);
|
||||||
expect(lteIds).toContain(mid.id);
|
expect(lteIds).toContain(mid.id);
|
||||||
expect(lteIds).not.toContain(high.id);
|
expect(lteIds).not.toContain(high.id);
|
||||||
@@ -674,7 +674,7 @@ describe('SegmentService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const gtResult = await SegmentService.getContacts(projectId, gtSegment.id);
|
const gtResult = await SegmentService.getContacts(projectId, gtSegment.id);
|
||||||
const gtIds = gtResult.contacts.map(c => c.id);
|
const gtIds = gtResult.data.map(c => c.id);
|
||||||
expect(gtIds).toContain(newer.id);
|
expect(gtIds).toContain(newer.id);
|
||||||
expect(gtIds).not.toContain(older.id);
|
expect(gtIds).not.toContain(older.id);
|
||||||
|
|
||||||
@@ -684,7 +684,7 @@ describe('SegmentService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const lteResult = await SegmentService.getContacts(projectId, lteSegment.id);
|
const lteResult = await SegmentService.getContacts(projectId, lteSegment.id);
|
||||||
const lteIds = lteResult.contacts.map(c => c.id);
|
const lteIds = lteResult.data.map(c => c.id);
|
||||||
expect(lteIds).toContain(older.id);
|
expect(lteIds).toContain(older.id);
|
||||||
expect(lteIds).toContain(newer.id);
|
expect(lteIds).toContain(newer.id);
|
||||||
});
|
});
|
||||||
@@ -705,7 +705,7 @@ describe('SegmentService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const result = await SegmentService.getContacts(projectId, segment.id);
|
const result = await SegmentService.getContacts(projectId, segment.id);
|
||||||
const ids = result.contacts.map(c => c.id);
|
const ids = result.data.map(c => c.id);
|
||||||
expect(ids).toContain(recent.id);
|
expect(ids).toContain(recent.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {describe, it, expect, beforeEach} from 'vitest';
|
import {beforeEach, describe, expect, it} from 'vitest';
|
||||||
import {TemplateType} from '@plunk/db';
|
import {TemplateType} from '@plunk/db';
|
||||||
import {TemplateService} from '../TemplateService';
|
import {TemplateService} from '../TemplateService';
|
||||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||||
@@ -104,18 +104,18 @@ describe('TemplateService', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const page1 = await TemplateService.list(projectId, 1, 10);
|
const page1 = await TemplateService.list(projectId, 1, 10);
|
||||||
expect(page1.templates).toHaveLength(10);
|
expect(page1.data).toHaveLength(10);
|
||||||
expect(page1.total).toBe(25);
|
expect(page1.total).toBe(25);
|
||||||
expect(page1.page).toBe(1);
|
expect(page1.page).toBe(1);
|
||||||
expect(page1.pageSize).toBe(10);
|
expect(page1.pageSize).toBe(10);
|
||||||
expect(page1.totalPages).toBe(3);
|
expect(page1.totalPages).toBe(3);
|
||||||
|
|
||||||
const page2 = await TemplateService.list(projectId, 2, 10);
|
const page2 = await TemplateService.list(projectId, 2, 10);
|
||||||
expect(page2.templates).toHaveLength(10);
|
expect(page2.data).toHaveLength(10);
|
||||||
expect(page2.page).toBe(2);
|
expect(page2.page).toBe(2);
|
||||||
|
|
||||||
const page3 = await TemplateService.list(projectId, 3, 10);
|
const page3 = await TemplateService.list(projectId, 3, 10);
|
||||||
expect(page3.templates).toHaveLength(5);
|
expect(page3.data).toHaveLength(5);
|
||||||
expect(page3.page).toBe(3);
|
expect(page3.page).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ describe('TemplateService', () => {
|
|||||||
const result = await TemplateService.list(projectId, 1, 20, 'welcome');
|
const result = await TemplateService.list(projectId, 1, 20, 'welcome');
|
||||||
|
|
||||||
expect(result.total).toBe(2);
|
expect(result.total).toBe(2);
|
||||||
expect(result.templates.every(t => t.name.toLowerCase().includes('welcome'))).toBe(true);
|
expect(result.data.every(t => t.name.toLowerCase().includes('welcome'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should filter templates by search query (description)', async () => {
|
it('should filter templates by search query (description)', async () => {
|
||||||
@@ -165,7 +165,7 @@ describe('TemplateService', () => {
|
|||||||
const result = await TemplateService.list(projectId, 1, 20, 'new');
|
const result = await TemplateService.list(projectId, 1, 20, 'new');
|
||||||
|
|
||||||
expect(result.total).toBe(2);
|
expect(result.total).toBe(2);
|
||||||
expect(result.templates.map(t => t.description)).toEqual(
|
expect(result.data.map(t => t.description)).toEqual(
|
||||||
expect.arrayContaining([expect.stringContaining('new')]),
|
expect.arrayContaining([expect.stringContaining('new')]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -196,11 +196,11 @@ describe('TemplateService', () => {
|
|||||||
|
|
||||||
const marketingResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.MARKETING);
|
const marketingResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.MARKETING);
|
||||||
expect(marketingResult.total).toBe(2);
|
expect(marketingResult.total).toBe(2);
|
||||||
expect(marketingResult.templates.every(t => t.type === TemplateType.MARKETING)).toBe(true);
|
expect(marketingResult.data.every(t => t.type === TemplateType.MARKETING)).toBe(true);
|
||||||
|
|
||||||
const transactionalResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.TRANSACTIONAL);
|
const transactionalResult = await TemplateService.list(projectId, 1, 20, undefined, TemplateType.TRANSACTIONAL);
|
||||||
expect(transactionalResult.total).toBe(1);
|
expect(transactionalResult.total).toBe(1);
|
||||||
expect(transactionalResult.templates[0].type).toBe(TemplateType.TRANSACTIONAL);
|
expect(transactionalResult.data[0].type).toBe(TemplateType.TRANSACTIONAL);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should combine search and type filters', async () => {
|
it('should combine search and type filters', async () => {
|
||||||
@@ -223,7 +223,7 @@ describe('TemplateService', () => {
|
|||||||
const result = await TemplateService.list(projectId, 1, 20, 'welcome', TemplateType.MARKETING);
|
const result = await TemplateService.list(projectId, 1, 20, 'welcome', TemplateType.MARKETING);
|
||||||
|
|
||||||
expect(result.total).toBe(1);
|
expect(result.total).toBe(1);
|
||||||
expect(result.templates[0].name).toBe('Welcome Email');
|
expect(result.data[0].name).toBe('Welcome Email');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return templates ordered by creation date (newest first)', async () => {
|
it('should return templates ordered by creation date (newest first)', async () => {
|
||||||
@@ -236,9 +236,9 @@ describe('TemplateService', () => {
|
|||||||
|
|
||||||
const result = await TemplateService.list(projectId, 1, 20);
|
const result = await TemplateService.list(projectId, 1, 20);
|
||||||
|
|
||||||
expect(result.templates[0].id).toBe(template3.id); // Newest
|
expect(result.data[0].id).toBe(template3.id); // Newest
|
||||||
expect(result.templates[1].id).toBe(template2.id);
|
expect(result.data[1].id).toBe(template2.id);
|
||||||
expect(result.templates[2].id).toBe(template1.id); // Oldest
|
expect(result.data[2].id).toBe(template1.id); // Oldest
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should only return templates for the specified project', async () => {
|
it('should only return templates for the specified project', async () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {Prisma, StepExecutionStatus, WorkflowExecutionStatus, WorkflowStepType} from '@plunk/db';
|
import {StepExecutionStatus, WorkflowExecutionStatus, WorkflowStepType} from '@plunk/db';
|
||||||
|
import {toPrismaJson} from '@plunk/types';
|
||||||
import {WorkflowExecutionService} from '../WorkflowExecutionService';
|
import {WorkflowExecutionService} from '../WorkflowExecutionService';
|
||||||
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
||||||
|
|
||||||
@@ -59,11 +60,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.CONDITION,
|
type: WorkflowStepType.CONDITION,
|
||||||
name: 'Check Premium Status',
|
name: 'Check Premium Status',
|
||||||
position: {x: 100, y: 0},
|
position: {x: 100, y: 0},
|
||||||
config: {
|
config: toPrismaJson({
|
||||||
field: 'data.isPremium',
|
field: 'data.isPremium',
|
||||||
operator: 'equals',
|
operator: 'equals',
|
||||||
value: true,
|
value: true,
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -74,7 +75,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.EXIT,
|
type: WorkflowStepType.EXIT,
|
||||||
name: 'Premium Path',
|
name: 'Premium Path',
|
||||||
position: {x: 200, y: -50},
|
position: {x: 200, y: -50},
|
||||||
config: {reason: 'Premium customer'},
|
config: toPrismaJson({reason: 'Premium customer'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.EXIT,
|
type: WorkflowStepType.EXIT,
|
||||||
name: 'Standard Path',
|
name: 'Standard Path',
|
||||||
position: {x: 200, y: 50},
|
position: {x: 200, y: 50},
|
||||||
config: {reason: 'Standard customer'},
|
config: toPrismaJson({reason: 'Standard customer'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,7 +98,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
data: {
|
data: {
|
||||||
fromStepId: conditionStep.id,
|
fromStepId: conditionStep.id,
|
||||||
toStepId: yesStep.id,
|
toStepId: yesStep.id,
|
||||||
condition: {branch: 'yes'},
|
condition: toPrismaJson({branch: 'yes'}),
|
||||||
priority: 1,
|
priority: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -106,7 +107,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
data: {
|
data: {
|
||||||
fromStepId: conditionStep.id,
|
fromStepId: conditionStep.id,
|
||||||
toStepId: noStep.id,
|
toStepId: noStep.id,
|
||||||
condition: {branch: 'no'},
|
condition: toPrismaJson({branch: 'no'}),
|
||||||
priority: 2,
|
priority: 2,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -118,7 +119,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
status: WorkflowExecutionStatus.RUNNING,
|
status: WorkflowExecutionStatus.RUNNING,
|
||||||
currentStepId: triggerStep.id,
|
currentStepId: triggerStep.id,
|
||||||
context: {},
|
context: toPrismaJson({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -160,11 +161,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.CONDITION,
|
type: WorkflowStepType.CONDITION,
|
||||||
name: 'Check Premium',
|
name: 'Check Premium',
|
||||||
position: {x: 100, y: 0},
|
position: {x: 100, y: 0},
|
||||||
config: {
|
config: toPrismaJson({
|
||||||
field: 'data.isPremium',
|
field: 'data.isPremium',
|
||||||
operator: 'equals',
|
operator: 'equals',
|
||||||
value: true,
|
value: true,
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,7 +175,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.EXIT,
|
type: WorkflowStepType.EXIT,
|
||||||
name: 'Premium',
|
name: 'Premium',
|
||||||
position: {x: 200, y: -50},
|
position: {x: 200, y: -50},
|
||||||
config: {},
|
config: toPrismaJson({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -184,7 +185,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.EXIT,
|
type: WorkflowStepType.EXIT,
|
||||||
name: 'Standard',
|
name: 'Standard',
|
||||||
position: {x: 200, y: 50},
|
position: {x: 200, y: 50},
|
||||||
config: {},
|
config: toPrismaJson({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -196,7 +197,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
data: {
|
data: {
|
||||||
fromStepId: conditionStep.id,
|
fromStepId: conditionStep.id,
|
||||||
toStepId: yesStep.id,
|
toStepId: yesStep.id,
|
||||||
condition: {branch: 'yes'},
|
condition: toPrismaJson({branch: 'yes'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -204,7 +205,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
data: {
|
data: {
|
||||||
fromStepId: conditionStep.id,
|
fromStepId: conditionStep.id,
|
||||||
toStepId: noStep.id,
|
toStepId: noStep.id,
|
||||||
condition: {branch: 'no'},
|
condition: toPrismaJson({branch: 'no'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,7 +215,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
status: WorkflowExecutionStatus.RUNNING,
|
status: WorkflowExecutionStatus.RUNNING,
|
||||||
currentStepId: triggerStep.id,
|
currentStepId: triggerStep.id,
|
||||||
context: {},
|
context: toPrismaJson({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,7 +251,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.CONDITION,
|
type: WorkflowStepType.CONDITION,
|
||||||
name: 'Check Country',
|
name: 'Check Country',
|
||||||
position: {x: 100, y: 0},
|
position: {x: 100, y: 0},
|
||||||
config: {field: 'data.country', operator: 'equals', value: 'US'},
|
config: toPrismaJson({field: 'data.country', operator: 'equals', value: 'US'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -261,7 +262,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.CONDITION,
|
type: WorkflowStepType.CONDITION,
|
||||||
name: 'Check Premium (US)',
|
name: 'Check Premium (US)',
|
||||||
position: {x: 200, y: -50},
|
position: {x: 200, y: -50},
|
||||||
config: {field: 'data.isPremium', operator: 'equals', value: true},
|
config: toPrismaJson({field: 'data.isPremium', operator: 'equals', value: true}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -271,7 +272,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.EXIT,
|
type: WorkflowStepType.EXIT,
|
||||||
name: 'US Premium',
|
name: 'US Premium',
|
||||||
position: {x: 300, y: -75},
|
position: {x: 300, y: -75},
|
||||||
config: {},
|
config: toPrismaJson({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -281,7 +282,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.EXIT,
|
type: WorkflowStepType.EXIT,
|
||||||
name: 'US Standard',
|
name: 'US Standard',
|
||||||
position: {x: 300, y: -25},
|
position: {x: 300, y: -25},
|
||||||
config: {},
|
config: toPrismaJson({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -291,7 +292,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.EXIT,
|
type: WorkflowStepType.EXIT,
|
||||||
name: 'Non-US',
|
name: 'Non-US',
|
||||||
position: {x: 200, y: 50},
|
position: {x: 200, y: 50},
|
||||||
config: {},
|
config: toPrismaJson({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -304,7 +305,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
data: {
|
data: {
|
||||||
fromStepId: condition1.id,
|
fromStepId: condition1.id,
|
||||||
toStepId: condition2.id,
|
toStepId: condition2.id,
|
||||||
condition: {branch: 'yes'},
|
condition: toPrismaJson({branch: 'yes'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -312,7 +313,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
data: {
|
data: {
|
||||||
fromStepId: condition1.id,
|
fromStepId: condition1.id,
|
||||||
toStepId: nonUsExit.id,
|
toStepId: nonUsExit.id,
|
||||||
condition: {branch: 'no'},
|
condition: toPrismaJson({branch: 'no'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -320,7 +321,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
data: {
|
data: {
|
||||||
fromStepId: condition2.id,
|
fromStepId: condition2.id,
|
||||||
toStepId: usPremiumExit.id,
|
toStepId: usPremiumExit.id,
|
||||||
condition: {branch: 'yes'},
|
condition: toPrismaJson({branch: 'yes'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -328,7 +329,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
data: {
|
data: {
|
||||||
fromStepId: condition2.id,
|
fromStepId: condition2.id,
|
||||||
toStepId: usStandardExit.id,
|
toStepId: usStandardExit.id,
|
||||||
condition: {branch: 'no'},
|
condition: toPrismaJson({branch: 'no'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -338,7 +339,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
status: WorkflowExecutionStatus.RUNNING,
|
status: WorkflowExecutionStatus.RUNNING,
|
||||||
currentStepId: triggerStep.id,
|
currentStepId: triggerStep.id,
|
||||||
context: {},
|
context: toPrismaJson({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -378,10 +379,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.WAIT_FOR_EVENT,
|
type: WorkflowStepType.WAIT_FOR_EVENT,
|
||||||
name: 'Wait for Purchase',
|
name: 'Wait for Purchase',
|
||||||
position: {x: 100, y: 0},
|
position: {x: 100, y: 0},
|
||||||
config: {
|
config: toPrismaJson({
|
||||||
eventName: 'purchase.completed',
|
eventName: 'purchase.completed',
|
||||||
timeout: 3600, // 1 hour
|
timeout: 3600, // 1 hour
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -391,7 +392,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.EXIT,
|
type: WorkflowStepType.EXIT,
|
||||||
name: 'Complete',
|
name: 'Complete',
|
||||||
position: {x: 200, y: 0},
|
position: {x: 200, y: 0},
|
||||||
config: {},
|
config: toPrismaJson({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -409,7 +410,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
status: WorkflowExecutionStatus.RUNNING,
|
status: WorkflowExecutionStatus.RUNNING,
|
||||||
currentStepId: triggerStep.id,
|
currentStepId: triggerStep.id,
|
||||||
context: {},
|
context: toPrismaJson({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -447,10 +448,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.WAIT_FOR_EVENT,
|
type: WorkflowStepType.WAIT_FOR_EVENT,
|
||||||
name: 'Wait for Event',
|
name: 'Wait for Event',
|
||||||
position: {x: 100, y: 0},
|
position: {x: 100, y: 0},
|
||||||
config: {
|
config: toPrismaJson({
|
||||||
eventName: 'user.verified',
|
eventName: 'user.verified',
|
||||||
timeout: 3600,
|
timeout: 3600,
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -460,7 +461,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.EXIT,
|
type: WorkflowStepType.EXIT,
|
||||||
name: 'Done',
|
name: 'Done',
|
||||||
position: {x: 200, y: 0},
|
position: {x: 200, y: 0},
|
||||||
config: {},
|
config: toPrismaJson({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -478,7 +479,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
status: WorkflowExecutionStatus.RUNNING,
|
status: WorkflowExecutionStatus.RUNNING,
|
||||||
currentStepId: triggerStep.id,
|
currentStepId: triggerStep.id,
|
||||||
context: {},
|
context: toPrismaJson({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -526,7 +527,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.DELAY,
|
type: WorkflowStepType.DELAY,
|
||||||
name: 'Wait 1 day',
|
name: 'Wait 1 day',
|
||||||
position: {x: 100, y: 0},
|
position: {x: 100, y: 0},
|
||||||
config: {amount: 1, unit: 'days'},
|
config: toPrismaJson({amount: 1, unit: 'days'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -536,7 +537,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.CONDITION,
|
type: WorkflowStepType.CONDITION,
|
||||||
name: 'Check Status',
|
name: 'Check Status',
|
||||||
position: {x: 200, y: 0},
|
position: {x: 200, y: 0},
|
||||||
config: {field: 'contact.subscribed', operator: 'equals', value: true},
|
config: toPrismaJson({field: 'contact.subscribed', operator: 'equals', value: true}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -546,7 +547,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.EXIT,
|
type: WorkflowStepType.EXIT,
|
||||||
name: 'Complete',
|
name: 'Complete',
|
||||||
position: {x: 300, y: 0},
|
position: {x: 300, y: 0},
|
||||||
config: {},
|
config: toPrismaJson({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -561,7 +562,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
data: {
|
data: {
|
||||||
fromStepId: condition.id,
|
fromStepId: condition.id,
|
||||||
toStepId: exit.id,
|
toStepId: exit.id,
|
||||||
condition: {branch: 'yes'},
|
condition: toPrismaJson({branch: 'yes'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -571,7 +572,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
status: WorkflowExecutionStatus.RUNNING,
|
status: WorkflowExecutionStatus.RUNNING,
|
||||||
currentStepId: triggerStep.id,
|
currentStepId: triggerStep.id,
|
||||||
context: {},
|
context: toPrismaJson({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -611,7 +612,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.CONDITION,
|
type: WorkflowStepType.CONDITION,
|
||||||
name: 'A/B Split',
|
name: 'A/B Split',
|
||||||
position: {x: 100, y: 0},
|
position: {x: 100, y: 0},
|
||||||
config: {field: 'data.segment', operator: 'equals', value: 'A'},
|
config: toPrismaJson({field: 'data.segment', operator: 'equals', value: 'A'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -621,7 +622,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.DELAY,
|
type: WorkflowStepType.DELAY,
|
||||||
name: 'Path A Delay',
|
name: 'Path A Delay',
|
||||||
position: {x: 200, y: -50},
|
position: {x: 200, y: -50},
|
||||||
config: {amount: 1, unit: 'hours'},
|
config: toPrismaJson({amount: 1, unit: 'hours'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -631,7 +632,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.DELAY,
|
type: WorkflowStepType.DELAY,
|
||||||
name: 'Path B Delay',
|
name: 'Path B Delay',
|
||||||
position: {x: 200, y: 50},
|
position: {x: 200, y: 50},
|
||||||
config: {amount: 2, unit: 'hours'},
|
config: toPrismaJson({amount: 2, unit: 'hours'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -641,7 +642,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.EXIT,
|
type: WorkflowStepType.EXIT,
|
||||||
name: 'Merge Point',
|
name: 'Merge Point',
|
||||||
position: {x: 300, y: 0},
|
position: {x: 300, y: 0},
|
||||||
config: {},
|
config: toPrismaJson({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -653,7 +654,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
data: {
|
data: {
|
||||||
fromStepId: condition.id,
|
fromStepId: condition.id,
|
||||||
toStepId: pathA.id,
|
toStepId: pathA.id,
|
||||||
condition: {branch: 'yes'},
|
condition: toPrismaJson({branch: 'yes'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -661,7 +662,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
data: {
|
data: {
|
||||||
fromStepId: condition.id,
|
fromStepId: condition.id,
|
||||||
toStepId: pathB.id,
|
toStepId: pathB.id,
|
||||||
condition: {branch: 'no'},
|
condition: toPrismaJson({branch: 'no'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -679,7 +680,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
status: WorkflowExecutionStatus.RUNNING,
|
status: WorkflowExecutionStatus.RUNNING,
|
||||||
currentStepId: triggerStep.id,
|
currentStepId: triggerStep.id,
|
||||||
context: {},
|
context: toPrismaJson({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -717,7 +718,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.CONDITION,
|
type: WorkflowStepType.CONDITION,
|
||||||
name: 'Bad Condition',
|
name: 'Bad Condition',
|
||||||
position: {x: 100, y: 0},
|
position: {x: 100, y: 0},
|
||||||
config: {}, // Invalid - missing required fields
|
config: toPrismaJson({}), // Invalid - missing required fields
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -731,7 +732,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
status: WorkflowExecutionStatus.RUNNING,
|
status: WorkflowExecutionStatus.RUNNING,
|
||||||
currentStepId: triggerStep.id,
|
currentStepId: triggerStep.id,
|
||||||
context: {},
|
context: toPrismaJson({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -764,11 +765,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.CONDITION,
|
type: WorkflowStepType.CONDITION,
|
||||||
name: 'Check Missing Field',
|
name: 'Check Missing Field',
|
||||||
position: {x: 100, y: 0},
|
position: {x: 100, y: 0},
|
||||||
config: {
|
config: toPrismaJson({
|
||||||
field: 'data.nonExistentField',
|
field: 'data.nonExistentField',
|
||||||
operator: 'equals',
|
operator: 'equals',
|
||||||
value: 'something',
|
value: 'something',
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -778,7 +779,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.EXIT,
|
type: WorkflowStepType.EXIT,
|
||||||
name: 'Exit',
|
name: 'Exit',
|
||||||
position: {x: 200, y: 0},
|
position: {x: 200, y: 0},
|
||||||
config: {},
|
config: toPrismaJson({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -790,7 +791,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
data: {
|
data: {
|
||||||
fromStepId: condition.id,
|
fromStepId: condition.id,
|
||||||
toStepId: noStep.id,
|
toStepId: noStep.id,
|
||||||
condition: {branch: 'no'},
|
condition: toPrismaJson({branch: 'no'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -800,7 +801,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
status: WorkflowExecutionStatus.RUNNING,
|
status: WorkflowExecutionStatus.RUNNING,
|
||||||
currentStepId: triggerStep.id,
|
currentStepId: triggerStep.id,
|
||||||
context: {},
|
context: toPrismaJson({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -834,7 +835,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.EXIT,
|
type: WorkflowStepType.EXIT,
|
||||||
name: 'Early Exit',
|
name: 'Early Exit',
|
||||||
position: {x: 100, y: 0},
|
position: {x: 100, y: 0},
|
||||||
config: {reason: 'User already converted'},
|
config: toPrismaJson({reason: 'User already converted'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -848,7 +849,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
status: WorkflowExecutionStatus.RUNNING,
|
status: WorkflowExecutionStatus.RUNNING,
|
||||||
currentStepId: triggerStep.id,
|
currentStepId: triggerStep.id,
|
||||||
context: {},
|
context: toPrismaJson({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -882,7 +883,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.TRIGGER,
|
type: WorkflowStepType.TRIGGER,
|
||||||
name: 'Start',
|
name: 'Start',
|
||||||
position: {x: 0, y: 0},
|
position: {x: 0, y: 0},
|
||||||
config: {},
|
config: toPrismaJson({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const exitStep = await factories.createWorkflowStep({
|
const exitStep = await factories.createWorkflowStep({
|
||||||
@@ -890,7 +891,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.EXIT,
|
type: WorkflowStepType.EXIT,
|
||||||
name: 'End',
|
name: 'End',
|
||||||
position: {x: 100, y: 0},
|
position: {x: 100, y: 0},
|
||||||
config: {},
|
config: toPrismaJson({}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.workflowTransition.create({
|
await prisma.workflowTransition.create({
|
||||||
@@ -910,7 +911,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
status: WorkflowExecutionStatus.RUNNING,
|
status: WorkflowExecutionStatus.RUNNING,
|
||||||
currentStepId: triggerStep.id,
|
currentStepId: triggerStep.id,
|
||||||
context: contextData as Prisma.InputJsonValue,
|
context: toPrismaJson(contextData),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -974,11 +975,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.CONDITION,
|
type: WorkflowStepType.CONDITION,
|
||||||
name: 'Check if first open',
|
name: 'Check if first open',
|
||||||
position: {x: 100, y: 0},
|
position: {x: 100, y: 0},
|
||||||
config: {
|
config: toPrismaJson({
|
||||||
field: 'event.isFirstOpen',
|
field: 'event.isFirstOpen',
|
||||||
operator: 'equals',
|
operator: 'equals',
|
||||||
value: true, // Use boolean, not string
|
value: true, // Use boolean, not string
|
||||||
} as Prisma.InputJsonValue,
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -988,7 +989,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.EXIT,
|
type: WorkflowStepType.EXIT,
|
||||||
name: 'First Open',
|
name: 'First Open',
|
||||||
position: {x: 200, y: 0},
|
position: {x: 200, y: 0},
|
||||||
config: {reason: 'first_open'} as Prisma.InputJsonValue,
|
config: toPrismaJson({reason: 'first_open'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -998,7 +999,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.EXIT,
|
type: WorkflowStepType.EXIT,
|
||||||
name: 'Not First Open',
|
name: 'Not First Open',
|
||||||
position: {x: 200, y: 100},
|
position: {x: 200, y: 100},
|
||||||
config: {reason: 'not_first_open'} as Prisma.InputJsonValue,
|
config: toPrismaJson({reason: 'not_first_open'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1007,10 +1008,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
data: {fromStepId: triggerStep!.id, toStepId: conditionStep.id},
|
data: {fromStepId: triggerStep!.id, toStepId: conditionStep.id},
|
||||||
});
|
});
|
||||||
await prisma.workflowTransition.create({
|
await prisma.workflowTransition.create({
|
||||||
data: {fromStepId: conditionStep.id, toStepId: yesStep.id, condition: {branch: 'yes'} as Prisma.InputJsonValue},
|
data: {fromStepId: conditionStep.id, toStepId: yesStep.id, condition: toPrismaJson({branch: 'yes'})},
|
||||||
});
|
});
|
||||||
await prisma.workflowTransition.create({
|
await prisma.workflowTransition.create({
|
||||||
data: {fromStepId: conditionStep.id, toStepId: noStep.id, condition: {branch: 'no'} as Prisma.InputJsonValue},
|
data: {fromStepId: conditionStep.id, toStepId: noStep.id, condition: toPrismaJson({branch: 'no'})},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create execution with event data
|
// Create execution with event data
|
||||||
@@ -1020,12 +1021,12 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
status: WorkflowExecutionStatus.RUNNING,
|
status: WorkflowExecutionStatus.RUNNING,
|
||||||
currentStepId: triggerStep!.id,
|
currentStepId: triggerStep!.id,
|
||||||
context: {
|
context: toPrismaJson({
|
||||||
subject: 'Welcome Email',
|
subject: 'Welcome Email',
|
||||||
from: '[email protected]',
|
from: '[email protected]',
|
||||||
isFirstOpen: true,
|
isFirstOpen: true,
|
||||||
openedAt: new Date().toISOString(),
|
openedAt: new Date().toISOString(),
|
||||||
} as Prisma.InputJsonValue,
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1056,11 +1057,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.CONDITION,
|
type: WorkflowStepType.CONDITION,
|
||||||
name: 'Check subject',
|
name: 'Check subject',
|
||||||
position: {x: 100, y: 0},
|
position: {x: 100, y: 0},
|
||||||
config: {
|
config: toPrismaJson({
|
||||||
field: 'event.subject',
|
field: 'event.subject',
|
||||||
operator: 'contains',
|
operator: 'contains',
|
||||||
value: 'Welcome',
|
value: 'Welcome',
|
||||||
} as Prisma.InputJsonValue,
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1070,7 +1071,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.EXIT,
|
type: WorkflowStepType.EXIT,
|
||||||
name: 'Done',
|
name: 'Done',
|
||||||
position: {x: 200, y: 0},
|
position: {x: 200, y: 0},
|
||||||
config: {reason: 'matched'} as Prisma.InputJsonValue,
|
config: toPrismaJson({reason: 'matched'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1081,7 +1082,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
data: {
|
data: {
|
||||||
fromStepId: conditionStep.id,
|
fromStepId: conditionStep.id,
|
||||||
toStepId: exitStep.id,
|
toStepId: exitStep.id,
|
||||||
condition: {branch: 'yes'} as Prisma.InputJsonValue,
|
condition: toPrismaJson({branch: 'yes'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1091,10 +1092,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
status: WorkflowExecutionStatus.RUNNING,
|
status: WorkflowExecutionStatus.RUNNING,
|
||||||
currentStepId: triggerStep!.id,
|
currentStepId: triggerStep!.id,
|
||||||
context: {
|
context: toPrismaJson({
|
||||||
subject: 'Welcome to Plunk!',
|
subject: 'Welcome to Plunk!',
|
||||||
from: '[email protected]',
|
from: '[email protected]',
|
||||||
} as Prisma.InputJsonValue,
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1122,11 +1123,11 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.CONDITION,
|
type: WorkflowStepType.CONDITION,
|
||||||
name: 'Check opens count',
|
name: 'Check opens count',
|
||||||
position: {x: 100, y: 0},
|
position: {x: 100, y: 0},
|
||||||
config: {
|
config: toPrismaJson({
|
||||||
field: 'event.opens',
|
field: 'event.opens',
|
||||||
operator: 'greaterThan',
|
operator: 'greaterThan',
|
||||||
value: '3',
|
value: '3',
|
||||||
} as Prisma.InputJsonValue,
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1136,7 +1137,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.EXIT,
|
type: WorkflowStepType.EXIT,
|
||||||
name: 'Done',
|
name: 'Done',
|
||||||
position: {x: 200, y: 0},
|
position: {x: 200, y: 0},
|
||||||
config: {reason: 'engaged'} as Prisma.InputJsonValue,
|
config: toPrismaJson({reason: 'engaged'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1147,7 +1148,7 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
data: {
|
data: {
|
||||||
fromStepId: conditionStep.id,
|
fromStepId: conditionStep.id,
|
||||||
toStepId: exitStep.id,
|
toStepId: exitStep.id,
|
||||||
condition: {branch: 'yes'} as Prisma.InputJsonValue,
|
condition: toPrismaJson({branch: 'yes'}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1157,10 +1158,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
status: WorkflowExecutionStatus.RUNNING,
|
status: WorkflowExecutionStatus.RUNNING,
|
||||||
currentStepId: triggerStep!.id,
|
currentStepId: triggerStep!.id,
|
||||||
context: {
|
context: toPrismaJson({
|
||||||
subject: 'Newsletter',
|
subject: 'Newsletter',
|
||||||
opens: 5,
|
opens: 5,
|
||||||
} as Prisma.InputJsonValue,
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1204,10 +1205,10 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
type: WorkflowStepType.WEBHOOK,
|
type: WorkflowStepType.WEBHOOK,
|
||||||
name: 'Send Webhook',
|
name: 'Send Webhook',
|
||||||
position: {x: 100, y: 0},
|
position: {x: 100, y: 0},
|
||||||
config: {
|
config: toPrismaJson({
|
||||||
url: 'https://webhook.example.com/test',
|
url: 'https://webhook.example.com/test',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
} as Prisma.InputJsonValue,
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1221,13 +1222,13 @@ describe('WorkflowExecutionService - Integration Tests', () => {
|
|||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
status: WorkflowExecutionStatus.RUNNING,
|
status: WorkflowExecutionStatus.RUNNING,
|
||||||
currentStepId: triggerStep!.id,
|
currentStepId: triggerStep!.id,
|
||||||
context: {
|
context: toPrismaJson({
|
||||||
subject: 'Welcome Email',
|
subject: 'Welcome Email',
|
||||||
from: '[email protected]',
|
from: '[email protected]',
|
||||||
messageId: 'msg-123',
|
messageId: 'msg-123',
|
||||||
isFirstOpen: true,
|
isFirstOpen: true,
|
||||||
openedAt: '2024-01-15T10:00:00Z',
|
openedAt: '2024-01-15T10:00:00Z',
|
||||||
} as Prisma.InputJsonValue,
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {describe, it, expect, beforeEach} from 'vitest';
|
import {beforeEach, describe, expect, it} from 'vitest';
|
||||||
import {
|
import {
|
||||||
WorkflowStepType,
|
|
||||||
StepExecutionStatus,
|
StepExecutionStatus,
|
||||||
WorkflowExecutionStatus,
|
|
||||||
TemplateType,
|
TemplateType,
|
||||||
|
WorkflowExecutionStatus,
|
||||||
|
WorkflowStepType,
|
||||||
WorkflowTriggerType,
|
WorkflowTriggerType,
|
||||||
} from '@plunk/db';
|
} from '@plunk/db';
|
||||||
import {WorkflowExecutionService} from '../WorkflowExecutionService';
|
import {WorkflowExecutionService} from '../WorkflowExecutionService';
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ describe('WorkflowService', () => {
|
|||||||
|
|
||||||
const page1 = await WorkflowService.list(projectId, 1, 10);
|
const page1 = await WorkflowService.list(projectId, 1, 10);
|
||||||
|
|
||||||
expect(page1.workflows).toHaveLength(10);
|
expect(page1.data).toHaveLength(10);
|
||||||
expect(page1.total).toBe(25);
|
expect(page1.total).toBe(25);
|
||||||
expect(page1.totalPages).toBe(3);
|
expect(page1.totalPages).toBe(3);
|
||||||
});
|
});
|
||||||
@@ -218,7 +218,7 @@ describe('WorkflowService', () => {
|
|||||||
const result = await WorkflowService.list(projectId, 1, 20, 'welcome');
|
const result = await WorkflowService.list(projectId, 1, 20, 'welcome');
|
||||||
|
|
||||||
expect(result.total).toBe(2);
|
expect(result.total).toBe(2);
|
||||||
expect(result.workflows.every(w => w.name.toLowerCase().includes('welcome'))).toBe(true);
|
expect(result.data.every(w => w.name.toLowerCase().includes('welcome'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include step and execution counts', async () => {
|
it('should include step and execution counts', async () => {
|
||||||
@@ -234,8 +234,8 @@ describe('WorkflowService', () => {
|
|||||||
|
|
||||||
const result = await WorkflowService.list(projectId);
|
const result = await WorkflowService.list(projectId);
|
||||||
|
|
||||||
const found = result.workflows.find(w => w.id === workflow.id) as
|
const found = result.data.find(w => w.id === workflow.id) as
|
||||||
| ((typeof result.workflows)[number] & {_count: {steps: number; executions: number}})
|
| ((typeof result.data)[number] & {_count: {steps: number; executions: number}})
|
||||||
| undefined;
|
| undefined;
|
||||||
expect(found?._count.steps).toBe(3); // TRIGGER + 2 added
|
expect(found?._count.steps).toBe(3); // TRIGGER + 2 added
|
||||||
expect(found?._count.executions).toBe(1);
|
expect(found?._count.executions).toBe(1);
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ export const Keys = {
|
|||||||
project(projectId: string): string {
|
project(projectId: string): string {
|
||||||
return `domain:project:${projectId}`;
|
return `domain:project:${projectId}`;
|
||||||
},
|
},
|
||||||
|
verifiedEmail(domainId: string): string {
|
||||||
|
return `domain:verified_email:${domainId}`;
|
||||||
|
},
|
||||||
|
unverifiedEmail(domainId: string, year: number, month: string): string {
|
||||||
|
return `domain:unverified_email:${domainId}:${year}-${month}`;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Billing: {
|
Billing: {
|
||||||
usage(projectId: string, sourceType: string, year: number, month: string): string {
|
usage(projectId: string, sourceType: string, year: number, month: string): string {
|
||||||
@@ -64,4 +70,29 @@ export const Keys = {
|
|||||||
return `workflows:enabled:${projectId}`;
|
return `workflows:enabled:${projectId}`;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Membership: {
|
||||||
|
access(userId: string, projectId: string): string {
|
||||||
|
return `membership:access:${userId}:${projectId}`;
|
||||||
|
},
|
||||||
|
admin(userId: string, projectId: string): string {
|
||||||
|
return `membership:admin:${userId}:${projectId}`;
|
||||||
|
},
|
||||||
|
full(userId: string, projectId: string): string {
|
||||||
|
return `membership:full:${userId}:${projectId}`;
|
||||||
|
},
|
||||||
|
owner(projectId: string): string {
|
||||||
|
return `membership:owner:${projectId}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Project: {
|
||||||
|
id(id: string): string {
|
||||||
|
return `project:id:${id}`;
|
||||||
|
},
|
||||||
|
secret(key: string): string {
|
||||||
|
return `project:secret:${key}`;
|
||||||
|
},
|
||||||
|
public(key: string): string {
|
||||||
|
return `project:public:${key}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** @type {import('next-sitemap').IConfig} */
|
/** @type {import('next-sitemap').IConfig} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://www.useplunk.com',
|
siteUrl: process.env.NEXT_PUBLIC_LANDING_URI || 'https://next.useplunk.com',
|
||||||
generateRobotsTxt: true,
|
generateRobotsTxt: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"@tiptap/starter-kit": "^3.11.0",
|
"@tiptap/starter-kit": "^3.11.0",
|
||||||
"juice": "^11.0.3",
|
"juice": "^11.0.3",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "^16.0.10",
|
"next": "^16.1.5",
|
||||||
"next-seo": "^6.6.0",
|
"next-seo": "^6.6.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
|||||||
@@ -128,6 +128,11 @@ export default function Footer() {
|
|||||||
Terms
|
Terms
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/dpa'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
DPA
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function GuideLayout({
|
|||||||
readTime,
|
readTime,
|
||||||
children,
|
children,
|
||||||
canonical,
|
canonical,
|
||||||
ogImage = 'https://www.useplunk.com/assets/card.png',
|
ogImage = 'https://next.useplunk.com/assets/card.png',
|
||||||
}: GuideLayoutProps) {
|
}: GuideLayoutProps) {
|
||||||
const [headings, setHeadings] = useState<{id: string; text: string; level: number}[]>([]);
|
const [headings, setHeadings] = useState<{id: string; text: string; level: number}[]>([]);
|
||||||
const [activeId, setActiveId] = useState<string>('');
|
const [activeId, setActiveId] = useState<string>('');
|
||||||
@@ -74,8 +74,8 @@ export function GuideLayout({
|
|||||||
|
|
||||||
// Generate breadcrumb items
|
// Generate breadcrumb items
|
||||||
const breadcrumbItems = [
|
const breadcrumbItems = [
|
||||||
{position: 1, name: 'Home', item: 'https://www.useplunk.com'},
|
{position: 1, name: 'Home', item: 'https://next.useplunk.com'},
|
||||||
{position: 2, name: 'Guides', item: 'https://www.useplunk.com/guides'},
|
{position: 2, name: 'Guides', item: 'https://next.useplunk.com/guides'},
|
||||||
{position: 3, name: title, item: canonical || ''},
|
{position: 3, name: title, item: canonical || ''},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import {AlertCircle, AlertTriangle, CheckCircle, Info, Mail, Server, Shield, Trash2, XCircle} from 'lucide-react';
|
import {
|
||||||
import type {EmailVerificationResult as VerificationResult} from '../../lib/emailVerification';
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
Forward,
|
||||||
|
Info,
|
||||||
|
Mail,
|
||||||
|
Server,
|
||||||
|
Shield,
|
||||||
|
Trash2,
|
||||||
|
User,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type {EmailVerificationResult as VerificationResult} from '@plunk/types';
|
||||||
|
|
||||||
interface EmailVerificationResultProps {
|
interface EmailVerificationResultProps {
|
||||||
result: VerificationResult;
|
result: VerificationResult;
|
||||||
@@ -41,7 +53,7 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
|
|||||||
<Server className="h-5 w-5 text-neutral-600" />
|
<Server className="h-5 w-5 text-neutral-600" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-neutral-900">Domain Exists</p>
|
<p className="font-medium text-neutral-900">Domain Exists</p>
|
||||||
<p className="text-sm text-neutral-600">DNS A/AAAA records found</p>
|
<p className="text-sm text-neutral-600">Domain has nameservers (NS records)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{result.domainExists ? (
|
{result.domainExists ? (
|
||||||
@@ -67,6 +79,22 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Website/A Records */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Server className="h-5 w-5 text-neutral-600" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-neutral-900">Has Website</p>
|
||||||
|
<p className="text-sm text-neutral-600">DNS A/AAAA records found</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{result.hasWebsite ? (
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Info className="h-5 w-5 text-neutral-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Disposable Email */}
|
{/* Disposable Email */}
|
||||||
<div className="flex items-center justify-between px-6 py-4">
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -83,6 +111,38 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Personal Email */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<User className="h-5 w-5 text-neutral-600" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-neutral-900">Personal Email</p>
|
||||||
|
<p className="text-sm text-neutral-600">Free email provider (Gmail, Hotmail, etc.)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{result.isPersonalEmail ? (
|
||||||
|
<Info className="h-5 w-5 text-blue-600" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-neutral-500">No</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alias/Forwarding Email */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Forward className="h-5 w-5 text-neutral-600" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-neutral-900">Forwarding Service</p>
|
||||||
|
<p className="text-sm text-neutral-600">Email alias/forwarding detected</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{result.isAlias ? (
|
||||||
|
<Info className="h-5 w-5 text-blue-600" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Typo Detection */}
|
{/* Typo Detection */}
|
||||||
<div className="flex items-center justify-between px-6 py-4">
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -167,4 +167,3 @@ export function convertToCompleteEmailHtml(html: string): string {
|
|||||||
const fragment = convertToEmailHtml(html);
|
const fragment = convertToEmailHtml(html);
|
||||||
return wrapEmailHtml(fragment);
|
return wrapEmailHtml(fragment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
export interface EmailVerificationResult {
|
import type {EmailVerificationResult} from '@plunk/types';
|
||||||
email: string;
|
|
||||||
valid: boolean;
|
|
||||||
isDisposable: boolean;
|
|
||||||
isTypo: boolean;
|
|
||||||
isPlusAddressed: boolean;
|
|
||||||
domainExists: boolean;
|
|
||||||
hasMxRecords: boolean;
|
|
||||||
suggestedEmail?: string;
|
|
||||||
reasons: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyEmail(email: string): Promise<EmailVerificationResult> {
|
export async function verifyEmail(email: string): Promise<EmailVerificationResult> {
|
||||||
const response = await fetch('/api/verify-email', {
|
const response = await fetch('/api/verify-email', {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {CheckCircle, Mail, Search, Shield} from 'lucide-react';
|
import {CheckCircle, Mail, Search, Shield, User} from 'lucide-react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Educational content for the email verification tool
|
* Educational content for the email verification tool
|
||||||
@@ -29,6 +29,11 @@ export const EMAIL_VERIFICATION_FEATURES = [
|
|||||||
description: 'Identify temporary email addresses that are often used for spam or fake signups.',
|
description: 'Identify temporary email addresses that are often used for spam or fake signups.',
|
||||||
icon: Mail,
|
icon: Mail,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Personal Email Detection',
|
||||||
|
description: 'Detect personal/free email providers like Gmail, Hotmail, Yahoo for B2B validation.',
|
||||||
|
icon: User,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Plus Addressing',
|
title: 'Plus Addressing',
|
||||||
description: 'Detect plus-addressed emails ([email protected]) which can be useful for tracking.',
|
description: 'Detect plus-addressed emails ([email protected]) which can be useful for tracking.',
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default function WithProviders(props: AppProps) {
|
|||||||
title: 'Plunk | The Open-Source Email Platform',
|
title: 'Plunk | The Open-Source Email Platform',
|
||||||
description:
|
description:
|
||||||
'Open-source email automation platform with workflows, segments, and developer API. Scale from 0 to millions of emails at $0.001 per email. Self-hostable and privacy-first.',
|
'Open-source email automation platform with workflows, segments, and developer API. Scale from 0 to millions of emails at $0.001 per email. Self-hostable and privacy-first.',
|
||||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk'}],
|
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk'}],
|
||||||
}}
|
}}
|
||||||
additionalMetaTags={[{property: 'title', content: 'Plunk | The Open-Source Email Platform'}]}
|
additionalMetaTags={[{property: 'title', content: 'Plunk | The Open-Source Email Platform'}]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,26 +1,15 @@
|
|||||||
import type {NextApiRequest, NextApiResponse} from 'next';
|
import type {NextApiRequest, NextApiResponse} from 'next';
|
||||||
|
import type {EmailVerificationResult} from '@plunk/types';
|
||||||
import {UtilitySchemas} from '@plunk/shared';
|
import {UtilitySchemas} from '@plunk/shared';
|
||||||
import {API_URI} from '../../lib/constants';
|
import {API_URI} from '../../lib/constants';
|
||||||
|
|
||||||
interface VerifyEmailResponse {
|
|
||||||
email: string;
|
|
||||||
valid: boolean;
|
|
||||||
isDisposable: boolean;
|
|
||||||
isTypo: boolean;
|
|
||||||
isPlusAddressed: boolean;
|
|
||||||
domainExists: boolean;
|
|
||||||
hasMxRecords: boolean;
|
|
||||||
suggestedEmail?: string;
|
|
||||||
reasons: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ErrorResponse {
|
interface ErrorResponse {
|
||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function handler(
|
export default async function handler(
|
||||||
req: NextApiRequest,
|
req: NextApiRequest,
|
||||||
res: NextApiResponse<VerifyEmailResponse | ErrorResponse>,
|
res: NextApiResponse<EmailVerificationResult | ErrorResponse>,
|
||||||
) {
|
) {
|
||||||
// Only allow POST requests
|
// Only allow POST requests
|
||||||
if (req.method !== 'POST') {
|
if (req.method !== 'POST') {
|
||||||
@@ -49,7 +38,7 @@ export default async function handler(
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${secretKey}`,
|
'Authorization': `Bearer ${secretKey}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({email}),
|
body: JSON.stringify({email}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,695 @@
|
|||||||
|
import {NextSeo} from 'next-seo';
|
||||||
|
import React from 'react';
|
||||||
|
import {Navbar} from '../components';
|
||||||
|
|
||||||
|
export default function DataProcessingAgreement() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NextSeo
|
||||||
|
title="Plunk Data Processing Agreement (DPA) | GDPR Compliance"
|
||||||
|
description="Data Processing Agreement for Plunk customers. GDPR-compliant DPA covering data processing activities, sub-processors, security measures, and data subject rights."
|
||||||
|
openGraph={{
|
||||||
|
title: 'Plunk Data Processing Agreement (DPA) | GDPR Compliance',
|
||||||
|
description:
|
||||||
|
'Data Processing Agreement for Plunk customers. GDPR-compliant DPA covering data processing activities, sub-processors, security measures, and data subject rights.',
|
||||||
|
}}
|
||||||
|
additionalMetaTags={[
|
||||||
|
{
|
||||||
|
property: 'title',
|
||||||
|
content: 'Plunk Data Processing Agreement (DPA) | GDPR Compliance',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-h-screen bg-white">
|
||||||
|
<div className="px-8 pb-32 sm:px-12 md:px-16 lg:px-20 xl:px-24 2xl:px-56">
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-4xl py-16">
|
||||||
|
{/* Hero */}
|
||||||
|
<div className="mb-12">
|
||||||
|
<h1 className="text-4xl font-bold text-neutral-900">Data Processing Agreement</h1>
|
||||||
|
<p className="mt-2 text-sm text-neutral-600">Last Updated: February 18, 2026</p>
|
||||||
|
<p className="mt-4 text-base leading-relaxed text-neutral-700">
|
||||||
|
This Data Processing Agreement ("DPA") forms part of the Terms of Service between you ("Customer", "Data
|
||||||
|
Controller") and Plunk ("Processor", "we", "us") and governs the processing of Personal Data in
|
||||||
|
accordance with GDPR requirements.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||||
|
<p className="text-sm leading-relaxed text-blue-900">
|
||||||
|
<strong className="font-semibold">GDPR Requirement:</strong> This DPA is required under Article 28 of
|
||||||
|
the GDPR. By using Plunk's hosted service, you accept and agree to the terms of this DPA.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table of Contents */}
|
||||||
|
<nav className="mb-12 rounded-lg border border-neutral-200 bg-neutral-50 p-6">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-neutral-900">Contents</h2>
|
||||||
|
<ol className="space-y-2 text-sm text-neutral-700">
|
||||||
|
<li>
|
||||||
|
<a href="#definitions" className="text-blue-600 hover:underline">
|
||||||
|
1. Definitions
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#scope" className="text-blue-600 hover:underline">
|
||||||
|
2. Scope & Applicability
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#processing-details" className="text-blue-600 hover:underline">
|
||||||
|
3. Processing Details
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#obligations" className="text-blue-600 hover:underline">
|
||||||
|
4. Data Processor Obligations
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#security" className="text-blue-600 hover:underline">
|
||||||
|
5. Security Measures
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#sub-processors" className="text-blue-600 hover:underline">
|
||||||
|
6. Sub-Processors
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#data-subject-rights" className="text-blue-600 hover:underline">
|
||||||
|
7. Data Subject Rights
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#data-breach" className="text-blue-600 hover:underline">
|
||||||
|
8. Data Breach Notification
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#international-transfers" className="text-blue-600 hover:underline">
|
||||||
|
9. International Data Transfers
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#audits" className="text-blue-600 hover:underline">
|
||||||
|
10. Audits & Compliance
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#deletion" className="text-blue-600 hover:underline">
|
||||||
|
11. Data Deletion & Return
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#liability" className="text-blue-600 hover:underline">
|
||||||
|
12. Liability & Indemnification
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#term" className="text-blue-600 hover:underline">
|
||||||
|
13. Term & Termination
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Content Sections */}
|
||||||
|
<div className="space-y-12">
|
||||||
|
{/* Section 1 */}
|
||||||
|
<section id="definitions" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">1. Definitions</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Terms used in this DPA have the meanings set forth in the GDPR. Specifically:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">"Personal Data"</strong> means any information
|
||||||
|
relating to an identified or identifiable natural person processed via Plunk
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">"Data Controller"</strong> means the Customer who
|
||||||
|
determines the purposes and means of processing Personal Data
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">"Data Processor"</strong> means Plunk, which
|
||||||
|
processes Personal Data on behalf of the Data Controller
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">"Sub-processor"</strong> means any third party
|
||||||
|
engaged by Plunk to process Personal Data
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">"Data Subject"</strong> means the individuals
|
||||||
|
whose Personal Data is processed (your contacts/subscribers)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">"GDPR"</strong> means Regulation (EU) 2016/679
|
||||||
|
(General Data Protection Regulation)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 2 */}
|
||||||
|
<section id="scope" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">2. Scope & Applicability</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Hosted Service Only</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
This DPA applies ONLY to customers using Plunk's hosted service (useplunk.com). Self-hosted
|
||||||
|
deployments are NOT covered by this DPA - you are solely responsible for GDPR compliance when
|
||||||
|
self-hosting.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Agreement Hierarchy</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
This DPA supplements the Plunk Terms of Service and Privacy Policy. In case of conflict regarding data
|
||||||
|
processing:
|
||||||
|
</p>
|
||||||
|
<ol className="mb-4 ml-6 list-decimal space-y-2 text-neutral-700">
|
||||||
|
<li>This DPA takes precedence</li>
|
||||||
|
<li>Then the Terms of Service</li>
|
||||||
|
<li>Then the Privacy Policy</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Acceptance</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
By using Plunk's hosted service, you acknowledge that you have read, understood, and agree to be bound
|
||||||
|
by this DPA. This constitutes a legally binding agreement between Customer and Plunk.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 3 */}
|
||||||
|
<section id="processing-details" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">3. Processing Details</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
As required by GDPR Article 28(3), the following details describe the processing activities:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Subject Matter</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Processing of Personal Data necessary to provide email automation, transactional email, marketing
|
||||||
|
campaigns, and workflow automation services.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Duration</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Processing occurs for the duration of your Plunk subscription/account, plus 30 days for API logs
|
||||||
|
(which are automatically deleted), and until you request account deletion.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Nature & Purpose</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Storing and managing contact databases</li>
|
||||||
|
<li>Sending transactional, marketing, and workflow-triggered emails</li>
|
||||||
|
<li>Tracking email delivery, opens, clicks, bounces, and complaints (per your settings)</li>
|
||||||
|
<li>Managing email templates and automation workflows</li>
|
||||||
|
<li>Processing email events and webhooks</li>
|
||||||
|
<li>Providing analytics and reporting</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Type of Personal Data</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Email addresses (required)</li>
|
||||||
|
<li>Names and custom contact fields (optional, Customer-defined)</li>
|
||||||
|
<li>Email content (subject lines, message bodies, attachments)</li>
|
||||||
|
<li>Subscription status and preferences</li>
|
||||||
|
<li>Email activity data (opens, clicks, bounces, complaints)</li>
|
||||||
|
<li>Timestamps and metadata</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Categories of Data Subjects</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Customer's email subscribers and contacts</li>
|
||||||
|
<li>Recipients of transactional emails</li>
|
||||||
|
<li>Marketing campaign recipients</li>
|
||||||
|
<li>Workflow automation recipients</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 4 */}
|
||||||
|
<section id="obligations" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">4. Data Processor Obligations</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Plunk commits to the following obligations as Data Processor:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Processing Instructions</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>
|
||||||
|
Process Personal Data only on documented instructions from Customer (via API, dashboard, etc.)
|
||||||
|
</li>
|
||||||
|
<li>Not process Personal Data for any other purpose without Customer's prior written consent</li>
|
||||||
|
<li>Immediately inform Customer if instructions violate GDPR or other EU data protection laws</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Confidentiality</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Ensure persons authorized to process Personal Data are bound by confidentiality obligations</li>
|
||||||
|
<li>Maintain confidentiality of all Personal Data processed via Plunk</li>
|
||||||
|
<li>
|
||||||
|
Not disclose Personal Data to third parties except as required by law or with Customer consent
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Cooperation</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Assist Customer in responding to Data Subject rights requests (see Section 7)</li>
|
||||||
|
<li>
|
||||||
|
Assist Customer in ensuring compliance with GDPR security, breach notification, and impact
|
||||||
|
assessment obligations
|
||||||
|
</li>
|
||||||
|
<li>Provide information necessary to demonstrate compliance with Article 28</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 5 */}
|
||||||
|
<section id="security" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">5. Security Measures</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
As required by GDPR Article 32, Plunk implements appropriate technical and organizational measures to
|
||||||
|
ensure a level of security appropriate to the risk:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Technical Safeguards</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Industry-standard password hashing (never stored in plaintext)</li>
|
||||||
|
<li>HTTPS-only API access (TLS 1.2+)</li>
|
||||||
|
<li>Database connections encrypted via TLS/SSL</li>
|
||||||
|
<li>HttpOnly, secure cookies with appropriate SameSite settings</li>
|
||||||
|
<li>Authentication tokens with limited expiration periods</li>
|
||||||
|
<li>Rate limiting to prevent brute force attacks</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Organizational Safeguards</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Access controls limiting personnel access to Personal Data</li>
|
||||||
|
<li>Automated bounce and complaint rate monitoring</li>
|
||||||
|
<li>Regular security updates and patching</li>
|
||||||
|
<li>Data segregation by project (multi-tenancy with isolation)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Data Residency</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Primary data storage: EU/EEA (Hetzner infrastructure)</li>
|
||||||
|
<li>Database and file storage remain within EU/EEA</li>
|
||||||
|
<li>Email delivery may transit non-EU regions (see Section 9)</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 6 */}
|
||||||
|
<section id="sub-processors" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">6. Sub-Processors</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Customer authorizes Plunk to engage the following sub-processors to process Personal Data:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-6 overflow-x-auto">
|
||||||
|
<table className="min-w-full border border-neutral-300">
|
||||||
|
<thead className="bg-neutral-50">
|
||||||
|
<tr>
|
||||||
|
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
|
||||||
|
Sub-Processor
|
||||||
|
</th>
|
||||||
|
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
|
||||||
|
Service
|
||||||
|
</th>
|
||||||
|
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
|
||||||
|
Location
|
||||||
|
</th>
|
||||||
|
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
|
||||||
|
Purpose
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white">
|
||||||
|
<tr>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Amazon Web Services (AWS SES)
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">Email Delivery</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Global (data in transit only)
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Sending emails to recipients
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">Stripe, Inc.</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Payment Processing
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
USA (PCI-DSS compliant)
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Billing for paid accounts
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Hetzner Online GmbH
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Infrastructure Hosting
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
EU/EEA (Germany)
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Database and application hosting
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Sub-Processor Obligations</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>All sub-processors are bound by data protection obligations equivalent to this DPA</li>
|
||||||
|
<li>Plunk remains fully liable to Customer for sub-processor performance</li>
|
||||||
|
<li>Sub-processors have executed Data Processing Agreements with Plunk</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Changes to Sub-Processors</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Plunk will provide 30 days advance notice of new or replacement sub-processors via email</li>
|
||||||
|
<li>Notice will be sent to the email address associated with your account</li>
|
||||||
|
<li>
|
||||||
|
If you object on reasonable grounds related to data protection, you may terminate your account
|
||||||
|
within 30 days
|
||||||
|
</li>
|
||||||
|
<li>Updated sub-processor list will be maintained on this page (check "Last Updated" date)</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 7 */}
|
||||||
|
<section id="data-subject-rights" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">7. Data Subject Rights</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Plunk will assist Customer in fulfilling Data Subject rights requests under GDPR Articles 15-22:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Self-Service via API</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Customer can fulfill most Data Subject requests independently via API:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">Access (Art. 15):</strong> Retrieve contact data
|
||||||
|
via GET /contacts/:id
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">Rectification (Art. 16):</strong> Update contact
|
||||||
|
data via PATCH /contacts/:id
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">Erasure (Art. 17):</strong> Delete contact via
|
||||||
|
DELETE /contacts/:id
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">Restriction (Art. 18):</strong> Update
|
||||||
|
subscription status to "unsubscribed"
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">Portability (Art. 20):</strong> Use API to access
|
||||||
|
data in JSON format
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Assistance from Plunk</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
If Customer cannot fulfill a request via API, contact legal@useplunk.com:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Provide Data Subject's email address and nature of request</li>
|
||||||
|
<li>Plunk will respond within 10 business days with requested information or assistance</li>
|
||||||
|
<li>
|
||||||
|
Customer remains responsible for verifying Data Subject identity before disclosing information
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Email Activity Data</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Email opens, clicks, bounces, and complaints are linked to contact records</li>
|
||||||
|
<li>Deleting a contact will cascade delete associated email activity</li>
|
||||||
|
<li>API logs (non-Personal Data) are automatically deleted after 30 days</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 8 */}
|
||||||
|
<section id="data-breach" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">8. Data Breach Notification</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Notification Obligation</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
In the event of a Personal Data breach affecting Customer data, Plunk will:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>
|
||||||
|
Notify Customer without undue delay and within 72 hours of becoming aware of the breach (per GDPR
|
||||||
|
Art. 33)
|
||||||
|
</li>
|
||||||
|
<li>Send notification to the primary email address associated with Customer's account</li>
|
||||||
|
<li>Provide information to enable Customer to meet any GDPR breach reporting obligations</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Breach Information</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Notifications will include (to the extent known):
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Nature of the breach (what happened)</li>
|
||||||
|
<li>Categories and approximate number of Data Subjects affected</li>
|
||||||
|
<li>Categories and approximate number of Personal Data records affected</li>
|
||||||
|
<li>Likely consequences of the breach</li>
|
||||||
|
<li>Measures taken or proposed to address the breach and mitigate harm</li>
|
||||||
|
<li>Contact point for more information (legal@useplunk.com)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Customer Responsibility</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Customer is responsible for notifying their supervisory authority if required by GDPR Art. 33</li>
|
||||||
|
<li>Customer is responsible for notifying affected Data Subjects if required by GDPR Art. 34</li>
|
||||||
|
<li>Plunk's breach notification to Customer does NOT constitute legal or compliance advice</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 9 */}
|
||||||
|
<section id="international-transfers" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">9. International Data Transfers</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Primary Storage (EU/EEA)</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>All contact data, templates, workflows, and account data stored in EU/EEA (Hetzner, Germany)</li>
|
||||||
|
<li>No routine transfers of stored Personal Data outside EU/EEA</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Data in Transit (Email Delivery)</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
When emails are sent to recipients, Personal Data may transit through non-EU regions:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>AWS SES processes emails globally for delivery purposes</li>
|
||||||
|
<li>Data is in transit only (not stored long-term outside EU/EEA)</li>
|
||||||
|
<li>Protected by AWS Data Processing Agreement and Standard Contractual Clauses (SCCs)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Payment Data (Stripe)</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Payment information (credit cards) processed by Stripe (USA-based)</li>
|
||||||
|
<li>Stripe is PCI-DSS Level 1 certified</li>
|
||||||
|
<li>Protected by Stripe's Data Processing Agreement and Standard Contractual Clauses</li>
|
||||||
|
<li>Plunk does NOT store credit card numbers (tokenized by Stripe)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Standard Contractual Clauses</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
All sub-processors that process Personal Data outside EU/EEA have executed Standard Contractual
|
||||||
|
Clauses (SCCs) approved by the European Commission, providing appropriate safeguards for international
|
||||||
|
transfers per GDPR Article 46.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 10 */}
|
||||||
|
<section id="audits" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">10. Audits & Compliance</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Information Provision</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Plunk will make available to Customer information necessary to demonstrate compliance with GDPR
|
||||||
|
Article 28 obligations, including:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>This DPA (publicly available)</li>
|
||||||
|
<li>Privacy Policy describing processing activities</li>
|
||||||
|
<li>Sub-processor list (Section 6 above)</li>
|
||||||
|
<li>Security measures documentation (available upon reasonable request)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Audit Rights</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Customer may audit Plunk's compliance with this DPA, subject to the following:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Audits limited to once per year unless required by supervisory authority</li>
|
||||||
|
<li>Request must be submitted in writing to legal@useplunk.com with 30 days notice</li>
|
||||||
|
<li>Audits must be conducted by independent third-party auditors bound by confidentiality</li>
|
||||||
|
<li>Audits conducted during business hours with minimal disruption to operations</li>
|
||||||
|
<li>Customer bears all costs of audit</li>
|
||||||
|
<li>Audit scope limited to GDPR compliance (not general security assessments)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Alternative to Audits</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
In lieu of conducting a full audit, Customer may request and review sub-processor certifications and
|
||||||
|
compliance documentation (SOC 2, ISO 27001, etc.) where available.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 11 */}
|
||||||
|
<section id="deletion" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">11. Data Deletion & Return</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Account Deletion</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
When Customer deletes their Plunk account (via dashboard or by request):
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>All Personal Data is immediately deleted from production systems</li>
|
||||||
|
<li>No grace period or recovery window (deletion is permanent)</li>
|
||||||
|
<li>
|
||||||
|
Customer should access or backup data via API before deletion (Plunk does NOT provide data export)
|
||||||
|
</li>
|
||||||
|
<li>Deletion includes: contacts, emails, templates, workflows, campaigns, and email activity</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Backup Retention</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Deleted data may remain in encrypted backups for up to 30 days for disaster recovery purposes</li>
|
||||||
|
<li>Backup data is not accessible or restorable after account deletion</li>
|
||||||
|
<li>Backups are automatically overwritten after retention period</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Legal Retention</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Plunk may retain certain data if required by law (e.g., accounting records, fraud prevention):
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Billing records: Retained per tax/accounting laws (typically 7 years)</li>
|
||||||
|
<li>Fraud/abuse records: Retained for security purposes (email addresses of suspended accounts)</li>
|
||||||
|
<li>Legal requests: Data subject to legal holds retained as required by law</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">No Data Return</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Plunk does NOT provide data export or return upon termination. Customer must retrieve data via API
|
||||||
|
before deleting account. Once deleted, data cannot be recovered.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 12 */}
|
||||||
|
<section id="liability" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">12. Liability & Indemnification</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Customer Responsibilities</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">Customer (Data Controller) is responsible for:</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>
|
||||||
|
Ensuring lawful basis for processing under GDPR (consent, contract, legitimate interest, etc.)
|
||||||
|
</li>
|
||||||
|
<li>Obtaining necessary consents from Data Subjects before adding them to Plunk</li>
|
||||||
|
<li>Providing privacy notices to Data Subjects per GDPR Article 13/14</li>
|
||||||
|
<li>Complying with anti-spam laws (CAN-SPAM, CASL, GDPR, etc.)</li>
|
||||||
|
<li>Verifying Data Subject identity before fulfilling rights requests</li>
|
||||||
|
<li>Notifying supervisory authorities and Data Subjects of breaches where required</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Plunk's Liability</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Plunk is liable to Customer for compliance with this DPA and GDPR Article 28 obligations</li>
|
||||||
|
<li>Plunk is liable for sub-processor acts/omissions to the same extent as its own acts</li>
|
||||||
|
<li>
|
||||||
|
Liability limitations in Terms of Service apply, except where prohibited by GDPR (particularly Art.
|
||||||
|
82)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Indemnification</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>
|
||||||
|
Customer indemnifies Plunk for claims arising from Customer's violation of GDPR or data protection
|
||||||
|
laws
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Plunk indemnifies Customer for claims arising solely from Plunk's breach of this DPA or GDPR Article
|
||||||
|
28
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">GDPR Fines</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Under GDPR Article 82(3), liability for damages is allocated between Controller and Processor based on
|
||||||
|
fault. Each party is liable only for the damage caused by its own GDPR violation.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 13 */}
|
||||||
|
<section id="term" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">13. Term & Termination</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Effective Date</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
This DPA is effective as of the date you first use Plunk's hosted service and remains in effect for
|
||||||
|
the duration of the Terms of Service.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Termination</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">This DPA terminates automatically when:</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Customer deletes their Plunk account</li>
|
||||||
|
<li>Terms of Service are terminated</li>
|
||||||
|
<li>All Personal Data has been deleted per Section 11</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Survival</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Obligations regarding confidentiality, data deletion, and liability survive termination to the extent
|
||||||
|
necessary to fulfill their purpose.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">DPA Updates</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Plunk may update this DPA to reflect changes in law, sub-processors, or processing activities</li>
|
||||||
|
<li>Material changes will be notified via email 30 days in advance</li>
|
||||||
|
<li>Continued use of Plunk after changes constitutes acceptance</li>
|
||||||
|
<li>Check "Last Updated" date at top of page for version tracking</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Contact Section */}
|
||||||
|
<section className="mb-12 rounded-lg border border-neutral-200 bg-neutral-50 p-6">
|
||||||
|
<h2 className="mb-4 text-xl font-semibold text-neutral-900">Questions or Requests?</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
For questions about this DPA, data processing activities, or to exercise audit rights:
|
||||||
|
</p>
|
||||||
|
<p className="mb-2 leading-relaxed text-neutral-700">
|
||||||
|
<strong className="font-semibold text-neutral-900">Email:</strong> legal@useplunk.com
|
||||||
|
</p>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
<strong className="font-semibold text-neutral-900">Response Time:</strong> Within 10 business days
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
For Data Subject rights requests, Customers should use the API (see Section 7) or contact
|
||||||
|
legal@useplunk.com with Data Subject's email address and nature of request.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ export default function EmailAPIGuide() {
|
|||||||
description="Complete guide to email APIs: how they work, implementation examples, best practices, and choosing the right solution for your application."
|
description="Complete guide to email APIs: how they work, implementation examples, best practices, and choosing the right solution for your application."
|
||||||
lastUpdated="2025-12-20"
|
lastUpdated="2025-12-20"
|
||||||
readTime="12 min"
|
readTime="12 min"
|
||||||
canonical="https://www.useplunk.com/guides/email-api-guide"
|
canonical="https://next.useplunk.com/guides/email-api-guide"
|
||||||
>
|
>
|
||||||
{/* Introduction */}
|
{/* Introduction */}
|
||||||
<section id="introduction" className="mb-12">
|
<section id="introduction" className="mb-12">
|
||||||
@@ -132,7 +132,7 @@ export default function EmailAPIGuide() {
|
|||||||
language="javascript"
|
language="javascript"
|
||||||
title="Node.js Example"
|
title="Node.js Example"
|
||||||
code={`// Using fetch (Node.js 18+ or with node-fetch)
|
code={`// Using fetch (Node.js 18+ or with node-fetch)
|
||||||
const response = await fetch('https://api.useplunk.com/v1/send', {
|
const response = await fetch('https://next-api.useplunk.com/v1/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -167,7 +167,7 @@ if (response.ok) {
|
|||||||
code={`import requests
|
code={`import requests
|
||||||
|
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
'https://api.useplunk.com/v1/send',
|
'https://next-api.useplunk.com/v1/send',
|
||||||
headers={
|
headers={
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': 'Bearer YOUR_API_KEY'
|
'Authorization': 'Bearer YOUR_API_KEY'
|
||||||
@@ -195,7 +195,7 @@ else:
|
|||||||
language="php"
|
language="php"
|
||||||
title="PHP Example"
|
title="PHP Example"
|
||||||
code={`<?php
|
code={`<?php
|
||||||
$ch = curl_init('https://api.useplunk.com/v1/send');
|
$ch = curl_init('https://next-api.useplunk.com/v1/send');
|
||||||
|
|
||||||
curl_setopt($ch, CURLOPT_POST, true);
|
curl_setopt($ch, CURLOPT_POST, true);
|
||||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
@@ -235,7 +235,7 @@ if ($httpCode === 200) {
|
|||||||
code={`require 'net/http'
|
code={`require 'net/http'
|
||||||
require 'json'
|
require 'json'
|
||||||
|
|
||||||
uri = URI('https://api.useplunk.com/v1/send')
|
uri = URI('https://next-api.useplunk.com/v1/send')
|
||||||
http = Net::HTTP.new(uri.host, uri.port)
|
http = Net::HTTP.new(uri.host, uri.port)
|
||||||
http.use_ssl = true
|
http.use_ssl = true
|
||||||
|
|
||||||
@@ -295,7 +295,7 @@ func main() {
|
|||||||
|
|
||||||
jsonData, _ := json.Marshal(email)
|
jsonData, _ := json.Marshal(email)
|
||||||
|
|
||||||
req, _ := http.NewRequest("POST", "https://api.useplunk.com/v1/send", bytes.NewBuffer(jsonData))
|
req, _ := http.NewRequest("POST", "https://next-api.useplunk.com/v1/send", bytes.NewBuffer(jsonData))
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer YOUR_API_KEY")
|
req.Header.Set("Authorization", "Bearer YOUR_API_KEY")
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function EmailBounceRate() {
|
|||||||
description="Understand email bounce rates, the difference between hard and soft bounces, and proven strategies to reduce bounces and protect your sender reputation."
|
description="Understand email bounce rates, the difference between hard and soft bounces, and proven strategies to reduce bounces and protect your sender reputation."
|
||||||
lastUpdated="2025-12-20"
|
lastUpdated="2025-12-20"
|
||||||
readTime="9 min"
|
readTime="9 min"
|
||||||
canonical="https://www.useplunk.com/guides/email-bounce-rate"
|
canonical="https://next.useplunk.com/guides/email-bounce-rate"
|
||||||
>
|
>
|
||||||
{/* Introduction */}
|
{/* Introduction */}
|
||||||
<section id="introduction" className="mb-12">
|
<section id="introduction" className="mb-12">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function EmailClickThroughRate() {
|
|||||||
description="Learn what affects email click-through rates, industry benchmarks, and proven tactics to optimize CTAs and boost engagement."
|
description="Learn what affects email click-through rates, industry benchmarks, and proven tactics to optimize CTAs and boost engagement."
|
||||||
lastUpdated="2025-12-20"
|
lastUpdated="2025-12-20"
|
||||||
readTime="10 min"
|
readTime="10 min"
|
||||||
canonical="https://www.useplunk.com/guides/email-click-through-rate"
|
canonical="https://next.useplunk.com/guides/email-click-through-rate"
|
||||||
>
|
>
|
||||||
{/* Introduction */}
|
{/* Introduction */}
|
||||||
<section id="introduction" className="mb-12">
|
<section id="introduction" className="mb-12">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function EmailDeliverability() {
|
|||||||
description="Learn proven strategies to improve email deliverability, avoid spam filters, and maximize inbox placement rates. Complete guide with best practices."
|
description="Learn proven strategies to improve email deliverability, avoid spam filters, and maximize inbox placement rates. Complete guide with best practices."
|
||||||
lastUpdated="2025-12-20"
|
lastUpdated="2025-12-20"
|
||||||
readTime="12 min"
|
readTime="12 min"
|
||||||
canonical="https://www.useplunk.com/guides/email-deliverability"
|
canonical="https://next.useplunk.com/guides/email-deliverability"
|
||||||
>
|
>
|
||||||
{/* Introduction */}
|
{/* Introduction */}
|
||||||
<section id="introduction" className="mb-12">
|
<section id="introduction" className="mb-12">
|
||||||
@@ -306,9 +306,7 @@ export default function EmailDeliverability() {
|
|||||||
{/* Monitoring */}
|
{/* Monitoring */}
|
||||||
<section id="monitoring" className="mb-12">
|
<section id="monitoring" className="mb-12">
|
||||||
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Monitoring Email Deliverability</h2>
|
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Monitoring Email Deliverability</h2>
|
||||||
<p className="text-neutral-700 leading-relaxed mb-6">
|
<p className="text-neutral-700 leading-relaxed mb-6">Track these metrics to stay on top of deliverability:</p>
|
||||||
Track these metrics to stay on top of deliverability:
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="rounded-xl border border-neutral-200 bg-white p-6">
|
<div className="rounded-xl border border-neutral-200 bg-white p-6">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function EmailMarketingBestPractices() {
|
|||||||
description="Master email marketing with proven best practices for content, design, timing, deliverability, and compliance. Comprehensive guide for 2025."
|
description="Master email marketing with proven best practices for content, design, timing, deliverability, and compliance. Comprehensive guide for 2025."
|
||||||
lastUpdated="2025-12-20"
|
lastUpdated="2025-12-20"
|
||||||
readTime="15 min"
|
readTime="15 min"
|
||||||
canonical="https://www.useplunk.com/guides/email-marketing-best-practices"
|
canonical="https://next.useplunk.com/guides/email-marketing-best-practices"
|
||||||
>
|
>
|
||||||
{/* Introduction */}
|
{/* Introduction */}
|
||||||
<section id="introduction" className="mb-12">
|
<section id="introduction" className="mb-12">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function EmailOpenRate() {
|
|||||||
description="Learn what affects email open rates, industry benchmarks, and proven tactics to improve opens. Complete guide with actionable tips."
|
description="Learn what affects email open rates, industry benchmarks, and proven tactics to improve opens. Complete guide with actionable tips."
|
||||||
lastUpdated="2025-12-20"
|
lastUpdated="2025-12-20"
|
||||||
readTime="10 min"
|
readTime="10 min"
|
||||||
canonical="https://www.useplunk.com/guides/email-open-rate"
|
canonical="https://next.useplunk.com/guides/email-open-rate"
|
||||||
>
|
>
|
||||||
{/* Introduction */}
|
{/* Introduction */}
|
||||||
<section id="introduction" className="mb-12">
|
<section id="introduction" className="mb-12">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function EmailSenderReputation() {
|
|||||||
description="Learn how sender reputation works, what affects it, and proven strategies to build and maintain a positive reputation for maximum deliverability."
|
description="Learn how sender reputation works, what affects it, and proven strategies to build and maintain a positive reputation for maximum deliverability."
|
||||||
lastUpdated="2025-12-20"
|
lastUpdated="2025-12-20"
|
||||||
readTime="11 min"
|
readTime="11 min"
|
||||||
canonical="https://www.useplunk.com/guides/email-sender-reputation"
|
canonical="https://next.useplunk.com/guides/email-sender-reputation"
|
||||||
>
|
>
|
||||||
{/* Introduction */}
|
{/* Introduction */}
|
||||||
<section id="introduction" className="mb-12">
|
<section id="introduction" className="mb-12">
|
||||||
|
|||||||
@@ -103,13 +103,13 @@ export default function GuidesIndex() {
|
|||||||
<NextSeo
|
<NextSeo
|
||||||
title="Email Marketing & Deliverability Guides | Plunk"
|
title="Email Marketing & Deliverability Guides | Plunk"
|
||||||
description="Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more. Free guides from Plunk."
|
description="Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more. Free guides from Plunk."
|
||||||
canonical="https://www.useplunk.com/guides"
|
canonical="https://next.useplunk.com/guides"
|
||||||
openGraph={{
|
openGraph={{
|
||||||
title: 'Email Marketing & Deliverability Guides | Plunk',
|
title: 'Email Marketing & Deliverability Guides | Plunk',
|
||||||
description:
|
description:
|
||||||
'Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more.',
|
'Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more.',
|
||||||
url: 'https://www.useplunk.com/guides',
|
url: 'https://next.useplunk.com/guides',
|
||||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Guides'}],
|
images: [{url: 'https://next.useplunk.com/assets/card.png', alt: 'Plunk Guides'}],
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function TransactionalVsMarketingEmail() {
|
|||||||
description="Understand the critical differences between transactional and marketing emails, including legal requirements, deliverability, and best practices."
|
description="Understand the critical differences between transactional and marketing emails, including legal requirements, deliverability, and best practices."
|
||||||
lastUpdated="2025-12-20"
|
lastUpdated="2025-12-20"
|
||||||
readTime="10 min"
|
readTime="10 min"
|
||||||
canonical="https://www.useplunk.com/guides/transactional-vs-marketing-email"
|
canonical="https://next.useplunk.com/guides/transactional-vs-marketing-email"
|
||||||
>
|
>
|
||||||
{/* Introduction */}
|
{/* Introduction */}
|
||||||
<section id="introduction" className="mb-12">
|
<section id="introduction" className="mb-12">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function WhatIsDKIM() {
|
|||||||
description="Learn how DKIM (DomainKeys Identified Mail) protects your emails from spoofing and improves deliverability. Complete guide with setup examples."
|
description="Learn how DKIM (DomainKeys Identified Mail) protects your emails from spoofing and improves deliverability. Complete guide with setup examples."
|
||||||
lastUpdated="2025-12-20"
|
lastUpdated="2025-12-20"
|
||||||
readTime="8 min"
|
readTime="8 min"
|
||||||
canonical="https://www.useplunk.com/guides/what-is-dkim"
|
canonical="https://next.useplunk.com/guides/what-is-dkim"
|
||||||
>
|
>
|
||||||
{/* Introduction */}
|
{/* Introduction */}
|
||||||
<section id="introduction" className="mb-12">
|
<section id="introduction" className="mb-12">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user