mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 08:42:13 +02:00
Compare commits
249 Commits
mobile-res
...
scim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3f3bfcebb | ||
|
|
0690417a54 | ||
|
|
bda70a24dc | ||
|
|
32cdd4fe65 | ||
|
|
4579db4c59 | ||
|
|
df3f8b6a74 | ||
|
|
0c9d2c821a | ||
|
|
ba49aaf0c3 | ||
|
|
6ea5ad7fe8 | ||
|
|
962866d109 | ||
|
|
115216561c | ||
|
|
f709c90cc4 | ||
|
|
d7f01b0189 | ||
|
|
c3eaa8995c | ||
|
|
53b482b9f3 | ||
|
|
d52670f39c | ||
|
|
fdc1332b9e | ||
|
|
a937416663 | ||
|
|
546d41da81 | ||
|
|
c4c6793b29 | ||
|
|
c894b112e6 | ||
|
|
304baf1bb4 | ||
|
|
9adea6b1ba | ||
|
|
5498521e02 | ||
|
|
9e97c6ddbc | ||
|
|
63272e09f8 | ||
|
|
327c28afdc | ||
|
|
896020b93b | ||
|
|
15a68472b0 | ||
|
|
0210480d97 | ||
|
|
72fdc06687 | ||
|
|
3710b81b9a | ||
|
|
9fcb3dc2e0 | ||
|
|
43e2ccf51a | ||
|
|
48c3d8603a | ||
|
|
9cfc912161 | ||
|
|
29e3ee57ab | ||
|
|
be7e849822 | ||
|
|
59d76b601a | ||
|
|
b77ef336b8 | ||
|
|
7df21fe8e5 | ||
|
|
f39e1943c7 | ||
|
|
966a903646 | ||
|
|
1d9d37c6d1 | ||
|
|
7edcc4dbce | ||
|
|
0939294d22 | ||
|
|
dbcbfe5f79 | ||
|
|
a638972817 | ||
|
|
37c6310465 | ||
|
|
a7d38389fd | ||
|
|
2f55336db7 | ||
|
|
f99a15b95b | ||
|
|
de5bff2ffe | ||
|
|
cef2764499 | ||
|
|
a7014ac3ff | ||
|
|
fa31dc670c | ||
|
|
4c2a12cf31 | ||
|
|
b4115e1529 | ||
|
|
3883790c50 | ||
|
|
1702558d73 | ||
|
|
cacdbff50e | ||
|
|
0bc6b432a2 | ||
|
|
eaa09d4a13 | ||
|
|
08c85dd31c | ||
|
|
42e82b6fb7 | ||
|
|
463a20f342 | ||
|
|
1b8a7e3261 | ||
|
|
8b27dd1f26 | ||
|
|
17c72f65e3 | ||
|
|
5eee900fd3 | ||
|
|
0a6cdd11af | ||
|
|
8514b6b82e | ||
|
|
dfa8f6cd24 | ||
|
|
61614227e1 | ||
|
|
f3d20eb544 | ||
|
|
a11ff57fda | ||
|
|
deb635bc80 | ||
|
|
c707830811 | ||
|
|
24ada68d1e | ||
|
|
ca23234ba9 | ||
|
|
ea40a955e9 | ||
|
|
a46ee07d70 | ||
|
|
5c5bab408d | ||
|
|
540d632baf | ||
|
|
74718017ad | ||
|
|
d16897db1b | ||
|
|
be3fc6f077 | ||
|
|
b7b577517c | ||
|
|
ccf7a96e43 | ||
|
|
892f3c052a | ||
|
|
00833a06f4 | ||
|
|
472adf610a | ||
|
|
976c36de9a | ||
|
|
6026c9c9af | ||
|
|
791aa1421b | ||
|
|
79dbc94f82 | ||
|
|
ded41fc7ec | ||
|
|
581c374745 | ||
|
|
64c0c8b4cb | ||
|
|
7d2241ba98 | ||
|
|
30bada5b7a | ||
|
|
61bfb37747 | ||
|
|
4686aa941a | ||
|
|
3c065c76b0 | ||
|
|
5dccd03ed4 | ||
|
|
a395a95997 | ||
|
|
89082b1232 | ||
|
|
7cb33de450 | ||
|
|
353ac875fb | ||
|
|
d6560fdb32 | ||
|
|
5115e21a7a | ||
|
|
0e6119ddce | ||
|
|
b842a49cfb | ||
|
|
9737e50467 | ||
|
|
91beb6091d | ||
|
|
68e610aa9f | ||
|
|
d673ef3a01 | ||
|
|
6dff8f07bf | ||
|
|
4ca836c91f | ||
|
|
d59ba73993 | ||
|
|
e878855b31 | ||
|
|
8f95ae65f6 | ||
|
|
010de82ccb | ||
|
|
08d42c7923 | ||
|
|
995b93f525 | ||
|
|
fc3c11b12d | ||
|
|
d0ce225b66 | ||
|
|
b486b59598 | ||
|
|
4d7135fb11 | ||
|
|
0c4464ed87 | ||
|
|
d705ea6896 | ||
|
|
ac146df9e8 | ||
|
|
3ce7d54eef | ||
|
|
418c89c15b | ||
|
|
80144814d1 | ||
|
|
f3223e397b | ||
|
|
fce5e18fba | ||
|
|
cdd60c1d6b | ||
|
|
cb35a0d420 | ||
|
|
b198d4d87d | ||
|
|
285a5355a7 | ||
|
|
777093d2e1 | ||
|
|
0444b09ad5 | ||
|
|
7be9c4b1e7 | ||
|
|
79910b6c0b | ||
|
|
4f76afb9f2 | ||
|
|
0686dea83c | ||
|
|
e1e27c4e94 | ||
|
|
6fe14fbed3 | ||
|
|
9ef248f71e | ||
|
|
e243a76dab | ||
|
|
71466089a4 | ||
|
|
31e6172af4 | ||
|
|
7a228f76e4 | ||
|
|
40d473d195 | ||
|
|
f2f5b757eb | ||
|
|
1d4d93ceec | ||
|
|
40819562f7 | ||
|
|
066ad4a52d | ||
|
|
a109ae33e0 | ||
|
|
19ac60d8db | ||
|
|
7557103cc0 | ||
|
|
d1bd8c09d1 | ||
|
|
861c1782fc | ||
|
|
f937749c7e | ||
|
|
6752ba8b63 | ||
|
|
dce9f2fe78 | ||
|
|
d18c3af5ac | ||
|
|
d48f864512 | ||
|
|
0976b2700c | ||
|
|
58990d9991 | ||
|
|
934b08d643 | ||
|
|
b832613fb2 | ||
|
|
3faa2fe302 | ||
|
|
a1fe600863 | ||
|
|
74af666d70 | ||
|
|
4707b4b4dd | ||
|
|
78d34542b6 | ||
|
|
141280ad0e | ||
|
|
92f978df20 | ||
|
|
e3db66734f | ||
|
|
618dcbdcce | ||
|
|
af66709363 | ||
|
|
5ebe067efd | ||
|
|
a59c98d7e6 | ||
|
|
5ff1d15b36 | ||
|
|
f4cdefc4f9 | ||
|
|
8b11be85bf | ||
|
|
6e2416910e | ||
|
|
0cd0e174bf | ||
|
|
b7153ed283 | ||
|
|
34718f6fa7 | ||
|
|
ed69c5de39 | ||
|
|
5f9f741b82 | ||
|
|
a427a82327 | ||
|
|
6244ff4ebc | ||
|
|
9007ed5ddc | ||
|
|
108d1fdfcc | ||
|
|
7678cc9d77 | ||
|
|
708ea2c977 | ||
|
|
0ebfb294ff | ||
|
|
d6d61a61fd | ||
|
|
46a0e54771 | ||
|
|
71807da876 | ||
|
|
d7b45106d8 | ||
|
|
1a39c2f6c5 | ||
|
|
7b2041f6a4 | ||
|
|
31cfba9ab8 | ||
|
|
1ead9679c3 | ||
|
|
01be21d0ed | ||
|
|
c8986fb314 | ||
|
|
951fcbe474 | ||
|
|
7483ff2c2f | ||
|
|
14fc484e37 | ||
|
|
bd2da4358b | ||
|
|
78d43e1a1c | ||
|
|
a84a6a0c55 | ||
|
|
66343e6920 | ||
|
|
6a7a8ad8d9 | ||
|
|
b8faa692cb | ||
|
|
ca99f452ac | ||
|
|
cd8d851366 | ||
|
|
16bed1861c | ||
|
|
c0909c68c8 | ||
|
|
97654f61a2 | ||
|
|
faa4d8372c | ||
|
|
da4741fcf4 | ||
|
|
3c420b2114 | ||
|
|
9c5a649157 | ||
|
|
4908e9cd1d | ||
|
|
f552115fd5 | ||
|
|
a96fc24562 | ||
|
|
a54d44df01 | ||
|
|
7afa17cd8d | ||
|
|
2d15d85310 | ||
|
|
1a577cf406 | ||
|
|
3869725742 | ||
|
|
2b286e76f1 | ||
|
|
3a791cec3b | ||
|
|
0e4557dba7 | ||
|
|
c594d390cb | ||
|
|
8a66434af9 | ||
|
|
c8ddba76f7 | ||
|
|
4831ed0535 | ||
|
|
7e4f1d6b55 | ||
|
|
1a254ee8cc | ||
|
|
429a1497ec | ||
|
|
e9bff64ea1 | ||
|
|
603f803dd5 |
16
.github/workflows/build.yml
vendored
16
.github/workflows/build.yml
vendored
@@ -209,22 +209,6 @@ jobs:
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./Dashboard/Dockerfile .
|
||||
|
||||
docker-build-haraka:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Preinstall
|
||||
run: npm run prerun
|
||||
|
||||
# build images
|
||||
- name: build docker image
|
||||
run: sudo docker build -f ./Haraka/Dockerfile .
|
||||
|
||||
|
||||
docker-build-probe:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
|
||||
68
.github/workflows/release.yml
vendored
68
.github/workflows/release.yml
vendored
@@ -70,7 +70,7 @@ jobs:
|
||||
|
||||
publish-mcp-server:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [generate-build-number]
|
||||
needs: [generate-build-number, publish-npm-packages]
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{ github.run_number }}
|
||||
NPM_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }}
|
||||
@@ -138,6 +138,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd MCP
|
||||
npm update @oneuptime/common
|
||||
npm install
|
||||
|
||||
- name: Build MCP server
|
||||
@@ -1052,67 +1053,6 @@ jobs:
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
|
||||
|
||||
haraka-docker-image-deploy:
|
||||
needs: [generate-build-number]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
oneuptime/haraka
|
||||
ghcr.io/oneuptime/haraka
|
||||
tags: |
|
||||
type=raw,value=release,enable=true
|
||||
type=semver,value=7.0.${{needs.generate-build-number.outputs.build_number}},pattern={{version}},enable=true
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Generate Dockerfile from Dockerfile.tpl
|
||||
run: npm run prerun
|
||||
|
||||
# Build and deploy haraka.
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
file: ./Haraka/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
|
||||
admin-dashboard-docker-image-deploy:
|
||||
needs: [generate-build-number]
|
||||
runs-on: ubuntu-latest
|
||||
@@ -1838,7 +1778,7 @@ jobs:
|
||||
|
||||
test-e2e-release-saas:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [open-telemetry-ingest-docker-image-deploy, copilot-docker-image-deploy, fluent-ingest-docker-image-deploy, docs-docker-image-deploy, api-reference-docker-image-deploy, workflow-docker-image-deploy, llm-docker-image-deploy, accounts-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, dashboard-docker-image-deploy, haraka-docker-image-deploy, probe-ingest-docker-image-deploy, server-monitor-ingest-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, worker-docker-image-deploy, otel-collector-docker-image-deploy, probe-docker-image-deploy, status-page-docker-image-deploy, test-docker-image-deploy, test-server-docker-image-deploy, publish-npm-packages, e2e-docker-image-deploy, helm-chart-deploy, generate-build-number, nginx-docker-image-deploy, incoming-request-ingest-docker-image-deploy]
|
||||
needs: [open-telemetry-ingest-docker-image-deploy, copilot-docker-image-deploy, fluent-ingest-docker-image-deploy, docs-docker-image-deploy, api-reference-docker-image-deploy, workflow-docker-image-deploy, llm-docker-image-deploy, accounts-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, dashboard-docker-image-deploy, probe-ingest-docker-image-deploy, server-monitor-ingest-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, worker-docker-image-deploy, otel-collector-docker-image-deploy, probe-docker-image-deploy, status-page-docker-image-deploy, test-docker-image-deploy, test-server-docker-image-deploy, publish-npm-packages, e2e-docker-image-deploy, helm-chart-deploy, generate-build-number, nginx-docker-image-deploy, incoming-request-ingest-docker-image-deploy]
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
@@ -1891,7 +1831,7 @@ jobs:
|
||||
test-e2e-release-self-hosted:
|
||||
runs-on: ubuntu-latest
|
||||
# After all the jobs runs
|
||||
needs: [open-telemetry-ingest-docker-image-deploy, publish-mcp-server, copilot-docker-image-deploy, incoming-request-ingest-docker-image-deploy, fluent-ingest-docker-image-deploy, docs-docker-image-deploy, api-reference-docker-image-deploy, workflow-docker-image-deploy, llm-docker-image-deploy, accounts-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, dashboard-docker-image-deploy, haraka-docker-image-deploy, probe-ingest-docker-image-deploy, server-monitor-ingest-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, worker-docker-image-deploy, otel-collector-docker-image-deploy, probe-docker-image-deploy, status-page-docker-image-deploy, test-docker-image-deploy, test-server-docker-image-deploy, publish-npm-packages, e2e-docker-image-deploy, helm-chart-deploy, generate-build-number, nginx-docker-image-deploy]
|
||||
needs: [open-telemetry-ingest-docker-image-deploy, publish-mcp-server, copilot-docker-image-deploy, incoming-request-ingest-docker-image-deploy, fluent-ingest-docker-image-deploy, docs-docker-image-deploy, api-reference-docker-image-deploy, workflow-docker-image-deploy, llm-docker-image-deploy, accounts-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, dashboard-docker-image-deploy, probe-ingest-docker-image-deploy, server-monitor-ingest-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, worker-docker-image-deploy, otel-collector-docker-image-deploy, probe-docker-image-deploy, status-page-docker-image-deploy, test-docker-image-deploy, test-server-docker-image-deploy, publish-npm-packages, e2e-docker-image-deploy, helm-chart-deploy, generate-build-number, nginx-docker-image-deploy]
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
|
||||
64
.github/workflows/test-release.yaml
vendored
64
.github/workflows/test-release.yaml
vendored
@@ -144,6 +144,7 @@ jobs:
|
||||
- name: Install dependencies and build
|
||||
run: |
|
||||
cd MCP
|
||||
npm update @oneuptime/common
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
@@ -1146,67 +1147,6 @@ jobs:
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
|
||||
haraka-docker-image-deploy:
|
||||
needs: generate-build-number
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Docker Meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
oneuptime/haraka
|
||||
ghcr.io/oneuptime/haraka
|
||||
tags: |
|
||||
type=raw,value=test,enable=true
|
||||
type=semver,value=7.0.${{needs.generate-build-number.outputs.build_number}}-test,pattern={{version}},enable=true
|
||||
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: latest
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Generate Dockerfile from Dockerfile.tpl
|
||||
run: npm run prerun
|
||||
|
||||
# Build and deploy haraka.
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v2.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
file: ./Haraka/Dockerfile
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
APP_VERSION=7.0.${{needs.generate-build-number.outputs.build_number}}
|
||||
|
||||
dashboard-docker-image-deploy:
|
||||
needs: generate-build-number
|
||||
runs-on: ubuntu-latest
|
||||
@@ -1768,7 +1708,7 @@ jobs:
|
||||
|
||||
test-helm-chart:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [infrastructure-agent-deploy, publish-mcp-server, llm-docker-image-deploy, publish-terraform-provider, open-telemetry-ingest-docker-image-deploy, copilot-docker-image-deploy, docs-docker-image-deploy, worker-docker-image-deploy, workflow-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, api-reference-docker-image-deploy, test-server-docker-image-deploy, test-docker-image-deploy, probe-ingest-docker-image-deploy, server-monitor-ingest-docker-image-deploy, probe-docker-image-deploy, haraka-docker-image-deploy, dashboard-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, accounts-docker-image-deploy, otel-collector-docker-image-deploy, status-page-docker-image-deploy, nginx-docker-image-deploy, e2e-docker-image-deploy, fluent-ingest-docker-image-deploy, incoming-request-ingest-docker-image-deploy]
|
||||
needs: [infrastructure-agent-deploy, publish-mcp-server, llm-docker-image-deploy, publish-terraform-provider, open-telemetry-ingest-docker-image-deploy, copilot-docker-image-deploy, docs-docker-image-deploy, worker-docker-image-deploy, workflow-docker-image-deploy, isolated-vm-docker-image-deploy, home-docker-image-deploy, api-reference-docker-image-deploy, test-server-docker-image-deploy, test-docker-image-deploy, probe-ingest-docker-image-deploy, server-monitor-ingest-docker-image-deploy, probe-docker-image-deploy, dashboard-docker-image-deploy, admin-dashboard-docker-image-deploy, app-docker-image-deploy, accounts-docker-image-deploy, otel-collector-docker-image-deploy, status-page-docker-image-deploy, nginx-docker-image-deploy, e2e-docker-image-deploy, fluent-ingest-docker-image-deploy, incoming-request-ingest-docker-image-deploy]
|
||||
env:
|
||||
CI_PIPELINE_ID: ${{github.run_number}}
|
||||
steps:
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -86,9 +86,6 @@ Backups/*.tar
|
||||
|
||||
.env
|
||||
|
||||
Haraka/dkim/keys/private_base64.txt
|
||||
Haraka/dkim/keys/public_base64.txt
|
||||
|
||||
.eslintcache
|
||||
|
||||
HelmChart/Values/*.values.yaml
|
||||
@@ -129,3 +126,4 @@ terraform-provider-example/**
|
||||
MCP/build/
|
||||
MCP/.env
|
||||
MCP/node_modules
|
||||
Dashboard/public/sw.js
|
||||
|
||||
11
APIReference/CodeExamples/DataTypes/Includes.md
Normal file
11
APIReference/CodeExamples/DataTypes/Includes.md
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"query": {
|
||||
"labels": {
|
||||
"_type": "Includes",
|
||||
"value": [
|
||||
"aaa00000-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
"bbb00000-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,16 @@ export default class ServiceHandler {
|
||||
},
|
||||
);
|
||||
|
||||
pageData.includesCode = await LocalCache.getOrSetString(
|
||||
"data-type",
|
||||
"includes",
|
||||
async () => {
|
||||
return await LocalFile.read(
|
||||
`${CodeExamplesPath}/DataTypes/Includes.md`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
pageData.lessThanOrNullCode = await LocalCache.getOrSetString(
|
||||
"data-type",
|
||||
"less-than-or-equal",
|
||||
|
||||
4
APIReference/package-lock.json
generated
4
APIReference/package-lock.json
generated
@@ -55,6 +55,7 @@
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"acme-client": "^5.3.0",
|
||||
"airtable": "^0.12.2",
|
||||
"axios": "^1.7.2",
|
||||
@@ -74,7 +75,6 @@
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^12.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
@@ -118,6 +118,7 @@
|
||||
"universal-cookie": "^7.2.1",
|
||||
"use-async-effect": "^2.2.6",
|
||||
"uuid": "^8.3.2",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.25.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -132,7 +133,6 @@
|
||||
"@types/jest": "^28.1.4",
|
||||
"@types/json2csv": "^5.0.3",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/node": "^17.0.45",
|
||||
"@types/node-cron": "^3.0.7",
|
||||
"@types/nodemailer": "^6.4.7",
|
||||
|
||||
@@ -395,5 +395,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 id="example-using-cursors" class="scroll-mt-24">
|
||||
Inlcudes
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 items-start gap-x-16 gap-y-10 xl:max-w-none xl:grid-cols-2">
|
||||
<div class="[&>:first-child]:mt-0 [&>:last-child]:mb-0">
|
||||
<p>
|
||||
Includes will get objects that match any of the values in the array. It is used to
|
||||
filter objects that have a field that matches any of the values in the array. For example, if you
|
||||
want to get all objects that have a label with ID `aaa00000-aaaa-aaaa-aaaa-aaaaaaaaaaaa` or
|
||||
`bbb00000-bbbb-bbbb-bbbb-bbbbbbbbbbbb`, you can use the `includes` query type.
|
||||
</p>
|
||||
|
||||
<div class="my-6">
|
||||
<ul role="list"
|
||||
class="m-0 max-w-[calc(theme(maxWidth.lg)-theme(spacing.8))] list-none divide-y divide-zinc-900/5 p-0 ">
|
||||
<li class="m-0 px-0 py-4 first:pt-0 last:pb-0">
|
||||
<dl class="m-0 flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<dt class="sr-only">Query</dt>
|
||||
<dd><code class="inline-code">query</code></dd>
|
||||
<dt class="sr-only">Type</dt>
|
||||
<dd class="font-mono text-xs text-zinc-400 ">Query</dd>
|
||||
<dt class="sr-only">Description</dt>
|
||||
<dd class="w-full flex-none [&>:first-child]:mt-0 [&>:last-child]:mb-0">
|
||||
<p>Here is an example of a less than or equal query</p>
|
||||
</dd>
|
||||
</dl>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="[&>:first-child]:mt-0 [&>:last-child]:mb-0">
|
||||
<%- include('../partials/code', {title: "Example Not EqualTo Request Body" , requestUrl: "" , requestType: "" ,
|
||||
code: pageData.includesCode }) -%>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</article>
|
||||
</main>
|
||||
4
Accounts/package-lock.json
generated
4
Accounts/package-lock.json
generated
@@ -59,6 +59,7 @@
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"acme-client": "^5.3.0",
|
||||
"airtable": "^0.12.2",
|
||||
"axios": "^1.7.2",
|
||||
@@ -78,7 +79,6 @@
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^12.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
@@ -122,6 +122,7 @@
|
||||
"universal-cookie": "^7.2.1",
|
||||
"use-async-effect": "^2.2.6",
|
||||
"uuid": "^8.3.2",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.25.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -136,7 +137,6 @@
|
||||
"@types/jest": "^28.1.4",
|
||||
"@types/json2csv": "^5.0.3",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/node": "^17.0.45",
|
||||
"@types/node-cron": "^3.0.7",
|
||||
"@types/nodemailer": "^6.4.7",
|
||||
|
||||
4
AdminDashboard/package-lock.json
generated
4
AdminDashboard/package-lock.json
generated
@@ -58,6 +58,7 @@
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"acme-client": "^5.3.0",
|
||||
"airtable": "^0.12.2",
|
||||
"axios": "^1.7.2",
|
||||
@@ -77,7 +78,6 @@
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^12.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
@@ -121,6 +121,7 @@
|
||||
"universal-cookie": "^7.2.1",
|
||||
"use-async-effect": "^2.2.6",
|
||||
"uuid": "^8.3.2",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.25.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -135,7 +136,6 @@
|
||||
"@types/jest": "^28.1.4",
|
||||
"@types/json2csv": "^5.0.3",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/node": "^17.0.45",
|
||||
"@types/node-cron": "^3.0.7",
|
||||
"@types/nodemailer": "^6.4.7",
|
||||
|
||||
@@ -67,7 +67,7 @@ const DashboardFooter: () => JSX.Element = () => {
|
||||
return (
|
||||
<>
|
||||
<Footer
|
||||
className="bg-white h-16 inset-x-0 bottom-0 px-8"
|
||||
className="bg-white px-8"
|
||||
copyright="HackerBay, Inc."
|
||||
links={[
|
||||
{
|
||||
|
||||
@@ -2,36 +2,33 @@ import PageMap from "../../Utils/PageMap";
|
||||
import RouteMap, { RouteUtil } from "../../Utils/RouteMap";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import IconProp from "Common/Types/Icon/IconProp";
|
||||
import NavBar from "Common/UI/Components/Navbar/NavBar";
|
||||
import NavBarItem from "Common/UI/Components/Navbar/NavBarItem";
|
||||
import NavBar, { NavItem } from "Common/UI/Components/Navbar/NavBar";
|
||||
import React, { FunctionComponent, ReactElement } from "react";
|
||||
|
||||
const DashboardNavbar: FunctionComponent = (): ReactElement => {
|
||||
return (
|
||||
<NavBar>
|
||||
<NavBarItem
|
||||
title="Users"
|
||||
icon={IconProp.User}
|
||||
route={RouteUtil.populateRouteParams(RouteMap[PageMap.USERS] as Route)}
|
||||
></NavBarItem>
|
||||
// Build the navigation items
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
id: "users-nav-bar-item",
|
||||
title: "Users",
|
||||
icon: IconProp.User,
|
||||
route: RouteUtil.populateRouteParams(RouteMap[PageMap.USERS] as Route),
|
||||
},
|
||||
{
|
||||
id: "projects-nav-bar-item",
|
||||
title: "Projects",
|
||||
icon: IconProp.Folder,
|
||||
route: RouteUtil.populateRouteParams(RouteMap[PageMap.PROJECTS] as Route),
|
||||
},
|
||||
{
|
||||
id: "settings-nav-bar-item",
|
||||
title: "Settings",
|
||||
icon: IconProp.Settings,
|
||||
route: RouteUtil.populateRouteParams(RouteMap[PageMap.SETTINGS] as Route),
|
||||
},
|
||||
];
|
||||
|
||||
<NavBarItem
|
||||
title="Projects"
|
||||
icon={IconProp.Folder}
|
||||
route={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.PROJECTS] as Route,
|
||||
)}
|
||||
></NavBarItem>
|
||||
|
||||
<NavBarItem
|
||||
title="Settings"
|
||||
icon={IconProp.Settings}
|
||||
route={RouteUtil.populateRouteParams(
|
||||
RouteMap[PageMap.SETTINGS] as Route,
|
||||
)}
|
||||
></NavBarItem>
|
||||
</NavBar>
|
||||
);
|
||||
return <NavBar items={navItems} />;
|
||||
};
|
||||
|
||||
export default DashboardNavbar;
|
||||
|
||||
@@ -241,6 +241,7 @@ const Projects: FunctionComponent = (): ReactElement => {
|
||||
},
|
||||
title: "Created At",
|
||||
type: FieldType.DateTime,
|
||||
hideOnMobile: true,
|
||||
},
|
||||
]}
|
||||
userPreferencesKey="admin-projects-table"
|
||||
|
||||
@@ -21,7 +21,7 @@ import React, { FunctionComponent, ReactElement, useEffect } from "react";
|
||||
|
||||
const Settings: FunctionComponent = (): ReactElement => {
|
||||
const [emailServerType, setemailServerType] = React.useState<EmailServerType>(
|
||||
EmailServerType.Internal,
|
||||
EmailServerType.CustomSMTP,
|
||||
);
|
||||
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(true);
|
||||
@@ -43,7 +43,7 @@ const Settings: FunctionComponent = (): ReactElement => {
|
||||
|
||||
if (globalConfig) {
|
||||
setemailServerType(
|
||||
globalConfig.emailServerType || EmailServerType.Internal,
|
||||
globalConfig.emailServerType || EmailServerType.CustomSMTP,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ const Settings: FunctionComponent = (): ReactElement => {
|
||||
/>
|
||||
|
||||
<CardModelDetail
|
||||
name="Internal SMTP Settings"
|
||||
name="Email Server Settings"
|
||||
cardProps={{
|
||||
title: "Email Server Settings",
|
||||
description:
|
||||
@@ -172,7 +172,7 @@ const Settings: FunctionComponent = (): ReactElement => {
|
||||
cardProps={{
|
||||
title: "Custom Email and SMTP Settings",
|
||||
description:
|
||||
"If you have not enabled Internal SMTP server to send emails. Please configure your SMTP server here.",
|
||||
"Please configure your SMTP server here to send emails.",
|
||||
}}
|
||||
isEditable={true}
|
||||
editButtonText="Edit SMTP Config"
|
||||
|
||||
@@ -54,6 +54,7 @@ const Settings: FunctionComponent = (): ReactElement => {
|
||||
title="Need help with setting up Global Probes?"
|
||||
description="Here is a guide which will help you get set up"
|
||||
link={Route.fromString("/docs/probe/custom-probe")}
|
||||
hideOnMobile={true}
|
||||
/>
|
||||
|
||||
<ModelTable<Probe>
|
||||
@@ -174,6 +175,7 @@ const Settings: FunctionComponent = (): ReactElement => {
|
||||
noValueMessage: "-",
|
||||
title: "Description",
|
||||
type: FieldType.LongText,
|
||||
hideOnMobile: true,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
@@ -181,6 +183,7 @@ const Settings: FunctionComponent = (): ReactElement => {
|
||||
},
|
||||
title: "Status",
|
||||
type: FieldType.Text,
|
||||
|
||||
getElement: (item: Probe): ReactElement => {
|
||||
if (
|
||||
item &&
|
||||
|
||||
@@ -116,6 +116,7 @@ const Users: FunctionComponent = (): ReactElement => {
|
||||
},
|
||||
title: "Email Verified",
|
||||
type: FieldType.Boolean,
|
||||
hideOnMobile: true,
|
||||
},
|
||||
{
|
||||
field: {
|
||||
@@ -123,6 +124,7 @@ const Users: FunctionComponent = (): ReactElement => {
|
||||
},
|
||||
title: "Created At",
|
||||
type: FieldType.DateTime,
|
||||
hideOnMobile: true,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@ import TelemetryAPI from "Common/Server/API/TelemetryAPI";
|
||||
import ProbeAPI from "Common/Server/API/ProbeAPI";
|
||||
import ProjectAPI from "Common/Server/API/ProjectAPI";
|
||||
import ProjectSsoAPI from "Common/Server/API/ProjectSSO";
|
||||
import ProjectScimAPI from "Common/Server/API/ProjectSCIM";
|
||||
|
||||
// Import API
|
||||
import ResellerPlanAPI from "Common/Server/API/ResellerPlanAPI";
|
||||
@@ -29,6 +30,7 @@ import MonitorTest from "Common/Models/DatabaseModels/MonitorTest";
|
||||
import UserEmailAPI from "Common/Server/API/UserEmailAPI";
|
||||
import UserNotificationLogTimelineAPI from "Common/Server/API/UserOnCallLogTimelineAPI";
|
||||
import UserSMSAPI from "Common/Server/API/UserSmsAPI";
|
||||
import UserPushAPI from "Common/Server/API/UserPushAPI";
|
||||
import ApiKeyPermissionService, {
|
||||
Service as ApiKeyPermissionServiceType,
|
||||
} from "Common/Server/Services/ApiKeyPermissionService";
|
||||
@@ -1572,6 +1574,10 @@ const BaseAPIFeatureSet: FeatureSet = {
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new ProjectSsoAPI().getRouter(),
|
||||
);
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new ProjectScimAPI().getRouter(),
|
||||
);
|
||||
app.use(
|
||||
`/${APP_NAME.toLocaleLowerCase()}`,
|
||||
new ResellerPlanAPI().getRouter(),
|
||||
@@ -1608,6 +1614,7 @@ const BaseAPIFeatureSet: FeatureSet = {
|
||||
);
|
||||
app.use(`/${APP_NAME.toLocaleLowerCase()}`, new UserEmailAPI().getRouter());
|
||||
app.use(`/${APP_NAME.toLocaleLowerCase()}`, new UserSMSAPI().getRouter());
|
||||
app.use(`/${APP_NAME.toLocaleLowerCase()}`, new UserPushAPI().getRouter());
|
||||
app.use(`/${APP_NAME.toLocaleLowerCase()}`, new ProbeAPI().getRouter());
|
||||
|
||||
app.use(
|
||||
|
||||
512
App/FeatureSet/Identity/API/ProjectSCIM.ts
Normal file
512
App/FeatureSet/Identity/API/ProjectSCIM.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
import SCIMUtil from "../Utils/SCIM";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import BadRequestException from "Common/Types/Exception/BadRequestException";
|
||||
import Exception from "Common/Types/Exception/Exception";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import ProjectScimService from "Common/Server/Services/ProjectScimService";
|
||||
import UserService from "Common/Server/Services/UserService";
|
||||
import TeamMemberService from "Common/Server/Services/TeamMemberService";
|
||||
import Select from "Common/Server/Types/Database/Select";
|
||||
import Express, {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
ExpressRouter,
|
||||
} from "Common/Server/Utils/Express";
|
||||
import logger from "Common/Server/Utils/Logger";
|
||||
import Response from "Common/Server/Utils/Response";
|
||||
import ProjectScim from "Common/Models/DatabaseModels/ProjectScim";
|
||||
import Team from "Common/Models/DatabaseModels/Team";
|
||||
import TeamMember from "Common/Models/DatabaseModels/TeamMember";
|
||||
import User from "Common/Models/DatabaseModels/User";
|
||||
import Email from "Common/Types/Email";
|
||||
import Name from "Common/Types/Name";
|
||||
|
||||
const router: ExpressRouter = Express.getRouter();
|
||||
|
||||
// Test SCIM connection
|
||||
router.post(
|
||||
"/test-connection",
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
const projectId: ObjectID = req.params["projectId"]
|
||||
? new ObjectID(req.params["projectId"])
|
||||
: req.body["projectId"];
|
||||
|
||||
if (!projectId) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("Project ID is required"),
|
||||
);
|
||||
}
|
||||
|
||||
const scimBaseUrl: string = req.body["scimBaseUrl"];
|
||||
const bearerToken: string = req.body["bearerToken"];
|
||||
|
||||
if (!scimBaseUrl || !bearerToken) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM Base URL and Bearer Token are required"),
|
||||
);
|
||||
}
|
||||
|
||||
const isConnected: boolean = await SCIMUtil.testConnection(
|
||||
URL.fromString(scimBaseUrl),
|
||||
bearerToken,
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
isConnected,
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.sendErrorResponse(req, res, err as Exception);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Sync users from SCIM provider
|
||||
router.post(
|
||||
"/:projectScimId/sync-users",
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
const projectScimId: ObjectID = new ObjectID(req.params["projectScimId"]!);
|
||||
|
||||
const projectScim: ProjectScim | null = await ProjectScimService.findOneBy({
|
||||
query: {
|
||||
_id: projectScimId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
scimBaseUrl: true,
|
||||
bearerToken: true,
|
||||
projectId: true,
|
||||
teams: {
|
||||
_id: true,
|
||||
name: true,
|
||||
} as Select<Team>,
|
||||
autoProvisionUsers: true,
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!projectScim) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration not found"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectScim.scimBaseUrl || !projectScim.bearerToken) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration is incomplete"),
|
||||
);
|
||||
}
|
||||
|
||||
// Get users from SCIM provider
|
||||
const scimUsers = await SCIMUtil.listUsers(
|
||||
projectScim.scimBaseUrl,
|
||||
projectScim.bearerToken,
|
||||
);
|
||||
|
||||
const syncResults = {
|
||||
totalScimUsers: scimUsers.totalResults,
|
||||
processedUsers: 0,
|
||||
createdUsers: 0,
|
||||
updatedUsers: 0,
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
// Process each SCIM user
|
||||
for (const scimUser of scimUsers.Resources) {
|
||||
try {
|
||||
syncResults.processedUsers++;
|
||||
|
||||
if (!scimUser.emails || scimUser.emails.length === 0) {
|
||||
syncResults.errors.push(`User ${scimUser.userName} has no email address`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const primaryEmail = scimUser.emails.find(e => e.primary) || scimUser.emails[0];
|
||||
if (!primaryEmail) {
|
||||
syncResults.errors.push(`User ${scimUser.userName} has no valid email address`);
|
||||
continue;
|
||||
}
|
||||
const email = new Email(primaryEmail.value);
|
||||
|
||||
// Check if user exists
|
||||
let user: User | null = await UserService.findOneBy({
|
||||
query: { email },
|
||||
select: {
|
||||
_id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (user && projectScim.autoProvisionUsers) {
|
||||
// Update existing user - only update if displayName exists
|
||||
if (scimUser.displayName) {
|
||||
await UserService.updateOneById({
|
||||
id: user.id!,
|
||||
data: {
|
||||
name: new Name(scimUser.displayName),
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
syncResults.updatedUsers++;
|
||||
} else if (!user && projectScim.autoProvisionUsers) {
|
||||
// Create new user
|
||||
const newUser = new User();
|
||||
newUser.name = new Name(scimUser.displayName || scimUser.userName);
|
||||
newUser.email = email;
|
||||
newUser.isEmailVerified = true;
|
||||
|
||||
const createdUser = await UserService.create({
|
||||
data: newUser,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (createdUser) {
|
||||
syncResults.createdUsers++;
|
||||
|
||||
// Add user to teams if specified
|
||||
if (projectScim.teams && projectScim.teams.length > 0) {
|
||||
for (const team of projectScim.teams) {
|
||||
try {
|
||||
// Create a new TeamMember instance
|
||||
const newTeamMember = new TeamMember();
|
||||
newTeamMember.teamId = team.id!;
|
||||
newTeamMember.userId = createdUser.id!;
|
||||
newTeamMember.projectId = projectScim.projectId!;
|
||||
newTeamMember.hasAcceptedInvitation = true;
|
||||
|
||||
await TeamMemberService.create({
|
||||
data: newTeamMember,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
} catch (teamError) {
|
||||
logger.error(`Error adding user to team: ${teamError}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (userError) {
|
||||
syncResults.errors.push(`Error processing user ${scimUser.userName}: ${userError}`);
|
||||
logger.error(`Error processing SCIM user: ${userError}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, syncResults);
|
||||
} catch (err) {
|
||||
return Response.sendErrorResponse(req, res, err as Exception);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Deprovision users from SCIM provider
|
||||
router.post(
|
||||
"/:projectScimId/deprovision-users",
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
const projectScimId: ObjectID = new ObjectID(req.params["projectScimId"]!);
|
||||
|
||||
const projectScim: ProjectScim | null = await ProjectScimService.findOneBy({
|
||||
query: {
|
||||
_id: projectScimId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
scimBaseUrl: true,
|
||||
bearerToken: true,
|
||||
projectId: true,
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!projectScim) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration not found"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectScim.autoDeprovisionUsers) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("Auto-deprovisioning is not enabled"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectScim.scimBaseUrl || !projectScim.bearerToken) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration is incomplete"),
|
||||
);
|
||||
}
|
||||
|
||||
// Get all users from the project by finding team members
|
||||
const teamMembers = await TeamMemberService.findBy({
|
||||
query: {
|
||||
projectId: projectScim.projectId!,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
user: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
} as Select<User>,
|
||||
},
|
||||
limit: 1000,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Extract unique users from team members
|
||||
const uniqueUserIds = new Set<string>();
|
||||
const projectUsers: User[] = teamMembers
|
||||
.map(tm => tm.user!)
|
||||
.filter(user => {
|
||||
if (user && user.id && !uniqueUserIds.has(user.id.toString())) {
|
||||
uniqueUserIds.add(user.id.toString());
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const deprovisionResults = {
|
||||
totalProjectUsers: projectUsers.length,
|
||||
processedUsers: 0,
|
||||
deprovisionedUsers: 0,
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
// Process each project user
|
||||
for (const user of projectUsers) {
|
||||
try {
|
||||
deprovisionResults.processedUsers++;
|
||||
|
||||
if (!user.email) {
|
||||
deprovisionResults.errors.push(`User ${user.id} has no email address`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if user exists in SCIM provider
|
||||
const scimUser = await SCIMUtil.getUserByUserName(
|
||||
projectScim.scimBaseUrl,
|
||||
projectScim.bearerToken,
|
||||
user.email.toString(),
|
||||
);
|
||||
|
||||
if (!scimUser) {
|
||||
// User not found in SCIM provider, deprovision from project
|
||||
try {
|
||||
// Remove user from all teams in this project
|
||||
const teamMembers = await TeamMemberService.findBy({
|
||||
query: {
|
||||
userId: user.id!,
|
||||
projectId: projectScim.projectId!,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
},
|
||||
limit: 1000,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const teamMember of teamMembers) {
|
||||
await TeamMemberService.deleteOneById({
|
||||
id: teamMember.id!,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
deprovisionResults.deprovisionedUsers++;
|
||||
logger.info(`Deprovisioned user ${user.email} from project due to SCIM removal`);
|
||||
} catch (deprovisionError) {
|
||||
deprovisionResults.errors.push(`Error deprovisioning user ${user.email}: ${deprovisionError}`);
|
||||
logger.error(`Error deprovisioning user: ${deprovisionError}`);
|
||||
}
|
||||
}
|
||||
} catch (userError) {
|
||||
deprovisionResults.errors.push(`Error processing user ${user.email}: ${userError}`);
|
||||
logger.error(`Error processing user for deprovisioning: ${userError}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, deprovisionResults);
|
||||
} catch (err) {
|
||||
return Response.sendErrorResponse(req, res, err as Exception);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Deprovision specific user
|
||||
router.post(
|
||||
"/:projectScimId/deprovision-user",
|
||||
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
|
||||
try {
|
||||
const projectScimId: ObjectID = new ObjectID(req.params["projectScimId"]!);
|
||||
|
||||
const projectScim: ProjectScim | null = await ProjectScimService.findOneBy({
|
||||
query: {
|
||||
_id: projectScimId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
scimBaseUrl: true,
|
||||
bearerToken: true,
|
||||
projectId: true,
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!projectScim) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration not found"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectScim.autoDeprovisionUsers) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("Auto-deprovisioning is not enabled"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectScim.scimBaseUrl || !projectScim.bearerToken) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration is incomplete"),
|
||||
);
|
||||
}
|
||||
|
||||
const userId: ObjectID = new ObjectID(req.body["userId"]);
|
||||
|
||||
// Get user details
|
||||
const user: User | null = await UserService.findOneBy({
|
||||
query: {
|
||||
_id: userId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("User not found"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("User has no email address"),
|
||||
);
|
||||
}
|
||||
|
||||
// Find user in SCIM provider first
|
||||
const scimUser = await SCIMUtil.getUserByUserName(
|
||||
projectScim.scimBaseUrl,
|
||||
projectScim.bearerToken,
|
||||
user.email.toString(),
|
||||
);
|
||||
|
||||
if (scimUser) {
|
||||
// Remove user from SCIM provider (deactivate instead of delete to preserve audit trail)
|
||||
await SCIMUtil.deactivateUser(
|
||||
projectScim.scimBaseUrl,
|
||||
projectScim.bearerToken,
|
||||
scimUser.id!,
|
||||
);
|
||||
logger.info(`Deactivated user ${user.email} in SCIM provider`);
|
||||
} else {
|
||||
logger.warn(`User ${user.email} not found in SCIM provider, proceeding with local removal only`);
|
||||
}
|
||||
|
||||
// Remove user from all teams in this project
|
||||
const teamMembers = await TeamMemberService.findBy({
|
||||
query: {
|
||||
userId: user.id!,
|
||||
projectId: projectScim.projectId!,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
},
|
||||
limit: 1000,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const teamMember of teamMembers) {
|
||||
await TeamMemberService.deleteOneById({
|
||||
id: teamMember.id!,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`Removed user ${user.email} from ${teamMembers.length} teams in project`);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
success: true,
|
||||
message: "User deprovisioned successfully",
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.sendErrorResponse(req, res, err as Exception);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -1,10 +1,8 @@
|
||||
import Hostname from "Common/Types/API/Hostname";
|
||||
import TwilioConfig from "Common/Types/CallAndSMS/TwilioConfig";
|
||||
import Email from "Common/Types/Email";
|
||||
import EmailServer from "Common/Types/Email/EmailServer";
|
||||
import BadDataException from "Common/Types/Exception/BadDataException";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import Port from "Common/Types/Port";
|
||||
import { AdminDashboardClientURL } from "Common/Server/EnvironmentConfig";
|
||||
import GlobalConfigService from "Common/Server/Services/GlobalConfigService";
|
||||
import GlobalConfig, {
|
||||
@@ -12,24 +10,6 @@ import GlobalConfig, {
|
||||
} from "Common/Models/DatabaseModels/GlobalConfig";
|
||||
import Phone from "Common/Types/Phone";
|
||||
|
||||
export const InternalSmtpPassword: string =
|
||||
process.env["INTERNAL_SMTP_PASSWORD"] || "";
|
||||
|
||||
export const InternalSmtpHost: Hostname = new Hostname(
|
||||
process.env["INTERNAL_SMTP_HOST"] || "haraka",
|
||||
);
|
||||
|
||||
export const InternalSmtpPort: Port = new Port(2525);
|
||||
|
||||
export const InternalSmtpSecure: boolean = false;
|
||||
|
||||
export const InternalSmtpEmail: Email = new Email(
|
||||
process.env["INTERNAL_SMTP_EMAIL"] || "noreply@oneuptime.com",
|
||||
);
|
||||
|
||||
export const InternalSmtpFromName: string =
|
||||
process.env["INTERNAL_SMTP_FROM_NAME"] || "OneUptime";
|
||||
|
||||
type GetGlobalSMTPConfig = () => Promise<EmailServer | null>;
|
||||
|
||||
export const getGlobalSMTPConfig: GetGlobalSMTPConfig =
|
||||
@@ -132,10 +112,10 @@ export const getEmailServerType: GetEmailServerTypeFunction =
|
||||
});
|
||||
|
||||
if (!globalConfig) {
|
||||
return EmailServerType.Internal;
|
||||
return EmailServerType.CustomSMTP;
|
||||
}
|
||||
|
||||
return globalConfig.emailServerType || EmailServerType.Internal;
|
||||
return globalConfig.emailServerType || EmailServerType.CustomSMTP;
|
||||
};
|
||||
|
||||
export interface SendGridConfig {
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
InternalSmtpEmail,
|
||||
InternalSmtpFromName,
|
||||
InternalSmtpHost,
|
||||
InternalSmtpPassword,
|
||||
InternalSmtpPort,
|
||||
InternalSmtpSecure,
|
||||
SendGridConfig,
|
||||
getEmailServerType,
|
||||
getGlobalSMTPConfig,
|
||||
@@ -37,6 +31,98 @@ import nodemailer, { Transporter } from "nodemailer";
|
||||
import Path from "path";
|
||||
import * as tls from "tls";
|
||||
|
||||
// Connection pool for email transporters
|
||||
class TransporterPool {
|
||||
private static pools: Map<string, Transporter> = new Map();
|
||||
private static semaphore: Map<string, number> = new Map();
|
||||
private static readonly MAX_CONCURRENT_CONNECTIONS = 100;
|
||||
|
||||
public static getTransporter(
|
||||
emailServer: EmailServer,
|
||||
options: { timeout?: number | undefined },
|
||||
): Transporter {
|
||||
const key: string = `${emailServer.host.toString()}:${emailServer.port.toNumber()}:${emailServer.username || "noauth"}`;
|
||||
|
||||
if (!this.pools.has(key)) {
|
||||
const transporter: Transporter = this.createTransporter(
|
||||
emailServer,
|
||||
options,
|
||||
);
|
||||
this.pools.set(key, transporter);
|
||||
this.semaphore.set(key, 0);
|
||||
}
|
||||
|
||||
return this.pools.get(key)!;
|
||||
}
|
||||
|
||||
private static createTransporter(
|
||||
emailServer: EmailServer,
|
||||
options: { timeout?: number | undefined },
|
||||
): Transporter {
|
||||
let tlsOptions: tls.ConnectionOptions | undefined = undefined;
|
||||
|
||||
if (!emailServer.secure) {
|
||||
tlsOptions = {
|
||||
rejectUnauthorized: false,
|
||||
};
|
||||
}
|
||||
|
||||
return nodemailer.createTransport({
|
||||
host: emailServer.host.toString(),
|
||||
port: emailServer.port.toNumber(),
|
||||
secure: emailServer.secure,
|
||||
tls: tlsOptions,
|
||||
auth:
|
||||
emailServer.username && emailServer.password
|
||||
? {
|
||||
user: emailServer.username,
|
||||
pass: emailServer.password,
|
||||
}
|
||||
: undefined,
|
||||
connectionTimeout: options.timeout || 60000,
|
||||
pool: true, // Enable connection pooling
|
||||
maxConnections: this.MAX_CONCURRENT_CONNECTIONS,
|
||||
});
|
||||
}
|
||||
|
||||
public static async acquireConnection(
|
||||
emailServer: EmailServer,
|
||||
): Promise<void> {
|
||||
const key: string = `${emailServer.host.toString()}:${emailServer.port.toNumber()}:${emailServer.username || "noauth"}`;
|
||||
|
||||
while ((this.semaphore.get(key) || 0) >= this.MAX_CONCURRENT_CONNECTIONS) {
|
||||
await new Promise<void>((resolve: () => void) => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
}
|
||||
|
||||
this.semaphore.set(key, (this.semaphore.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
public static releaseConnection(emailServer: EmailServer): void {
|
||||
const key: string = `${emailServer.host.toString()}:${emailServer.port.toNumber()}:${emailServer.username || "noauth"}`;
|
||||
const current: number = this.semaphore.get(key) || 0;
|
||||
this.semaphore.set(key, Math.max(0, current - 1));
|
||||
}
|
||||
|
||||
public static async cleanup(): Promise<void> {
|
||||
const closePromises: Promise<void>[] = [];
|
||||
|
||||
for (const [, transporter] of this.pools) {
|
||||
closePromises.push(
|
||||
new Promise<void>((resolve: () => void) => {
|
||||
transporter.close();
|
||||
resolve();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(closePromises);
|
||||
this.pools.clear();
|
||||
this.semaphore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export default class MailService {
|
||||
public static isSMTPConfigValid(obj: JSONObject): boolean {
|
||||
if (!obj["SMTP_USERNAME"]) {
|
||||
@@ -110,19 +196,6 @@ export default class MailService {
|
||||
};
|
||||
}
|
||||
|
||||
public static getInternalEmailServer(): EmailServer {
|
||||
return {
|
||||
id: undefined,
|
||||
username: InternalSmtpEmail.toString(),
|
||||
password: InternalSmtpPassword,
|
||||
host: InternalSmtpHost,
|
||||
port: InternalSmtpPort,
|
||||
fromEmail: InternalSmtpEmail,
|
||||
fromName: InternalSmtpFromName,
|
||||
secure: InternalSmtpSecure,
|
||||
};
|
||||
}
|
||||
|
||||
public static async getGlobalFromEmail(): Promise<Email> {
|
||||
const emailServer: EmailServer | null = await this.getGlobalSmtpSettings();
|
||||
|
||||
@@ -205,30 +278,7 @@ export default class MailService {
|
||||
timeout?: number | undefined;
|
||||
},
|
||||
): Transporter {
|
||||
let tlsOptions: tls.ConnectionOptions | undefined = undefined;
|
||||
|
||||
if (!emailServer.secure) {
|
||||
tlsOptions = {
|
||||
rejectUnauthorized: false,
|
||||
};
|
||||
}
|
||||
|
||||
const privateMailer: Transporter = nodemailer.createTransport({
|
||||
host: emailServer.host.toString(),
|
||||
port: emailServer.port.toNumber(),
|
||||
secure: emailServer.secure,
|
||||
tls: tlsOptions,
|
||||
auth:
|
||||
emailServer.username && emailServer.password
|
||||
? {
|
||||
user: emailServer.username,
|
||||
pass: emailServer.password,
|
||||
}
|
||||
: undefined,
|
||||
connectionTimeout: options.timeout || undefined,
|
||||
});
|
||||
|
||||
return privateMailer;
|
||||
return TransporterPool.getTransporter(emailServer, options);
|
||||
}
|
||||
|
||||
private static async transportMail(
|
||||
@@ -242,12 +292,49 @@ export default class MailService {
|
||||
const mailer: Transporter = this.createMailer(options.emailServer, {
|
||||
timeout: options.timeout,
|
||||
});
|
||||
await mailer.sendMail({
|
||||
from: `${options.emailServer.fromName.toString()} <${options.emailServer.fromEmail.toString()}>`,
|
||||
to: mail.toEmail.toString(),
|
||||
subject: mail.subject,
|
||||
html: mail.body,
|
||||
});
|
||||
|
||||
let lastError: any;
|
||||
const maxRetries: number = 3;
|
||||
|
||||
// Acquire connection slot to prevent overwhelming the server
|
||||
await TransporterPool.acquireConnection(options.emailServer);
|
||||
|
||||
try {
|
||||
for (let attempt: number = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await mailer.sendMail({
|
||||
from: `${options.emailServer.fromName.toString()} <${options.emailServer.fromEmail.toString()}>`,
|
||||
to: mail.toEmail.toString(),
|
||||
subject: mail.subject,
|
||||
html: mail.body,
|
||||
});
|
||||
return; // Success, exit the function
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
logger.error(`Email send attempt ${attempt} failed:`);
|
||||
logger.error(error);
|
||||
|
||||
if (attempt === maxRetries) {
|
||||
break; // Don't wait after the last attempt
|
||||
}
|
||||
|
||||
// Wait before retrying with jitter to prevent thundering herd
|
||||
const baseWaitTime: number = Math.pow(2, attempt - 1) * 1000;
|
||||
const jitter: number = Math.random() * 1000; // Add up to 1 second of jitter
|
||||
const waitTime: number = baseWaitTime + jitter;
|
||||
|
||||
await new Promise<void>((resolve: (value: void) => void) => {
|
||||
setTimeout(resolve, waitTime);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here, all retries failed
|
||||
throw lastError;
|
||||
} finally {
|
||||
// Always release the connection slot
|
||||
TransporterPool.releaseConnection(options.emailServer);
|
||||
}
|
||||
}
|
||||
|
||||
public static async send(
|
||||
@@ -434,17 +521,6 @@ export default class MailService {
|
||||
options.emailServer = globalEmailServer;
|
||||
}
|
||||
|
||||
if (
|
||||
emailServerType === EmailServerType.Internal &&
|
||||
(!options || !options.emailServer)
|
||||
) {
|
||||
if (!options) {
|
||||
options = {};
|
||||
}
|
||||
|
||||
options.emailServer = this.getInternalEmailServer();
|
||||
}
|
||||
|
||||
if (options && options.emailServer && emailLog) {
|
||||
emailLog.fromEmail = options.emailServer.fromEmail;
|
||||
}
|
||||
@@ -518,4 +594,8 @@ export default class MailService {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public static async cleanup(): Promise<void> {
|
||||
await TransporterPool.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
4
App/package-lock.json
generated
4
App/package-lock.json
generated
@@ -65,6 +65,7 @@
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"acme-client": "^5.3.0",
|
||||
"airtable": "^0.12.2",
|
||||
"axios": "^1.7.2",
|
||||
@@ -84,7 +85,6 @@
|
||||
"json5": "^2.2.3",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^12.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
@@ -128,6 +128,7 @@
|
||||
"universal-cookie": "^7.2.1",
|
||||
"use-async-effect": "^2.2.6",
|
||||
"uuid": "^8.3.2",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "^3.25.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -142,7 +143,6 @@
|
||||
"@types/jest": "^28.1.4",
|
||||
"@types/json2csv": "^5.0.3",
|
||||
"@types/jsonwebtoken": "^8.5.9",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/node": "^17.0.45",
|
||||
"@types/node-cron": "^3.0.7",
|
||||
"@types/nodemailer": "^6.4.7",
|
||||
|
||||
@@ -64,6 +64,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "AlertOwnerTeam",
|
||||
})
|
||||
@Index(["alertId", "teamId", "projectId"])
|
||||
export default class AlertOwnerTeam extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -63,6 +63,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "AlertOwnerUser",
|
||||
})
|
||||
@Index(["alertId", "userId", "projectId"])
|
||||
export default class AlertOwnerUser extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -76,6 +76,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "AlertSeverity",
|
||||
})
|
||||
@Index(["projectId", "order"])
|
||||
export default class AlertSeverity extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -76,6 +76,10 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "AlertState",
|
||||
})
|
||||
@Index(["projectId", "isCreatedState"])
|
||||
@Index(["projectId", "isResolvedState"])
|
||||
@Index(["projectId", "isAcknowledgedState"])
|
||||
@Index(["projectId", "order"])
|
||||
export default class AlertState extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -60,6 +60,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "AlertStateTimeline",
|
||||
})
|
||||
@Index(["alertId", "startsAt"])
|
||||
@TableMetadata({
|
||||
tableName: "AlertStateTimeline",
|
||||
singularName: "Alert State Timeline",
|
||||
|
||||
@@ -17,7 +17,6 @@ import Port from "../../Types/Port";
|
||||
import { Column, Entity } from "typeorm";
|
||||
|
||||
export enum EmailServerType {
|
||||
Internal = "Internal",
|
||||
Sendgrid = "Sendgrid",
|
||||
CustomSMTP = "Custom SMTP",
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "IncidentOwnerTeam",
|
||||
})
|
||||
@Index(["incidentId", "teamId", "projectId"])
|
||||
export default class IncidentOwnerTeam extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -63,6 +63,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "IncidentOwnerUser",
|
||||
})
|
||||
@Index(["incidentId", "userId", "projectId"])
|
||||
export default class IncidentOwnerUser extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -76,6 +76,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "IncidentSeverity",
|
||||
})
|
||||
@Index(["projectId", "order"])
|
||||
export default class IncidentSeverity extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -76,6 +76,9 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "IncidentState",
|
||||
})
|
||||
@Index(["projectId", "isCreatedState"])
|
||||
@Index(["projectId", "isResolvedState"])
|
||||
@Index(["projectId", "order"])
|
||||
export default class IncidentState extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -24,6 +24,8 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@EnableDocumentation()
|
||||
@CanAccessIfCanReadOn("incident")
|
||||
@TenantColumn("projectId")
|
||||
@Index(["incidentId", "startsAt"]) // Composite index for efficient incident timeline queries
|
||||
@Index(["incidentId", "projectId", "startsAt"]) // Alternative composite index including project
|
||||
@TableAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
@@ -60,6 +62,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "IncidentStateTimeline",
|
||||
})
|
||||
@Index(["incidentId", "startsAt"])
|
||||
@TableMetadata({
|
||||
tableName: "IncidentStateTimeline",
|
||||
singularName: "Incident State Timeline",
|
||||
|
||||
@@ -126,6 +126,7 @@ import User from "./User";
|
||||
import UserCall from "./UserCall";
|
||||
// Notification Methods
|
||||
import UserEmail from "./UserEmail";
|
||||
import UserPush from "./UserPush";
|
||||
// User Notification Rules
|
||||
import UserNotificationRule from "./UserNotificationRule";
|
||||
import UserNotificationSetting from "./UserNotificationSetting";
|
||||
@@ -178,6 +179,7 @@ import ProjectUser from "./ProjectUser";
|
||||
import OnCallDutyPolicyUserOverride from "./OnCallDutyPolicyUserOverride";
|
||||
import MonitorFeed from "./MonitorFeed";
|
||||
import MetricType from "./MetricType";
|
||||
import ProjectSCIM from "./ProjectScim";
|
||||
|
||||
const AllModelTypes: Array<{
|
||||
new (): BaseModel;
|
||||
@@ -294,6 +296,7 @@ const AllModelTypes: Array<{
|
||||
UserEmail,
|
||||
UserSms,
|
||||
UserCall,
|
||||
UserPush,
|
||||
|
||||
UserNotificationRule,
|
||||
UserOnCallLog,
|
||||
@@ -378,6 +381,8 @@ const AllModelTypes: Array<{
|
||||
MetricType,
|
||||
|
||||
OnCallDutyPolicyTimeLog,
|
||||
|
||||
ProjectSCIM
|
||||
];
|
||||
|
||||
const modelTypeMap: { [key: string]: { new (): BaseModel } } = {};
|
||||
|
||||
@@ -57,10 +57,10 @@ import TelemetryService from "./TelemetryService";
|
||||
],
|
||||
})
|
||||
@EnableWorkflow({
|
||||
create: true,
|
||||
delete: true,
|
||||
update: true,
|
||||
read: true,
|
||||
create: false,
|
||||
delete: false,
|
||||
update: false,
|
||||
read: false,
|
||||
})
|
||||
@CrudApiEndpoint(new Route("/metric-type"))
|
||||
@SlugifyColumn("name", "slug")
|
||||
|
||||
@@ -72,6 +72,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "MonitorOwnerTeam",
|
||||
})
|
||||
@Index(["monitorId", "teamId", "projectId"])
|
||||
export default class MonitorOwnerTeam extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -71,6 +71,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "MonitorOwnerUser",
|
||||
})
|
||||
@Index(["monitorId", "userId", "projectId"])
|
||||
export default class MonitorOwnerUser extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -24,6 +24,8 @@ export type MonitorStepProbeResponse = Dictionary<ProbeMonitorResponse>;
|
||||
|
||||
@EnableDocumentation()
|
||||
@TenantColumn("projectId")
|
||||
@Index(["monitorId", "probeId"]) // Composite index for efficient monitor-probe relationship queries
|
||||
@Index(["monitorId", "projectId"]) // Alternative index for monitor queries within project
|
||||
@TableAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
|
||||
@@ -76,6 +76,8 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "MonitorStatus",
|
||||
})
|
||||
@Index(["projectId", "isOperationalState"])
|
||||
@Index(["projectId", "isOfflineState"])
|
||||
export default class MonitorStatus extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -25,6 +25,8 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@EnableDocumentation()
|
||||
@CanAccessIfCanReadOn("monitor")
|
||||
@TenantColumn("projectId")
|
||||
@Index(["monitorId", "projectId", "startsAt"]) // Composite index for efficient timeline queries
|
||||
@Index(["monitorId", "startsAt"]) // Alternative index for monitor-specific timeline queries
|
||||
@TableAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
@@ -62,6 +64,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "MonitorStatusTimeline",
|
||||
})
|
||||
@Index(["monitorId", "startsAt"])
|
||||
@TableMetadata({
|
||||
tableName: "MonitorStatusTimeline",
|
||||
singularName: "Monitor Status Event",
|
||||
|
||||
@@ -51,6 +51,9 @@ import Alert from "./Alert";
|
||||
@Entity({
|
||||
name: "OnCallDutyPolicyExecutionLogTimeline",
|
||||
})
|
||||
@Index(["onCallDutyPolicyExecutionLogId", "createdAt"])
|
||||
@Index(["projectId", "createdAt"])
|
||||
@Index(["alertSentToUserId", "projectId"])
|
||||
@TableMetadata({
|
||||
tableName: "OnCallDutyPolicyExecutionLogTimeline",
|
||||
singularName: "On-Call Duty Execution Log Timeline",
|
||||
|
||||
@@ -72,6 +72,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "OnCallDutyPolicyOwnerTeam",
|
||||
})
|
||||
@Index(["onCallDutyPolicyId", "teamId", "projectId"])
|
||||
export default class OnCallDutyPolicyOwnerTeam extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -71,6 +71,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "OnCallDutyPolicyOwnerUser",
|
||||
})
|
||||
@Index(["onCallDutyPolicyId", "userId", "projectId"])
|
||||
export default class OnCallDutyPolicyOwnerUser extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
549
Common/Models/DatabaseModels/ProjectScim.ts
Normal file
549
Common/Models/DatabaseModels/ProjectScim.ts
Normal file
@@ -0,0 +1,549 @@
|
||||
import Project from "./Project";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
|
||||
import Route from "../../Types/API/Route";
|
||||
import URL from "../../Types/API/URL";
|
||||
import { PlanType } from "../../Types/Billing/SubscriptionPlan";
|
||||
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
|
||||
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
|
||||
import TableBillingAccessControl from "../../Types/Database/AccessControl/TableBillingAccessControl";
|
||||
import ColumnLength from "../../Types/Database/ColumnLength";
|
||||
import ColumnType from "../../Types/Database/ColumnType";
|
||||
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
|
||||
import TableColumn from "../../Types/Database/TableColumn";
|
||||
import TableColumnType from "../../Types/Database/TableColumnType";
|
||||
import TableMetadata from "../../Types/Database/TableMetadata";
|
||||
import TenantColumn from "../../Types/Database/TenantColumn";
|
||||
import UniqueColumnBy from "../../Types/Database/UniqueColumnBy";
|
||||
import IconProp from "../../Types/Icon/IconProp";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
JoinTable,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
} from "typeorm";
|
||||
|
||||
@TableBillingAccessControl({
|
||||
create: PlanType.Scale,
|
||||
read: PlanType.Scale,
|
||||
update: PlanType.Scale,
|
||||
delete: PlanType.Scale,
|
||||
})
|
||||
@TenantColumn("projectId")
|
||||
@TableAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectUser,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
delete: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.DeleteProjectSCIM,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSCIM,
|
||||
],
|
||||
})
|
||||
@CrudApiEndpoint(new Route("/project-scim"))
|
||||
@TableMetadata({
|
||||
tableName: "ProjectSCIM",
|
||||
singularName: "SCIM",
|
||||
pluralName: "SCIM",
|
||||
icon: IconProp.User,
|
||||
tableDescription: "Manage SCIM user provisioning for your project",
|
||||
})
|
||||
@Entity({
|
||||
name: "ProjectSCIM",
|
||||
})
|
||||
export default class ProjectSCIM extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectUser,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "projectId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: Project,
|
||||
title: "Project",
|
||||
description: "Relation to Project Resource in which this object belongs",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return Project;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "projectId" })
|
||||
public project?: Project = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectUser,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Project ID",
|
||||
description: "ID of your OneUptime Project in which this object belongs",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public projectId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectUser,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSCIM,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: true,
|
||||
type: TableColumnType.ShortText,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Name",
|
||||
description: "Any friendly name of this object",
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: ColumnType.ShortText,
|
||||
length: ColumnLength.ShortText,
|
||||
})
|
||||
@UniqueColumnBy("projectId")
|
||||
public name?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectUser,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSCIM,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: true,
|
||||
type: TableColumnType.LongText,
|
||||
canReadOnRelationQuery: true,
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: ColumnType.LongText,
|
||||
})
|
||||
public description?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSCIM,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: true,
|
||||
type: TableColumnType.LongURL,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "SCIM Base URL",
|
||||
description: "Base URL for SCIM server (e.g., https://yourapp.scim.com/v2)",
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: ColumnType.LongURL,
|
||||
transformer: URL.getDatabaseTransformer(),
|
||||
})
|
||||
@UniqueColumnBy("projectId")
|
||||
public scimBaseUrl?: URL = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSCIM,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: true,
|
||||
type: TableColumnType.VeryLongText,
|
||||
canReadOnRelationQuery: false,
|
||||
title: "Bearer Token",
|
||||
description: "Bearer token for authenticating with SCIM server",
|
||||
})
|
||||
@Column({
|
||||
nullable: false,
|
||||
type: ColumnType.VeryLongText,
|
||||
})
|
||||
public bearerToken?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSCIM,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
required: false,
|
||||
type: TableColumnType.EntityArray,
|
||||
modelType: Team,
|
||||
title: "Default Teams",
|
||||
description: "Teams to add users to when they are provisioned via SCIM",
|
||||
})
|
||||
@ManyToMany(
|
||||
() => {
|
||||
return Team;
|
||||
},
|
||||
{ eager: false },
|
||||
)
|
||||
@JoinTable({
|
||||
name: "ProjectScimTeam",
|
||||
inverseJoinColumn: {
|
||||
name: "teamId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
joinColumn: {
|
||||
name: "projectScimId",
|
||||
referencedColumnName: "_id",
|
||||
},
|
||||
})
|
||||
public teams?: Array<Team> = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "createdByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: User,
|
||||
title: "Created by User",
|
||||
description:
|
||||
"Relation to User who created this object (if this object was created by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "createdByUserId" })
|
||||
public createdByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Created by User ID",
|
||||
description:
|
||||
"User ID who created this object (if this object was created by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public createdByUserId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "deletedByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
title: "Deleted by User",
|
||||
modelType: User,
|
||||
description:
|
||||
"Relation to User who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
cascade: false,
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "deletedByUserId" })
|
||||
public deletedByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Deleted by User ID",
|
||||
description:
|
||||
"User ID who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public deletedByUserId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectUser,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSCIM,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
defaultValue: false,
|
||||
title: "Enabled",
|
||||
description: "Is SCIM provisioning enabled for this project",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
})
|
||||
public isEnabled?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSCIM,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
defaultValue: true,
|
||||
title: "Auto Provision Users",
|
||||
description: "Automatically create users when they are provisioned via SCIM",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: true,
|
||||
})
|
||||
public autoProvisionUsers?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.EditProjectSCIM,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
defaultValue: false,
|
||||
title: "Auto Deprovision Users",
|
||||
description: "Automatically remove users when they are deprovisioned via SCIM",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
})
|
||||
public autoDeprovisionUsers?: boolean = undefined;
|
||||
|
||||
// Is this integration tested?
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.CreateProjectSCIM,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectSCIM,
|
||||
],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Tested",
|
||||
description: "Has this SCIM integration been tested?",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
})
|
||||
public isTested?: boolean = undefined;
|
||||
}
|
||||
@@ -64,6 +64,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "ScheduledMaintenanceOwnerTeam",
|
||||
})
|
||||
@Index(["scheduledMaintenanceId", "teamId", "projectId"])
|
||||
export default class ScheduledMaintenanceOwnerTeam extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -63,6 +63,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "ScheduledMaintenanceOwnerUser",
|
||||
})
|
||||
@Index(["scheduledMaintenanceId", "userId", "projectId"])
|
||||
export default class ScheduledMaintenanceOwnerUser extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -76,6 +76,9 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "ScheduledMaintenanceState",
|
||||
})
|
||||
@Index(["projectId", "order"])
|
||||
@Index(["projectId", "isOngoingState"])
|
||||
@Index(["projectId", "isEndedState"])
|
||||
export default class ScheduledMaintenanceState extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -59,6 +59,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "ScheduledMaintenanceStateTimeline",
|
||||
})
|
||||
@Index(["scheduledMaintenanceId", "startsAt"])
|
||||
@TableMetadata({
|
||||
tableName: "ScheduledMaintenanceStateTimeline",
|
||||
icon: IconProp.List,
|
||||
|
||||
@@ -2049,6 +2049,41 @@ export default class StatusPage extends BaseModel {
|
||||
})
|
||||
public subscriberEmailNotificationFooterText?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.CreateProjectStatusPage,
|
||||
],
|
||||
read: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.ReadProjectStatusPage,
|
||||
],
|
||||
update: [
|
||||
Permission.ProjectOwner,
|
||||
Permission.ProjectAdmin,
|
||||
Permission.ProjectMember,
|
||||
Permission.EditProjectStatusPage,
|
||||
],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
title: "Enable Custom Subscriber Email Notification Footer Text",
|
||||
description: "Enable custom footer text in subscriber email notifications.",
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
nullable: false,
|
||||
})
|
||||
public enableCustomSubscriberEmailNotificationFooterText?: boolean =
|
||||
undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
Permission.ProjectOwner,
|
||||
|
||||
@@ -72,6 +72,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "StatusPageOwnerTeam",
|
||||
})
|
||||
@Index(["statusPageId", "teamId", "projectId"])
|
||||
export default class StatusPageOwnerTeam extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -71,6 +71,7 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "StatusPageOwnerUser",
|
||||
})
|
||||
@Index(["statusPageId", "userId", "projectId"])
|
||||
export default class StatusPageOwnerUser extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [
|
||||
|
||||
@@ -4,6 +4,7 @@ import Project from "./Project";
|
||||
import User from "./User";
|
||||
import UserCall from "./UserCall";
|
||||
import UserEmail from "./UserEmail";
|
||||
import UserPush from "./UserPush";
|
||||
import UserSMS from "./UserSMS";
|
||||
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
|
||||
import Route from "../../Types/API/Route";
|
||||
@@ -290,6 +291,52 @@ class UserNotificationRule extends BaseModel {
|
||||
})
|
||||
public userCallId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [Permission.CurrentUser],
|
||||
read: [Permission.CurrentUser],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "userPushId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: UserPush,
|
||||
title: "User Push",
|
||||
description: "Relation to User Push Resource in which this object belongs",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return UserPush;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "userPushId" })
|
||||
public userPush?: UserPush = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [Permission.CurrentUser],
|
||||
read: [Permission.CurrentUser],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: false,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "User Push ID",
|
||||
description: "ID of User Push in which this object belongs",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public userPushId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [Permission.CurrentUser],
|
||||
read: [Permission.CurrentUser],
|
||||
|
||||
@@ -284,6 +284,22 @@ class UserNotificationSetting extends BaseModel {
|
||||
default: false,
|
||||
})
|
||||
public alertByCall?: boolean = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [Permission.CurrentUser],
|
||||
read: [Permission.CurrentUser],
|
||||
update: [Permission.CurrentUser],
|
||||
})
|
||||
@TableColumn({
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
})
|
||||
public alertByPush?: boolean = undefined;
|
||||
}
|
||||
|
||||
export default UserNotificationSetting;
|
||||
|
||||
@@ -10,6 +10,7 @@ import User from "./User";
|
||||
import UserCall from "./UserCall";
|
||||
import UserEmail from "./UserEmail";
|
||||
import UserNotificationRule from "./UserNotificationRule";
|
||||
import UserPush from "./UserPush";
|
||||
import UserOnCallLog from "./UserOnCallLog";
|
||||
import UserSMS from "./UserSMS";
|
||||
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
|
||||
@@ -51,6 +52,9 @@ import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
@Entity({
|
||||
name: "UserOnCallLogTimeline",
|
||||
})
|
||||
@Index(["userId", "createdAt"])
|
||||
@Index(["onCallDutyPolicyExecutionLogId", "status"])
|
||||
@Index(["projectId", "status"])
|
||||
@TableMetadata({
|
||||
tableName: "UserOnCallLogTimeline",
|
||||
singularName: "User On-Call Log Timeline",
|
||||
@@ -876,6 +880,52 @@ export default class UserOnCallLogTimeline extends BaseModel {
|
||||
})
|
||||
public userEmailId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [Permission.CurrentUser],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "userPushId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: UserPush,
|
||||
title: "User Push",
|
||||
description: "Relation to User Push Resource in which this object belongs",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return UserPush;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "userPushId" })
|
||||
public userPush?: UserPush = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [Permission.CurrentUser],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: false,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "User Push ID",
|
||||
description: "ID of User Push in which this object belongs",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public userPushId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [],
|
||||
|
||||
301
Common/Models/DatabaseModels/UserPush.ts
Normal file
301
Common/Models/DatabaseModels/UserPush.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import Project from "./Project";
|
||||
import User from "./User";
|
||||
import BaseModel from "./DatabaseBaseModel/DatabaseBaseModel";
|
||||
import Route from "../../Types/API/Route";
|
||||
import AllowAccessIfSubscriptionIsUnpaid from "../../Types/Database/AccessControl/AllowAccessIfSubscriptionIsUnpaid";
|
||||
import ColumnAccessControl from "../../Types/Database/AccessControl/ColumnAccessControl";
|
||||
import TableAccessControl from "../../Types/Database/AccessControl/TableAccessControl";
|
||||
import ColumnLength from "../../Types/Database/ColumnLength";
|
||||
import ColumnType from "../../Types/Database/ColumnType";
|
||||
import CrudApiEndpoint from "../../Types/Database/CrudApiEndpoint";
|
||||
import CurrentUserCanAccessRecordBy from "../../Types/Database/CurrentUserCanAccessRecordBy";
|
||||
import TableColumn from "../../Types/Database/TableColumn";
|
||||
import TableColumnType from "../../Types/Database/TableColumnType";
|
||||
import TableMetadata from "../../Types/Database/TableMetadata";
|
||||
import TenantColumn from "../../Types/Database/TenantColumn";
|
||||
import IconProp from "../../Types/Icon/IconProp";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Permission from "../../Types/Permission";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
@TenantColumn("projectId")
|
||||
@AllowAccessIfSubscriptionIsUnpaid()
|
||||
@TableAccessControl({
|
||||
create: [Permission.CurrentUser],
|
||||
read: [Permission.CurrentUser],
|
||||
delete: [Permission.CurrentUser],
|
||||
update: [Permission.CurrentUser],
|
||||
})
|
||||
@CrudApiEndpoint(new Route("/user-push"))
|
||||
@Entity({
|
||||
name: "UserPush",
|
||||
})
|
||||
@TableMetadata({
|
||||
tableName: "UserPush",
|
||||
singularName: "Device for Push Notifications",
|
||||
pluralName: "Devices for Push Notifications",
|
||||
icon: IconProp.Bell,
|
||||
tableDescription: "Devices which will be used for push notifications.",
|
||||
})
|
||||
@CurrentUserCanAccessRecordBy("userId")
|
||||
class UserPush extends BaseModel {
|
||||
@ColumnAccessControl({
|
||||
create: [Permission.CurrentUser],
|
||||
read: [Permission.CurrentUser],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "projectId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: Project,
|
||||
title: "Project",
|
||||
description: "Relation to Project Resource in which this object belongs",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return Project;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "projectId" })
|
||||
public project?: Project = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [Permission.CurrentUser],
|
||||
read: [Permission.CurrentUser],
|
||||
update: [],
|
||||
})
|
||||
@Index()
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
required: true,
|
||||
canReadOnRelationQuery: true,
|
||||
title: "Project ID",
|
||||
description: "ID of your OneUptime Project in which this object belongs",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: false,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public projectId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [Permission.CurrentUser],
|
||||
read: [Permission.CurrentUser],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
title: "Device Token",
|
||||
required: true,
|
||||
unique: false,
|
||||
type: TableColumnType.VeryLongText,
|
||||
canReadOnRelationQuery: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.VeryLongText,
|
||||
unique: false,
|
||||
nullable: false,
|
||||
})
|
||||
public deviceToken?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [Permission.CurrentUser],
|
||||
read: [Permission.CurrentUser],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
title: "Device Type",
|
||||
required: true,
|
||||
unique: false,
|
||||
type: TableColumnType.ShortText,
|
||||
canReadOnRelationQuery: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ShortText,
|
||||
length: ColumnLength.ShortText,
|
||||
unique: false,
|
||||
nullable: false,
|
||||
})
|
||||
public deviceType?: "web" = "web" as const; // Only web support for now
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [Permission.CurrentUser],
|
||||
read: [Permission.CurrentUser],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
title: "Device Name",
|
||||
required: false,
|
||||
unique: false,
|
||||
type: TableColumnType.ShortText,
|
||||
canReadOnRelationQuery: true,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ShortText,
|
||||
length: ColumnLength.ShortText,
|
||||
unique: false,
|
||||
nullable: true,
|
||||
})
|
||||
public deviceName?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [Permission.CurrentUser],
|
||||
read: [Permission.CurrentUser],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "user",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: User,
|
||||
title: "User",
|
||||
description: "Relation to User who this device belongs to",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "CASCADE",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "userId" })
|
||||
public user?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [Permission.CurrentUser],
|
||||
read: [Permission.CurrentUser],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "User ID",
|
||||
description: "User ID who this device belongs to",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
@Index()
|
||||
public userId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [Permission.CurrentUser],
|
||||
read: [Permission.CurrentUser],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "createdByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
modelType: User,
|
||||
title: "Created by User",
|
||||
description:
|
||||
"Relation to User who created this object (if this object was created by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "createdByUserId" })
|
||||
public createdByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [Permission.CurrentUser],
|
||||
read: [Permission.CurrentUser],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Created by User ID",
|
||||
description:
|
||||
"User ID who created this object (if this object was created by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public createdByUserId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
manyToOneRelationColumn: "deletedByUserId",
|
||||
type: TableColumnType.Entity,
|
||||
title: "Deleted by User",
|
||||
modelType: User,
|
||||
description:
|
||||
"Relation to User who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@ManyToOne(
|
||||
() => {
|
||||
return User;
|
||||
},
|
||||
{
|
||||
cascade: false,
|
||||
eager: false,
|
||||
nullable: true,
|
||||
onDelete: "SET NULL",
|
||||
orphanedRowAction: "nullify",
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: "deletedByUserId" })
|
||||
public deletedByUser?: User = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
type: TableColumnType.ObjectID,
|
||||
title: "Deleted by User ID",
|
||||
description:
|
||||
"User ID who deleted this object (if this object was deleted by a User)",
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.ObjectID,
|
||||
nullable: true,
|
||||
transformer: ObjectID.getDatabaseTransformer(),
|
||||
})
|
||||
public deletedByUserId?: ObjectID = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [Permission.CurrentUser],
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({
|
||||
title: "Is Verified",
|
||||
description: "Is this device verified?",
|
||||
isDefaultValueColumn: true,
|
||||
type: TableColumnType.Boolean,
|
||||
defaultValue: false,
|
||||
})
|
||||
@Column({
|
||||
type: ColumnType.Boolean,
|
||||
default: false,
|
||||
})
|
||||
public isVerified?: boolean = undefined;
|
||||
}
|
||||
|
||||
export default UserPush;
|
||||
144
Common/Scripts/generate-service-worker.js
Executable file
144
Common/Scripts/generate-service-worker.js
Executable file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Universal Service Worker Generator for OneUptime Services
|
||||
*
|
||||
* This script can be used by any OneUptime service to generate
|
||||
* a service worker from a template with dynamic versioning.
|
||||
*
|
||||
* Usage:
|
||||
* node generate-service-worker.js [template-path] [output-path]
|
||||
*
|
||||
* Example:
|
||||
* node generate-service-worker.js sw.js.template public/sw.js
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Default values
|
||||
const DEFAULT_APP_VERSION = '1.0.0';
|
||||
const DEFAULT_GIT_SHA = 'local';
|
||||
|
||||
/**
|
||||
* Get app version from environment or package.json
|
||||
*/
|
||||
function getAppVersion(packageJsonPath) {
|
||||
// First try environment variable (Docker build)
|
||||
if (process.env.APP_VERSION) {
|
||||
return process.env.APP_VERSION;
|
||||
}
|
||||
|
||||
// Fallback to package.json version
|
||||
try {
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
return packageJson.version || DEFAULT_APP_VERSION;
|
||||
} catch (error) {
|
||||
console.warn('Could not read package.json, using default version');
|
||||
return DEFAULT_APP_VERSION;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git SHA from environment
|
||||
*/
|
||||
function getGitSha() {
|
||||
// Try environment variable first (Docker build)
|
||||
if (process.env.GIT_SHA) {
|
||||
return process.env.GIT_SHA.substring(0, 8); // Short SHA
|
||||
}
|
||||
|
||||
// Try to get from git command if available
|
||||
try {
|
||||
const { execSync } = require('child_process');
|
||||
const gitSha = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
|
||||
return gitSha;
|
||||
} catch (error) {
|
||||
// Fallback to timestamp-based hash for local development
|
||||
const timestamp = Date.now().toString();
|
||||
const hash = crypto.createHash('md5').update(timestamp).digest('hex');
|
||||
return hash.substring(0, 8);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate service worker from template
|
||||
*/
|
||||
function generateServiceWorker(templatePath, outputPath, serviceName = 'OneUptime') {
|
||||
// Check if template exists
|
||||
if (!fs.existsSync(templatePath)) {
|
||||
console.error('❌ Service worker template not found:', templatePath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read template
|
||||
const template = fs.readFileSync(templatePath, 'utf8');
|
||||
|
||||
// Get version information
|
||||
const packageJsonPath = path.join(path.dirname(templatePath), 'package.json');
|
||||
const appVersion = getAppVersion(packageJsonPath);
|
||||
const gitSha = getGitSha();
|
||||
const buildTimestamp = new Date().toISOString();
|
||||
|
||||
console.log(`🔧 Generating service worker for ${serviceName}...`);
|
||||
console.log(` App Version: ${appVersion}`);
|
||||
console.log(` Git SHA: ${gitSha}`);
|
||||
console.log(` Build Time: ${buildTimestamp}`);
|
||||
|
||||
// Replace placeholders
|
||||
const generatedContent = template
|
||||
.replace(/\{\{APP_VERSION\}\}/g, appVersion)
|
||||
.replace(/\{\{GIT_SHA\}\}/g, gitSha)
|
||||
.replace(/\{\{BUILD_TIMESTAMP\}\}/g, buildTimestamp)
|
||||
.replace(/\{\{SERVICE_NAME\}\}/g, serviceName);
|
||||
|
||||
// Add generation comment at the top
|
||||
const header = `/*
|
||||
* Generated Service Worker for ${serviceName}
|
||||
*
|
||||
* Generated at: ${buildTimestamp}
|
||||
* App Version: ${appVersion}
|
||||
* Git SHA: ${gitSha}
|
||||
*
|
||||
* DO NOT EDIT THIS FILE DIRECTLY
|
||||
* Edit the template file instead and run the generator script
|
||||
*/
|
||||
|
||||
`;
|
||||
|
||||
const finalContent = header + generatedContent;
|
||||
|
||||
// Ensure output directory exists
|
||||
const outputDir = path.dirname(outputPath);
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write generated service worker
|
||||
fs.writeFileSync(outputPath, finalContent, 'utf8');
|
||||
|
||||
console.log('✅ Service worker generated successfully:', outputPath);
|
||||
console.log(` Cache version: oneuptime-v${appVersion}-${gitSha}`);
|
||||
}
|
||||
|
||||
// Command line interface
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
const templatePath = args[0] || 'sw.js.template';
|
||||
const outputPath = args[1] || 'public/sw.js';
|
||||
const serviceName = args[2] || path.basename(process.cwd());
|
||||
|
||||
try {
|
||||
// Resolve paths relative to current working directory
|
||||
const resolvedTemplatePath = path.resolve(templatePath);
|
||||
const resolvedOutputPath = path.resolve(outputPath);
|
||||
|
||||
generateServiceWorker(resolvedTemplatePath, resolvedOutputPath, serviceName);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to generate service worker:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { generateServiceWorker, getAppVersion, getGitSha };
|
||||
476
Common/Server/API/ProjectSCIM.ts
Normal file
476
Common/Server/API/ProjectSCIM.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
import ProjectScimService, {
|
||||
Service as ProjectScimServiceType,
|
||||
} from "../Services/ProjectScimService";
|
||||
import { ExpressRequest, ExpressResponse } from "../Utils/Express";
|
||||
import Response from "../Utils/Response";
|
||||
import BaseAPI from "./BaseAPI";
|
||||
import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
|
||||
import BadDataException from "../../Types/Exception/BadDataException";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import PositiveNumber from "../../Types/PositiveNumber";
|
||||
import ProjectSCIM from "../../Models/DatabaseModels/ProjectScim";
|
||||
import URL from "../../Types/API/URL";
|
||||
import BadRequestException from "../../Types/Exception/BadRequestException";
|
||||
import Exception from "../../Types/Exception/Exception";
|
||||
import UserService from "../Services/UserService";
|
||||
import TeamMemberService from "../Services/TeamMemberService";
|
||||
import User from "../../Models/DatabaseModels/User";
|
||||
import Team from "../../Models/DatabaseModels/Team";
|
||||
import TeamMember from "../../Models/DatabaseModels/TeamMember";
|
||||
import Select from "../Types/Database/Select";
|
||||
import Email from "../../Types/Email";
|
||||
import Name from "../../Types/Name";
|
||||
import logger from "../Utils/Logger";
|
||||
import SCIMUtil from "../Utils/SCIM";
|
||||
|
||||
export default class ProjectScimAPI extends BaseAPI<
|
||||
ProjectSCIM,
|
||||
ProjectScimServiceType
|
||||
> {
|
||||
public constructor() {
|
||||
super(ProjectSCIM, ProjectScimService);
|
||||
|
||||
// SCIM Fetch API
|
||||
this.router.post(
|
||||
`${new this.entityType()
|
||||
.getCrudApiPath()
|
||||
?.toString()}/:projectId/scim-list`,
|
||||
async (req: ExpressRequest, res: ExpressResponse) => {
|
||||
const projectId: ObjectID = new ObjectID(
|
||||
req.params["projectId"] as string,
|
||||
);
|
||||
|
||||
if (!projectId) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Invalid project id."),
|
||||
);
|
||||
}
|
||||
|
||||
const scim: Array<ProjectSCIM> = await this.service.findBy({
|
||||
query: {
|
||||
projectId: projectId,
|
||||
isEnabled: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
select: {
|
||||
name: true,
|
||||
description: true,
|
||||
_id: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.sendEntityArrayResponse(
|
||||
req,
|
||||
res,
|
||||
scim,
|
||||
new PositiveNumber(scim.length),
|
||||
ProjectSCIM,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Test SCIM connection
|
||||
this.router.post(
|
||||
`${new this.entityType()
|
||||
.getCrudApiPath()
|
||||
?.toString()}/test-connection`,
|
||||
async (req: ExpressRequest, res: ExpressResponse) => {
|
||||
try {
|
||||
const scimBaseUrl: string = req.body["scimBaseUrl"];
|
||||
const bearerToken: string = req.body["bearerToken"];
|
||||
|
||||
if (!scimBaseUrl || !bearerToken) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM Base URL and Bearer Token are required"),
|
||||
);
|
||||
}
|
||||
|
||||
const isConnected: boolean = await SCIMUtil.testConnection(
|
||||
URL.fromString(scimBaseUrl),
|
||||
bearerToken,
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
isConnected,
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.sendErrorResponse(req, res, err as Exception);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Sync users from SCIM provider
|
||||
this.router.post(
|
||||
`${new this.entityType()
|
||||
.getCrudApiPath()
|
||||
?.toString()}/:projectScimId/sync-users`,
|
||||
async (req: ExpressRequest, res: ExpressResponse) => {
|
||||
try {
|
||||
const projectScimId: ObjectID = new ObjectID(req.params["projectScimId"] as string);
|
||||
|
||||
const projectScim: ProjectSCIM | null = await this.service.findOneBy({
|
||||
query: {
|
||||
_id: projectScimId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
scimBaseUrl: true,
|
||||
bearerToken: true,
|
||||
projectId: true,
|
||||
teams: {
|
||||
_id: true,
|
||||
name: true,
|
||||
} as Select<Team>,
|
||||
autoProvisionUsers: true,
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!projectScim) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration not found"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectScim.scimBaseUrl || !projectScim.bearerToken) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration is incomplete"),
|
||||
);
|
||||
}
|
||||
|
||||
// Get users from SCIM provider
|
||||
const scimUsers = await SCIMUtil.listUsers(
|
||||
projectScim.scimBaseUrl,
|
||||
projectScim.bearerToken,
|
||||
);
|
||||
|
||||
const syncResults = {
|
||||
totalScimUsers: scimUsers.totalResults,
|
||||
processedUsers: 0,
|
||||
createdUsers: 0,
|
||||
updatedUsers: 0,
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
// Process each SCIM user
|
||||
for (const scimUser of scimUsers.Resources) {
|
||||
try {
|
||||
syncResults.processedUsers++;
|
||||
|
||||
const primaryEmail = scimUser.emails?.[0];
|
||||
if (!primaryEmail) {
|
||||
syncResults.errors.push(`User ${scimUser.userName} has no email address`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const email = new Email(primaryEmail.value);
|
||||
|
||||
// Check if user exists
|
||||
let user: User | null = await UserService.findOneBy({
|
||||
query: { email },
|
||||
select: {
|
||||
_id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (user && projectScim.autoProvisionUsers) {
|
||||
// Update existing user if displayName is provided
|
||||
if (scimUser.displayName) {
|
||||
await UserService.updateOneById({
|
||||
id: user.id!,
|
||||
data: {
|
||||
name: new Name(scimUser.displayName),
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
syncResults.updatedUsers++;
|
||||
} else if (!user && projectScim.autoProvisionUsers) {
|
||||
// Create new user
|
||||
const newUser = new User();
|
||||
newUser.name = new Name(scimUser.displayName || scimUser.userName);
|
||||
newUser.email = email;
|
||||
newUser.isEmailVerified = true;
|
||||
|
||||
const createdUser = await UserService.create({
|
||||
data: newUser,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (createdUser) {
|
||||
syncResults.createdUsers++;
|
||||
|
||||
// Add user to configured teams if any
|
||||
if (projectScim.teams && projectScim.teams.length > 0) {
|
||||
for (const team of projectScim.teams) {
|
||||
try {
|
||||
// Create a new TeamMember instance
|
||||
// Note: Using regular import to avoid dynamic import issues
|
||||
const newTeamMember = new TeamMember();
|
||||
newTeamMember.teamId = team.id!;
|
||||
newTeamMember.userId = createdUser.id!;
|
||||
newTeamMember.projectId = projectScim.projectId!;
|
||||
newTeamMember.hasAcceptedInvitation = true;
|
||||
|
||||
await TeamMemberService.create({
|
||||
data: newTeamMember,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
} catch (teamErr) {
|
||||
logger.error(`Failed to add user ${email.toString()} to team ${team.name}: ${teamErr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (userErr) {
|
||||
syncResults.errors.push(`Error processing user ${scimUser.userName}: ${userErr}`);
|
||||
logger.error(`SCIM user sync error: ${userErr}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, syncResults);
|
||||
} catch (err) {
|
||||
return Response.sendErrorResponse(req, res, err as Exception);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Provision user to SCIM provider
|
||||
this.router.post(
|
||||
`${new this.entityType()
|
||||
.getCrudApiPath()
|
||||
?.toString()}/:projectScimId/provision-user`,
|
||||
async (req: ExpressRequest, res: ExpressResponse) => {
|
||||
try {
|
||||
const projectScimId: ObjectID = new ObjectID(req.params["projectScimId"] as string);
|
||||
const userId: ObjectID = new ObjectID(req.body["userId"]);
|
||||
|
||||
const projectScim: ProjectSCIM | null = await this.service.findOneBy({
|
||||
query: {
|
||||
_id: projectScimId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
scimBaseUrl: true,
|
||||
bearerToken: true,
|
||||
autoProvisionUsers: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!projectScim) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration not found"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectScim.autoProvisionUsers) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("Auto provisioning is disabled"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectScim.scimBaseUrl || !projectScim.bearerToken) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration is incomplete"),
|
||||
);
|
||||
}
|
||||
|
||||
const user: User | null = await UserService.findOneById({
|
||||
id: userId,
|
||||
select: {
|
||||
_id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("User not found"),
|
||||
);
|
||||
}
|
||||
|
||||
// Check if user already exists in SCIM provider
|
||||
const existingScimUser = await SCIMUtil.getUserByUserName(
|
||||
projectScim.scimBaseUrl,
|
||||
projectScim.bearerToken,
|
||||
user.email?.toString() || "",
|
||||
);
|
||||
|
||||
if (existingScimUser) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("User already exists in SCIM provider"),
|
||||
);
|
||||
}
|
||||
|
||||
// Create SCIM user
|
||||
const scimUser = SCIMUtil.convertUserToSCIMUser(user);
|
||||
const createdScimUser = await SCIMUtil.createUser(
|
||||
projectScim.scimBaseUrl,
|
||||
projectScim.bearerToken,
|
||||
scimUser,
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
scimUserId: createdScimUser.id,
|
||||
success: true,
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.sendErrorResponse(req, res, err as Exception);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Deprovision user from SCIM provider
|
||||
this.router.post(
|
||||
`${new this.entityType()
|
||||
.getCrudApiPath()
|
||||
?.toString()}/:projectScimId/deprovision-user`,
|
||||
async (req: ExpressRequest, res: ExpressResponse) => {
|
||||
try {
|
||||
const projectScimId: ObjectID = new ObjectID(req.params["projectScimId"] as string);
|
||||
const userId: ObjectID = new ObjectID(req.body["userId"]);
|
||||
|
||||
const projectScim: ProjectSCIM | null = await this.service.findOneBy({
|
||||
query: {
|
||||
_id: projectScimId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
scimBaseUrl: true,
|
||||
bearerToken: true,
|
||||
autoDeprovisionUsers: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!projectScim) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration not found"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectScim.autoDeprovisionUsers) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("Auto deprovisioning is disabled"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectScim.scimBaseUrl || !projectScim.bearerToken) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("SCIM configuration is incomplete"),
|
||||
);
|
||||
}
|
||||
|
||||
const user: User | null = await UserService.findOneById({
|
||||
id: userId,
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("User not found"),
|
||||
);
|
||||
}
|
||||
|
||||
// Find user in SCIM provider
|
||||
const scimUser = await SCIMUtil.getUserByUserName(
|
||||
projectScim.scimBaseUrl,
|
||||
projectScim.bearerToken,
|
||||
user.email?.toString() || "",
|
||||
);
|
||||
|
||||
if (!scimUser) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadRequestException("User not found in SCIM provider"),
|
||||
);
|
||||
}
|
||||
|
||||
// Deactivate or delete the user based on preference
|
||||
if (req.body["deleteUser"]) {
|
||||
await SCIMUtil.deleteUser(
|
||||
projectScim.scimBaseUrl,
|
||||
projectScim.bearerToken,
|
||||
scimUser.id!,
|
||||
);
|
||||
} else {
|
||||
await SCIMUtil.deactivateUser(
|
||||
projectScim.scimBaseUrl,
|
||||
projectScim.bearerToken,
|
||||
scimUser.id!,
|
||||
);
|
||||
}
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
success: true,
|
||||
action: req.body["deleteUser"] ? "deleted" : "deactivated",
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.sendErrorResponse(req, res, err as Exception);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1152,6 +1152,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
select: {
|
||||
_id: true,
|
||||
createdAt: true,
|
||||
startsAt: true,
|
||||
incidentId: true,
|
||||
incidentState: {
|
||||
_id: true,
|
||||
@@ -1164,7 +1165,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
},
|
||||
|
||||
sort: {
|
||||
createdAt: SortOrder.Descending, // new note first
|
||||
startsAt: SortOrder.Descending, // newer state changes first
|
||||
},
|
||||
skip: 0,
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
@@ -1340,6 +1341,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
select: {
|
||||
_id: true,
|
||||
createdAt: true,
|
||||
startsAt: true,
|
||||
scheduledMaintenanceId: true,
|
||||
scheduledMaintenanceState: {
|
||||
_id: true,
|
||||
@@ -1352,7 +1354,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
},
|
||||
|
||||
sort: {
|
||||
createdAt: SortOrder.Descending, // new note first
|
||||
startsAt: SortOrder.Descending, // newer state changes first
|
||||
},
|
||||
skip: 0,
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
@@ -1878,6 +1880,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
select: {
|
||||
_id: true,
|
||||
createdAt: true,
|
||||
startsAt: true,
|
||||
scheduledMaintenanceId: true,
|
||||
scheduledMaintenanceState: {
|
||||
name: true,
|
||||
@@ -1889,7 +1892,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
},
|
||||
|
||||
sort: {
|
||||
createdAt: SortOrder.Descending, // new note first
|
||||
startsAt: SortOrder.Descending, // newer state changes first
|
||||
},
|
||||
skip: 0,
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
@@ -2341,7 +2344,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
? "true"
|
||||
: "false",
|
||||
subscriberEmailNotificationFooterText:
|
||||
statusPage.subscriberEmailNotificationFooterText || "",
|
||||
StatusPageServiceType.getSubscriberEmailFooterText(statusPage),
|
||||
|
||||
manageSubscriptionUrl: manageUrlink,
|
||||
},
|
||||
@@ -2924,6 +2927,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
select: {
|
||||
_id: true,
|
||||
createdAt: true,
|
||||
startsAt: true,
|
||||
incidentId: true,
|
||||
incidentState: {
|
||||
name: true,
|
||||
@@ -2931,7 +2935,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
},
|
||||
},
|
||||
sort: {
|
||||
createdAt: SortOrder.Descending, // new note first
|
||||
startsAt: SortOrder.Descending, // newer state changes first
|
||||
},
|
||||
|
||||
skip: 0,
|
||||
|
||||
302
Common/Server/API/UserPushAPI.ts
Normal file
302
Common/Server/API/UserPushAPI.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import UserMiddleware from "../Middleware/UserAuthorization";
|
||||
import UserPushService, {
|
||||
Service as UserPushServiceType,
|
||||
} from "../Services/UserPushService";
|
||||
import PushNotificationService from "../Services/PushNotificationService";
|
||||
import PushNotificationUtil from "../Utils/PushNotificationUtil";
|
||||
import {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
NextFunction,
|
||||
OneUptimeRequest,
|
||||
} from "../Utils/Express";
|
||||
import Response from "../Utils/Response";
|
||||
import BaseAPI from "./BaseAPI";
|
||||
import BadDataException from "../../Types/Exception/BadDataException";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import UserPush from "../../Models/DatabaseModels/UserPush";
|
||||
import PushNotificationMessage from "../../Types/PushNotification/PushNotificationMessage";
|
||||
|
||||
export default class UserPushAPI extends BaseAPI<
|
||||
UserPush,
|
||||
UserPushServiceType
|
||||
> {
|
||||
public constructor() {
|
||||
super(UserPush, UserPushService);
|
||||
|
||||
this.router.post(
|
||||
`/user-push/register`,
|
||||
UserMiddleware.getUserMiddleware,
|
||||
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
|
||||
try {
|
||||
req = req as OneUptimeRequest;
|
||||
|
||||
if (!req.body.deviceToken) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Device token is required"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.body.deviceType || req.body.deviceType !== "web") {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Only web device type is supported"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!req.body.projectId) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Project ID is required"),
|
||||
);
|
||||
}
|
||||
|
||||
// Check if device is already registered
|
||||
const existingDevice: UserPush | null = await this.service.findOneBy({
|
||||
query: {
|
||||
userId: (req as OneUptimeRequest).userAuthorization!.userId!,
|
||||
projectId: new ObjectID(req.body.projectId),
|
||||
deviceToken: req.body.deviceToken,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingDevice) {
|
||||
// Mark as used and return a specific response indicating device was already registered
|
||||
throw new BadDataException(
|
||||
"This device is already registered for push notifications",
|
||||
);
|
||||
}
|
||||
|
||||
// Create new device registration
|
||||
const userPush: UserPush = new UserPush();
|
||||
userPush.userId = (
|
||||
req as OneUptimeRequest
|
||||
).userAuthorization!.userId!;
|
||||
userPush.projectId = new ObjectID(req.body.projectId);
|
||||
userPush.deviceToken = req.body.deviceToken;
|
||||
userPush.deviceType = req.body.deviceType;
|
||||
userPush.deviceName = req.body.deviceName || "Unknown Device";
|
||||
userPush.isVerified = true; // For web push, we consider it verified immediately
|
||||
|
||||
const savedDevice: UserPush = await this.service.create({
|
||||
data: userPush,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
success: true,
|
||||
deviceId: savedDevice._id!.toString(),
|
||||
});
|
||||
} catch (error: any) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.router.post(
|
||||
`/user-push/:deviceId/test-notification`,
|
||||
UserMiddleware.getUserMiddleware,
|
||||
async (req: ExpressRequest, res: ExpressResponse) => {
|
||||
req = req as OneUptimeRequest;
|
||||
|
||||
if (!req.params["deviceId"]) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Device ID is required"),
|
||||
);
|
||||
}
|
||||
|
||||
// Get the device
|
||||
const device: UserPush | null = await this.service.findOneById({
|
||||
id: new ObjectID(req.params["deviceId"]),
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
deviceToken: true,
|
||||
deviceType: true,
|
||||
isVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Device not found"),
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the device belongs to the current user
|
||||
if (
|
||||
device.userId?.toString() !==
|
||||
(req as OneUptimeRequest).userAuthorization!.userId!.toString()
|
||||
) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Unauthorized access to device"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!device.isVerified) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Device is not verified"),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Send test notification
|
||||
const testMessage: PushNotificationMessage =
|
||||
PushNotificationUtil.createGenericNotification({
|
||||
title: "Test Notification from OneUptime",
|
||||
body: "This is a test notification to verify your device is working correctly.",
|
||||
clickAction: "/dashboard",
|
||||
tag: "test-notification",
|
||||
requireInteraction: false,
|
||||
});
|
||||
|
||||
await PushNotificationService.sendPushNotification(
|
||||
{
|
||||
deviceTokens: [device.deviceToken!],
|
||||
message: testMessage,
|
||||
deviceType: device.deviceType!,
|
||||
},
|
||||
{
|
||||
isSensitive: false,
|
||||
},
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, {
|
||||
success: true,
|
||||
message: "Test notification sent successfully",
|
||||
});
|
||||
} catch (error: any) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException(
|
||||
`Failed to send test notification: ${error.message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.router.post(
|
||||
`/user-push/:deviceId/verify`,
|
||||
UserMiddleware.getUserMiddleware,
|
||||
async (req: ExpressRequest, res: ExpressResponse) => {
|
||||
req = req as OneUptimeRequest;
|
||||
|
||||
if (!req.params["deviceId"]) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Device ID is required"),
|
||||
);
|
||||
}
|
||||
|
||||
const device: UserPush | null = await this.service.findOneById({
|
||||
id: new ObjectID(req.params["deviceId"]),
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Device not found"),
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the device belongs to the current user
|
||||
if (
|
||||
device.userId?.toString() !==
|
||||
(req as OneUptimeRequest).userAuthorization!.userId!.toString()
|
||||
) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Unauthorized access to device"),
|
||||
);
|
||||
}
|
||||
|
||||
await this.service.verifyDevice(device._id!.toString());
|
||||
|
||||
return Response.sendEmptySuccessResponse(req, res);
|
||||
},
|
||||
);
|
||||
|
||||
this.router.post(
|
||||
`/user-push/:deviceId/unverify`,
|
||||
UserMiddleware.getUserMiddleware,
|
||||
async (req: ExpressRequest, res: ExpressResponse) => {
|
||||
req = req as OneUptimeRequest;
|
||||
|
||||
if (!req.params["deviceId"]) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Device ID is required"),
|
||||
);
|
||||
}
|
||||
|
||||
const device: UserPush | null = await this.service.findOneById({
|
||||
id: new ObjectID(req.params["deviceId"]),
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Device not found"),
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the device belongs to the current user
|
||||
if (
|
||||
device.userId?.toString() !==
|
||||
(req as OneUptimeRequest).userAuthorization!.userId!.toString()
|
||||
) {
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new BadDataException("Unauthorized access to device"),
|
||||
);
|
||||
}
|
||||
|
||||
await this.service.unverifyDevice(device._id!.toString());
|
||||
|
||||
return Response.sendEmptySuccessResponse(req, res);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -327,3 +327,13 @@ export const SlackAppClientSecret: string | null =
|
||||
process.env["SLACK_APP_CLIENT_SECRET"] || null;
|
||||
export const SlackAppSigningSecret: string | null =
|
||||
process.env["SLACK_APP_SIGNING_SECRET"] || null;
|
||||
|
||||
// VAPID Configuration for Web Push Notifications
|
||||
export const VapidPublicKey: string | undefined =
|
||||
process.env["VAPID_PUBLIC_KEY"] || undefined;
|
||||
|
||||
export const VapidPrivateKey: string | undefined =
|
||||
process.env["VAPID_PRIVATE_KEY"] || undefined;
|
||||
|
||||
export const VapidSubject: string =
|
||||
process.env["VAPID_SUBJECT"] || "mailto:support@oneuptime.com";
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1752659054949 implements MigrationInterface {
|
||||
public name = "MigrationName1752659054949";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "UserPush" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "projectId" uuid NOT NULL, "deviceToken" character varying(500) NOT NULL, "deviceType" character varying(100) NOT NULL, "deviceName" character varying(100), "userId" uuid, "createdByUserId" uuid, "deletedByUserId" uuid, "isVerified" boolean NOT NULL DEFAULT false, "lastUsedAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_bc3271178002ba8d92824d36db6" PRIMARY KEY ("_id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_24d281c51868189d985c4a81cb" ON "UserPush" ("projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_507f0b3fea4f091410f99d2170" ON "UserPush" ("userId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserNotificationRule" ADD "userPushId" uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserNotificationSetting" ADD "alertByPush" boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_e6d756cbda1e68aae728531269" ON "UserNotificationRule" ("userPushId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserPush" ADD CONSTRAINT "FK_24d281c51868189d985c4a81cb8" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserPush" ADD CONSTRAINT "FK_507f0b3fea4f091410f99d2170a" FOREIGN KEY ("userId") REFERENCES "User"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserPush" ADD CONSTRAINT "FK_2d2819503cd8a8517e9ce502bd8" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserPush" ADD CONSTRAINT "FK_964b240ccbb12a9a8c947272540" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserNotificationRule" ADD CONSTRAINT "FK_e6d756cbda1e68aae7285312694" FOREIGN KEY ("userPushId") REFERENCES "UserPush"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserNotificationRule" DROP CONSTRAINT "FK_e6d756cbda1e68aae7285312694"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserPush" DROP CONSTRAINT "FK_964b240ccbb12a9a8c947272540"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserPush" DROP CONSTRAINT "FK_2d2819503cd8a8517e9ce502bd8"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserPush" DROP CONSTRAINT "FK_507f0b3fea4f091410f99d2170a"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserPush" DROP CONSTRAINT "FK_24d281c51868189d985c4a81cb8"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_e6d756cbda1e68aae728531269"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserNotificationSetting" DROP COLUMN "alertByPush"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserNotificationRule" DROP COLUMN "userPushId"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_507f0b3fea4f091410f99d2170"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_24d281c51868189d985c4a81cb"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "UserPush"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1752774923063 implements MigrationInterface {
|
||||
public name = "MigrationName1752774923063";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "UserPush" DROP COLUMN "lastUsedAt"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserPush" ADD "lastUsedAt" TIMESTAMP WITH TIME ZONE`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1753109689244 implements MigrationInterface {
|
||||
public name = "MigrationName1753109689244";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserOnCallLogTimeline" ADD "userPushId" uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_d3e187a7828a990cdf6429a692" ON "UserOnCallLogTimeline" ("userPushId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserOnCallLogTimeline" ADD CONSTRAINT "FK_d3e187a7828a990cdf6429a692f" FOREIGN KEY ("userPushId") REFERENCES "UserPush"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserOnCallLogTimeline" DROP CONSTRAINT "FK_d3e187a7828a990cdf6429a692f"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_d3e187a7828a990cdf6429a692"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserOnCallLogTimeline" DROP COLUMN "userPushId"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddEnableCustomSubscriberEmailNotificationFooterText1753131488925
|
||||
implements MigrationInterface
|
||||
{
|
||||
public name =
|
||||
"AddEnableCustomSubscriberEmailNotificationFooterText1753131488925";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPage" ADD "enableCustomSubscriberEmailNotificationFooterText" boolean NOT NULL DEFAULT false`,
|
||||
);
|
||||
|
||||
// Data migration: Set existing status pages to have enableCustomSubscriberEmailNotificationFooterText = true
|
||||
// This ensures backward compatibility for existing status pages that already have custom footer text
|
||||
await queryRunner.query(
|
||||
`UPDATE "StatusPage" SET "enableCustomSubscriberEmailNotificationFooterText" = true WHERE "subscriberEmailNotificationFooterText" IS NOT NULL AND "subscriberEmailNotificationFooterText" != ''`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "StatusPage" DROP COLUMN "enableCustomSubscriberEmailNotificationFooterText"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1753343522987 implements MigrationInterface {
|
||||
public name = "MigrationName1753343522987";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserPush" ALTER COLUMN "deviceToken" TYPE text`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "UserPush" ALTER COLUMN "deviceToken" TYPE character varying(500)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1753377161288 implements MigrationInterface {
|
||||
public name = "MigrationName1753377161288";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_16db786b562f1db40c93d463c7" ON "IncidentStateTimeline" ("incidentId", "projectId", "startsAt") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_410cf30b966f88c287d368aa48" ON "IncidentStateTimeline" ("incidentId", "startsAt") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_ac648c5f1961bc1d5ec1ba21bd" ON "MonitorProbe" ("monitorId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_bde10e600047b06718db90a636" ON "MonitorProbe" ("monitorId", "probeId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_570f164ca5b3559eb8555eb1b1" ON "MonitorStatusTimeline" ("monitorId", "startsAt") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_466d392af405ccf2e8b552eb0e" ON "MonitorStatusTimeline" ("monitorId", "projectId", "startsAt") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_466d392af405ccf2e8b552eb0e"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_570f164ca5b3559eb8555eb1b1"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_bde10e600047b06718db90a636"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_ac648c5f1961bc1d5ec1ba21bd"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_410cf30b966f88c287d368aa48"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_16db786b562f1db40c93d463c7"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddPerformanceIndexes1753378524062 implements MigrationInterface {
|
||||
public name = "AddPerformanceIndexes1753378524062";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_3c2f8998deba67cedb958fc08f" ON "IncidentSeverity" ("projectId", "order") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_2283c2d1aab23419b784db0d84" ON "IncidentState" ("projectId", "order") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_4ed23cf5e6614ee930972ab6b5" ON "IncidentState" ("projectId", "isResolvedState") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_b231eb3cdc945e53947495cf76" ON "IncidentState" ("projectId", "isCreatedState") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_5c9760b0f7df9fe68efd52151d" ON "MonitorStatus" ("projectId", "isOfflineState") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_9c64d2b5df8c5cac0ece90d899" ON "MonitorStatus" ("projectId", "isOperationalState") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_4490b10d3394a9be5f27f8fc3b" ON "IncidentOwnerTeam" ("incidentId", "teamId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_1d8d2229e31e4ec13ec99c79ae" ON "IncidentOwnerUser" ("incidentId", "userId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7b7272644aab237d503ed3429a" ON "MonitorOwnerTeam" ("monitorId", "teamId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_6f6246149ab744fd62ada06ee5" ON "MonitorOwnerUser" ("monitorId", "userId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_c98e7e9e31d674cf5c47b15f36" ON "AlertSeverity" ("projectId", "order") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_3bb6dc217814170a3b37e21bf5" ON "AlertState" ("projectId", "order") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_b20be7b2ca1a6dc602da305f8a" ON "AlertState" ("projectId", "isAcknowledgedState") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_ae2854ea86740fdd56eaf2fea9" ON "AlertState" ("projectId", "isResolvedState") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_91ad158d170a9b51a2046fcc87" ON "AlertState" ("projectId", "isCreatedState") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_d640454e87b3dd4f24f9c527d2" ON "AlertStateTimeline" ("alertId", "startsAt") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_dfbcaebaa02d06a556fd2e155c" ON "AlertOwnerTeam" ("alertId", "teamId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_042a7841d65141fb940de9d881" ON "AlertOwnerUser" ("alertId", "userId", "projectId") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_042a7841d65141fb940de9d881"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_dfbcaebaa02d06a556fd2e155c"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_d640454e87b3dd4f24f9c527d2"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_91ad158d170a9b51a2046fcc87"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_ae2854ea86740fdd56eaf2fea9"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_b20be7b2ca1a6dc602da305f8a"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_3bb6dc217814170a3b37e21bf5"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_c98e7e9e31d674cf5c47b15f36"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_6f6246149ab744fd62ada06ee5"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_7b7272644aab237d503ed3429a"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_1d8d2229e31e4ec13ec99c79ae"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_4490b10d3394a9be5f27f8fc3b"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_9c64d2b5df8c5cac0ece90d899"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_5c9760b0f7df9fe68efd52151d"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_b231eb3cdc945e53947495cf76"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_4ed23cf5e6614ee930972ab6b5"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_2283c2d1aab23419b784db0d84"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_3c2f8998deba67cedb958fc08f"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1753383711511 implements MigrationInterface {
|
||||
public name = "MigrationName1753383711511";
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_b03e14b5a5fc9f5b8603283c88" ON "OnCallDutyPolicyExecutionLogTimeline" ("alertSentToUserId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_114e3f761691867aa919ab6b6e" ON "OnCallDutyPolicyExecutionLogTimeline" ("projectId", "createdAt") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_f34e1244e487f705e7c6b25831" ON "OnCallDutyPolicyExecutionLogTimeline" ("onCallDutyPolicyExecutionLogId", "createdAt") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_34f21c8ae164fb90be806818a8" ON "OnCallDutyPolicyOwnerTeam" ("onCallDutyPolicyId", "teamId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_1539db4bbd6ada58abb940b058" ON "OnCallDutyPolicyOwnerUser" ("onCallDutyPolicyId", "userId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_00439dd14338c3ee4e81d0714a" ON "ScheduledMaintenanceState" ("projectId", "isEndedState") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_7addde4d27f13be56651000df9" ON "ScheduledMaintenanceState" ("projectId", "isOngoingState") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_e84431ba010571147933477cff" ON "ScheduledMaintenanceState" ("projectId", "order") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_b737666365dbea2e4c914fc6d3" ON "ScheduledMaintenanceOwnerTeam" ("scheduledMaintenanceId", "teamId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_a4621b7155a01292b92569549f" ON "ScheduledMaintenanceOwnerUser" ("scheduledMaintenanceId", "userId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_c4ac940ddb05242a166567edbb" ON "ScheduledMaintenanceStateTimeline" ("scheduledMaintenanceId", "startsAt") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_4873976169085f14bdc39e168d" ON "StatusPageOwnerTeam" ("statusPageId", "teamId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_a9f80dc4f648f0957ce695dc61" ON "StatusPageOwnerUser" ("statusPageId", "userId", "projectId") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_33ba145fe2826bb953e2ce9d3d" ON "UserOnCallLogTimeline" ("projectId", "status") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_90363cc35c22e377df8fdc5dfb" ON "UserOnCallLogTimeline" ("onCallDutyPolicyExecutionLogId", "status") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_89cccd6782b1ee84d20e9690d0" ON "UserOnCallLogTimeline" ("userId", "createdAt") `,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_89cccd6782b1ee84d20e9690d0"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_90363cc35c22e377df8fdc5dfb"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_33ba145fe2826bb953e2ce9d3d"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_a9f80dc4f648f0957ce695dc61"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_4873976169085f14bdc39e168d"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_c4ac940ddb05242a166567edbb"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_a4621b7155a01292b92569549f"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_b737666365dbea2e4c914fc6d3"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_e84431ba010571147933477cff"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_7addde4d27f13be56651000df9"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_00439dd14338c3ee4e81d0714a"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_1539db4bbd6ada58abb940b058"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_34f21c8ae164fb90be806818a8"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_f34e1244e487f705e7c6b25831"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_114e3f761691867aa919ab6b6e"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "public"."IDX_b03e14b5a5fc9f5b8603283c88"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1754227633113 implements MigrationInterface {
|
||||
public name = 'MigrationName1754227633113'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE TABLE "ProjectSCIM" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "projectId" uuid NOT NULL, "name" character varying(100) NOT NULL, "description" character varying NOT NULL, "scimBaseUrl" text NOT NULL, "bearerToken" text NOT NULL, "createdByUserId" uuid, "deletedByUserId" uuid, "isEnabled" boolean NOT NULL DEFAULT false, "autoProvisionUsers" boolean NOT NULL DEFAULT true, "autoDeprovisionUsers" boolean NOT NULL DEFAULT false, "isTested" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_51e71d70211675a5c918aee4e68" PRIMARY KEY ("_id"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_f916360335859c26c4d7051239" ON "ProjectSCIM" ("projectId") `);
|
||||
await queryRunner.query(`CREATE TABLE "ProjectScimTeam" ("projectScimId" uuid NOT NULL, "teamId" uuid NOT NULL, CONSTRAINT "PK_db724b66b4fa8c880ce5ccf820b" PRIMARY KEY ("projectScimId", "teamId"))`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_b9a28efd66600267f0e9de0731" ON "ProjectScimTeam" ("projectScimId") `);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_bb0eda2ef0c773f975e9ad8448" ON "ProjectScimTeam" ("teamId") `);
|
||||
await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type":"Recurring","value":{"intervalType":"Day","intervalCount":{"_type":"PositiveNumber","value":1}}}'`);
|
||||
await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type":"RestrictionTimes","value":{"restictionType":"None","dayRestrictionTimes":null,"weeklyRestrictionTimes":[]}}'`);
|
||||
await queryRunner.query(`ALTER TABLE "ProjectSCIM" ADD CONSTRAINT "FK_f916360335859c26c4d7051239b" FOREIGN KEY ("projectId") REFERENCES "Project"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "ProjectSCIM" ADD CONSTRAINT "FK_5d5d587984f156e5215d51daff7" FOREIGN KEY ("createdByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "ProjectSCIM" ADD CONSTRAINT "FK_9cadda4fc2af268b5670d02bf76" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`);
|
||||
await queryRunner.query(`ALTER TABLE "ProjectScimTeam" ADD CONSTRAINT "FK_b9a28efd66600267f0e9de0731b" FOREIGN KEY ("projectScimId") REFERENCES "ProjectSCIM"("_id") ON DELETE CASCADE ON UPDATE CASCADE`);
|
||||
await queryRunner.query(`ALTER TABLE "ProjectScimTeam" ADD CONSTRAINT "FK_bb0eda2ef0c773f975e9ad8448a" FOREIGN KEY ("teamId") REFERENCES "Team"("_id") ON DELETE CASCADE ON UPDATE CASCADE`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "ProjectScimTeam" DROP CONSTRAINT "FK_bb0eda2ef0c773f975e9ad8448a"`);
|
||||
await queryRunner.query(`ALTER TABLE "ProjectScimTeam" DROP CONSTRAINT "FK_b9a28efd66600267f0e9de0731b"`);
|
||||
await queryRunner.query(`ALTER TABLE "ProjectSCIM" DROP CONSTRAINT "FK_9cadda4fc2af268b5670d02bf76"`);
|
||||
await queryRunner.query(`ALTER TABLE "ProjectSCIM" DROP CONSTRAINT "FK_5d5d587984f156e5215d51daff7"`);
|
||||
await queryRunner.query(`ALTER TABLE "ProjectSCIM" DROP CONSTRAINT "FK_f916360335859c26c4d7051239b"`);
|
||||
await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "restrictionTimes" SET DEFAULT '{"_type": "RestrictionTimes", "value": {"restictionType": "None", "dayRestrictionTimes": null, "weeklyRestrictionTimes": []}}'`);
|
||||
await queryRunner.query(`ALTER TABLE "OnCallDutyPolicyScheduleLayer" ALTER COLUMN "rotation" SET DEFAULT '{"_type": "Recurring", "value": {"intervalType": "Day", "intervalCount": {"_type": "PositiveNumber", "value": 1}}}'`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_bb0eda2ef0c773f975e9ad8448"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_b9a28efd66600267f0e9de0731"`);
|
||||
await queryRunner.query(`DROP TABLE "ProjectScimTeam"`);
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_f916360335859c26c4d7051239"`);
|
||||
await queryRunner.query(`DROP TABLE "ProjectSCIM"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -138,6 +138,15 @@ import { MigrationName1749065784320 } from "./1749065784320-MigrationName";
|
||||
import { MigrationName1749133333893 } from "./1749133333893-MigrationName";
|
||||
import { MigrationName1749813704371 } from "./1749813704371-MigrationName";
|
||||
import { MigrationName1750250435756 } from "./1750250435756-MigrationName";
|
||||
import { MigrationName1752659054949 } from "./1752659054949-MigrationName";
|
||||
import { MigrationName1752774923063 } from "./1752774923063-MigrationName";
|
||||
import { MigrationName1753109689244 } from "./1753109689244-MigrationName";
|
||||
import { AddEnableCustomSubscriberEmailNotificationFooterText1753131488925 } from "./1753131488925-AddEnableCustomSubscriberEmailNotificationFooterText";
|
||||
import { MigrationName1753343522987 } from "./1753343522987-MigrationName";
|
||||
import { MigrationName1753377161288 } from "./1753377161288-MigrationName";
|
||||
import { AddPerformanceIndexes1753378524062 } from "./1753378524062-AddPerformanceIndexes";
|
||||
import { MigrationName1753383711511 } from "./1753383711511-MigrationName";
|
||||
import { MigrationName1754227633113 } from "./1754227633113-MigrationName";
|
||||
|
||||
export default [
|
||||
InitialMigration,
|
||||
@@ -280,4 +289,13 @@ export default [
|
||||
MigrationName1749133333893,
|
||||
MigrationName1749813704371,
|
||||
MigrationName1750250435756,
|
||||
MigrationName1752659054949,
|
||||
MigrationName1752774923063,
|
||||
MigrationName1753109689244,
|
||||
AddEnableCustomSubscriberEmailNotificationFooterText1753131488925,
|
||||
MigrationName1753343522987,
|
||||
MigrationName1753377161288,
|
||||
AddPerformanceIndexes1753378524062,
|
||||
MigrationName1753383711511,
|
||||
MigrationName1754227633113
|
||||
];
|
||||
|
||||
@@ -16,6 +16,11 @@ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
export enum QueueName {
|
||||
Workflow = "Workflow",
|
||||
Worker = "Worker",
|
||||
Telemetry = "Telemetry",
|
||||
FluentIngest = "FluentIngest",
|
||||
IncomingRequestIngest = "IncomingRequestIngest",
|
||||
ServerMonitorIngest = "ServerMonitorIngest",
|
||||
ProbeIngest = "ProbeIngest",
|
||||
}
|
||||
|
||||
export type QueueJob = Job;
|
||||
@@ -133,4 +138,97 @@ export default class Queue {
|
||||
|
||||
return jobAdded;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static async getQueueSize(queueName: QueueName): Promise<number> {
|
||||
const queue: BullQueue = this.getQueue(queueName);
|
||||
const waitingCount: number = await queue.getWaitingCount();
|
||||
const activeCount: number = await queue.getActiveCount();
|
||||
const delayedCount: number = await queue.getDelayedCount();
|
||||
|
||||
return waitingCount + activeCount + delayedCount;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static async getQueueStats(queueName: QueueName): Promise<{
|
||||
waiting: number;
|
||||
active: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
delayed: number;
|
||||
total: number;
|
||||
}> {
|
||||
const queue: BullQueue = this.getQueue(queueName);
|
||||
const waitingCount: number = await queue.getWaitingCount();
|
||||
const activeCount: number = await queue.getActiveCount();
|
||||
const completedCount: number = await queue.getCompletedCount();
|
||||
const failedCount: number = await queue.getFailedCount();
|
||||
const delayedCount: number = await queue.getDelayedCount();
|
||||
|
||||
return {
|
||||
waiting: waitingCount,
|
||||
active: activeCount,
|
||||
completed: completedCount,
|
||||
failed: failedCount,
|
||||
delayed: delayedCount,
|
||||
total:
|
||||
waitingCount +
|
||||
activeCount +
|
||||
completedCount +
|
||||
failedCount +
|
||||
delayedCount,
|
||||
};
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static async getFailedJobs(
|
||||
queueName: QueueName,
|
||||
options?: {
|
||||
start?: number;
|
||||
end?: number;
|
||||
},
|
||||
): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
data: JSONObject;
|
||||
failedReason: string;
|
||||
stackTrace?: string;
|
||||
processedOn: Date | null;
|
||||
finishedOn: Date | null;
|
||||
attemptsMade: number;
|
||||
}>
|
||||
> {
|
||||
const queue: BullQueue = this.getQueue(queueName);
|
||||
const start: number = options?.start || 0;
|
||||
const end: number = options?.end || 100;
|
||||
const failed: Job[] = await queue.getFailed(start, end);
|
||||
|
||||
return failed.map((job: Job) => {
|
||||
const result: {
|
||||
id: string;
|
||||
name: string;
|
||||
data: JSONObject;
|
||||
failedReason: string;
|
||||
stackTrace?: string;
|
||||
processedOn: Date | null;
|
||||
finishedOn: Date | null;
|
||||
attemptsMade: number;
|
||||
} = {
|
||||
id: job.id || "unknown",
|
||||
name: job.name || "unknown",
|
||||
data: job.data as JSONObject,
|
||||
failedReason: job.failedReason || "No reason provided",
|
||||
processedOn: job.processedOn ? new Date(job.processedOn) : null,
|
||||
finishedOn: job.finishedOn ? new Date(job.finishedOn) : null,
|
||||
attemptsMade: job.attemptsMade || 0,
|
||||
};
|
||||
|
||||
if (job.stacktrace && job.stacktrace.length > 0) {
|
||||
result.stackTrace = job.stacktrace.join('\n');
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ export default class ClusterKeyAuthorization {
|
||||
} else if (req.headers && req.headers["clusterkey"]) {
|
||||
// Header keys are automatically transformed to lowercase
|
||||
clusterKey = req.headers["clusterkey"] as string;
|
||||
} else if (req.headers && req.headers["x-clusterkey"]) {
|
||||
// KEDA TriggerAuthentication sends headers with X- prefix
|
||||
clusterKey = req.headers["x-clusterkey"] as string;
|
||||
} else if (req.body && req.body.clusterKey) {
|
||||
clusterKey = req.body.clusterKey;
|
||||
} else {
|
||||
|
||||
@@ -96,38 +96,40 @@ export default class ProjectMiddleware {
|
||||
props: { isRoot: true },
|
||||
});
|
||||
|
||||
tenantId = apiKeyModel?.projectId || null;
|
||||
|
||||
if (!tenantId) {
|
||||
throw new BadDataException("Invalid API Key");
|
||||
}
|
||||
|
||||
(req as OneUptimeRequest).tenantId = tenantId;
|
||||
|
||||
if (apiKeyModel) {
|
||||
(req as OneUptimeRequest).userType = UserType.API;
|
||||
// TODO: Add API key permissions.
|
||||
// (req as OneUptimeRequest).permissions =
|
||||
// apiKeyModel.permissions || [];
|
||||
(req as OneUptimeRequest).userGlobalAccessPermission =
|
||||
await APIKeyAccessPermission.getDefaultApiGlobalPermission(
|
||||
tenantId,
|
||||
);
|
||||
tenantId = apiKeyModel?.projectId || null;
|
||||
|
||||
const userTenantAccessPermission: UserTenantAccessPermission | null =
|
||||
await APIKeyAccessPermission.getApiTenantAccessPermission(
|
||||
tenantId,
|
||||
apiKeyModel.id!,
|
||||
);
|
||||
if (!tenantId) {
|
||||
throw new BadDataException("Invalid API Key");
|
||||
}
|
||||
|
||||
if (userTenantAccessPermission) {
|
||||
(req as OneUptimeRequest).userTenantAccessPermission = {};
|
||||
(
|
||||
(req as OneUptimeRequest)
|
||||
.userTenantAccessPermission as Dictionary<UserTenantAccessPermission>
|
||||
)[tenantId.toString()] = userTenantAccessPermission;
|
||||
(req as OneUptimeRequest).tenantId = tenantId;
|
||||
|
||||
return next();
|
||||
if (apiKeyModel) {
|
||||
(req as OneUptimeRequest).userType = UserType.API;
|
||||
// TODO: Add API key permissions.
|
||||
// (req as OneUptimeRequest).permissions =
|
||||
// apiKeyModel.permissions || [];
|
||||
(req as OneUptimeRequest).userGlobalAccessPermission =
|
||||
await APIKeyAccessPermission.getDefaultApiGlobalPermission(
|
||||
tenantId,
|
||||
);
|
||||
|
||||
const userTenantAccessPermission: UserTenantAccessPermission | null =
|
||||
await APIKeyAccessPermission.getApiTenantAccessPermission(
|
||||
tenantId,
|
||||
apiKeyModel.id!,
|
||||
);
|
||||
|
||||
if (userTenantAccessPermission) {
|
||||
(req as OneUptimeRequest).userTenantAccessPermission = {};
|
||||
(
|
||||
(req as OneUptimeRequest)
|
||||
.userTenantAccessPermission as Dictionary<UserTenantAccessPermission>
|
||||
)[tenantId.toString()] = userTenantAccessPermission;
|
||||
|
||||
return next();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import { MessageBlocksByWorkspaceType } from "./WorkspaceNotificationRuleService
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
import MetricType from "../../Models/DatabaseModels/MetricType";
|
||||
import Dictionary from "../../Types/Dictionary";
|
||||
import OnCallDutyPolicy from "../../Models/DatabaseModels/OnCallDutyPolicy";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
@@ -272,6 +273,7 @@ export class Service extends DatabaseService<Model> {
|
||||
throw new BadDataException("currentAlertStateId is required");
|
||||
}
|
||||
|
||||
// Get alert data for feed creation
|
||||
const alert: Model | null = await this.findOneById({
|
||||
id: createdItem.id,
|
||||
select: {
|
||||
@@ -304,147 +306,258 @@ export class Service extends DatabaseService<Model> {
|
||||
throw new BadDataException("Alert not found");
|
||||
}
|
||||
|
||||
const createdByUserId: ObjectID | undefined | null =
|
||||
createdItem.createdByUserId || createdItem.createdByUser?.id;
|
||||
// Execute core operations in parallel first
|
||||
const coreOperations: Array<Promise<any>> = [];
|
||||
|
||||
// send message to workspaces - slack, teams, etc.
|
||||
const workspaceResult: {
|
||||
channelsCreated: Array<NotificationRuleWorkspaceChannel>;
|
||||
} | null =
|
||||
await AlertWorkspaceMessages.createChannelsAndInviteUsersToChannels({
|
||||
projectId: createdItem.projectId,
|
||||
alertId: createdItem.id!,
|
||||
alertNumber: createdItem.alertNumber!,
|
||||
});
|
||||
// Create feed item asynchronously
|
||||
coreOperations.push(this.createAlertFeedAsync(alert, createdItem));
|
||||
|
||||
logger.debug("Alert created. Workspace result:");
|
||||
logger.debug(workspaceResult);
|
||||
|
||||
if (workspaceResult && workspaceResult.channelsCreated?.length > 0) {
|
||||
// update alert with these channels.
|
||||
await this.updateOneById({
|
||||
id: createdItem.id!,
|
||||
data: {
|
||||
postUpdatesToWorkspaceChannels: workspaceResult.channelsCreated || [],
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let feedInfoInMarkdown: string = `#### 🚨 Alert ${createdItem.alertNumber?.toString()} Created:
|
||||
|
||||
**${createdItem.title || "No title provided."}**:
|
||||
|
||||
${createdItem.description || "No description provided."}
|
||||
|
||||
`;
|
||||
|
||||
if (alert.currentAlertState?.name) {
|
||||
feedInfoInMarkdown += `🔴 **Alert State**: ${alert.currentAlertState.name} \n\n`;
|
||||
}
|
||||
|
||||
if (alert.alertSeverity?.name) {
|
||||
feedInfoInMarkdown += `⚠️ **Severity**: ${alert.alertSeverity.name} \n\n`;
|
||||
}
|
||||
|
||||
if (alert.monitor) {
|
||||
feedInfoInMarkdown += `🌎 **Resources Affected**:\n`;
|
||||
|
||||
const monitor: Monitor = alert.monitor;
|
||||
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(createdItem.projectId!, monitor.id!)).toString()})\n`;
|
||||
|
||||
feedInfoInMarkdown += `\n\n`;
|
||||
}
|
||||
|
||||
if (createdItem.rootCause) {
|
||||
feedInfoInMarkdown += `\n
|
||||
📄 **Root Cause**:
|
||||
|
||||
${createdItem.rootCause || "No root cause provided."}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
if (createdItem.remediationNotes) {
|
||||
feedInfoInMarkdown += `\n
|
||||
🎯 **Remediation Notes**:
|
||||
|
||||
${createdItem.remediationNotes || "No remediation notes provided."}
|
||||
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
const alertCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
|
||||
await AlertWorkspaceMessages.getAlertCreateMessageBlocks({
|
||||
alertId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
});
|
||||
|
||||
await AlertFeedService.createAlertFeedItem({
|
||||
alertId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
alertFeedEventType: AlertFeedEventType.AlertCreated,
|
||||
displayColor: Red500,
|
||||
feedInfoInMarkdown: feedInfoInMarkdown,
|
||||
userId: createdByUserId || undefined,
|
||||
workspaceNotification: {
|
||||
appendMessageBlocks: alertCreateMessageBlocks,
|
||||
sendWorkspaceNotification: true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.changeAlertState({
|
||||
projectId: createdItem.projectId,
|
||||
alertId: createdItem.id,
|
||||
alertStateId: createdItem.currentAlertStateId,
|
||||
notifyOwners: false,
|
||||
rootCause: createdItem.rootCause,
|
||||
stateChangeLog: createdItem.createdStateLog,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
// add owners.
|
||||
// Handle state change asynchronously
|
||||
coreOperations.push(this.handleAlertStateChangeAsync(createdItem));
|
||||
|
||||
// Handle owner assignment asynchronously
|
||||
if (
|
||||
onCreate.createBy.miscDataProps &&
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
await this.addOwners(
|
||||
createdItem.projectId,
|
||||
createdItem.id,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
coreOperations.push(
|
||||
this.addOwners(
|
||||
createdItem.projectId,
|
||||
createdItem.id,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
createdItem.onCallDutyPolicies?.length &&
|
||||
createdItem.onCallDutyPolicies?.length > 0
|
||||
) {
|
||||
for (const policy of createdItem.onCallDutyPolicies) {
|
||||
await OnCallDutyPolicyService.executePolicy(
|
||||
new ObjectID(policy._id as string),
|
||||
{
|
||||
triggeredByAlertId: createdItem.id!,
|
||||
userNotificationEventType: UserNotificationEventType.AlertCreated,
|
||||
},
|
||||
// Execute core operations in parallel with error handling
|
||||
Promise.allSettled(coreOperations)
|
||||
.then((coreResults: any[]) => {
|
||||
// Log any errors from core operations
|
||||
coreResults.forEach((result: any, index: number) => {
|
||||
if (result.status === "rejected") {
|
||||
logger.error(
|
||||
`Core operation ${index} failed in AlertService.onCreateSuccess: ${result.reason}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle on-call duty policies asynchronously
|
||||
if (
|
||||
createdItem.onCallDutyPolicies?.length &&
|
||||
createdItem.onCallDutyPolicies?.length > 0
|
||||
) {
|
||||
this.executeAlertOnCallDutyPoliciesAsync(createdItem).catch(
|
||||
(error: Error) => {
|
||||
logger.error(
|
||||
`On-call duty policy execution failed in AlertService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Handle workspace operations after core operations complete
|
||||
if (createdItem.projectId && createdItem.id) {
|
||||
// Run workspace operations in background without blocking response
|
||||
this.handleAlertWorkspaceOperationsAsync(createdItem).catch(
|
||||
(error: Error) => {
|
||||
logger.error(
|
||||
`Workspace operations failed in AlertService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
logger.error(
|
||||
`Critical error in AlertService core operations: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return createdItem;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async handleAlertWorkspaceOperationsAsync(
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!createdItem.projectId || !createdItem.id) {
|
||||
throw new BadDataException(
|
||||
"projectId and id are required for workspace operations",
|
||||
);
|
||||
}
|
||||
|
||||
// send message to workspaces - slack, teams, etc.
|
||||
const workspaceResult: {
|
||||
channelsCreated: Array<NotificationRuleWorkspaceChannel>;
|
||||
} | null =
|
||||
await AlertWorkspaceMessages.createChannelsAndInviteUsersToChannels({
|
||||
projectId: createdItem.projectId,
|
||||
alertId: createdItem.id,
|
||||
alertNumber: createdItem.alertNumber!,
|
||||
});
|
||||
|
||||
logger.debug("Alert created. Workspace result:");
|
||||
logger.debug(workspaceResult);
|
||||
|
||||
if (workspaceResult && workspaceResult.channelsCreated?.length > 0) {
|
||||
// update alert with these channels.
|
||||
await this.updateOneById({
|
||||
id: createdItem.id,
|
||||
data: {
|
||||
postUpdatesToWorkspaceChannels:
|
||||
workspaceResult.channelsCreated || [],
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error in handleAlertWorkspaceOperationsAsync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async createAlertFeedAsync(
|
||||
alert: Model,
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const createdByUserId: ObjectID | undefined | null =
|
||||
createdItem.createdByUserId || createdItem.createdByUser?.id;
|
||||
|
||||
let feedInfoInMarkdown: string = `#### 🚨 Alert ${createdItem.alertNumber?.toString()} Created:
|
||||
|
||||
**${createdItem.title || "No title provided."}**:
|
||||
|
||||
${createdItem.description || "No description provided."}
|
||||
|
||||
`;
|
||||
|
||||
if (alert.currentAlertState?.name) {
|
||||
feedInfoInMarkdown += `🔴 **Alert State**: ${alert.currentAlertState.name} \n\n`;
|
||||
}
|
||||
|
||||
if (alert.alertSeverity?.name) {
|
||||
feedInfoInMarkdown += `⚠️ **Severity**: ${alert.alertSeverity.name} \n\n`;
|
||||
}
|
||||
|
||||
if (alert.monitor) {
|
||||
feedInfoInMarkdown += `🌎 **Resources Affected**:\n`;
|
||||
|
||||
const monitor: Monitor = alert.monitor;
|
||||
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(createdItem.projectId!, monitor.id!)).toString()})\n`;
|
||||
|
||||
feedInfoInMarkdown += `\n\n`;
|
||||
}
|
||||
|
||||
if (createdItem.rootCause) {
|
||||
feedInfoInMarkdown += `\n
|
||||
📄 **Root Cause**:
|
||||
|
||||
${createdItem.rootCause || "No root cause provided."}
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
if (createdItem.remediationNotes) {
|
||||
feedInfoInMarkdown += `\n
|
||||
🎯 **Remediation Notes**:
|
||||
|
||||
${createdItem.remediationNotes || "No remediation notes provided."}
|
||||
|
||||
|
||||
`;
|
||||
}
|
||||
|
||||
const alertCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
|
||||
await AlertWorkspaceMessages.getAlertCreateMessageBlocks({
|
||||
alertId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
});
|
||||
|
||||
await AlertFeedService.createAlertFeedItem({
|
||||
alertId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
alertFeedEventType: AlertFeedEventType.AlertCreated,
|
||||
displayColor: Red500,
|
||||
feedInfoInMarkdown: feedInfoInMarkdown,
|
||||
userId: createdByUserId || undefined,
|
||||
workspaceNotification: {
|
||||
appendMessageBlocks: alertCreateMessageBlocks,
|
||||
sendWorkspaceNotification: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Error in createAlertFeedAsync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async handleAlertStateChangeAsync(createdItem: Model): Promise<void> {
|
||||
try {
|
||||
if (!createdItem.projectId || !createdItem.id) {
|
||||
throw new BadDataException(
|
||||
"projectId and id are required for state change",
|
||||
);
|
||||
}
|
||||
|
||||
await this.changeAlertState({
|
||||
projectId: createdItem.projectId,
|
||||
alertId: createdItem.id,
|
||||
alertStateId: createdItem.currentAlertStateId!,
|
||||
notifyOwners: false,
|
||||
rootCause: createdItem.rootCause,
|
||||
stateChangeLog: createdItem.createdStateLog,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Error in handleAlertStateChangeAsync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async executeAlertOnCallDutyPoliciesAsync(
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (
|
||||
createdItem.onCallDutyPolicies?.length &&
|
||||
createdItem.onCallDutyPolicies?.length > 0
|
||||
) {
|
||||
// Execute all on-call policies in parallel
|
||||
const policyPromises: Promise<void>[] =
|
||||
createdItem.onCallDutyPolicies.map((policy: OnCallDutyPolicy) => {
|
||||
return OnCallDutyPolicyService.executePolicy(
|
||||
new ObjectID(policy["_id"] as string),
|
||||
{
|
||||
triggeredByAlertId: createdItem.id!,
|
||||
userNotificationEventType:
|
||||
UserNotificationEventType.AlertCreated,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.allSettled(policyPromises);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error in executeAlertOnCallDutyPoliciesAsync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async getWorkspaceChannelForAlert(data: {
|
||||
alertId: ObjectID;
|
||||
|
||||
@@ -59,9 +59,10 @@ import WorkspaceType from "../../Types/Workspace/WorkspaceType";
|
||||
import { MessageBlocksByWorkspaceType } from "./WorkspaceNotificationRuleService";
|
||||
import NotificationRuleWorkspaceChannel from "../../Types/Workspace/NotificationRules/NotificationRuleWorkspaceChannel";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
import { Dictionary } from "lodash";
|
||||
import MetricType from "../../Models/DatabaseModels/MetricType";
|
||||
import UpdateBy from "../Types/Database/UpdateBy";
|
||||
import OnCallDutyPolicy from "../../Models/DatabaseModels/OnCallDutyPolicy";
|
||||
import Dictionary from "../../Types/Dictionary";
|
||||
|
||||
// key is incidentId for this dictionary.
|
||||
type UpdateCarryForward = Dictionary<{
|
||||
@@ -544,6 +545,7 @@ export class Service extends DatabaseService<Model> {
|
||||
throw new BadDataException("id is required");
|
||||
}
|
||||
|
||||
// Get incident data for feed creation
|
||||
const incident: Model | null = await this.findOneById({
|
||||
id: createdItem.id,
|
||||
select: {
|
||||
@@ -576,202 +578,343 @@ export class Service extends DatabaseService<Model> {
|
||||
throw new BadDataException("Incident not found");
|
||||
}
|
||||
|
||||
// release the mutex.
|
||||
if (onCreate.carryForward && onCreate.carryForward.mutex) {
|
||||
const mutex: SemaphoreMutex = onCreate.carryForward.mutex;
|
||||
const projectId: ObjectID = createdItem.projectId!;
|
||||
// Execute core operations in parallel first
|
||||
const coreOperations: Array<Promise<any>> = [];
|
||||
|
||||
try {
|
||||
await Semaphore.release(mutex);
|
||||
logger.debug(
|
||||
"Mutex released - IncidentService.incident-create " +
|
||||
projectId.toString() +
|
||||
" at " +
|
||||
OneUptimeDate.getCurrentDateAsFormattedString(),
|
||||
// Create feed item asynchronously
|
||||
coreOperations.push(this.createIncidentFeedAsync(incident, createdItem));
|
||||
|
||||
// Handle state change asynchronously
|
||||
coreOperations.push(this.handleIncidentStateChangeAsync(createdItem));
|
||||
|
||||
// Handle owner assignment asynchronously
|
||||
if (
|
||||
onCreate.createBy.miscDataProps &&
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
coreOperations.push(
|
||||
this.addOwners(
|
||||
createdItem.projectId,
|
||||
createdItem.id,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Handle monitor status change and active monitoring asynchronously
|
||||
if (createdItem.changeMonitorStatusToId && createdItem.projectId) {
|
||||
coreOperations.push(
|
||||
this.handleMonitorStatusChangeAsync(createdItem, onCreate),
|
||||
);
|
||||
}
|
||||
|
||||
coreOperations.push(
|
||||
this.disableActiveMonitoringIfManualIncident(createdItem.id!),
|
||||
);
|
||||
|
||||
// Release mutex immediately
|
||||
this.releaseMutexAsync(onCreate, createdItem.projectId!);
|
||||
|
||||
// Execute core operations in parallel with error handling
|
||||
Promise.allSettled(coreOperations)
|
||||
.then((coreResults: any[]) => {
|
||||
// Log any errors from core operations
|
||||
coreResults.forEach((result: any, index: number) => {
|
||||
if (result.status === "rejected") {
|
||||
logger.error(
|
||||
`Core operation ${index} failed in IncidentService.onCreateSuccess: ${result.reason}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle on-call duty policies asynchronously
|
||||
if (
|
||||
createdItem.onCallDutyPolicies?.length &&
|
||||
createdItem.onCallDutyPolicies?.length > 0
|
||||
) {
|
||||
this.executeOnCallDutyPoliciesAsync(createdItem).catch(
|
||||
(error: Error) => {
|
||||
logger.error(
|
||||
`On-call duty policy execution failed in IncidentService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Handle workspace operations after core operations complete
|
||||
if (createdItem.projectId && createdItem.id) {
|
||||
// Run workspace operations in background without blocking response
|
||||
this.handleIncidentWorkspaceOperationsAsync(createdItem).catch(
|
||||
(error: Error) => {
|
||||
logger.error(
|
||||
`Workspace operations failed in IncidentService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
logger.error(
|
||||
`Critical error in IncidentService core operations: ${error}`,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
"Mutex release failed - IncidentService.incident-create " +
|
||||
projectId.toString() +
|
||||
" at " +
|
||||
OneUptimeDate.getCurrentDateAsFormattedString(),
|
||||
});
|
||||
|
||||
return createdItem;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async handleIncidentWorkspaceOperationsAsync(
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!createdItem.projectId || !createdItem.id) {
|
||||
throw new BadDataException(
|
||||
"projectId and id are required for workspace operations",
|
||||
);
|
||||
logger.error(err);
|
||||
}
|
||||
|
||||
// send message to workspaces - slack, teams, etc.
|
||||
const workspaceResult: {
|
||||
channelsCreated: Array<NotificationRuleWorkspaceChannel>;
|
||||
} | null =
|
||||
await IncidentWorkspaceMessages.createChannelsAndInviteUsersToChannels({
|
||||
projectId: createdItem.projectId,
|
||||
incidentId: createdItem.id,
|
||||
incidentNumber: createdItem.incidentNumber!,
|
||||
});
|
||||
|
||||
if (workspaceResult && workspaceResult.channelsCreated?.length > 0) {
|
||||
// update incident with these channels.
|
||||
await this.updateOneById({
|
||||
id: createdItem.id,
|
||||
data: {
|
||||
postUpdatesToWorkspaceChannels:
|
||||
workspaceResult.channelsCreated || [],
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error in handleIncidentWorkspaceOperationsAsync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const createdByUserId: ObjectID | undefined | null =
|
||||
createdItem.createdByUserId || createdItem.createdByUser?.id;
|
||||
@CaptureSpan()
|
||||
private async createIncidentFeedAsync(
|
||||
incident: Model,
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const createdByUserId: ObjectID | undefined | null =
|
||||
createdItem.createdByUserId || createdItem.createdByUser?.id;
|
||||
|
||||
// send message to workspaces - slack, teams, etc.
|
||||
const workspaceResult: {
|
||||
channelsCreated: Array<NotificationRuleWorkspaceChannel>;
|
||||
} | null =
|
||||
await IncidentWorkspaceMessages.createChannelsAndInviteUsersToChannels({
|
||||
projectId: createdItem.projectId,
|
||||
incidentId: createdItem.id!,
|
||||
incidentNumber: createdItem.incidentNumber!,
|
||||
});
|
||||
|
||||
if (workspaceResult && workspaceResult.channelsCreated?.length > 0) {
|
||||
// update incident with these channels.
|
||||
await this.updateOneById({
|
||||
id: createdItem.id!,
|
||||
data: {
|
||||
postUpdatesToWorkspaceChannels: workspaceResult.channelsCreated || [],
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let feedInfoInMarkdown: string = `#### 🚨 Incident ${createdItem.incidentNumber?.toString()} Created:
|
||||
|
||||
let feedInfoInMarkdown: string = `#### 🚨 Incident ${createdItem.incidentNumber?.toString()} Created:
|
||||
|
||||
**${createdItem.title || "No title provided."}**:
|
||||
|
||||
${createdItem.description || "No description provided."}
|
||||
|
||||
`;
|
||||
|
||||
if (incident.currentIncidentState?.name) {
|
||||
feedInfoInMarkdown += `🔴 **Incident State**: ${incident.currentIncidentState.name} \n\n`;
|
||||
}
|
||||
|
||||
if (incident.incidentSeverity?.name) {
|
||||
feedInfoInMarkdown += `⚠️ **Severity**: ${incident.incidentSeverity.name} \n\n`;
|
||||
}
|
||||
|
||||
if (incident.monitors && incident.monitors.length > 0) {
|
||||
feedInfoInMarkdown += `🌎 **Resources Affected**:\n`;
|
||||
|
||||
for (const monitor of incident.monitors) {
|
||||
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(createdItem.projectId!, monitor.id!)).toString()})\n`;
|
||||
if (incident.currentIncidentState?.name) {
|
||||
feedInfoInMarkdown += `🔴 **Incident State**: ${incident.currentIncidentState.name} \n\n`;
|
||||
}
|
||||
|
||||
feedInfoInMarkdown += `\n\n`;
|
||||
}
|
||||
if (incident.incidentSeverity?.name) {
|
||||
feedInfoInMarkdown += `⚠️ **Severity**: ${incident.incidentSeverity.name} \n\n`;
|
||||
}
|
||||
|
||||
if (createdItem.rootCause) {
|
||||
feedInfoInMarkdown += `\n
|
||||
if (incident.monitors && incident.monitors.length > 0) {
|
||||
feedInfoInMarkdown += `🌎 **Resources Affected**:\n`;
|
||||
|
||||
for (const monitor of incident.monitors) {
|
||||
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(createdItem.projectId!, monitor.id!)).toString()})\n`;
|
||||
}
|
||||
|
||||
feedInfoInMarkdown += `\n\n`;
|
||||
}
|
||||
|
||||
if (createdItem.rootCause) {
|
||||
feedInfoInMarkdown += `\n
|
||||
📄 **Root Cause**:
|
||||
|
||||
${createdItem.rootCause || "No root cause provided."}
|
||||
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (createdItem.remediationNotes) {
|
||||
feedInfoInMarkdown += `\n
|
||||
if (createdItem.remediationNotes) {
|
||||
feedInfoInMarkdown += `\n
|
||||
🎯 **Remediation Notes**:
|
||||
|
||||
${createdItem.remediationNotes || "No remediation notes provided."}
|
||||
|
||||
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
const incidentCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
|
||||
await IncidentWorkspaceMessages.getIncidentCreateMessageBlocks({
|
||||
const incidentCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
|
||||
await IncidentWorkspaceMessages.getIncidentCreateMessageBlocks({
|
||||
incidentId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
});
|
||||
|
||||
await IncidentFeedService.createIncidentFeedItem({
|
||||
incidentId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
incidentFeedEventType: IncidentFeedEventType.IncidentCreated,
|
||||
displayColor: Red500,
|
||||
feedInfoInMarkdown: feedInfoInMarkdown,
|
||||
userId: createdByUserId || undefined,
|
||||
workspaceNotification: {
|
||||
appendMessageBlocks: incidentCreateMessageBlocks,
|
||||
sendWorkspaceNotification: true,
|
||||
},
|
||||
});
|
||||
|
||||
await IncidentFeedService.createIncidentFeedItem({
|
||||
incidentId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
incidentFeedEventType: IncidentFeedEventType.IncidentCreated,
|
||||
displayColor: Red500,
|
||||
feedInfoInMarkdown: feedInfoInMarkdown,
|
||||
userId: createdByUserId || undefined,
|
||||
workspaceNotification: {
|
||||
appendMessageBlocks: incidentCreateMessageBlocks,
|
||||
sendWorkspaceNotification: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!createdItem.currentIncidentStateId) {
|
||||
throw new BadDataException("currentIncidentStateId is required");
|
||||
} catch (error) {
|
||||
logger.error(`Error in createIncidentFeedAsync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (createdItem.changeMonitorStatusToId && createdItem.projectId) {
|
||||
// change status of all the monitors.
|
||||
await MonitorService.changeMonitorStatus(
|
||||
createdItem.projectId,
|
||||
createdItem.monitors?.map((monitor: Monitor) => {
|
||||
return new ObjectID(monitor._id || "");
|
||||
}) || [],
|
||||
createdItem.changeMonitorStatusToId,
|
||||
true, // notifyMonitorOwners
|
||||
createdItem.rootCause ||
|
||||
"Status was changed because Incident #" +
|
||||
createdItem.incidentNumber?.toString() +
|
||||
" was created.",
|
||||
createdItem.createdStateLog,
|
||||
onCreate.createBy.props,
|
||||
);
|
||||
}
|
||||
|
||||
await this.changeIncidentState({
|
||||
projectId: createdItem.projectId,
|
||||
incidentId: createdItem.id,
|
||||
incidentStateId: createdItem.currentIncidentStateId,
|
||||
shouldNotifyStatusPageSubscribers: Boolean(
|
||||
createdItem.shouldStatusPageSubscribersBeNotifiedOnIncidentCreated,
|
||||
),
|
||||
isSubscribersNotified: Boolean(
|
||||
createdItem.shouldStatusPageSubscribersBeNotifiedOnIncidentCreated,
|
||||
), // we dont want to notify subscribers when incident state changes because they are already notified when the incident is created.
|
||||
notifyOwners: false,
|
||||
rootCause: createdItem.rootCause,
|
||||
stateChangeLog: createdItem.createdStateLog,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
// add owners.
|
||||
|
||||
if (
|
||||
onCreate.createBy.miscDataProps &&
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
await this.addOwners(
|
||||
createdItem.projectId,
|
||||
createdItem.id,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
createdItem.onCallDutyPolicies?.length &&
|
||||
createdItem.onCallDutyPolicies?.length > 0
|
||||
) {
|
||||
for (const policy of createdItem.onCallDutyPolicies) {
|
||||
await OnCallDutyPolicyService.executePolicy(
|
||||
new ObjectID(policy._id as string),
|
||||
{
|
||||
triggeredByIncidentId: createdItem.id!,
|
||||
userNotificationEventType:
|
||||
UserNotificationEventType.IncidentCreated,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// check if the incident is created manaull by a user and if thats the case, then disable active monitoting on that monitor.
|
||||
|
||||
await this.disableActiveMonitoringIfManualIncident(createdItem.id!);
|
||||
|
||||
return createdItem;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async handleIncidentStateChangeAsync(
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!createdItem.currentIncidentStateId) {
|
||||
throw new BadDataException("currentIncidentStateId is required");
|
||||
}
|
||||
|
||||
if (!createdItem.projectId || !createdItem.id) {
|
||||
throw new BadDataException(
|
||||
"projectId and id are required for state change",
|
||||
);
|
||||
}
|
||||
|
||||
await this.changeIncidentState({
|
||||
projectId: createdItem.projectId,
|
||||
incidentId: createdItem.id,
|
||||
incidentStateId: createdItem.currentIncidentStateId,
|
||||
shouldNotifyStatusPageSubscribers: Boolean(
|
||||
createdItem.shouldStatusPageSubscribersBeNotifiedOnIncidentCreated,
|
||||
),
|
||||
isSubscribersNotified: Boolean(
|
||||
createdItem.shouldStatusPageSubscribersBeNotifiedOnIncidentCreated,
|
||||
), // we dont want to notify subscribers when incident state changes because they are already notified when the incident is created.
|
||||
notifyOwners: false,
|
||||
rootCause: createdItem.rootCause,
|
||||
stateChangeLog: createdItem.createdStateLog,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Error in handleIncidentStateChangeAsync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async executeOnCallDutyPoliciesAsync(
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (
|
||||
createdItem.onCallDutyPolicies?.length &&
|
||||
createdItem.onCallDutyPolicies?.length > 0
|
||||
) {
|
||||
// Execute all on-call policies in parallel
|
||||
const policyPromises: Promise<void>[] =
|
||||
createdItem.onCallDutyPolicies.map((policy: OnCallDutyPolicy) => {
|
||||
return OnCallDutyPolicyService.executePolicy(
|
||||
new ObjectID(policy["_id"] as string),
|
||||
{
|
||||
triggeredByIncidentId: createdItem.id!,
|
||||
userNotificationEventType:
|
||||
UserNotificationEventType.IncidentCreated,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.allSettled(policyPromises);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error in executeOnCallDutyPoliciesAsync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async handleMonitorStatusChangeAsync(
|
||||
createdItem: Model,
|
||||
onCreate: OnCreate<Model>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (createdItem.changeMonitorStatusToId && createdItem.projectId) {
|
||||
// change status of all the monitors.
|
||||
await MonitorService.changeMonitorStatus(
|
||||
createdItem.projectId,
|
||||
createdItem.monitors?.map((monitor: Monitor) => {
|
||||
return new ObjectID(monitor._id || "");
|
||||
}) || [],
|
||||
createdItem.changeMonitorStatusToId,
|
||||
true, // notifyMonitorOwners
|
||||
createdItem.rootCause ||
|
||||
"Status was changed because Incident #" +
|
||||
createdItem.incidentNumber?.toString() +
|
||||
" was created.",
|
||||
createdItem.createdStateLog,
|
||||
onCreate.createBy.props,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error in handleMonitorStatusChangeAsync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private releaseMutexAsync(
|
||||
onCreate: OnCreate<Model>,
|
||||
projectId: ObjectID,
|
||||
): void {
|
||||
// Release mutex in background without blocking
|
||||
if (onCreate.carryForward && onCreate.carryForward.mutex) {
|
||||
const mutex: SemaphoreMutex = onCreate.carryForward.mutex;
|
||||
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
await Semaphore.release(mutex);
|
||||
logger.debug(
|
||||
"Mutex released - IncidentService.incident-create " +
|
||||
projectId.toString() +
|
||||
" at " +
|
||||
OneUptimeDate.getCurrentDateAsFormattedString(),
|
||||
);
|
||||
} catch (err) {
|
||||
logger.debug(
|
||||
"Mutex release failed - IncidentService.incident-create " +
|
||||
projectId.toString() +
|
||||
" at " +
|
||||
OneUptimeDate.getCurrentDateAsFormattedString(),
|
||||
);
|
||||
logger.error(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async disableActiveMonitoringIfManualIncident(
|
||||
incidentId: ObjectID,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -116,8 +116,9 @@ export class Service extends DatabaseService<IncidentStateTimeline> {
|
||||
throw new BadDataException("incidentStateId is null");
|
||||
}
|
||||
|
||||
const stateBeforeThis: IncidentStateTimeline | null =
|
||||
await this.findOneBy({
|
||||
// Execute queries for before and after states in parallel for better performance
|
||||
const [stateBeforeThis, stateAfterThis] = await Promise.all([
|
||||
this.findOneBy({
|
||||
query: {
|
||||
incidentId: createBy.data.incidentId,
|
||||
startsAt: QueryHelper.lessThanEqualTo(createBy.data.startsAt),
|
||||
@@ -138,7 +139,25 @@ export class Service extends DatabaseService<IncidentStateTimeline> {
|
||||
startsAt: true,
|
||||
endsAt: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
this.findOneBy({
|
||||
query: {
|
||||
incidentId: createBy.data.incidentId,
|
||||
startsAt: QueryHelper.greaterThan(createBy.data.startsAt),
|
||||
},
|
||||
sort: {
|
||||
startsAt: SortOrder.Ascending,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
select: {
|
||||
incidentStateId: true,
|
||||
startsAt: true,
|
||||
endsAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
logger.debug("State Before this");
|
||||
logger.debug(stateBeforeThis);
|
||||
@@ -197,26 +216,6 @@ export class Service extends DatabaseService<IncidentStateTimeline> {
|
||||
}
|
||||
}
|
||||
|
||||
const stateAfterThis: IncidentStateTimeline | null = await this.findOneBy(
|
||||
{
|
||||
query: {
|
||||
incidentId: createBy.data.incidentId,
|
||||
startsAt: QueryHelper.greaterThan(createBy.data.startsAt),
|
||||
},
|
||||
sort: {
|
||||
startsAt: SortOrder.Ascending,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
select: {
|
||||
incidentStateId: true,
|
||||
startsAt: true,
|
||||
endsAt: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// compute ends at. It's the start of the next status.
|
||||
if (stateAfterThis && stateAfterThis.startsAt) {
|
||||
createBy.data.endsAt = stateAfterThis.startsAt;
|
||||
|
||||
@@ -66,6 +66,7 @@ import LabelService from "./LabelService";
|
||||
import QueryOperator from "../../Types/BaseDatabase/QueryOperator";
|
||||
import { FindWhere } from "../../Types/BaseDatabase/Query";
|
||||
import logger from "../Utils/Logger";
|
||||
import PushNotificationUtil from "../Utils/PushNotificationUtil";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
@@ -501,20 +502,132 @@ ${createdItem.description?.trim() || "No description provided."}
|
||||
feedInfoInMarkdown += `\n\n`;
|
||||
}
|
||||
|
||||
// send message to workspaces - slack, teams, etc.
|
||||
// Parallelize operations that don't depend on each other
|
||||
const parallelOperations: Array<Promise<any>> = [];
|
||||
|
||||
// 1. Essential monitor status operation (must complete first)
|
||||
await this.changeMonitorStatus(
|
||||
createdItem.projectId,
|
||||
[createdItem.id],
|
||||
createdItem.currentMonitorStatusId,
|
||||
false, // notifyOwners = false
|
||||
"This status was created when the monitor was created.",
|
||||
undefined,
|
||||
onCreate.createBy.props,
|
||||
);
|
||||
|
||||
// 2. Start core operations in parallel that can run asynchronously (excluding workspace operations)
|
||||
|
||||
// Add default probes if needed (can be slow with many probes)
|
||||
if (
|
||||
createdItem.monitorType &&
|
||||
MonitorTypeHelper.isProbableMonitor(createdItem.monitorType)
|
||||
) {
|
||||
parallelOperations.push(
|
||||
this.addDefaultProbesToMonitor(
|
||||
createdItem.projectId,
|
||||
createdItem.id,
|
||||
).catch((error: Error) => {
|
||||
logger.error("Error in adding default probes");
|
||||
logger.error(error);
|
||||
// Don't fail monitor creation due to probe creation issues
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Billing operations
|
||||
if (IsBillingEnabled) {
|
||||
parallelOperations.push(
|
||||
ActiveMonitoringMeteredPlan.reportQuantityToBillingProvider(
|
||||
createdItem.projectId,
|
||||
).catch((error: Error) => {
|
||||
logger.error("Error in billing operations");
|
||||
logger.error(error);
|
||||
// Don't fail monitor creation due to billing issues
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Owner operations
|
||||
if (
|
||||
onCreate.createBy.miscDataProps &&
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
parallelOperations.push(
|
||||
this.addOwners(
|
||||
createdItem.projectId,
|
||||
createdItem.id,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
).catch((error: Error) => {
|
||||
logger.error("Error in adding owners");
|
||||
logger.error(error);
|
||||
// Don't fail monitor creation due to owner issues
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Probe status refresh (can be expensive with many probes)
|
||||
parallelOperations.push(
|
||||
this.refreshMonitorProbeStatus(createdItem.id).catch((error: Error) => {
|
||||
logger.error("Error in refreshing probe status");
|
||||
logger.error(error);
|
||||
// Don't fail monitor creation due to probe status issues
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for core operations to complete, then handle workspace operations
|
||||
Promise.allSettled(parallelOperations)
|
||||
.then(() => {
|
||||
// Handle workspace operations after core operations complete
|
||||
// Run workspace operations in background without blocking response
|
||||
this.handleWorkspaceOperationsAsync({
|
||||
projectId: createdItem.projectId!,
|
||||
monitorId: createdItem.id!,
|
||||
monitorName: createdItem.name!,
|
||||
feedInfoInMarkdown,
|
||||
createdByUserId,
|
||||
}).catch((error: Error) => {
|
||||
logger.error("Error in workspace operations");
|
||||
logger.error(error);
|
||||
// Don't fail monitor creation due to workspace issues
|
||||
});
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
logger.error("Error in parallel monitor creation operations");
|
||||
logger.error(error);
|
||||
});
|
||||
|
||||
return createdItem;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async handleWorkspaceOperationsAsync(data: {
|
||||
projectId: ObjectID;
|
||||
monitorId: ObjectID;
|
||||
monitorName: string;
|
||||
feedInfoInMarkdown: string;
|
||||
createdByUserId: ObjectID | undefined | null;
|
||||
}): Promise<void> {
|
||||
// send message to workspaces - slack, teams, etc.
|
||||
const workspaceResult: {
|
||||
channelsCreated: Array<NotificationRuleWorkspaceChannel>;
|
||||
} | null =
|
||||
await MonitorWorkspaceMessages.createChannelsAndInviteUsersToChannels({
|
||||
projectId: createdItem.projectId,
|
||||
monitorId: createdItem.id!,
|
||||
monitorName: createdItem.name!,
|
||||
projectId: data.projectId,
|
||||
monitorId: data.monitorId,
|
||||
monitorName: data.monitorName,
|
||||
});
|
||||
|
||||
if (workspaceResult && workspaceResult.channelsCreated?.length > 0) {
|
||||
// update incident with these channels.
|
||||
// update monitor with these channels.
|
||||
await this.updateOneById({
|
||||
id: createdItem.id!,
|
||||
id: data.monitorId,
|
||||
data: {
|
||||
postUpdatesToWorkspaceChannels: workspaceResult.channelsCreated || [],
|
||||
},
|
||||
@@ -526,72 +639,22 @@ ${createdItem.description?.trim() || "No description provided."}
|
||||
|
||||
const monitorCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
|
||||
await MonitorWorkspaceMessages.getMonitorCreateMessageBlocks({
|
||||
monitorId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
monitorId: data.monitorId,
|
||||
projectId: data.projectId,
|
||||
});
|
||||
|
||||
await MonitorFeedService.createMonitorFeedItem({
|
||||
monitorId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
monitorId: data.monitorId,
|
||||
projectId: data.projectId,
|
||||
monitorFeedEventType: MonitorFeedEventType.MonitorCreated,
|
||||
displayColor: Green500,
|
||||
feedInfoInMarkdown: feedInfoInMarkdown,
|
||||
userId: createdByUserId || undefined,
|
||||
feedInfoInMarkdown: data.feedInfoInMarkdown,
|
||||
userId: data.createdByUserId || undefined,
|
||||
workspaceNotification: {
|
||||
appendMessageBlocks: monitorCreateMessageBlocks,
|
||||
sendWorkspaceNotification: true,
|
||||
},
|
||||
});
|
||||
|
||||
await this.changeMonitorStatus(
|
||||
createdItem.projectId,
|
||||
[createdItem.id],
|
||||
createdItem.currentMonitorStatusId,
|
||||
false, // notifyOwners = false
|
||||
"This status was created when the monitor was created.",
|
||||
undefined,
|
||||
onCreate.createBy.props,
|
||||
);
|
||||
|
||||
if (
|
||||
createdItem.monitorType &&
|
||||
MonitorTypeHelper.isProbableMonitor(createdItem.monitorType)
|
||||
) {
|
||||
await this.addDefaultProbesToMonitor(
|
||||
createdItem.projectId,
|
||||
createdItem.id,
|
||||
);
|
||||
}
|
||||
|
||||
if (IsBillingEnabled) {
|
||||
await ActiveMonitoringMeteredPlan.reportQuantityToBillingProvider(
|
||||
createdItem.projectId,
|
||||
);
|
||||
}
|
||||
|
||||
// add owners.
|
||||
|
||||
if (
|
||||
onCreate.createBy.miscDataProps &&
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
await this.addOwners(
|
||||
createdItem.projectId,
|
||||
createdItem.id,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
);
|
||||
}
|
||||
|
||||
// refresh probe status.
|
||||
await this.refreshMonitorProbeStatus(createdItem.id);
|
||||
|
||||
return createdItem;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
@@ -760,21 +823,32 @@ ${createdItem.description?.trim() || "No description provided."}
|
||||
|
||||
const totalProbes: Array<Probe> = [...globalProbes, ...projectProbes];
|
||||
|
||||
if (totalProbes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create all monitor probes in parallel for better performance
|
||||
const createPromises: Array<Promise<MonitorProbe>> = [];
|
||||
|
||||
for (const probe of totalProbes) {
|
||||
const monitorProbe: MonitorProbe = new MonitorProbe();
|
||||
|
||||
monitorProbe.monitorId = monitorId;
|
||||
monitorProbe.probeId = probe.id!;
|
||||
monitorProbe.projectId = projectId;
|
||||
monitorProbe.isEnabled = true;
|
||||
|
||||
await MonitorProbeService.create({
|
||||
data: monitorProbe,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
createPromises.push(
|
||||
MonitorProbeService.create({
|
||||
data: monitorProbe,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Execute all creates in parallel
|
||||
await Promise.all(createPromises);
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
@@ -1085,6 +1159,14 @@ ${createdItem.description?.trim() || "No description provided."}
|
||||
emailEnvelope: emailMessage,
|
||||
smsMessage: sms,
|
||||
callRequestMessage: callMessage,
|
||||
pushNotificationMessage:
|
||||
PushNotificationUtil.createMonitorProbeStatusNotification({
|
||||
title: "OneUptime: Monitor Probe Status",
|
||||
body: `Probes for monitor ${monitor.name} is ${enabledStatus}`,
|
||||
tag: "monitor-probe-status",
|
||||
monitorId: monitor.id!.toString(),
|
||||
monitorName: monitor.name!,
|
||||
}),
|
||||
eventType:
|
||||
NotificationSettingEventType.SEND_MONITOR_NOTIFICATION_WHEN_NO_PROBES_ARE_MONITORING_THE_MONITOR,
|
||||
});
|
||||
@@ -1184,6 +1266,11 @@ ${createdItem.description?.trim() || "No description provided."}
|
||||
emailEnvelope: emailMessage,
|
||||
smsMessage: sms,
|
||||
callRequestMessage: callMessage,
|
||||
pushNotificationMessage:
|
||||
PushNotificationUtil.createMonitorCreatedNotification({
|
||||
monitorName: monitor.name!,
|
||||
monitorId: monitor.id!.toString(),
|
||||
}),
|
||||
eventType:
|
||||
NotificationSettingEventType.SEND_MONITOR_NOTIFICATION_WHEN_PORBE_STATUS_CHANGES,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { IsBillingEnabled } from "../EnvironmentConfig";
|
||||
import {
|
||||
IsBillingEnabled,
|
||||
NotificationSlackWebhookOnSubscriptionUpdate,
|
||||
} from "../EnvironmentConfig";
|
||||
import logger from "../Utils/Logger";
|
||||
import BaseService from "./BaseService";
|
||||
import BillingService from "./BillingService";
|
||||
@@ -7,6 +10,9 @@ import BadDataException from "../../Types/Exception/BadDataException";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import Project from "../../Models/DatabaseModels/Project";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
import SlackUtil from "../Utils/Workspace/Slack/Slack";
|
||||
import URL from "../../Types/API/URL";
|
||||
import Exception from "../../Types/Exception/Exception";
|
||||
|
||||
export class NotificationService extends BaseService {
|
||||
public constructor() {
|
||||
@@ -105,6 +111,17 @@ export class NotificationService extends BaseService {
|
||||
} USD.`,
|
||||
);
|
||||
|
||||
// Send Slack notification for balance refill
|
||||
this.sendBalanceRefillSlackNotification({
|
||||
project: project,
|
||||
amountInUSD: amountInUSD,
|
||||
currentBalanceInUSD: updatedAmount / 100,
|
||||
}).catch((error: Exception) => {
|
||||
logger.error(
|
||||
"Error sending slack message for balance refill: " + error,
|
||||
);
|
||||
});
|
||||
|
||||
project.smsOrCallCurrentBalanceInUSDCents = updatedAmount;
|
||||
|
||||
return updatedAmount;
|
||||
@@ -194,6 +211,34 @@ export class NotificationService extends BaseService {
|
||||
|
||||
return project?.smsOrCallCurrentBalanceInUSDCents || 0;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async sendBalanceRefillSlackNotification(data: {
|
||||
project: Project;
|
||||
amountInUSD: number;
|
||||
currentBalanceInUSD: number;
|
||||
}): Promise<void> {
|
||||
const { project, amountInUSD, currentBalanceInUSD } = data;
|
||||
|
||||
if (NotificationSlackWebhookOnSubscriptionUpdate) {
|
||||
const slackMessage: string = `*SMS and Call Balance Refilled:*
|
||||
*Project Name:* ${project.name?.toString() || "N/A"}
|
||||
*Project ID:* ${project.id?.toString() || "N/A"}
|
||||
*Refill Amount:* $${amountInUSD} USD
|
||||
*Current Balance:* $${currentBalanceInUSD} USD
|
||||
|
||||
${project.createdOwnerName && project.createdOwnerEmail ? `*Project Created By:* ${project.createdOwnerName.toString()} (${project.createdOwnerEmail.toString()})` : ""}`;
|
||||
|
||||
SlackUtil.sendMessageToChannelViaIncomingWebhook({
|
||||
url: URL.fromString(NotificationSlackWebhookOnSubscriptionUpdate),
|
||||
text: slackMessage,
|
||||
}).catch((error: Exception) => {
|
||||
logger.error(
|
||||
"Error sending slack message for balance refill: " + error,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new NotificationService();
|
||||
|
||||
@@ -18,6 +18,8 @@ import OnCallDutyPolicySchedule from "../../Models/DatabaseModels/OnCallDutyPoli
|
||||
import OnCallDutyPolicyFeedService from "./OnCallDutyPolicyFeedService";
|
||||
import { OnCallDutyPolicyFeedEventType } from "../../Models/DatabaseModels/OnCallDutyPolicyFeed";
|
||||
import { Gray500, Red500 } from "../../Types/BrandColors";
|
||||
import PushNotificationMessage from "../../Types/PushNotification/PushNotificationMessage";
|
||||
import PushNotificationUtil from "../Utils/PushNotificationUtil";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
@@ -128,12 +130,18 @@ export class Service extends DatabaseService<Model> {
|
||||
],
|
||||
};
|
||||
|
||||
const pushMessage: PushNotificationMessage =
|
||||
PushNotificationUtil.createOnCallPolicyAddedNotification({
|
||||
policyName: createdModel.onCallDutyPolicy?.name || "No name provided",
|
||||
});
|
||||
|
||||
await UserNotificationSettingService.sendUserNotification({
|
||||
userId: sendEmailToUserId,
|
||||
projectId: createdModel!.projectId!,
|
||||
emailEnvelope: emailMessage,
|
||||
smsMessage: sms,
|
||||
callRequestMessage: callMessage,
|
||||
pushNotificationMessage: pushMessage,
|
||||
eventType:
|
||||
NotificationSettingEventType.SEND_WHEN_USER_IS_ADDED_TO_ON_CALL_POLICY,
|
||||
});
|
||||
@@ -304,12 +312,18 @@ export class Service extends DatabaseService<Model> {
|
||||
],
|
||||
};
|
||||
|
||||
const pushMessage: PushNotificationMessage =
|
||||
PushNotificationUtil.createOnCallPolicyRemovedNotification({
|
||||
policyName: deletedItem.onCallDutyPolicy?.name || "No name provided",
|
||||
});
|
||||
|
||||
await UserNotificationSettingService.sendUserNotification({
|
||||
userId: sendEmailToUserId,
|
||||
projectId: deletedItem!.projectId!,
|
||||
emailEnvelope: emailMessage,
|
||||
smsMessage: sms,
|
||||
callRequestMessage: callMessage,
|
||||
pushNotificationMessage: pushMessage,
|
||||
eventType:
|
||||
NotificationSettingEventType.SEND_WHEN_USER_IS_REMOVED_FROM_ON_CALL_POLICY,
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@ import User from "../../Models/DatabaseModels/User";
|
||||
import OnCallDutyPolicyFeedService from "./OnCallDutyPolicyFeedService";
|
||||
import { OnCallDutyPolicyFeedEventType } from "../../Models/DatabaseModels/OnCallDutyPolicyFeed";
|
||||
import { Gray500, Red500 } from "../../Types/BrandColors";
|
||||
import PushNotificationMessage from "../../Types/PushNotification/PushNotificationMessage";
|
||||
import PushNotificationUtil from "../Utils/PushNotificationUtil";
|
||||
import Team from "../../Models/DatabaseModels/Team";
|
||||
import OnCallDutyPolicyTimeLogService from "./OnCallDutyPolicyTimeLogService";
|
||||
import OneUptimeDate from "../../Types/Date";
|
||||
@@ -127,12 +129,18 @@ export class Service extends DatabaseService<Model> {
|
||||
],
|
||||
};
|
||||
|
||||
const pushMessage: PushNotificationMessage =
|
||||
PushNotificationUtil.createOnCallPolicyAddedNotification({
|
||||
policyName: createdModel.onCallDutyPolicy?.name || "No name provided",
|
||||
});
|
||||
|
||||
await UserNotificationSettingService.sendUserNotification({
|
||||
userId: sendEmailToUserId,
|
||||
projectId: createdModel!.projectId!,
|
||||
emailEnvelope: emailMessage,
|
||||
smsMessage: sms,
|
||||
callRequestMessage: callMessage,
|
||||
pushNotificationMessage: pushMessage,
|
||||
eventType:
|
||||
NotificationSettingEventType.SEND_WHEN_USER_IS_ADDED_TO_ON_CALL_POLICY,
|
||||
});
|
||||
@@ -308,12 +316,19 @@ export class Service extends DatabaseService<Model> {
|
||||
],
|
||||
};
|
||||
|
||||
const pushMessage: PushNotificationMessage =
|
||||
PushNotificationUtil.createOnCallPolicyRemovedNotification({
|
||||
policyName:
|
||||
deletedItem.onCallDutyPolicy?.name || "No name provided",
|
||||
});
|
||||
|
||||
UserNotificationSettingService.sendUserNotification({
|
||||
userId: sendEmailToUserId,
|
||||
projectId: deletedItem!.projectId!,
|
||||
emailEnvelope: emailMessage,
|
||||
smsMessage: sms,
|
||||
callRequestMessage: callMessage,
|
||||
pushNotificationMessage: pushMessage,
|
||||
eventType:
|
||||
NotificationSettingEventType.SEND_WHEN_USER_IS_REMOVED_FROM_ON_CALL_POLICY,
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@ import { OnCallDutyPolicyFeedEventType } from "../../Models/DatabaseModels/OnCal
|
||||
import { Gray500, Red500 } from "../../Types/BrandColors";
|
||||
import UserService from "./UserService";
|
||||
import User from "../../Models/DatabaseModels/User";
|
||||
import PushNotificationMessage from "../../Types/PushNotification/PushNotificationMessage";
|
||||
import PushNotificationUtil from "../Utils/PushNotificationUtil";
|
||||
import OnCallDutyPolicyTimeLogService from "./OnCallDutyPolicyTimeLogService";
|
||||
import OneUptimeDate from "../../Types/Date";
|
||||
import logger from "../Utils/Logger";
|
||||
@@ -110,12 +112,18 @@ export class Service extends DatabaseService<Model> {
|
||||
],
|
||||
};
|
||||
|
||||
const pushMessage: PushNotificationMessage =
|
||||
PushNotificationUtil.createOnCallPolicyAddedNotification({
|
||||
policyName: createdModel.onCallDutyPolicy?.name || "",
|
||||
});
|
||||
|
||||
await UserNotificationSettingService.sendUserNotification({
|
||||
userId: sendEmailToUserId,
|
||||
projectId: createdModel!.projectId!,
|
||||
emailEnvelope: emailMessage,
|
||||
smsMessage: sms,
|
||||
callRequestMessage: callMessage,
|
||||
pushNotificationMessage: pushMessage,
|
||||
eventType:
|
||||
NotificationSettingEventType.SEND_WHEN_USER_IS_ADDED_TO_ON_CALL_POLICY,
|
||||
});
|
||||
@@ -306,12 +314,18 @@ export class Service extends DatabaseService<Model> {
|
||||
],
|
||||
};
|
||||
|
||||
const pushMessage: PushNotificationMessage =
|
||||
PushNotificationUtil.createOnCallPolicyRemovedNotification({
|
||||
policyName: deletedItem.onCallDutyPolicy?.name || "",
|
||||
});
|
||||
|
||||
UserNotificationSettingService.sendUserNotification({
|
||||
userId: sendEmailToUserId,
|
||||
projectId: deletedItem!.projectId!,
|
||||
emailEnvelope: emailMessage,
|
||||
smsMessage: sms,
|
||||
callRequestMessage: callMessage,
|
||||
pushNotificationMessage: pushMessage,
|
||||
eventType:
|
||||
NotificationSettingEventType.SEND_WHEN_USER_IS_REMOVED_FROM_ON_CALL_POLICY,
|
||||
});
|
||||
|
||||
@@ -34,6 +34,8 @@ import { Green500 } from "../../Types/BrandColors";
|
||||
import OnCallDutyPolicyTimeLogService from "./OnCallDutyPolicyTimeLogService";
|
||||
import DeleteBy from "../Types/Database/DeleteBy";
|
||||
import { OnDelete } from "../Types/Database/Hooks";
|
||||
import PushNotificationMessage from "../../Types/PushNotification/PushNotificationMessage";
|
||||
import PushNotificationUtil from "../Utils/PushNotificationUtil";
|
||||
|
||||
export class Service extends DatabaseService<OnCallDutyPolicySchedule> {
|
||||
private layerUtil = new LayerUtil();
|
||||
@@ -253,12 +255,21 @@ export class Service extends DatabaseService<OnCallDutyPolicySchedule> {
|
||||
],
|
||||
};
|
||||
|
||||
const pushMessage: PushNotificationMessage =
|
||||
PushNotificationUtil.createGenericNotification({
|
||||
title: "On-Call Duty Ended",
|
||||
body: `You are no longer on-call for ${onCallPolicy.name!} as your roster on schedule ${onCallSchedule.name} has ended.`,
|
||||
tag: "on-call-duty-ended",
|
||||
requireInteraction: false,
|
||||
});
|
||||
|
||||
await UserNotificationSettingService.sendUserNotification({
|
||||
userId: sendEmailToUserId,
|
||||
projectId: projectId,
|
||||
emailEnvelope: emailMessage,
|
||||
smsMessage: sms,
|
||||
callRequestMessage: callMessage,
|
||||
pushNotificationMessage: pushMessage,
|
||||
eventType:
|
||||
NotificationSettingEventType.SEND_WHEN_USER_IS_NO_LONGER_ACTIVE_ON_ON_CALL_ROSTER,
|
||||
});
|
||||
@@ -360,12 +371,21 @@ export class Service extends DatabaseService<OnCallDutyPolicySchedule> {
|
||||
],
|
||||
};
|
||||
|
||||
const pushMessage: PushNotificationMessage =
|
||||
PushNotificationUtil.createGenericNotification({
|
||||
title: "On-Call Duty Started",
|
||||
body: `You are now on-call for ${onCallPolicy.name!} on schedule ${onCallSchedule.name}.`,
|
||||
tag: "on-call-duty-started",
|
||||
requireInteraction: true,
|
||||
});
|
||||
|
||||
await UserNotificationSettingService.sendUserNotification({
|
||||
userId: sendEmailToUserId,
|
||||
projectId: projectId,
|
||||
emailEnvelope: emailMessage,
|
||||
smsMessage: sms,
|
||||
callRequestMessage: callMessage,
|
||||
pushNotificationMessage: pushMessage,
|
||||
eventType:
|
||||
NotificationSettingEventType.SEND_WHEN_USER_IS_ON_CALL_ROSTER,
|
||||
});
|
||||
@@ -487,12 +507,21 @@ export class Service extends DatabaseService<OnCallDutyPolicySchedule> {
|
||||
],
|
||||
};
|
||||
|
||||
const pushMessage: PushNotificationMessage =
|
||||
PushNotificationUtil.createGenericNotification({
|
||||
title: "Next On-Call Duty",
|
||||
body: `You are next on-call for ${onCallPolicy.name!} on schedule ${onCallSchedule.name}.`,
|
||||
tag: "next-on-call-duty",
|
||||
requireInteraction: false,
|
||||
});
|
||||
|
||||
await UserNotificationSettingService.sendUserNotification({
|
||||
userId: sendEmailToUserId,
|
||||
projectId: projectId,
|
||||
emailEnvelope: emailMessage,
|
||||
smsMessage: sms,
|
||||
callRequestMessage: callMessage,
|
||||
pushNotificationMessage: pushMessage,
|
||||
eventType:
|
||||
NotificationSettingEventType.SEND_WHEN_USER_IS_NEXT_ON_CALL_ROSTER,
|
||||
});
|
||||
|
||||
@@ -28,6 +28,8 @@ import DatabaseConfig from "../DatabaseConfig";
|
||||
import URL from "../../Types/API/URL";
|
||||
import UpdateBy from "../Types/Database/UpdateBy";
|
||||
import MonitorService from "./MonitorService";
|
||||
import PushNotificationMessage from "../../Types/PushNotification/PushNotificationMessage";
|
||||
import PushNotificationUtil from "../Utils/PushNotificationUtil";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
import { IsBillingEnabled } from "../EnvironmentConfig";
|
||||
import GlobalCache from "../Infrastructure/GlobalCache";
|
||||
@@ -365,12 +367,33 @@ export class Service extends DatabaseService<Model> {
|
||||
],
|
||||
};
|
||||
|
||||
const pushMessageParams: {
|
||||
probeName: string;
|
||||
projectName: string;
|
||||
connectionStatus: string;
|
||||
clickAction?: string;
|
||||
} = {
|
||||
probeName: probe.name!,
|
||||
projectName: probe.project?.name || "Project",
|
||||
connectionStatus: connectionStatus,
|
||||
};
|
||||
|
||||
if (vars["viewProbesLink"]) {
|
||||
pushMessageParams.clickAction = vars["viewProbesLink"];
|
||||
}
|
||||
|
||||
const pushMessage: PushNotificationMessage =
|
||||
PushNotificationUtil.createProbeStatusChangedNotification(
|
||||
pushMessageParams,
|
||||
);
|
||||
|
||||
await UserNotificationSettingService.sendUserNotification({
|
||||
userId: user.id!,
|
||||
projectId: probe.projectId!,
|
||||
emailEnvelope: emailMessage,
|
||||
smsMessage: sms,
|
||||
callRequestMessage: callMessage,
|
||||
pushNotificationMessage: pushMessage,
|
||||
eventType:
|
||||
NotificationSettingEventType.SEND_PROBE_STATUS_CHANGED_OWNER_NOTIFICATION,
|
||||
});
|
||||
|
||||
10
Common/Server/Services/ProjectScimService.ts
Normal file
10
Common/Server/Services/ProjectScimService.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import DatabaseService from "./DatabaseService";
|
||||
import Model from "../../Models/DatabaseModels/ProjectScim";
|
||||
|
||||
export class Service extends DatabaseService<Model> {
|
||||
public constructor() {
|
||||
super(Model);
|
||||
}
|
||||
}
|
||||
|
||||
export default new Service();
|
||||
253
Common/Server/Services/PushNotificationService.ts
Normal file
253
Common/Server/Services/PushNotificationService.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import PushNotificationRequest from "../../Types/PushNotification/PushNotificationRequest";
|
||||
import PushNotificationMessage from "../../Types/PushNotification/PushNotificationMessage";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import logger from "../Utils/Logger";
|
||||
import UserPushService from "./UserPushService";
|
||||
import UserOnCallLogTimelineService from "./UserOnCallLogTimelineService";
|
||||
import UserNotificationStatus from "../../Types/UserNotification/UserNotificationStatus";
|
||||
import {
|
||||
VapidPublicKey,
|
||||
VapidPrivateKey,
|
||||
VapidSubject,
|
||||
} from "../EnvironmentConfig";
|
||||
import webpush from "web-push";
|
||||
import PushNotificationUtil from "../Utils/PushNotificationUtil";
|
||||
import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
|
||||
import UserPush from "../../Models/DatabaseModels/UserPush";
|
||||
|
||||
export interface PushNotificationOptions {
|
||||
projectId?: ObjectID | undefined;
|
||||
isSensitive?: boolean;
|
||||
userOnCallLogTimelineId?: ObjectID | undefined;
|
||||
}
|
||||
|
||||
export default class PushNotificationService {
|
||||
public static isWebPushInitialized = false;
|
||||
|
||||
public static initializeWebPush(): void {
|
||||
if (this.isWebPushInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!VapidPublicKey || !VapidPrivateKey) {
|
||||
logger.warn(
|
||||
"VAPID keys not configured. Web push notifications will not work.",
|
||||
);
|
||||
logger.warn(`VapidPublicKey present: ${Boolean(VapidPublicKey)}`);
|
||||
logger.warn(`VapidPrivateKey present: ${Boolean(VapidPrivateKey)}`);
|
||||
logger.warn(`VapidSubject: ${VapidSubject}`);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Initializing web push with VAPID subject: ${VapidSubject}`);
|
||||
webpush.setVapidDetails(VapidSubject, VapidPublicKey, VapidPrivateKey);
|
||||
this.isWebPushInitialized = true;
|
||||
logger.info("Web push notifications initialized successfully");
|
||||
}
|
||||
|
||||
public static async sendPushNotification(
|
||||
request: PushNotificationRequest,
|
||||
options: PushNotificationOptions = {},
|
||||
): Promise<void> {
|
||||
logger.info(
|
||||
`Sending push notification to ${request.deviceTokens?.length} devices`,
|
||||
);
|
||||
|
||||
if (!request.deviceTokens || request.deviceTokens.length === 0) {
|
||||
logger.error("No device tokens provided for push notification");
|
||||
throw new Error("No device tokens provided");
|
||||
}
|
||||
|
||||
if (request.deviceType !== "web") {
|
||||
logger.error(`Unsupported device type: ${request.deviceType}`);
|
||||
throw new Error("Only web push notifications are supported");
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Sending web push notifications to ${request.deviceTokens.length} devices`,
|
||||
);
|
||||
logger.info(`Notification message: ${JSON.stringify(request.message)}`);
|
||||
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
for (const deviceToken of request.deviceTokens) {
|
||||
promises.push(
|
||||
this.sendWebPushNotification(deviceToken, request.message, options),
|
||||
);
|
||||
}
|
||||
|
||||
const results: Array<any> = await Promise.allSettled(promises);
|
||||
|
||||
let successCount: number = 0;
|
||||
let errorCount: number = 0;
|
||||
|
||||
results.forEach((result: any, index: number) => {
|
||||
if (result.status === "fulfilled") {
|
||||
successCount++;
|
||||
logger.info(`Device ${index + 1}: Notification sent successfully`);
|
||||
} else {
|
||||
errorCount++;
|
||||
logger.error(
|
||||
`Failed to send notification to device ${index + 1}: ${result.reason}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Push notification results: ${successCount} successful, ${errorCount} failed`,
|
||||
);
|
||||
|
||||
// Update user on call log timeline status if provided
|
||||
if (options.userOnCallLogTimelineId) {
|
||||
const status: UserNotificationStatus =
|
||||
successCount > 0
|
||||
? UserNotificationStatus.Sent
|
||||
: UserNotificationStatus.Error;
|
||||
const statusMessage: string =
|
||||
successCount > 0
|
||||
? "Push notification sent successfully"
|
||||
: `Failed to send push notification: ${errorCount} errors`;
|
||||
|
||||
await UserOnCallLogTimelineService.updateOneById({
|
||||
id: options.userOnCallLogTimelineId,
|
||||
data: {
|
||||
status,
|
||||
statusMessage,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (errorCount > 0 && successCount === 0) {
|
||||
throw new Error(
|
||||
`Failed to send push notification to all ${errorCount} devices`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static async sendWebPushNotification(
|
||||
deviceToken: string,
|
||||
message: PushNotificationMessage,
|
||||
_options: PushNotificationOptions,
|
||||
): Promise<void> {
|
||||
if (!this.isWebPushInitialized) {
|
||||
this.initializeWebPush();
|
||||
}
|
||||
|
||||
if (!this.isWebPushInitialized) {
|
||||
throw new Error("Web push notifications not configured");
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: string = JSON.stringify({
|
||||
title: message.title,
|
||||
body: message.body,
|
||||
icon: message.icon || PushNotificationUtil.DEFAULT_ICON,
|
||||
badge: message.badge || PushNotificationUtil.DEFAULT_BADGE,
|
||||
data: message.data || {},
|
||||
tag: message.tag || "oneuptime-notification",
|
||||
requireInteraction: message.requireInteraction || false,
|
||||
actions: message.actions || [],
|
||||
url: message.url || message.clickAction,
|
||||
});
|
||||
|
||||
logger.debug(`Sending push notification with payload: ${payload}`);
|
||||
logger.debug(`Device token: ${deviceToken}`);
|
||||
|
||||
let subscriptionObject: any;
|
||||
try {
|
||||
subscriptionObject = JSON.parse(deviceToken);
|
||||
logger.debug(
|
||||
`Parsed subscription object: ${JSON.stringify(subscriptionObject)}`,
|
||||
);
|
||||
} catch (parseError) {
|
||||
logger.error(`Failed to parse device token: ${parseError}`);
|
||||
throw new Error(`Invalid device token format: ${parseError}`);
|
||||
}
|
||||
|
||||
const result: webpush.SendResult = await webpush.sendNotification(
|
||||
subscriptionObject,
|
||||
payload,
|
||||
{
|
||||
TTL: 24 * 60 * 60, // 24 hours
|
||||
},
|
||||
);
|
||||
|
||||
logger.debug(`Web push notification sent successfully:`);
|
||||
logger.debug(`Result: ${JSON.stringify(result, null, 2)}`);
|
||||
logger.debug(`Payload: ${JSON.stringify(payload, null, 2)}`);
|
||||
logger.debug(
|
||||
`Subscription object: ${JSON.stringify(subscriptionObject, null, 2)}`,
|
||||
);
|
||||
|
||||
logger.info(`Web push notification sent successfully`);
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to send web push notification: ${error.message}`);
|
||||
logger.error(error);
|
||||
|
||||
// If the subscription is no longer valid, remove it
|
||||
if (error.statusCode === 410 || error.statusCode === 404) {
|
||||
logger.info("Removing invalid web push subscription");
|
||||
// You would implement removal logic here
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public static async sendPushNotificationToUser(
|
||||
userId: ObjectID,
|
||||
projectId: ObjectID,
|
||||
message: PushNotificationMessage,
|
||||
options: PushNotificationOptions = {},
|
||||
): Promise<void> {
|
||||
// Get all verified push devices for the user
|
||||
const userPushDevices: UserPush[] = await UserPushService.findBy({
|
||||
query: {
|
||||
userId: userId,
|
||||
projectId: projectId,
|
||||
isVerified: true,
|
||||
},
|
||||
select: {
|
||||
deviceToken: true,
|
||||
deviceType: true,
|
||||
_id: true,
|
||||
},
|
||||
limit: LIMIT_PER_PROJECT,
|
||||
skip: 0,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (userPushDevices.length === 0) {
|
||||
logger.info(
|
||||
`No verified web push devices found for user ${userId.toString()}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get web device tokens
|
||||
const webDevices: string[] = [];
|
||||
|
||||
for (const device of userPushDevices) {
|
||||
if (device.deviceType === "web") {
|
||||
webDevices.push(device.deviceToken!);
|
||||
}
|
||||
}
|
||||
|
||||
// Send notifications to web devices
|
||||
if (webDevices.length > 0) {
|
||||
await this.sendPushNotification(
|
||||
{
|
||||
deviceTokens: webDevices,
|
||||
message: message,
|
||||
deviceType: "web",
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -547,36 +547,7 @@ ${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
|
||||
onCreate: OnCreate<Model>,
|
||||
createdItem: Model,
|
||||
): Promise<Model> {
|
||||
// create new scheduled maintenance state timeline.
|
||||
|
||||
const createdByUserId: ObjectID | undefined | null =
|
||||
createdItem.createdByUserId || createdItem.createdByUser?.id;
|
||||
|
||||
// send message to workspaces - slack, teams, etc.
|
||||
const workspaceResult: {
|
||||
channelsCreated: Array<NotificationRuleWorkspaceChannel>;
|
||||
} | null =
|
||||
await ScheduledMaintenanceWorkspaceMessages.createChannelsAndInviteUsersToChannels(
|
||||
{
|
||||
projectId: createdItem.projectId!,
|
||||
scheduledMaintenanceId: createdItem.id!,
|
||||
scheduledMaintenanceNumber: createdItem.scheduledMaintenanceNumber!,
|
||||
},
|
||||
);
|
||||
|
||||
if (workspaceResult && workspaceResult.channelsCreated?.length > 0) {
|
||||
// update scheduledMaintenance with these channels.
|
||||
await this.updateOneById({
|
||||
id: createdItem.id!,
|
||||
data: {
|
||||
postUpdatesToWorkspaceChannels: workspaceResult.channelsCreated || [],
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Get scheduled maintenance data for feed creation
|
||||
const scheduledMaintenance: Model | null = await this.findOneById({
|
||||
id: createdItem.id!,
|
||||
select: {
|
||||
@@ -606,83 +577,23 @@ ${resourcesAffected ? `**Resources Affected:** ${resourcesAffected}` : ""}
|
||||
throw new BadDataException("Scheduled Maintenance not found");
|
||||
}
|
||||
|
||||
let feedInfoInMarkdown: string = `#### 🕒 Scheduled Maintenance ${createdItem.scheduledMaintenanceNumber?.toString()} Created:
|
||||
|
||||
**${createdItem.title || "No title provided."}**:
|
||||
|
||||
${createdItem.description || "No description provided."}
|
||||
|
||||
`;
|
||||
// Execute core operations in parallel first
|
||||
const coreOperations: Array<Promise<any>> = [];
|
||||
|
||||
// add starts at and ends at.
|
||||
if (scheduledMaintenance.startsAt) {
|
||||
feedInfoInMarkdown += `**Starts At**: ${OneUptimeDate.getDateAsLocalFormattedString(scheduledMaintenance.startsAt)} \n\n`;
|
||||
}
|
||||
|
||||
if (scheduledMaintenance.endsAt) {
|
||||
feedInfoInMarkdown += `**Ends At**: ${OneUptimeDate.getDateAsLocalFormattedString(scheduledMaintenance.endsAt)} \n\n`;
|
||||
}
|
||||
|
||||
if (scheduledMaintenance.currentScheduledMaintenanceState?.name) {
|
||||
feedInfoInMarkdown += `⏳ **Scheduled Maintenance State**: ${scheduledMaintenance.currentScheduledMaintenanceState.name} \n\n`;
|
||||
}
|
||||
|
||||
if (
|
||||
scheduledMaintenance.monitors &&
|
||||
scheduledMaintenance.monitors.length > 0
|
||||
) {
|
||||
feedInfoInMarkdown += `🌎 **Resources Affected**:\n`;
|
||||
|
||||
for (const monitor of scheduledMaintenance.monitors) {
|
||||
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(createdItem.projectId!, monitor.id!)).toString()})\n`;
|
||||
}
|
||||
|
||||
feedInfoInMarkdown += `\n\n`;
|
||||
}
|
||||
|
||||
const scheduledMaintenanceCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
|
||||
await ScheduledMaintenanceWorkspaceMessages.getScheduledMaintenanceCreateMessageBlocks(
|
||||
{
|
||||
scheduledMaintenanceId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
},
|
||||
);
|
||||
|
||||
await ScheduledMaintenanceFeedService.createScheduledMaintenanceFeedItem({
|
||||
scheduledMaintenanceId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
scheduledMaintenanceFeedEventType:
|
||||
ScheduledMaintenanceFeedEventType.ScheduledMaintenanceCreated,
|
||||
displayColor: Red500,
|
||||
feedInfoInMarkdown: feedInfoInMarkdown,
|
||||
userId: createdByUserId || undefined,
|
||||
workspaceNotification: {
|
||||
appendMessageBlocks: scheduledMaintenanceCreateMessageBlocks,
|
||||
sendWorkspaceNotification: true,
|
||||
},
|
||||
});
|
||||
|
||||
const timeline: ScheduledMaintenanceStateTimeline =
|
||||
new ScheduledMaintenanceStateTimeline();
|
||||
timeline.projectId = createdItem.projectId!;
|
||||
timeline.scheduledMaintenanceId = createdItem.id!;
|
||||
timeline.isOwnerNotified = true; // ignore notifying owners because you already notify for Scheduled Event, no need to notify them for timeline event.
|
||||
timeline.shouldStatusPageSubscribersBeNotified = Boolean(
|
||||
createdItem.shouldStatusPageSubscribersBeNotifiedOnEventCreated,
|
||||
// Create feed item asynchronously
|
||||
coreOperations.push(
|
||||
this.createScheduledMaintenanceFeedAsync(
|
||||
scheduledMaintenance,
|
||||
createdItem,
|
||||
),
|
||||
);
|
||||
timeline.isStatusPageSubscribersNotified = Boolean(
|
||||
createdItem.shouldStatusPageSubscribersBeNotifiedOnEventCreated,
|
||||
); // ignore notifying subscribers because you already notify for Scheduled Event, no need to notify them for timeline event.
|
||||
timeline.scheduledMaintenanceStateId =
|
||||
createdItem.currentScheduledMaintenanceStateId!;
|
||||
|
||||
await ScheduledMaintenanceStateTimelineService.create({
|
||||
data: timeline,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
// Create state timeline asynchronously
|
||||
coreOperations.push(
|
||||
this.createScheduledMaintenanceStateTimelineAsync(createdItem),
|
||||
);
|
||||
|
||||
// Handle owner assignment asynchronously
|
||||
if (
|
||||
createdItem.projectId &&
|
||||
createdItem.id &&
|
||||
@@ -690,21 +601,200 @@ ${createdItem.description || "No description provided."}
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
await this.addOwners(
|
||||
createdItem.projectId!,
|
||||
createdItem.id!,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
coreOperations.push(
|
||||
this.addOwners(
|
||||
createdItem.projectId!,
|
||||
createdItem.id!,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Execute core operations in parallel with error handling
|
||||
Promise.allSettled(coreOperations)
|
||||
.then((coreResults: any[]) => {
|
||||
// Log any errors from core operations
|
||||
coreResults.forEach((result: any, index: number) => {
|
||||
if (result.status === "rejected") {
|
||||
logger.error(
|
||||
`Core operation ${index} failed in ScheduledMaintenanceService.onCreateSuccess: ${result.reason}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle workspace operations after core operations complete
|
||||
if (createdItem.projectId && createdItem.id) {
|
||||
// Run workspace operations in background without blocking response
|
||||
this.handleScheduledMaintenanceWorkspaceOperationsAsync(
|
||||
createdItem,
|
||||
).catch((error: Error) => {
|
||||
logger.error(
|
||||
`Workspace operations failed in ScheduledMaintenanceService.onCreateSuccess: ${error}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
logger.error(
|
||||
`Critical error in ScheduledMaintenanceService core operations: ${error}`,
|
||||
);
|
||||
});
|
||||
|
||||
return createdItem;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async handleScheduledMaintenanceWorkspaceOperationsAsync(
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!createdItem.projectId || !createdItem.id) {
|
||||
throw new BadDataException(
|
||||
"projectId and id are required for workspace operations",
|
||||
);
|
||||
}
|
||||
|
||||
// send message to workspaces - slack, teams, etc.
|
||||
const workspaceResult: {
|
||||
channelsCreated: Array<NotificationRuleWorkspaceChannel>;
|
||||
} | null =
|
||||
await ScheduledMaintenanceWorkspaceMessages.createChannelsAndInviteUsersToChannels(
|
||||
{
|
||||
projectId: createdItem.projectId,
|
||||
scheduledMaintenanceId: createdItem.id,
|
||||
scheduledMaintenanceNumber: createdItem.scheduledMaintenanceNumber!,
|
||||
},
|
||||
);
|
||||
|
||||
if (workspaceResult && workspaceResult.channelsCreated?.length > 0) {
|
||||
// update scheduledMaintenance with these channels.
|
||||
await this.updateOneById({
|
||||
id: createdItem.id,
|
||||
data: {
|
||||
postUpdatesToWorkspaceChannels:
|
||||
workspaceResult.channelsCreated || [],
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error in handleScheduledMaintenanceWorkspaceOperationsAsync: ${error}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async createScheduledMaintenanceFeedAsync(
|
||||
scheduledMaintenance: Model,
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const createdByUserId: ObjectID | undefined | null =
|
||||
createdItem.createdByUserId || createdItem.createdByUser?.id;
|
||||
|
||||
let feedInfoInMarkdown: string = `#### 🕒 Scheduled Maintenance ${createdItem.scheduledMaintenanceNumber?.toString()} Created:
|
||||
|
||||
**${createdItem.title || "No title provided."}**:
|
||||
|
||||
${createdItem.description || "No description provided."}
|
||||
|
||||
`;
|
||||
|
||||
// add starts at and ends at.
|
||||
if (scheduledMaintenance.startsAt) {
|
||||
feedInfoInMarkdown += `**Starts At**: ${OneUptimeDate.getDateAsLocalFormattedString(scheduledMaintenance.startsAt)} \n\n`;
|
||||
}
|
||||
|
||||
if (scheduledMaintenance.endsAt) {
|
||||
feedInfoInMarkdown += `**Ends At**: ${OneUptimeDate.getDateAsLocalFormattedString(scheduledMaintenance.endsAt)} \n\n`;
|
||||
}
|
||||
|
||||
if (scheduledMaintenance.currentScheduledMaintenanceState?.name) {
|
||||
feedInfoInMarkdown += `⏳ **Scheduled Maintenance State**: ${scheduledMaintenance.currentScheduledMaintenanceState.name} \n\n`;
|
||||
}
|
||||
|
||||
if (
|
||||
scheduledMaintenance.monitors &&
|
||||
scheduledMaintenance.monitors.length > 0
|
||||
) {
|
||||
feedInfoInMarkdown += `🌎 **Resources Affected**:\n`;
|
||||
|
||||
for (const monitor of scheduledMaintenance.monitors) {
|
||||
feedInfoInMarkdown += `- [${monitor.name}](${(await MonitorService.getMonitorLinkInDashboard(createdItem.projectId!, monitor.id!)).toString()})\n`;
|
||||
}
|
||||
|
||||
feedInfoInMarkdown += `\n\n`;
|
||||
}
|
||||
|
||||
const scheduledMaintenanceCreateMessageBlocks: Array<MessageBlocksByWorkspaceType> =
|
||||
await ScheduledMaintenanceWorkspaceMessages.getScheduledMaintenanceCreateMessageBlocks(
|
||||
{
|
||||
scheduledMaintenanceId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
},
|
||||
);
|
||||
|
||||
await ScheduledMaintenanceFeedService.createScheduledMaintenanceFeedItem({
|
||||
scheduledMaintenanceId: createdItem.id!,
|
||||
projectId: createdItem.projectId!,
|
||||
scheduledMaintenanceFeedEventType:
|
||||
ScheduledMaintenanceFeedEventType.ScheduledMaintenanceCreated,
|
||||
displayColor: Red500,
|
||||
feedInfoInMarkdown: feedInfoInMarkdown,
|
||||
userId: createdByUserId || undefined,
|
||||
workspaceNotification: {
|
||||
appendMessageBlocks: scheduledMaintenanceCreateMessageBlocks,
|
||||
sendWorkspaceNotification: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`Error in createScheduledMaintenanceFeedAsync: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async createScheduledMaintenanceStateTimelineAsync(
|
||||
createdItem: Model,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const timeline: ScheduledMaintenanceStateTimeline =
|
||||
new ScheduledMaintenanceStateTimeline();
|
||||
timeline.projectId = createdItem.projectId!;
|
||||
timeline.scheduledMaintenanceId = createdItem.id!;
|
||||
timeline.isOwnerNotified = true; // ignore notifying owners because you already notify for Scheduled Event, no need to notify them for timeline event.
|
||||
timeline.shouldStatusPageSubscribersBeNotified = Boolean(
|
||||
createdItem.shouldStatusPageSubscribersBeNotifiedOnEventCreated,
|
||||
);
|
||||
timeline.isStatusPageSubscribersNotified = Boolean(
|
||||
createdItem.shouldStatusPageSubscribersBeNotifiedOnEventCreated,
|
||||
); // ignore notifying subscribers because you already notify for Scheduled Event, no need to notify them for timeline event.
|
||||
timeline.scheduledMaintenanceStateId =
|
||||
createdItem.currentScheduledMaintenanceStateId!;
|
||||
|
||||
await ScheduledMaintenanceStateTimelineService.create({
|
||||
data: timeline,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Error in createScheduledMaintenanceStateTimelineAsync: ${error}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async addOwners(
|
||||
projectId: ObjectID,
|
||||
|
||||
@@ -84,6 +84,20 @@ export class Service extends DatabaseService<StatusPage> {
|
||||
super(StatusPage);
|
||||
}
|
||||
|
||||
public static getDefaultEmailFooterText(): string {
|
||||
return "This is an automated email sent to you because you are subscribed to this Status Page.";
|
||||
}
|
||||
|
||||
public static getSubscriberEmailFooterText(statusPage: StatusPage): string {
|
||||
if (
|
||||
statusPage.enableCustomSubscriberEmailNotificationFooterText &&
|
||||
statusPage.subscriberEmailNotificationFooterText
|
||||
) {
|
||||
return statusPage.subscriberEmailNotificationFooterText;
|
||||
}
|
||||
return this.getDefaultEmailFooterText();
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
protected override async onBeforeCreate(
|
||||
createBy: CreateBy<StatusPage>,
|
||||
@@ -154,10 +168,19 @@ export class Service extends DatabaseService<StatusPage> {
|
||||
createBy.data.defaultBarColor = Green;
|
||||
}
|
||||
|
||||
// For new status pages, set enableCustomSubscriberEmailNotificationFooterText to false by default
|
||||
// and provide a default custom footer text only if not provided
|
||||
if (
|
||||
createBy.data.enableCustomSubscriberEmailNotificationFooterText ===
|
||||
undefined
|
||||
) {
|
||||
createBy.data.enableCustomSubscriberEmailNotificationFooterText = false;
|
||||
}
|
||||
|
||||
if (!createBy.data.subscriberEmailNotificationFooterText) {
|
||||
createBy.data.subscriberEmailNotificationFooterText =
|
||||
"This is an automated email sent to you because you are subscribed to " +
|
||||
createBy.data.name;
|
||||
(createBy.data?.pageTitle || createBy.data?.name || "Status Page");
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -171,8 +194,7 @@ export class Service extends DatabaseService<StatusPage> {
|
||||
onCreate: OnCreate<StatusPage>,
|
||||
createdItem: StatusPage,
|
||||
): Promise<StatusPage> {
|
||||
// add owners.
|
||||
|
||||
// Execute owner assignment asynchronously
|
||||
if (
|
||||
createdItem.projectId &&
|
||||
createdItem.id &&
|
||||
@@ -180,16 +202,19 @@ export class Service extends DatabaseService<StatusPage> {
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] ||
|
||||
onCreate.createBy.miscDataProps["ownerUsers"])
|
||||
) {
|
||||
await this.addOwners(
|
||||
// Run owner assignment in background without blocking
|
||||
this.addOwners(
|
||||
createdItem.projectId!,
|
||||
createdItem.id!,
|
||||
(onCreate.createBy.miscDataProps["ownerUsers"] as Array<ObjectID>) ||
|
||||
(onCreate.createBy.miscDataProps!["ownerUsers"] as Array<ObjectID>) ||
|
||||
[],
|
||||
(onCreate.createBy.miscDataProps["ownerTeams"] as Array<ObjectID>) ||
|
||||
(onCreate.createBy.miscDataProps!["ownerTeams"] as Array<ObjectID>) ||
|
||||
[],
|
||||
false,
|
||||
onCreate.createBy.props,
|
||||
);
|
||||
).catch((error: Error) => {
|
||||
logger.error(`Error in StatusPageService owner assignment: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
return createdItem;
|
||||
@@ -730,7 +755,7 @@ export class Service extends DatabaseService<StatusPage> {
|
||||
vars: {
|
||||
statusPageName: statusPageName,
|
||||
subscriberEmailNotificationFooterText:
|
||||
statuspage.subscriberEmailNotificationFooterText || "",
|
||||
Service.getSubscriberEmailFooterText(statuspage),
|
||||
statusPageUrl: statusPageURL,
|
||||
hasResources: report.totalResources > 0 ? "true" : "false",
|
||||
report: report as any,
|
||||
|
||||
@@ -839,6 +839,7 @@ Stay informed about service availability! 🚀`;
|
||||
logoFileId: true,
|
||||
allowSubscribersToChooseResources: true,
|
||||
subscriberEmailNotificationFooterText: true,
|
||||
enableCustomSubscriberEmailNotificationFooterText: true,
|
||||
allowSubscribersToChooseEventTypes: true,
|
||||
smtpConfig: {
|
||||
_id: true,
|
||||
|
||||
@@ -45,8 +45,11 @@ import AlertSeverity from "../../Models/DatabaseModels/AlertSeverity";
|
||||
import AlertSeverityService from "./AlertSeverityService";
|
||||
import WorkspaceNotificationRule from "../../Models/DatabaseModels/WorkspaceNotificationRule";
|
||||
import WorkspaceNotificationRuleService from "./WorkspaceNotificationRuleService";
|
||||
import PushNotificationService from "./PushNotificationService";
|
||||
import NotificationRuleEventType from "../../Types/Workspace/NotificationRules/EventType";
|
||||
import NotificationRuleWorkspaceChannel from "../../Types/Workspace/NotificationRules/NotificationRuleWorkspaceChannel";
|
||||
import PushNotificationUtil from "../Utils/PushNotificationUtil";
|
||||
import PushNotificationMessage from "../../Types/PushNotification/PushNotificationMessage";
|
||||
import logger from "../Utils/Logger";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
|
||||
@@ -135,6 +138,11 @@ export class Service extends DatabaseService<Model> {
|
||||
email: true,
|
||||
isVerified: true,
|
||||
},
|
||||
userPush: {
|
||||
deviceToken: true,
|
||||
deviceType: true,
|
||||
isVerified: true,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
@@ -585,6 +593,140 @@ export class Service extends DatabaseService<Model> {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// send push notification.
|
||||
if (
|
||||
notificationRuleItem.userPush?.deviceToken &&
|
||||
notificationRuleItem.userPush?.isVerified
|
||||
) {
|
||||
// send push notification for alert
|
||||
if (
|
||||
options.userNotificationEventType ===
|
||||
UserNotificationEventType.AlertCreated &&
|
||||
alert
|
||||
) {
|
||||
// create a log.
|
||||
logTimelineItem.status = UserNotificationStatus.Sending;
|
||||
logTimelineItem.statusMessage = `Sending push notification to device.`;
|
||||
logTimelineItem.userPushId = notificationRuleItem.userPush.id!;
|
||||
|
||||
const updatedLog: UserOnCallLogTimeline =
|
||||
await UserOnCallLogTimelineService.create({
|
||||
data: logTimelineItem,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
const pushMessage: PushNotificationMessage =
|
||||
PushNotificationUtil.createAlertCreatedNotification({
|
||||
alertTitle: alert.title!,
|
||||
projectName: alert.project?.name || "OneUptime",
|
||||
alertViewLink: (
|
||||
await AlertService.getAlertLinkInDashboard(
|
||||
alert.projectId!,
|
||||
alert.id!,
|
||||
)
|
||||
).toString(),
|
||||
});
|
||||
|
||||
// send push notification.
|
||||
PushNotificationService.sendPushNotification(
|
||||
{
|
||||
deviceTokens: [notificationRuleItem.userPush.deviceToken!],
|
||||
message: pushMessage,
|
||||
deviceType: notificationRuleItem.userPush.deviceType!,
|
||||
},
|
||||
{
|
||||
projectId: options.projectId,
|
||||
userOnCallLogTimelineId: updatedLog.id!,
|
||||
},
|
||||
).catch(async (err: Error) => {
|
||||
await UserOnCallLogTimelineService.updateOneById({
|
||||
id: updatedLog.id!,
|
||||
data: {
|
||||
status: UserNotificationStatus.Error,
|
||||
statusMessage: err.message || "Error sending push notification.",
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// send push notification for incident
|
||||
if (
|
||||
options.userNotificationEventType ===
|
||||
UserNotificationEventType.IncidentCreated &&
|
||||
incident
|
||||
) {
|
||||
// create a log.
|
||||
logTimelineItem.status = UserNotificationStatus.Sending;
|
||||
logTimelineItem.statusMessage = `Sending push notification to device.`;
|
||||
logTimelineItem.userPushId = notificationRuleItem.userPush.id!;
|
||||
|
||||
const updatedLog: UserOnCallLogTimeline =
|
||||
await UserOnCallLogTimelineService.create({
|
||||
data: logTimelineItem,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
const pushMessage: PushNotificationMessage =
|
||||
PushNotificationUtil.createIncidentCreatedNotification({
|
||||
incidentTitle: incident.title!,
|
||||
projectName: incident.project?.name || "OneUptime",
|
||||
incidentViewLink: (
|
||||
await IncidentService.getIncidentLinkInDashboard(
|
||||
incident.projectId!,
|
||||
incident.id!,
|
||||
)
|
||||
).toString(),
|
||||
});
|
||||
|
||||
// send push notification.
|
||||
PushNotificationService.sendPushNotification(
|
||||
{
|
||||
deviceTokens: [notificationRuleItem.userPush.deviceToken!],
|
||||
message: pushMessage,
|
||||
deviceType: notificationRuleItem.userPush.deviceType!,
|
||||
},
|
||||
{
|
||||
projectId: options.projectId,
|
||||
userOnCallLogTimelineId: updatedLog.id!,
|
||||
},
|
||||
).catch(async (err: Error) => {
|
||||
await UserOnCallLogTimelineService.updateOneById({
|
||||
id: updatedLog.id!,
|
||||
data: {
|
||||
status: UserNotificationStatus.Error,
|
||||
statusMessage: err.message || "Error sending push notification.",
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
notificationRuleItem.userPush?.deviceToken &&
|
||||
!notificationRuleItem.userPush?.isVerified
|
||||
) {
|
||||
// create a log.
|
||||
logTimelineItem.status = UserNotificationStatus.Error;
|
||||
logTimelineItem.statusMessage = `Push notification not sent because device is not verified.`;
|
||||
|
||||
await UserOnCallLogTimelineService.create({
|
||||
data: logTimelineItem,
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
@@ -989,9 +1131,13 @@ export class Service extends DatabaseService<Model> {
|
||||
!createBy.data.userEmail &&
|
||||
!createBy.data.userSms &&
|
||||
!createBy.data.userSmsId &&
|
||||
!createBy.data.userEmailId
|
||||
!createBy.data.userEmailId &&
|
||||
!createBy.data.userPushId &&
|
||||
!createBy.data.userPush
|
||||
) {
|
||||
throw new BadDataException("Call, SMS, or Email is required");
|
||||
throw new BadDataException(
|
||||
"Call, SMS, Email, or Push notification is required",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -9,6 +9,7 @@ import TeamMemberService from "./TeamMemberService";
|
||||
import UserCallService from "./UserCallService";
|
||||
import UserEmailService from "./UserEmailService";
|
||||
import UserSmsService from "./UserSmsService";
|
||||
import PushNotificationService from "./PushNotificationService";
|
||||
import { CallRequestMessage } from "../../Types/Call/CallRequest";
|
||||
import { LIMIT_PER_PROJECT } from "../../Types/Database/LimitMax";
|
||||
import { EmailEnvelope } from "../../Types/Email/EmailMessage";
|
||||
@@ -17,6 +18,7 @@ import NotificationSettingEventType from "../../Types/NotificationSetting/Notifi
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import PositiveNumber from "../../Types/PositiveNumber";
|
||||
import { SMSMessage } from "../../Types/SMS/SMS";
|
||||
import PushNotificationMessage from "../../Types/PushNotification/PushNotificationMessage";
|
||||
import UserCall from "../../Models/DatabaseModels/UserCall";
|
||||
import UserEmail from "../../Models/DatabaseModels/UserEmail";
|
||||
import UserNotificationSetting from "../../Models/DatabaseModels/UserNotificationSetting";
|
||||
@@ -36,6 +38,7 @@ export class Service extends DatabaseService<UserNotificationSetting> {
|
||||
emailEnvelope: EmailEnvelope;
|
||||
smsMessage: SMSMessage;
|
||||
callRequestMessage: CallRequestMessage;
|
||||
pushNotificationMessage: PushNotificationMessage;
|
||||
}): Promise<void> {
|
||||
if (!data.projectId) {
|
||||
throw new BadDataException(
|
||||
@@ -54,6 +57,7 @@ export class Service extends DatabaseService<UserNotificationSetting> {
|
||||
alertByEmail: true,
|
||||
alertBySMS: true,
|
||||
alertByCall: true,
|
||||
alertByPush: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
@@ -157,6 +161,22 @@ export class Service extends DatabaseService<UserNotificationSetting> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (notificationSettings.alertByPush) {
|
||||
logger.debug(
|
||||
`Sending push notification to user ${data.userId.toString()} for event ${data.eventType}`,
|
||||
);
|
||||
PushNotificationService.sendPushNotificationToUser(
|
||||
data.userId,
|
||||
data.projectId,
|
||||
data.pushNotificationMessage,
|
||||
{
|
||||
projectId: data.projectId,
|
||||
},
|
||||
).catch((err: Error) => {
|
||||
logger.error(err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
95
Common/Server/Services/UserPushService.ts
Normal file
95
Common/Server/Services/UserPushService.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import CreateBy from "../Types/Database/CreateBy";
|
||||
import DeleteBy from "../Types/Database/DeleteBy";
|
||||
import { OnCreate, OnDelete } from "../Types/Database/Hooks";
|
||||
import DatabaseService from "./DatabaseService";
|
||||
import BadDataException from "../../Types/Exception/BadDataException";
|
||||
import PositiveNumber from "../../Types/PositiveNumber";
|
||||
import UserPush from "../../Models/DatabaseModels/UserPush";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
|
||||
export class Service extends DatabaseService<UserPush> {
|
||||
public constructor() {
|
||||
super(UserPush);
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
protected override async onBeforeCreate(
|
||||
createBy: CreateBy<UserPush>,
|
||||
): Promise<OnCreate<UserPush>> {
|
||||
if (!createBy.data.deviceToken) {
|
||||
throw new BadDataException("Device token is required");
|
||||
}
|
||||
|
||||
if (!createBy.data.deviceType) {
|
||||
throw new BadDataException("Device type is required");
|
||||
}
|
||||
|
||||
// Validate device type
|
||||
const validDeviceTypes: string[] = ["web", "android", "ios"];
|
||||
if (!validDeviceTypes.includes(createBy.data.deviceType)) {
|
||||
throw new BadDataException(
|
||||
"Device type must be one of: " + validDeviceTypes.join(", "),
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this device token already exists for this user and project
|
||||
const existingCount: PositiveNumber = await this.countBy({
|
||||
query: {
|
||||
deviceToken: createBy.data.deviceToken,
|
||||
userId: createBy.data.userId!,
|
||||
projectId: createBy.data.projectId!,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingCount.toNumber() > 0) {
|
||||
throw new BadDataException(
|
||||
"This device is already registered for push notifications",
|
||||
);
|
||||
}
|
||||
|
||||
return { carryForward: null, createBy };
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
protected override async onBeforeDelete(
|
||||
deleteBy: DeleteBy<UserPush>,
|
||||
): Promise<OnDelete<UserPush>> {
|
||||
// Add any cleanup logic here if needed
|
||||
return { carryForward: null, deleteBy };
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async verifyDevice(deviceId: string): Promise<void> {
|
||||
await this.updateOneBy({
|
||||
query: {
|
||||
_id: deviceId,
|
||||
},
|
||||
data: {
|
||||
isVerified: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async unverifyDevice(deviceId: string): Promise<void> {
|
||||
await this.updateOneBy({
|
||||
query: {
|
||||
_id: deviceId,
|
||||
},
|
||||
data: {
|
||||
isVerified: false,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new Service();
|
||||
@@ -137,7 +137,7 @@ export default class IncomingRequestCriteria {
|
||||
input.dataToProcess.monitorId.toString() +
|
||||
" is true",
|
||||
);
|
||||
return `Incoming request / heartbeat received in ${value} minutes.`;
|
||||
return `Incoming request / heartbeat received in ${value} minutes. It was received ${differenceInMinutes} minutes ago.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -153,7 +153,7 @@ export default class IncomingRequestCriteria {
|
||||
input.dataToProcess.monitorId.toString() +
|
||||
" is true",
|
||||
);
|
||||
return `Incoming request / heartbeat not received in ${value} minutes.`;
|
||||
return `Incoming request / heartbeat not received in ${value} minutes. It was received ${differenceInMinutes} minutes ago.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -56,9 +56,13 @@ export default class ServerMonitorCriteria {
|
||||
const lastCheckTime: Date = (input.dataToProcess as ServerMonitorResponse)
|
||||
.requestReceivedAt;
|
||||
|
||||
const timeNow: Date =
|
||||
(input.dataToProcess as ServerMonitorResponse).timeNow ||
|
||||
OneUptimeDate.getCurrentDate();
|
||||
|
||||
const differenceInMinutes: number = OneUptimeDate.getDifferenceInMinutes(
|
||||
lastCheckTime,
|
||||
OneUptimeDate.getCurrentDate(),
|
||||
timeNow,
|
||||
);
|
||||
|
||||
let offlineIfNotCheckedInMinutes: number = 3;
|
||||
|
||||
@@ -228,6 +228,8 @@ export default class MonitorResourceUtil {
|
||||
await MonitorService.updateOneById({
|
||||
id: monitor.id!,
|
||||
data: {
|
||||
incomingRequestMonitorHeartbeatCheckedAt:
|
||||
OneUptimeDate.getCurrentDate(),
|
||||
incomingMonitorRequest: {
|
||||
...dataToProcess,
|
||||
} as any,
|
||||
@@ -1372,7 +1374,7 @@ export default class MonitorResourceUtil {
|
||||
}
|
||||
|
||||
if (input.monitor.monitorType === MonitorType.SSLCertificate) {
|
||||
// check server monitor
|
||||
// check SSL monitor
|
||||
const sslMonitorResult: string | null =
|
||||
await SSLMonitorCriteria.isMonitorInstanceCriteriaFilterMet({
|
||||
dataToProcess: input.dataToProcess,
|
||||
|
||||
308
Common/Server/Utils/PushNotificationUtil.ts
Normal file
308
Common/Server/Utils/PushNotificationUtil.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import PushNotificationMessage from "../../Types/PushNotification/PushNotificationMessage";
|
||||
|
||||
export default class PushNotificationUtil {
|
||||
public static readonly DEFAULT_ICON =
|
||||
"/dashboard/assets/img/OneUptimePNG/1.png";
|
||||
public static readonly DEFAULT_BADGE =
|
||||
"/dashboard/assets/img/OneUptimePNG/6.png";
|
||||
|
||||
private static applyDefaults(
|
||||
notification: Partial<PushNotificationMessage>,
|
||||
): PushNotificationMessage {
|
||||
return {
|
||||
icon: PushNotificationUtil.DEFAULT_ICON,
|
||||
badge: PushNotificationUtil.DEFAULT_BADGE,
|
||||
...notification,
|
||||
} as PushNotificationMessage;
|
||||
}
|
||||
public static createIncidentCreatedNotification(params: {
|
||||
incidentTitle: string;
|
||||
projectName: string;
|
||||
incidentViewLink: string;
|
||||
}): PushNotificationMessage {
|
||||
const { incidentTitle, projectName, incidentViewLink } = params;
|
||||
return PushNotificationUtil.applyDefaults({
|
||||
title: `New Incident: ${incidentTitle}`,
|
||||
body: `A new incident has been created in ${projectName}. Click to view details.`,
|
||||
clickAction: incidentViewLink,
|
||||
url: incidentViewLink,
|
||||
tag: "incident-created",
|
||||
requireInteraction: true,
|
||||
data: {
|
||||
type: "incident-created",
|
||||
incidentTitle: incidentTitle,
|
||||
projectName: projectName,
|
||||
url: incidentViewLink,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static createIncidentStateChangedNotification(params: {
|
||||
incidentTitle: string;
|
||||
projectName: string;
|
||||
newState: string;
|
||||
incidentViewLink: string;
|
||||
}): PushNotificationMessage {
|
||||
const { incidentTitle, projectName, newState, incidentViewLink } = params;
|
||||
return PushNotificationUtil.applyDefaults({
|
||||
title: `Incident Updated: ${incidentTitle}`,
|
||||
body: `Incident state changed to ${newState} in ${projectName}. Click to view details.`,
|
||||
clickAction: incidentViewLink,
|
||||
url: incidentViewLink,
|
||||
tag: "incident-state-changed",
|
||||
requireInteraction: true,
|
||||
data: {
|
||||
type: "incident-state-changed",
|
||||
incidentTitle: incidentTitle,
|
||||
projectName: projectName,
|
||||
newState: newState,
|
||||
url: incidentViewLink,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static createIncidentNotePostedNotification(params: {
|
||||
incidentTitle: string;
|
||||
projectName: string;
|
||||
isPrivateNote: boolean;
|
||||
incidentViewLink: string;
|
||||
}): PushNotificationMessage {
|
||||
const { incidentTitle, projectName, isPrivateNote, incidentViewLink } =
|
||||
params;
|
||||
const noteType: string = isPrivateNote ? "Private" : "Public";
|
||||
return PushNotificationUtil.applyDefaults({
|
||||
title: `${noteType} Note Added: ${incidentTitle}`,
|
||||
body: `A ${noteType.toLowerCase()} note has been posted on incident in ${projectName}. Click to view details.`,
|
||||
clickAction: incidentViewLink,
|
||||
url: incidentViewLink,
|
||||
tag: "incident-note-posted",
|
||||
requireInteraction: true,
|
||||
data: {
|
||||
type: "incident-note-posted",
|
||||
incidentTitle: incidentTitle,
|
||||
projectName: projectName,
|
||||
isPrivateNote: isPrivateNote,
|
||||
url: incidentViewLink,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static createAlertCreatedNotification(params: {
|
||||
alertTitle: string;
|
||||
projectName: string;
|
||||
alertViewLink: string;
|
||||
}): PushNotificationMessage {
|
||||
const { alertTitle, projectName, alertViewLink } = params;
|
||||
return PushNotificationUtil.applyDefaults({
|
||||
title: `New Alert: ${alertTitle}`,
|
||||
body: `A new alert has been created in ${projectName}. Click to view details.`,
|
||||
clickAction: alertViewLink,
|
||||
url: alertViewLink,
|
||||
tag: "alert-created",
|
||||
requireInteraction: true,
|
||||
data: {
|
||||
type: "alert-created",
|
||||
alertTitle: alertTitle,
|
||||
projectName: projectName,
|
||||
url: alertViewLink,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static createMonitorStatusChangedNotification(params: {
|
||||
monitorName: string;
|
||||
projectName: string;
|
||||
newStatus: string;
|
||||
monitorViewLink: string;
|
||||
}): PushNotificationMessage {
|
||||
const { monitorName, projectName, newStatus, monitorViewLink } = params;
|
||||
return PushNotificationUtil.applyDefaults({
|
||||
title: `Monitor ${newStatus}: ${monitorName}`,
|
||||
body: `Monitor status changed to ${newStatus} in ${projectName}. Click to view details.`,
|
||||
clickAction: monitorViewLink,
|
||||
url: monitorViewLink,
|
||||
tag: "monitor-status-changed",
|
||||
requireInteraction: true,
|
||||
data: {
|
||||
type: "monitor-status-changed",
|
||||
monitorName: monitorName,
|
||||
projectName: projectName,
|
||||
newStatus: newStatus,
|
||||
url: monitorViewLink,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static createScheduledMaintenanceNotification(params: {
|
||||
title: string;
|
||||
projectName: string;
|
||||
state: string;
|
||||
viewLink: string;
|
||||
}): PushNotificationMessage {
|
||||
const { title, projectName, state, viewLink } = params;
|
||||
return PushNotificationUtil.applyDefaults({
|
||||
title: `Scheduled Maintenance ${state}: ${title}`,
|
||||
body: `Scheduled maintenance ${state.toLowerCase()} in ${projectName}. Click to view details.`,
|
||||
clickAction: viewLink,
|
||||
url: viewLink,
|
||||
tag: "scheduled-maintenance",
|
||||
requireInteraction: false,
|
||||
data: {
|
||||
type: "scheduled-maintenance",
|
||||
title: title,
|
||||
projectName: projectName,
|
||||
state: state,
|
||||
url: viewLink,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static createGenericNotification(params: {
|
||||
title: string;
|
||||
body: string;
|
||||
clickAction?: string;
|
||||
tag?: string;
|
||||
requireInteraction?: boolean;
|
||||
}): PushNotificationMessage {
|
||||
const {
|
||||
title,
|
||||
body,
|
||||
clickAction,
|
||||
tag,
|
||||
requireInteraction = false,
|
||||
} = params;
|
||||
const notification: Partial<PushNotificationMessage> = {
|
||||
title: title,
|
||||
body: body,
|
||||
tag: tag || "OneUptime",
|
||||
requireInteraction: requireInteraction,
|
||||
data: {
|
||||
type: "generic",
|
||||
},
|
||||
};
|
||||
|
||||
if (clickAction) {
|
||||
notification.clickAction = clickAction;
|
||||
notification.url = clickAction;
|
||||
notification.data!["url"] = clickAction;
|
||||
}
|
||||
|
||||
return PushNotificationUtil.applyDefaults(notification);
|
||||
}
|
||||
|
||||
public static createMonitorProbeStatusNotification(params: {
|
||||
title: string;
|
||||
body: string;
|
||||
tag: string;
|
||||
monitorId: string;
|
||||
monitorName: string;
|
||||
}): PushNotificationMessage {
|
||||
const { title, body, tag, monitorId, monitorName } = params;
|
||||
return PushNotificationUtil.applyDefaults({
|
||||
title: title,
|
||||
body: body,
|
||||
tag: tag,
|
||||
requireInteraction: false,
|
||||
data: {
|
||||
type: "monitor-probe-status",
|
||||
monitorId: monitorId,
|
||||
monitorName: monitorName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static createMonitorCreatedNotification(params: {
|
||||
monitorName: string;
|
||||
monitorId: string;
|
||||
}): PushNotificationMessage {
|
||||
const { monitorName, monitorId } = params;
|
||||
return PushNotificationUtil.applyDefaults({
|
||||
title: "OneUptime: New Monitor Created",
|
||||
body: `New monitor was created: ${monitorName}`,
|
||||
tag: "monitor-created",
|
||||
requireInteraction: false,
|
||||
data: {
|
||||
type: "monitor-created",
|
||||
monitorId: monitorId,
|
||||
monitorName: monitorName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static createOnCallPolicyAddedNotification(params: {
|
||||
policyName: string;
|
||||
}): PushNotificationMessage {
|
||||
const { policyName } = params;
|
||||
return PushNotificationUtil.applyDefaults({
|
||||
title: "Added to On-Call Policy",
|
||||
body: `You have been added to the on-call duty policy ${policyName}.`,
|
||||
tag: "on-call-policy-added",
|
||||
requireInteraction: false,
|
||||
data: {
|
||||
type: "on-call-policy-added",
|
||||
policyName: policyName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static createOnCallPolicyRemovedNotification(params: {
|
||||
policyName: string;
|
||||
}): PushNotificationMessage {
|
||||
const { policyName } = params;
|
||||
return PushNotificationUtil.applyDefaults({
|
||||
title: "Removed from On-Call Policy",
|
||||
body: `You have been removed from the on-call duty policy ${policyName}.`,
|
||||
tag: "on-call-policy-removed",
|
||||
requireInteraction: false,
|
||||
data: {
|
||||
type: "on-call-policy-removed",
|
||||
policyName: policyName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static createProbeDisconnectedNotification(params: {
|
||||
probeName: string;
|
||||
}): PushNotificationMessage {
|
||||
const { probeName } = params;
|
||||
return PushNotificationUtil.applyDefaults({
|
||||
title: "OneUptime: Probe Disconnected",
|
||||
body: `Your probe ${probeName} is disconnected. It was last seen 5 minutes ago.`,
|
||||
tag: "probe-disconnected",
|
||||
requireInteraction: false,
|
||||
data: {
|
||||
type: "probe-disconnected",
|
||||
probeName: probeName,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public static createProbeStatusChangedNotification(params: {
|
||||
probeName: string;
|
||||
projectName: string;
|
||||
connectionStatus: string;
|
||||
clickAction?: string;
|
||||
}): PushNotificationMessage {
|
||||
const { probeName, projectName, connectionStatus, clickAction } = params;
|
||||
const notification: Partial<PushNotificationMessage> = {
|
||||
title: `Probe ${connectionStatus}: ${probeName}`,
|
||||
body: `Probe ${probeName} is ${connectionStatus} in ${projectName}. Click to view details.`,
|
||||
tag: "probe-status-changed",
|
||||
requireInteraction: true,
|
||||
data: {
|
||||
type: "probe-status-changed",
|
||||
probeName: probeName,
|
||||
projectName: projectName,
|
||||
connectionStatus: connectionStatus,
|
||||
},
|
||||
};
|
||||
|
||||
if (clickAction) {
|
||||
notification.clickAction = clickAction;
|
||||
notification.url = clickAction;
|
||||
notification.data!["url"] = clickAction;
|
||||
}
|
||||
|
||||
return PushNotificationUtil.applyDefaults(notification);
|
||||
}
|
||||
}
|
||||
528
Common/Server/Utils/SCIM.ts
Normal file
528
Common/Server/Utils/SCIM.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
import URL from "../../Types/API/URL";
|
||||
import Email from "../../Types/Email";
|
||||
import ServerException from "../../Types/Exception/ServerException";
|
||||
import { JSONObject } from "../../Types/JSON";
|
||||
import Name from "../../Types/Name";
|
||||
import logger from "./Logger";
|
||||
import API from "../../Utils/API";
|
||||
import HTTPResponse from "../../Types/API/HTTPResponse";
|
||||
import HTTPErrorResponse from "../../Types/API/HTTPErrorResponse";
|
||||
import Headers from "../../Types/API/Headers";
|
||||
import Route from "../../Types/API/Route";
|
||||
|
||||
export interface SCIMUser {
|
||||
id?: string;
|
||||
userName: string;
|
||||
name?: {
|
||||
formatted?: string;
|
||||
familyName?: string;
|
||||
givenName?: string;
|
||||
};
|
||||
displayName?: string;
|
||||
emails: Array<{
|
||||
value: string;
|
||||
type?: string;
|
||||
primary?: boolean;
|
||||
}>;
|
||||
active: boolean;
|
||||
groups?: Array<{
|
||||
value: string;
|
||||
display?: string;
|
||||
}>;
|
||||
meta?: {
|
||||
resourceType: string;
|
||||
created?: string;
|
||||
lastModified?: string;
|
||||
location?: string;
|
||||
version?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SCIMGroup {
|
||||
id?: string;
|
||||
displayName: string;
|
||||
members?: Array<{
|
||||
value: string;
|
||||
display?: string;
|
||||
type?: string;
|
||||
}>;
|
||||
meta?: {
|
||||
resourceType: string;
|
||||
created?: string;
|
||||
lastModified?: string;
|
||||
location?: string;
|
||||
version?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SCIMListResponse<T> {
|
||||
schemas: string[];
|
||||
totalResults: number;
|
||||
startIndex: number;
|
||||
itemsPerPage: number;
|
||||
Resources: T[];
|
||||
}
|
||||
|
||||
export interface SCIMError {
|
||||
schemas: string[];
|
||||
scimType?: string;
|
||||
detail: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export default class SCIMUtil {
|
||||
public static readonly SCIM_SCHEMAS = {
|
||||
CORE_USER: "urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
CORE_GROUP: "urn:ietf:params:scim:schemas:core:2.0:Group",
|
||||
LIST_RESPONSE: "urn:ietf:params:scim:api:messages:2.0:ListResponse",
|
||||
ERROR: "urn:ietf:params:scim:api:messages:2.0:Error",
|
||||
PATCH_OP: "urn:ietf:params:scim:api:messages:2.0:PatchOp",
|
||||
};
|
||||
|
||||
public static createHeaders(bearerToken: string): Headers {
|
||||
return {
|
||||
"Content-Type": "application/scim+json",
|
||||
Authorization: `Bearer ${bearerToken}`,
|
||||
Accept: "application/scim+json",
|
||||
};
|
||||
}
|
||||
|
||||
public static async createUser(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
user: Omit<SCIMUser, "id" | "meta">,
|
||||
): Promise<SCIMUser> {
|
||||
try {
|
||||
const userData = {
|
||||
schemas: [SCIMUtil.SCIM_SCHEMAS.CORE_USER],
|
||||
...user,
|
||||
};
|
||||
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.post<JSONObject>(
|
||||
scimBaseUrl.addRoute(new Route("/Users")),
|
||||
userData,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw new ServerException(response.message || "Failed to create SCIM user");
|
||||
}
|
||||
|
||||
logger.info(`SCIM user created: ${user.userName}`);
|
||||
return response.data as unknown as SCIMUser;
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to create SCIM user: ${user.userName} - ${error.message}`);
|
||||
throw new ServerException(`Failed to create SCIM user: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async updateUser(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
userId: string,
|
||||
user: Partial<SCIMUser>,
|
||||
): Promise<SCIMUser> {
|
||||
try {
|
||||
const userData = {
|
||||
schemas: [SCIMUtil.SCIM_SCHEMAS.CORE_USER],
|
||||
...user,
|
||||
};
|
||||
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.put<JSONObject>(
|
||||
scimBaseUrl.addRoute(new Route(`/Users/${userId}`)),
|
||||
userData,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw new ServerException(response.message || "Failed to update SCIM user");
|
||||
}
|
||||
|
||||
logger.info(`SCIM user updated: ${userId}`);
|
||||
return response.data as unknown as SCIMUser;
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to update SCIM user: ${userId} - ${error.message}`);
|
||||
throw new ServerException(`Failed to update SCIM user: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async deleteUser(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.delete<JSONObject>(
|
||||
scimBaseUrl.addRoute(new Route(`/Users/${userId}`)),
|
||||
undefined,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw new ServerException(response.message || "Failed to delete SCIM user");
|
||||
}
|
||||
|
||||
logger.info(`SCIM user deleted: ${userId}`);
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to delete SCIM user: ${userId} - ${error.message}`);
|
||||
throw new ServerException(`Failed to delete SCIM user: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async deactivateUser(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
userId: string,
|
||||
): Promise<SCIMUser> {
|
||||
try {
|
||||
const patchData = {
|
||||
schemas: [SCIMUtil.SCIM_SCHEMAS.PATCH_OP],
|
||||
Operations: [
|
||||
{
|
||||
op: "replace",
|
||||
path: "active",
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.patch<JSONObject>(
|
||||
scimBaseUrl.addRoute(new Route(`/Users/${userId}`)),
|
||||
patchData,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw new ServerException(response.message || "Failed to deactivate SCIM user");
|
||||
}
|
||||
|
||||
logger.info(`SCIM user deactivated: ${userId}`);
|
||||
return response.data as unknown as SCIMUser;
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to deactivate SCIM user: ${userId} - ${error.message}`);
|
||||
throw new ServerException(`Failed to deactivate SCIM user: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async getUser(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
userId: string,
|
||||
): Promise<SCIMUser> {
|
||||
try {
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.get<JSONObject>(
|
||||
scimBaseUrl.addRoute(new Route(`/Users/${userId}`)),
|
||||
undefined,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw new ServerException(response.message || "Failed to get SCIM user");
|
||||
}
|
||||
|
||||
return response.data as unknown as SCIMUser;
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to get SCIM user: ${userId} - ${error.message}`);
|
||||
throw new ServerException(`Failed to get SCIM user: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async getUserByUserName(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
userName: string,
|
||||
): Promise<SCIMUser | null> {
|
||||
try {
|
||||
const usersUrl = scimBaseUrl.addRoute(new Route("/Users"));
|
||||
usersUrl.addQueryParam("filter", `userName eq "${userName}"`);
|
||||
usersUrl.addQueryParam("count", "1");
|
||||
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.get<JSONObject>(
|
||||
usersUrl,
|
||||
undefined,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw new ServerException(response.message || "Failed to get SCIM user by username");
|
||||
}
|
||||
|
||||
const listResponse = response.data as unknown as SCIMListResponse<SCIMUser>;
|
||||
if (listResponse.totalResults > 0) {
|
||||
return listResponse.Resources[0] || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to get SCIM user by username: ${userName} - ${error.message}`);
|
||||
throw new ServerException(`Failed to get SCIM user: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async listUsers(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
options?: {
|
||||
startIndex?: number;
|
||||
count?: number;
|
||||
filter?: string;
|
||||
},
|
||||
): Promise<SCIMListResponse<SCIMUser>> {
|
||||
try {
|
||||
const usersUrl = scimBaseUrl.addRoute(new Route("/Users"));
|
||||
usersUrl.addQueryParam("startIndex", (options?.startIndex || 1).toString());
|
||||
usersUrl.addQueryParam("count", (options?.count || 100).toString());
|
||||
|
||||
if (options?.filter) {
|
||||
usersUrl.addQueryParam("filter", options.filter);
|
||||
}
|
||||
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.get<JSONObject>(
|
||||
usersUrl,
|
||||
undefined,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw new ServerException(response.message || "Failed to list SCIM users");
|
||||
}
|
||||
|
||||
return response.data as unknown as SCIMListResponse<SCIMUser>;
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to list SCIM users - ${error.message}`);
|
||||
throw new ServerException(`Failed to list SCIM users: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async testConnection(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const usersUrl = scimBaseUrl.addRoute(new Route("/Users"));
|
||||
usersUrl.addQueryParam("count", "1");
|
||||
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.get<JSONObject>(
|
||||
usersUrl,
|
||||
undefined,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
logger.error(`SCIM connection test failed: ${scimBaseUrl.toString()} - ${response.message}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`SCIM connection test successful: ${scimBaseUrl.toString()}`);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
logger.error(`SCIM connection test failed: ${scimBaseUrl.toString()} - ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static convertOneUptimeUserToSCIMUser(
|
||||
email: Email,
|
||||
name?: Name,
|
||||
isActive: boolean = true,
|
||||
): Omit<SCIMUser, "id" | "meta"> {
|
||||
const emailValue = email.toString();
|
||||
|
||||
const result: Omit<SCIMUser, "id" | "meta"> = {
|
||||
userName: emailValue,
|
||||
displayName: name?.toString() || emailValue,
|
||||
emails: [
|
||||
{
|
||||
value: emailValue,
|
||||
type: "work",
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
active: isActive,
|
||||
};
|
||||
|
||||
if (name) {
|
||||
result.name = {
|
||||
formatted: name.toString(),
|
||||
givenName: name.toString().split(" ")[0] || "",
|
||||
familyName: name.toString().split(" ").slice(1).join(" ") || "",
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static extractEmailFromSCIMUser(scimUser: SCIMUser): Email | null {
|
||||
if (!scimUser.emails || scimUser.emails.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find primary email first
|
||||
const primaryEmail = scimUser.emails.find(email => email.primary);
|
||||
if (primaryEmail) {
|
||||
return new Email(primaryEmail.value);
|
||||
}
|
||||
|
||||
// Otherwise, use the first email
|
||||
return new Email(scimUser.emails[0]!.value);
|
||||
}
|
||||
|
||||
public static extractNameFromSCIMUser(scimUser: SCIMUser): Name | null {
|
||||
if (scimUser.name?.formatted) {
|
||||
return new Name(scimUser.name.formatted);
|
||||
}
|
||||
|
||||
if (scimUser.displayName) {
|
||||
return new Name(scimUser.displayName);
|
||||
}
|
||||
|
||||
if (scimUser.name?.givenName || scimUser.name?.familyName) {
|
||||
const fullName = `${scimUser.name.givenName || ""} ${scimUser.name.familyName || ""}`.trim();
|
||||
if (fullName) {
|
||||
return new Name(fullName);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Group Operations
|
||||
public static async createGroup(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
group: Omit<SCIMGroup, "id" | "meta">,
|
||||
): Promise<SCIMGroup> {
|
||||
try {
|
||||
const groupData = {
|
||||
schemas: [SCIMUtil.SCIM_SCHEMAS.CORE_GROUP],
|
||||
...group,
|
||||
};
|
||||
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.post<JSONObject>(
|
||||
scimBaseUrl.addRoute(new Route("/Groups")),
|
||||
groupData,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw new ServerException(response.message || "Failed to create SCIM group");
|
||||
}
|
||||
|
||||
logger.info(`SCIM group created: ${group.displayName}`);
|
||||
return response.data as unknown as SCIMGroup;
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to create SCIM group: ${group.displayName} - ${error.message}`);
|
||||
throw new ServerException(`Failed to create SCIM group: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async addUserToGroup(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
groupId: string,
|
||||
userId: string,
|
||||
userDisplayName?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const patchData = {
|
||||
schemas: [SCIMUtil.SCIM_SCHEMAS.PATCH_OP],
|
||||
Operations: [
|
||||
{
|
||||
op: "add",
|
||||
path: "members",
|
||||
value: [
|
||||
{
|
||||
value: userId,
|
||||
display: userDisplayName,
|
||||
type: "User",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.patch<JSONObject>(
|
||||
scimBaseUrl.addRoute(new Route(`/Groups/${groupId}`)),
|
||||
patchData,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw new ServerException(response.message || "Failed to add user to group");
|
||||
}
|
||||
|
||||
logger.info(`SCIM user ${userId} added to group ${groupId}`);
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to add SCIM user ${userId} to group ${groupId} - ${error.message}`);
|
||||
throw new ServerException(`Failed to add user to group: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static async removeUserFromGroup(
|
||||
scimBaseUrl: URL,
|
||||
bearerToken: string,
|
||||
groupId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const patchData = {
|
||||
schemas: [SCIMUtil.SCIM_SCHEMAS.PATCH_OP],
|
||||
Operations: [
|
||||
{
|
||||
op: "remove",
|
||||
path: `members[value eq "${userId}"]`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response: HTTPResponse<JSONObject> | HTTPErrorResponse = await API.patch<JSONObject>(
|
||||
scimBaseUrl.addRoute(new Route(`/Groups/${groupId}`)),
|
||||
patchData,
|
||||
SCIMUtil.createHeaders(bearerToken),
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
throw new ServerException(response.message || "Failed to remove user from group");
|
||||
}
|
||||
|
||||
logger.info(`SCIM user ${userId} removed from group ${groupId}`);
|
||||
} catch (error: any) {
|
||||
logger.error(`Failed to remove SCIM user ${userId} from group ${groupId} - ${error.message}`);
|
||||
throw new ServerException(`Failed to remove user from group: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
public static convertUserToSCIMUser(user: any): Omit<SCIMUser, "id" | "meta"> {
|
||||
const firstName = user.name?.firstName || "";
|
||||
const lastName = user.name?.lastName || "";
|
||||
|
||||
return {
|
||||
userName: user.email?.toString() || "",
|
||||
emails: [
|
||||
{
|
||||
value: user.email?.toString() || "",
|
||||
type: "work",
|
||||
primary: true,
|
||||
},
|
||||
],
|
||||
name: {
|
||||
formatted: `${firstName} ${lastName}`.trim() || user.name?.toString() || "",
|
||||
givenName: firstName,
|
||||
familyName: lastName,
|
||||
},
|
||||
displayName: user.name?.toString() || user.email?.toString() || "",
|
||||
active: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Dictionary } from "lodash";
|
||||
import { JSONArray, JSONObject, JSONValue } from "../../../Types/JSON";
|
||||
import ObjectID from "../../../Types/ObjectID";
|
||||
import TelemetryType from "../../../Types/Telemetry/TelemetryType";
|
||||
@@ -9,6 +8,7 @@ import logger from "../Logger";
|
||||
import MetricType from "../../../Models/DatabaseModels/MetricType";
|
||||
import MetricTypeService from "../../Services/MetricTypeService";
|
||||
import TelemetryService from "../../../Models/DatabaseModels/TelemetryService";
|
||||
import Dictionary from "../../../Types/Dictionary";
|
||||
|
||||
export type AttributeType = string | number | boolean | null;
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import SlackActionType from "./ActionTypes";
|
||||
import WorkspaceProjectAuthTokenService from "../../../../Services/WorkspaceProjectAuthTokenService";
|
||||
import logger from "../../../Logger";
|
||||
import { JSONArray, JSONObject } from "../../../../../Types/JSON";
|
||||
import { Dictionary } from "lodash";
|
||||
import CaptureSpan from "../../../Telemetry/CaptureSpan";
|
||||
import Dictionary from "../../../../../Types/Dictionary";
|
||||
|
||||
export interface SlackAction {
|
||||
actionValue?: string | undefined;
|
||||
|
||||
@@ -295,7 +295,7 @@ describe("StatementGenerator", () => {
|
||||
(\n<columns-create-statement>
|
||||
)
|
||||
ENGINE = MergeTree
|
||||
PARTITION BY (column_ObjectID)
|
||||
PARTITION BY (column_ObjectID)
|
||||
|
||||
PRIMARY KEY (${'column_ObjectID'})
|
||||
ORDER BY (${'column_ObjectID'})
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import Navbar, { ComponentProps } from "../../../UI/Components/Navbar/NavBar";
|
||||
import Navbar, {
|
||||
ComponentProps,
|
||||
NavItem,
|
||||
} from "../../../UI/Components/Navbar/NavBar";
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
import "@testing-library/jest-dom/extend-expect";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { ReactElement } from "react-markdown/lib/react-markdown";
|
||||
import Route from "../../../Types/API/Route";
|
||||
import IconProp from "../../../Types/Icon/IconProp";
|
||||
|
||||
describe("Navbar", () => {
|
||||
const defaultProps: ComponentProps = {
|
||||
@@ -26,7 +30,12 @@ describe("Navbar", () => {
|
||||
});
|
||||
|
||||
it("renders with a rightElement", () => {
|
||||
const rightElement: ReactElement = <div>Right Element</div>;
|
||||
const rightElement: NavItem = {
|
||||
id: "test-right-element",
|
||||
title: "Right Element",
|
||||
icon: IconProp.User,
|
||||
route: new Route("/test"),
|
||||
};
|
||||
const customProps: ComponentProps = { ...defaultProps, rightElement };
|
||||
render(<Navbar {...customProps} />);
|
||||
expect(screen.getByText("Right Element")).toBeInTheDocument();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Dictionary } from "lodash";
|
||||
import DatabaseProperty from "../Database/DatabaseProperty";
|
||||
import Dictionary from "../Dictionary";
|
||||
import BadDataException from "../Exception/BadDataException";
|
||||
import { JSONObject, ObjectType } from "../JSON";
|
||||
import { FindOperator } from "typeorm";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user